4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

個人開発(インフラ)

Last updated at Posted at 2023-10-31

はじめに

今回、以下の様な技術stackで個人開発を行ったので、備忘録として残そうと思います

  • Go(API)
  • Next.js・TypeScript(フロント)
  • AWS・Terraform(インフラ)
  • github actions(CI/CD)

本記事では、AWSを用いたインフラ構築での取り組みについて簡単に触れたいと思います
バックエンド側、フロント側の記事については以下に置いておきます。

バックエンド側

フロント側

github repository

アプリケーション側

インフラ側

システム概要図

image.png

  • s3
  • dynamodb
  • ECS
  • ECR
  • Fargate
  • VPC
  • Systems Manager
  • Lambda
  • API Gateway
  • RDS
  • Load Balancer

その他使用技術

Terraform(IaC)

Terraformは、HashiCorpが開発したオープンソースの「Infrastructure as Code」ツールのひとつである。Terraformを使用すると、クラウドリソースやオンプレミスリソースをコードで定義し、その定義に基づいてリソースを作成、更新、削除することができる。
今回は構築したAWSのリソース全てをTerraform(HCL)で記述し、管理している。
現在、世の中に普及しているIaCツールにはTerraformの他に、「AWS CDK」,「Cloud Formation」,「Pulumi」がある。
それぞれの特徴として、

  • マルチクラウド
    • Terraform
    • Pulumi
  • 多言語対応
    • AWS CDK
    • Pulumi

などがあり、それぞれにメリット・デメリットがあるが、今回は現状の普及率とマルチクラウド対応を利点を考慮し、Terraformを採用した

Github Actions

GitHub Actionsは、GitHubのリポジトリ内でCI/CD(Continuous Integration/Continuous Deployment)ワークフローを自動化するためのツールであり、コードのプッシュやプルリクエストの作成など、GitHubでの特定のイベントをトリガーとして、ビルド、テスト、デプロイなどのタスクを自動的に実行することができる。

Github Actions <=> AWS連携について

GitHub ActionsがAWSのリソースにアクセスできるように認証を行う必要がある。そこで今回はOIDCを利用してAWS認証を行う。
OIDC(OpenID Connect)は、OAuth 2.0プロトコルの上に構築された認証プロトコルで、ユーザーの認証情報を安全に共有するためのIDトークンという形式を提供ししている。

OIDC認証のメリット

OIDCのメリットを説明する前に、OIDCを使用せず、Github ActionsにAWS認証を付与しようとすると、以下の方法が考えられます。

  1. IAM ユーザー作成
  2. 認証情報(Access Key ID、Secret Access Key)を生成
  3. GitHub ActionsのSecretsに認証情報を設定

上記のようにすれば、簡単に2サービス間で連携が取れます。

しかしこの方法はセキュリティリスクや管理コスト増加の観点からあまり推奨された方法ではありません。
一方、OIDCを使えば永続的な認証情報を預けることなく、OIDCの仕様に則った一時的なトークン発行のみで、AWSリソースの操作ができるようになる。ゆえに、AWS認証情報をgithubで直接管理運用する必要がなくなる。
このような理由から、OIDC認証を採用することとした。

今回用いるOIDC認証の手順は以下の通りです。

  1. AWSの管理画面でIDプロバイダを作成
  2. Github Actionsで使用するIAMロールを作成
  3. ポリシーを編集して、特定リポジトリの特定ブランチのみ認証を行うようにする

tfstateファイルの動的管理

Terraformを使用する際、tfstateファイルはTerraformのインフラストラクチャの現在の状態を表す重要なファイルである。複数の開発者やCI/CDツールがTerraformを同時に実行する場合、tfstateの一貫性や競合を避けるための適切な管理が必要となる。
tfstateファイル管理する方法として、localで保持する方法やgitで管理する方法などが挙げられますが、どちらも一貫性の担保が困難なことやセキュリティリスクが高いことから推奨されない。

ゆえに今回はAWSのサービスであるs3とDynamoDBを用いてtfstateファイルを管理する手法を用います。

S3を使う理由はtfstateファイルを管理するためですが、DynamoDBはファイルの一貫性を保つために必要です。S3でtfstateファイルを管理することによって複数人でtfstateファイルを扱うことが可能となる。しかし、それによって状態のconflictリスクがある。
そこで、TerraformではDynamoDB(S3の場合)を用いてtfstateファイルの一貫性を保つことができる機能を提供しています。

上記手法は公式でも推奨されています。

xxx.tf
terraform {
  backend "s3" {
    bucket         = "recruit-info-service-tfstate"
    key            = "terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "recruit-info-service-tfstate-locking"
    encrypt        = true
  }
}

backend "s3" とすることで、Terraformはs3でtfstateファイルを管理することができる。

Terraformを用いたAWSのリソース構築

ここから実際にインフラ環境をTerraformで作成したリソースについて説明していきます

ネットワーク構築

network.tf
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "aws-ecs-terraform"
  cidr = "10.0.0.0/16"

  azs             = ["${local.region}a", "${local.region}c"]
  public_subnets  = ["10.0.11.0/24", "10.0.12.0/24"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]

  public_subnet_names  = ["Public Subnet 1a", "Public Subnet 1c"]
  private_subnet_names = ["Private Subnet 1a", "Private Subnet 1c"]

  enable_dns_hostnames = true
  enable_dns_support   = true

  enable_nat_gateway = true
  single_nat_gateway = false
}

# RDSをaのAZに配置
resource "aws_subnet" "rds_subnet_a" {
  vpc_id                  = module.vpc.vpc_id
  cidr_block              = "10.0.5.0/24"
  availability_zone       = "${local.region}a"
  map_public_ip_on_launch = false

  tags = {
    Name                  = "${local.app} RDS Private Subnet 1a"
    "MapPublicIpOnLaunch" = "false"
    "Type"                = "rds"
  }
}

# RDSをcのAZに配置
resource "aws_subnet" "rds_subnet_c" {
  vpc_id                  = module.vpc.vpc_id
  cidr_block              = "10.0.6.0/24"
  availability_zone       = "${local.region}c"
  map_public_ip_on_launch = false

  tags = {
    Name                  = "${local.app} RDS Private Subnet 1c"
    "MapPublicIpOnLaunch" = "false"
    "Type"                = "rds"
  }
}

resource "aws_db_subnet_group" "my_db_subnet_group" {
  name       = "${local.app}-db-subnet-group"
  subnet_ids = [aws_subnet.rds_subnet_a.id, aws_subnet.rds_subnet_c.id]

  tags = {
    Name = "${local.app}-db-subnet-group"
  }
}

以下で上記設定の詳細を説明していく。

source = "terraform-aws-modules/vpc/aws"

とすることでterraformのVPC moduleを使用する。

enable_dns_hostnames = true

上記記述で、AWSのDNSサーバーによる名前解決が有効になる

enable_dns_support   = true

上記記述により、VPC 内のリソースにパブリック DNS ホスト名を自動的に割り当てられる

enable_nat_gateway = true
single_nat_gateway = false

上記記述により、指定された各AZにNATゲートウェイが作成されます。
インターネットゲートウェイとは異なり、NATゲートウェイを用いる際は明示的に記述する必要がある。

resource "aws_subnet" "rds_subnet_a" {
  vpc_id                  = module.vpc.vpc_id
  cidr_block              = "10.0.5.0/24"
  availability_zone       = "${local.region}a"
  map_public_ip_on_launch = false

  tags = {
    Name                  = "${local.app} RDS Private Subnet 1a"
    "MapPublicIpOnLaunch" = "false"
    "Type"                = "rds"
  }
}

また上記の様にすることで、RDSを設置するSubnet情報を明示的に指定しています
map_public_ip_on_launch = false
とすることでデフォルトではパブリックIPアドレスは付与されません。
RDSはインターネットに直接公開することはないので、falseと設定する

resource "aws_db_subnet_group" "my_db_subnet_group" {
  name       = "${local.app}-db-subnet-group"
  subnet_ids = [aws_subnet.rds_subnet_a.id, aws_subnet.rds_subnet_c.id]

  tags = {
    Name = "${local.app}-db-subnet-group"
  }
}

上記はRDSのためのサブネットグループを定義するTerraformリソースです。
RDSインスタンスをVPC内の特定のサブネットに配置するためには、サブネットグループを使用して、どのサブネットにデータベースを配置するかを指定します。サブネットグループなしでRDSインスタンスをVPC内に作成することはできません。

以上で、後にFargate, ALB, RDS, (MySQLのAutoMigrate用の)Lambdaを設置するためのネットワークの設定が終了しました。

セキュリティーグループ

セキュリティグループは、AWSの仮想プライベートクラウド (VPC) 内のリソースへのネットワークトラフィックを制御する仮想ファイアウォールのことである

security_group.tf
resource "aws_security_group" "alb" {
  name        = "${local.app}-alb"
  description = "For ALB."
  vpc_id      = module.vpc.vpc_id
  ingress {
    description = "Allow HTTP from ALL."
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    description = "Allow all to outbound."
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name = "${local.app}-alb"
  }
}

resource "aws_security_group" "ecs" {
  name        = "${local.app}-ecs"
  description = "For ECS."
  vpc_id      = module.vpc.vpc_id
  egress {
    description = "Allow all to outbound."
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name = "${local.app}-ecs"
  }
}

resource "aws_security_group_rule" "ecs_from_alb" {
  description              = "Allow 8080 from Security Group for ALB."
  type                     = "ingress"
  from_port                = 8080
  to_port                  = 8080
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.alb.id
  security_group_id        = aws_security_group.ecs.id
}

resource "aws_security_group" "rds" {
  name        = "${local.app}-rds"
  description = "For RDS."
  vpc_id      = module.vpc.vpc_id

  ingress {
    description     = "Allow MySQL from ECS security group"
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.ecs.id]
  }

  egress {
    description = "Allow all outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.app}-rds"
  }
}

# For Lambda
resource "aws_security_group" "lambda_sg" {
  name        = "${local.app}-lambda"
  description = "For ${local.app} Lambda"
  vpc_id      = module.vpc.vpc_id

  egress {
    description = "Allow all to outbound."
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.app}-lambda"
  }
}

今回設定するセキュリティーグループは大まかに4つです

  1. ALB用
  2. ECS用
  3. RDS用
  4. Lambda用
aws_security_group "alb"

目的: ALB用のセキュリティグループを定義
ingress: 全てのIPアドレス (0.0.0.0/0) からのHTTP (ポート80) のトラフィックを許可
egress: すべての外部へのトラフィックを許可

aws_security_group "ecs"

目的: ECS用のセキュリティグループを定義
egress: すべての外部へのトラフィックを許可

aws_security_group_rule "ecs_from_alb"

目的: ALBからECSへのトラフィックを許可するためのセキュリティグループルールを定義
type: トラフィックの方向を示す。この場合は"ingress"なので、受信トラフィックを意味する
from_port & to_port: ポート8080のトラフィックを許可
protocol: TCPプロトコルのトラフィックを許可
source_security_group_id: このルールのソースとしてALBのセキュリティグループを指定
security_group_id: このルールが適用されるECSのセキュリティグループを指定

aws_security_group "rds"

目的: RDS用のセキュリティグループを定義
ingress: ECSのセキュリティグループからのMySQL (ポート3306) のトラフィックを許可
egress: すべての外部へのトラフィックを許可

aws_security_group "lambda_sg"

目的: AWS Lambda 用のセキュリティグループを定義
egress: すべての外部へのトラフィックを許可

ロードバランサー

ロードバランサーは、入力トラフィックを複数のターゲット(EC2サーバやコンテナ)に分散するネットワークデバイスであり、システムの可用性と冗長性が向上させる。

alb.tf
resource "aws_lb" "alb" {
  name               = "${local.app}-alb"
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = module.vpc.public_subnets
}

resource "aws_lb_listener" "alb_listener" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "Fixed response content"
      status_code  = "200"
    }
  }
}

resource "aws_lb_listener_rule" "alb_listener_rule" {
  listener_arn = aws_lb_listener.alb_listener.arn

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.target_group.arn
  }

  condition {
    path_pattern {
      values = ["*"]
    }
  }
}

resource "aws_lb_target_group" "target_group" {
  name        = "${local.app}-tg"
  port        = 8080
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = module.vpc.vpc_id

  health_check {
    healthy_threshold = 3
    interval          = 30
    path              = "/health_checks"
    protocol          = "HTTP"
    timeout           = 5
  }
}

これより、上記設定の詳細について説明していく

resource "aws_lb" "alb"
  • ここではALBのリソースを作成する
  • load_balancer_typeはロードバランサーのタイプを指定し、この場合は "application"とする
  • security_groupsはALBに関連付けるセキュリティグループを指定する
  • subnetsはALBを配置するパブリックサブネットを指定する
aws_lb_listener "alb_listener"

ALB (Application Load Balancer) のリスナーとは、特定のIPアドレスとポートでクライアントからの接続を待ち受ける設定を持つコンポーネントであり、リスナーはクライアントからの接続要求が受信されたときの動作を定義する

aws_lb_listener_rule "alb_listener_rule"

ここでは、albのリスナールールを定義します。
リスナールールとは、受信トラフィックのルーティング方法を定義するための条件とアクションのセットであり、リスナールールを使用すると、特定の条件に基づいてトラフィックを異なるターゲットグループにルーティングすることができる

aws_lb_target_group "target_group"

ここでは、albに紐付けるターゲットグループを定義している
ターゲットグループとは、ロードバランサーがトラフィックをルーティングする対象の一群のリソースであり、特定のポートとプロトコルでリッスンし、健康状態のチェックを行う設定を担う

ターゲットグループの設定では、health_checkの記述も含まれる

health_check {
  healthy_threshold = 3
  interval          = 30
  path              = "/health_checks"
  protocol          = "HTTP"
  timeout           = 5
}

登録されたターゲットの健康状態を定期的にチェックする
パスに30秒に1回リクエストを送信して、3回連続で5秒以内にレスポンスが返って来ればコンテナは問題なく起動したと判断される。ヘルスチェックに失敗した場合、ALBはターゲットグループへのトラフィックの送信を停止する。

ECR

今回、API側のGoとフロント側のNext.jsに関してDockerを使ってコンテナ化してデプロイします。
デプロイを行うためには、ローカルで作成したコンテナイメージを格納、管理するためのレジストリにプッシュする必要があります。

今回はコンテナレジストリとしてAmazon ECRを使用を使用しています
ECRの特徴を以下で説明しておきます。

  1. セキュリティ: プライベートなコンテナイメージを保存するためのセキュアな場所を提供し、IAMを使用して、リソースへのアクセスを制御することができる
  2. スケーラビリティ: 大量のコンテナイメージを保存するための高いスケーラビリティを持っており、ユーザーは数から数百万のイメージを簡単に保存・取得できる。
  3. 統合: Amazon ECSやAWS FargateなどのAWSのコンテナ管理サービスとシームレスに統合されており、これによりイメージのデプロイが容易になる。
  4. イメージのスキャン: 公開されている脆弱性に関する情報を基にDockerイメージの脆弱性を自動的にスキャンする機能を有する。
  5. フサイクルポリシー: 古いイメージや不要になったイメージを自動的に削除するためのライフサイクルポリシーを設定することがき、不要なストレージコストを削減することができる。

terraformでECRを作成すると以下の様になる

ecr.tf
# GoのECRリポジトリ
resource "aws_ecr_repository" "go_ecr_repository" {
  name                 = "${local.app}-go"
  image_tag_mutability = "IMMUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecr_lifecycle_policy" "go_ecr_lifecycle_policy" {
  repository = aws_ecr_repository.go_ecr_repository.name

  policy = <<EOF
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep last 30 images for Go",
            "selection": {
                "tagStatus": "any",
                "countType": "imageCountMoreThan",
                "countNumber": 30
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
EOF
}

# Next.jsのECRリポジトリ
resource "aws_ecr_repository" "nextjs_ecr_repository" {
  name                 = "${local.app}-nextjs"
  image_tag_mutability = "IMMUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecr_lifecycle_policy" "nextjs_ecr_lifecycle_policy" {
  repository = aws_ecr_repository.nextjs_ecr_repository.name

  policy = <<EOF
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep last 30 images for Next.js",
            "selection": {
                "tagStatus": "any",
                "countType": "imageCountMoreThan",
                "countNumber": 30
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
EOF
}

上記ではGo(API側)とNext.jsのDockerイメージを格納するためのレジストリを作成ている

今回のレジストリ作成のポイントとして、以下の2点が挙げる

  1. イメージタグの上書きを禁止
  2. イメージpush時の脆弱性スキャン

1に関して該当箇所は下記です

image_tag_mutability = "IMMUTABLE"

image_tag_mutability の設定はリポジトリ内のDockerイメージタグの変更可能性を制御し、MUTABLEと IMMUTABLEの2つのオプションがあります。

MUTABLE: 同じタグを持つ新しいイメージをリポジトリにプッシュすることができ、同じタグを再利用してイメージを上書きすることができる

IMMUTABLE: この設定を選択すると、一度タグ付けされたイメージは上書きできず、同じタグで新しいイメージをプッシュしようとすると、エラーが発生する

今回はIMMUTABLEを用いており、その利点を簡単に説明します

一貫性: IMMUTABLE設定を使用すると、特定のタグに関連付けられたイメージが常に同じであることが保証され、デプロイメントやロールバックの際の混乱や誤解を防げる

セキュリティ: 意図しないイメージの上書きや、不正なイメージのプッシュを防ぐことができ、リポジトリの内容が予期しない変更から保護される

監査とトレーサビリティ: 各タグが一意で変更できないため、特定のタグを使用してデプロイされたイメージのバージョンや内容を正確に追跡することが容易になる

信頼性: デプロイメントプロセス中に、同じタグが異なるイメージに関連付けられている可能性を排除することで、デプロイメントの信頼性が向上する

下記記事を読みました。

2つ目に関しては以下です

image_scanning_configuration {
  scan_on_push = true
}

これは、ECRにイメージがプッシュされるたびに脆弱性スキャンを自動的にトリガーする設定
これを設定することで、リポジトリに新しいコンテナイメージがプッシュされるたびに、そのイメージは自動的に脆弱性スキャンされ、セキュリティリスクを含む可能性のある新しいイメージがデプロイされるのを防げる。

ECS on Fargate

まず、ECSとはフルマネージドなコンテナオーケストレーションサービスであり、コンテナ化されたアプリケーションを簡単にデプロイ、管理、およびスケーリングすることができます。

二つの起動タイプと特徴

ECSでアプリケーションを運用していくには大きく2つの実行環境があります
以下でその2つの特徴について説明した上で、メリットデメリットや今回の選定について説明していきます

EC2起動タイプ
クラスター内のEC2インスタンス上でコンテナを実行する
インスタンスの選択、スケーリング、パッチ適用などの管理が必要となる

<メリット>
柔軟性: インスタンスタイプの選択や、OSレベルでのカスタマイズが可能
コスト: 予約インスタンスやスポットインスタンスを利用することでコストを最適化できる

<デメリット>
管理の手間: EC2インスタンスのライフサイクルやセキュリティの管理が必要
スケーリング: 手動でのスケーリングや、Auto Scaling の設定が必要

Fargate起動タイプ
サーバーレスなコンテナ実行環境で、インスタンスの管理が不要
タスクやサービスのスペックを指定するだけで、インフラ自体ははAWSが裏側で自動でハンドリングしてくれる

<メリット>
管理コスト: サーバーやクラスターの管理、OSのパッチ適用などが不要。
シンプルなスケーリング: タスク数やサービスのデプロイを指定するだけで自動的にスケーリングしてくれる仕様
セキュリティ: 各タスクが独自の隔離境界を持つため、セキュリティが強化される

<デメリット>
コスト: ユースケースによってはEC2起動タイプよりもコストが高くなる可能性がある
柔軟性の制限: OSレベルでのカスタマイズが制限される

上記の様にまとめることができる
今回のユースケースでは

  1. アプリケーションエンジニア1人での開発
  2. OSレベルの細かい設定をする知識・技術力はない

のように考え、ECS on Fargateでの運用を仮で決めた

ECS構成要素

クラスタ (Cluster)
クラスタはECSリソースの論理的なグループであり、クラスタ内でタスクやサービスを実行することで、リソースの管理や分離を効果的に行うことができる

サービス (Service)
サービスは指定された数のタスクのインスタンスを維持・実行するための管理エンティティ
例えば、ウェブアプリケーションのバックエンドとして常に3つのタスクを実行しておきたい場合、サービスを使用してそれを実現できる
サービスはタスクが失敗した場合や新しいバージョンのデプロイ時にタスクを自動的に置き換えることができる

タスク (Task)
タスクはECSで実行される単位で、一つ以上のDockerコンテナから成り立ち、タスクはタスク定義に基づいて実行される

タスク定義 (Task Definition)
タスク定義はタスクを実行するための「設計図」
どのDockerイメージを使用するか、どれだけのCPUやメモリを割り当てるか、ボリューム、環境変数、ネットワーク設定など、タスクの実行に関する詳細を指定する

TerraformでECSを管理すると以下のようになる

ers.tf
resource "aws_ecs_task_definition" "ecs_task_definition" {
  family                   = local.app
  network_mode             = "awsvpc"
  cpu                      = 256
  memory                   = 512
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.ecs.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
  container_definitions    = <<CONTAINERS
[
  {
    "name": "${local.app}",
    "image": "medpeer/health_check:latest",
    "portMappings": [
      {
        "containerPort": 8080
      }
      
    ],
    "healthCheck": {
      "command": ["CMD-SHELL", "curl -f http://localhost:8080/health_checks || exit 1"],
      "interval": 30,
      "timeout": 5,
      "retries": 3,
      "startPeriod": 10
    },
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${aws_cloudwatch_log_group.cloudwatch_log_group.name}",
        "awslogs-region": "${local.region}",
        "awslogs-stream-prefix": "${local.app}"
      }
    },
    "environment": [
      {
        "name": "NGINX_PORT",
        "value": "8080"
      },
      {
        "name": "HEALTH_CHECK_PATH",
        "value": "/health_checks"
      }
    ]
  }
]
CONTAINERS
}

resource "aws_ecs_service" "ecs_service" {
  name            = local.app
  launch_type     = "FARGATE"
  cluster         = aws_ecs_cluster.ecs_cluster.id
  task_definition = aws_ecs_task_definition.ecs_task_definition.arn
  desired_count   = 2
  network_configuration {
    subnets         = module.vpc.private_subnets
    security_groups = [aws_security_group.ecs.id]
  }
}

resource "aws_ecs_cluster" "ecs_cluster" {
  name = local.app
}

resource "aws_ecs_task_definition" "nextjs_ecs_task_definition" {
  family                   = "${local.app}-nextjs"
  network_mode             = "awsvpc"
  cpu                      = 256
  memory                   = 512
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.ecs.arn
  task_role_arn            = aws_iam_role.ecs_task.arn

  container_definitions = <<CONTAINERS
[
  {
    "name": "${local.app}-nextjs",
    "image": "medpeer/nextjs_app:latest",
    "portMappings": [
      {
        "containerPort": 8080
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${aws_cloudwatch_log_group.cloudwatch_log_group.name}",
        "awslogs-region": "${local.region}",
        "awslogs-stream-prefix": "${local.app}-nextjs"
      }
    },
    "environment": [
      {
        "name": "NGINX_PORT",
        "value": "8080"
      },
      {
        "name": "NEXTJS_ENV_VAR",
        "value": "Your value here"
      }
    ]
  }
]
CONTAINERS
}

resource "aws_ecs_service" "nextjs_ecs_service" {
  name            = "${local.app}-nextjs"
  launch_type     = "FARGATE"
  cluster         = aws_ecs_cluster.nextjs_ecs_cluster.id
  task_definition = aws_ecs_task_definition.nextjs_ecs_task_definition.arn
  desired_count   = 2
  network_configuration {
    subnets         = module.vpc.private_subnets
    security_groups = [aws_security_group.ecs.id]
  }
  load_balancer {
    target_group_arn = aws_lb_target_group.nextjs_target_group.arn
    container_name   = "${local.app}-nextjs"
    container_port   = 8080
  }
  depends_on = [aws_lb_listener_rule.alb_listener_rule]
}

resource "aws_ecs_cluster" "nextjs_ecs_cluster" {
  name = "${local.app}-nextjs"
}

今回の2種類のコンテナの構成について簡潔に説明する

  • ALBに紐付けるコンテナはNext.jsを動かすコンテナのみ
  • コンテナ間でAPIのやり取りをする
  • APIコンテナに関しては/health_checkに対して、ヘルスチェックを行う

以下に2コンテナ間のHTTP通信用のsecurity_groupを示す

security_group.tf
resource "aws_security_group_rule" "ecs_from_nextjs" {
  description              = "Allow 8080 from next.js to API."
  type                     = "ingress"
  from_port                = 8080
  to_port                  = 8080
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.ecs.id
  security_group_id        = aws_security_group.ecs.id
}

上記記述に合わせて、IAMの設定も行います

FargateでECSを実行する場合、以下二つのロールの付与が必要となってきます。
それぞれ簡単に説明していいます

タスク実行ロール
ECSタスクがAWSサービスとのやり取りを行うために使用される
ECRからDockerイメージをプルするための認証や、ログをCloudWatch Logsに送信するための権限などが含まれる

タスクロール
タスク内のアプリケーションがAWSサービスとのやり取りを行うために使用され、タスクが実行中にアプリケーションによって使われます

今回、タスクロールには付与する権限自体はない
それに対してタスク実行ロールには以下のような権限を与える必要がある

  1. ECRレポジトリにログインし、イメージをpullしてくる権限
  2. CloudWatchのロググループにログを出力する権限

異常を踏まえ、iamの設定をTerraformで書くと以下のようになる

iam.tf
data "aws_iam_policy_document" "ecs_task_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_task" {
  name               = "${local.app}-ecs-task"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}

data "aws_iam_policy_document" "ecs_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs" {
  name               = "${local.app}-ecs"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

resource "aws_iam_role_policy_attachment" "ecs_basic" {
  role       = aws_iam_role.ecs.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

続いて、コンテナの実行ログを出力していくcloudwatchのロググループをTerraformで作成していく

cloudwatch.tf
resource "aws_cloudwatch_log_group" "cloudwatch_log_group" {
  name              = "/aws/ecs/${local.app}"
  retention_in_days = 3
}

とする

retention_in_days = 3

こちらでログの保存期間を指定している(指定可能な日数は公式で決まっている)

このロググループをecs.tfの方でも指定している

RDS(MySQL)

RDSはクラウド上でリレーショナルデータベースを簡単に、効率的に、そしてスケーラブルに運用するためのサービスです

今回の流れをおさらいすると、「Fargate上のECSで運用されているGoアプリケーションが発行するSQLクエリが、ECSタスクからRDSに送信され、RDSがそれを処理して結果を返す」と言うものです。
RDSとECSの間の通信は、高速でセキュアなVPC内のネットワークを通じて行われます。

RDSインスタンスを立ち上げる際、Terraformでは以下のように記述します

rds.tf
data "aws_ssm_parameter" "rds_password" {
  name = "/${local.app}/rds/password"
}

resource "aws_db_instance" "my_db_instance" {
  db_subnet_group_name = aws_db_subnet_group.my_db_subnet_group.name
  allocated_storage    = 20
  storage_type         = "gp2"
  engine               = "mysql"
  engine_version       = "5.7"
  instance_class       = "db.t2.micro"
  db_name              = "mydb"
  username             = "admin"
  password             = data.aws_ssm_parameter.rds_password.value
  parameter_group_name = "default.mysql5.7"
  skip_final_snapshot  = true

  vpc_security_group_ids = [aws_security_group.rds.id]

  tags = {
    Name = "${local.app} RDS Instance"
  }
}

これより、より詳しく説明していきます

allocated_storage    = 20
storage_type         = "gp2"
engine               = "mysql"
engine_version       = "5.7"
instance_class       = "db.t2.micro"

この辺はRDSインスタンスの容量等を指定しています

data "aws_ssm_parameter" "rds_password" {
  name = "/${local.app}/rds/password"
}

こちらは、SSM Parameter Storeに格納しておいたMySQLのPassword情報を取得しています

SSM Parameter storeとは、はセンシティブな情報を安全に保存するためのセキュアなストレージサービスで、データは暗号化されて保存され、必要に応じてKMSキーを使用して暗号化・復号化することができるサービスです

こちらを扱うことで、秘匿情報であるMySQLの接続情報をセキュアに扱うことができます

また、TerraformがSSMにアクセスするにはIAM権限が必要になります
以下に今回設定したiamを示します

iam.tf
data "aws_iam_policy_document" "ssm_get_parameter" {
  statement {
    actions   = ["ssm:GetParameter"]
    resources = ["arn:aws:ssm:ap-northeast-1:113713103169:parameter/recruit-service-board/rds/*"]
  }
}

resource "aws_iam_policy" "ssm_get_parameter" {
  name        = "${local.app}-ssm-get-parameter"
  description = "Allows access to SSM GetParameter"
  policy      = data.aws_iam_policy_document.ssm_get_parameter.json
}

最後に、下記について説明します

skip_final_snapshot  = true

こちらの設定は、RDSインスタンスを削除する際に、最終的なDBスナップショットを取得するかどうかを制御する設定項目です。
trueにすると、インスタンスを削除した際にも最終的なDBスナップショットを取得しません。
今回は別途で料金がかかってしまうので、明示的にtrueと記述しました。

Github Actionsを用いたCI/CD構築

今回はアプリ側とインフラ側の2か所でパイプラインを構築した

インフラ側

インフラ側ではTerraformで書いたインフラリソースをAWSへ反映させるまでを行います
GitHub Actionsを用いて以下の時に発火するイベントによって起動するワークフローを作成していきます。

  • mainブランチに向けてPRが作成された時
  • mainブランチにmerge or pushされた時
terraform.yml
name: "Terraform"

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

env:
  OIDC_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/recruit-info-service-github-actions

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Assume Role
        uses: aws-actions/configure-aws-credentials@v1-node16
        with:
          role-to-assume: ${{ env.OIDC_ARN }}
          aws-region: ap-northeast-1

      - name: Terraform Format
        id: fmt
        run: terraform fmt -check

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        if: github.event_name == 'pull_request'
        run: terraform plan -no-color -input=false

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

今回のワークフローの肝となるところについて、詳細説明していきます

- name: Terraform Format
    id: fmt
    run: terraform fmt -check

hclファイル内のコードがフォーマットされているかをチェックする
正しくフォーマットされていない場合、ワークフローが失敗します

- name: Terraform Init
    id: init
    run: terraform init

ここでは、Terraformの初期化を行っている
後のterraformコマンドが適切に実行されるための準備でもある

- name: Terraform Validate
  id: validate
  run: terraform validate -no-color

HCLの記述が正しいかどうか構文チェックを行いまディレクトリ内のすべての HCL ファイルに対して構文チェックを実行し、構文エラーがある場合はワークフローが失敗する

- name: Terraform Plan
    id: plan
    if: github.event_name == 'pull_request'
    run: terraform plan -no-color -input=false

ここでは、pull requestが作成・更新されたタイミングでterraformの設定に基づいたリソース変更のシュミレーションを行い、その結果を表示する

- name: Terraform Apply
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    run: terraform apply -auto-approve -input=false

ここでは、mainブランチに対してpushされた時、terraformで書かれたインフラリソースをAWS上に反映させます。

アプリケーション側

アプリケーション側のCI/CDに関しては大きく分けて以下2種類があります
1. mainブランチにmergeされたときにAWSにアクセスし、各リソースを追加・更新するワークフロー

build-deploy.yml
name: "APP Build and Deploy"

on:
  push:
    branches:
      - main

env:
  OIDC_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/recruit-info-service-github-actions
  ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com
  ECR_REPOSITORY: recruit-service-board
  APP: recruit-service-board
  NEXT_ECR_REPOSITORY: next-service-board
  NEXT_APP: next-service-board
  NEXT_DOCKERFILE: ./front/front.dockerfile

permissions:
  id-token: write
  contents: read

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - uses: actions/cache@v2
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - name: Assume Role
        uses: aws-actions/configure-aws-credentials@v1-node16
        with:
          role-to-assume: ${{ env.OIDC_ARN }}
          aws-region: ap-northeast-1

      - name: Login to ECR
        uses: docker/login-action@v1
        with:
          registry: ${{ env.ECR_REGISTRY }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./app.dockerfile
          push: true
          tags: |
            ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

        - name: Build and push Next.js
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ${{ env.NEXT_DOCKERFILE }}
          push: true
          tags: |
            ${{ env.ECR_REGISTRY }}/${{ env.NEXT_ECR_REPOSITORY }}:${{ github.sha }}
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-next,mode=max

      - name: Move Next.js cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-next /tmp/.buildx-cache


      - name: Fill in the new image ID in the Amazon ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ./aws/task-definition.json
          container-name: ${{ env.APP }}
          image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
      
      - name: Fill in the new image ID in the Next.js Amazon ECS task definition
        id: task-def-next
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ./aws/task-definition-next.json
          container-name: ${{ env.NEXT_APP }}
          image: ${{ env.ECR_REGISTRY }}/${{ env.NEXT_ECR_REPOSITORY }}:${{ github.sha }}


      - name: Trigger Lambda through API Gateway
        run: |
          response_body=$(curl -s ${{ secrets.API_GATEWAY_ENDPOINT }})
          response_code=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.API_GATEWAY_ENDPOINT }})
          echo "Response Body: $response_body"
          echo "Response Code: $response_code"
          if [ "$response_code" -ne 200 ]; then
            echo "Failed to trigger Lambda through API Gateway. HTTP Response code: $response_code"
            exit 1
          fi

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.APP }}
          cluster: ${{ env.APP }}
          wait-for-service-stability: true
        timeout-minutes: 5

      - name: Deploy Next.js Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def-next.outputs.task-definition }}
          service: ${{ env.NEXT_APP }}
          cluster: ${{ env.NEXT_APP }}
          wait-for-service-stability: true
        timeout-minutes: 5

上記ワークフローがの内容に関して、軽く触れる

  • 今回のワークフローが発火する条件として、直接mainにコードがpushされた時とbranchがmainmergeされた時となっている
on:
  push:
    branches:
      - main
  • ここでは各種権限を設定している
permissions:
  id-token: write => (OIDC認証用)
  contents: read => (イメージビルドの際、リポジトリコンテンツ参照用)
  • dockerコマンドの機能を拡張することができるBuildxプラグインを導入
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v2
  • ここでOIDC認証により、Github ActionsがAWSへアクセスできるようになる
- name: Assume Role
  uses: aws-actions/configure-aws-credentials@v1-node16
  with:
    role-to-assume: ${{ env.OIDC_ARN }}
    aws-region: ap-northeast-1
  • ECRへのログインを行う
- name: Login to ECR
  uses: docker/login-action@v1
  with:
    registry: ${{ env.ECR_REGISTRY }}
  • Docker イメージのビルド・プッシュ・キャッシュの生成を行う
- name: Build and push
    uses: docker/build-push-action@v5
    with:
        context: .
        file: ./app.dockerfile
        push: true
        tags: |
        ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
        cache-from: type=local,src=/tmp/.buildx-cache
        cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
  • 各デプロイでimageタグに置き換え、最新に保つ
- name: Fill in the new image ID in the Amazon ECS task definition
    id: task-def
    uses: aws-actions/amazon-ecs-render-task-definition@v1
    with:
        task-definition: ./aws/task-definition.json
        container-name: ${{ env.APP }}
        image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
  • deployのタイミングでRDSに対してmigrationを回す
- name: Trigger Lambda through API Gateway
        run: |
          response_body=$(curl -s ${{ secrets.API_GATEWAY_ENDPOINT }})
          response_code=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.API_GATEWAY_ENDPOINT }})
          echo "Response Body: $response_body"
          echo "Response Code: $response_code"
          if [ "$response_code" -ne 200 ]; then
            echo "Failed to trigger Lambda through API Gateway. HTTP Response code: $response_code"
            exit 1
          fi

こちらに関して、説明していきます。
このワークフローの役割としては、「デプロイしたらRDSに対してmigratoinをする」ことです
そのために今回の実装では以下のようなアーキテクチャを(泣く泣く)採用しています

image.png

  • CIでLambdaを起動するTriggerとなるAPI Gatewayにアクセスする
response_body=$(curl -s ${{ secrets.API_GATEWAY_ENDPOINT }})
response_code=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.API_GATEWAY_ENDPOINT }})

(API GatewayとLambda関数の間の通信は、VPCの境界をまたぐ形で行われるが、実際のトラフィックはVPCの外部ネットワークを経由せず、AWSの内部ネットワークを経由して行われているため、API Gatewayはパブリックに公開されている場合でも、VPC内のLambda関数を直接トリガーすることができる)

  • Lambdaには以下のようなGoのスクリプトのbinaryファイルとして実行しています
lambda_migrate_handler.go
package main

import (
	"context"
	"log"
	"recruit-info-service/db"
	"recruit-info-service/model"

	"github.com/aws/aws-lambda-go/lambda"
)

func HandleRequest(ctx context.Context) (string, error) {
	log.Println("Starting the migration for the recruit info service DB...")
	dbConn := db.NewDB()
	defer log.Println("Successfully Migrated")
	defer db.CloseDB(dbConn)
	dbConn.AutoMigrate(
		&model.User{}, 
		&model.Company{}, 
		&model.Technology{}, 
		&model.CompanyTechnology{},
		&model.TechnologyTag{},
		&model.TechnologyTechnologyTag{},
		&model.Like{},
		&model.Comment{},
	)
	return "Migration completed successfully!", nil
}

func main() {
	lambda.Start(HandleRequest)
}
  • dbConn := db.NewDB()の段階で必要なDB情報をSSM parameter Storeに取りにいく

  • Lambda関数がRDSに対してmigrate処理をする

その際、以下2つのセキュリティーグループを追加で定義します

  • Lambdaのセキュリティグループの設定
    • RDSへの接続を許可するためのingressルール(RDSが使用しているポートへの接続を許可するルール)を追加する必要がある
ingress {
    description     = "Allow MySQL connection to RDS"
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.rds.id]
  }
  • RDSのセキュリティグループの設定
    • Lambdaからの接続を許可するためのingressルールを追加する必要がある
ingress {
    description     = "Allow MySQL from Lambda security group"
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.lambda_sg.id]
  }

以上で「Github ActionsがAPI Gatewayエンドポイントを叩き、VPC内のLambdaを起動し、RDSに対してmigrate処理を実行する」ことができる

  • 最新のイメージが指定されたタスク定義をデプロイ
- name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.APP }}
          cluster: ${{ env.APP }}
          wait-for-service-stability: true

wait-for-service-stability: trueとすることで、サービスの安定稼働まで実行完了をwaitしてくれる

2. Goのアプリケーションコードに対して自動テスト、 Linterの実行

go-test.yml
name: Go Lint and Test

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  lint-and-test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v2
      with:
        go-version: 1.x

    - name: Install dependencies
      run: go mod download

    - name: Run golangci-lint
      uses: golangci/golangci-lint-action@v3
      with:
        version: latest

    - name: Run Go Test
      run: go test ./tests/...
  • golangcli-lintを使用してGoのコードにLinterを通す
- name: Run golangci-lint
      uses: golangci/golangci-lint-action@v3
      with:
        version: latest

  • テストコードの自動実行
- name: Run Go Test
      run: go test ./tests/...

最後に

今回、初めて個人で「AWS ✖︎ Terraform ✖︎ Github Actions」というstackでインフラ構築をしてみた
まずは以下のような反省点を挙げたい

  • コストのことを全く考えていなかった
    3週間リソース立ち上げっぱなしにしただけで144ドルの請求が来た時にはもう泣いた
    次はもっとリソース毎のコストを意識したい
    image.png

  • Terraformが全くDRYじゃない
    security_group.tfを作成しているのに各リソースファイルでセキュリティグループの定義をしていたり、moduleをうまく使えていないところが多々ある

  • デプロイ時の自動DB MIgrateの方法が全然いいアーキテクチャじゃない

  • 力尽きてALBのDNSに名前解決を施していない

以上のように、課題が非常に多く残る個人開発になってしまったが、初めて触る技術が多くすごくワクワクしながら取り組めたので、いい経験になったと思う

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?