はじめに
今回、以下の様な技術stackで個人開発を行ったので、備忘録として残そうと思います
- Go(API)
- Next.js・TypeScript(フロント)
- AWS・Terraform(インフラ)
- github actions(CI/CD)
本記事では、AWSを用いたインフラ構築での取り組みについて簡単に触れたいと思います
バックエンド側、フロント側の記事については以下に置いておきます。
バックエンド側
フロント側
github repository
アプリケーション側
インフラ側
システム概要図
- 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認証を付与しようとすると、以下の方法が考えられます。
- IAM ユーザー作成
- 認証情報(Access Key ID、Secret Access Key)を生成
- GitHub ActionsのSecretsに認証情報を設定
上記のようにすれば、簡単に2サービス間で連携が取れます。
しかしこの方法はセキュリティリスクや管理コスト増加の観点からあまり推奨された方法ではありません。
一方、OIDCを使えば永続的な認証情報を預けることなく、OIDCの仕様に則った一時的なトークン発行のみで、AWSリソースの操作ができるようになる。ゆえに、AWS認証情報をgithubで直接管理運用する必要がなくなる。
このような理由から、OIDC認証を採用することとした。
今回用いるOIDC認証の手順は以下の通りです。
- AWSの管理画面でIDプロバイダを作成
- Github Actionsで使用するIAMロールを作成
- ポリシーを編集して、特定リポジトリの特定ブランチのみ認証を行うようにする
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ファイルの一貫性を保つことができる機能を提供しています。
上記手法は公式でも推奨されています。
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で作成したリソースについて説明していきます
ネットワーク構築
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) 内のリソースへのネットワークトラフィックを制御する仮想ファイアウォールのことである
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つです
- ALB用
- ECS用
- RDS用
- 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サーバやコンテナ)に分散するネットワークデバイスであり、システムの可用性と冗長性が向上させる。
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の特徴を以下で説明しておきます。
- セキュリティ: プライベートなコンテナイメージを保存するためのセキュアな場所を提供し、IAMを使用して、リソースへのアクセスを制御することができる
- スケーラビリティ: 大量のコンテナイメージを保存するための高いスケーラビリティを持っており、ユーザーは数から数百万のイメージを簡単に保存・取得できる。
- 統合: Amazon ECSやAWS FargateなどのAWSのコンテナ管理サービスとシームレスに統合されており、これによりイメージのデプロイが容易になる。
- イメージのスキャン: 公開されている脆弱性に関する情報を基にDockerイメージの脆弱性を自動的にスキャンする機能を有する。
- フサイクルポリシー: 古いイメージや不要になったイメージを自動的に削除するためのライフサイクルポリシーを設定することがき、不要なストレージコストを削減することができる。
terraformでECRを作成すると以下の様になる
# 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点が挙げる
- イメージタグの上書きを禁止
- イメージ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人での開発
- OSレベルの細かい設定をする知識・技術力はない
のように考え、ECS on Fargateでの運用を仮で決めた
ECS構成要素
クラスタ (Cluster)
クラスタはECSリソースの論理的なグループであり、クラスタ内でタスクやサービスを実行することで、リソースの管理や分離を効果的に行うことができる
サービス (Service)
サービスは指定された数のタスクのインスタンスを維持・実行するための管理エンティティ
例えば、ウェブアプリケーションのバックエンドとして常に3つのタスクを実行しておきたい場合、サービスを使用してそれを実現できる
サービスはタスクが失敗した場合や新しいバージョンのデプロイ時にタスクを自動的に置き換えることができる
タスク (Task)
タスクはECSで実行される単位で、一つ以上のDockerコンテナから成り立ち、タスクはタスク定義に基づいて実行される
タスク定義 (Task Definition)
タスク定義はタスクを実行するための「設計図」
どのDockerイメージを使用するか、どれだけのCPUやメモリを割り当てるか、ボリューム、環境変数、ネットワーク設定など、タスクの実行に関する詳細を指定する
TerraformでECSを管理すると以下のようになる
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を示す
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サービスとのやり取りを行うために使用され、タスクが実行中にアプリケーションによって使われます
今回、タスクロールには付与する権限自体はない
それに対してタスク実行ロールには以下のような権限を与える必要がある
- ECRレポジトリにログインし、イメージをpullしてくる権限
- CloudWatchのロググループにログを出力する権限
異常を踏まえ、iamの設定をTerraformで書くと以下のようになる
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で作成していく
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では以下のように記述します
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を示します
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された時
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にアクセスし、各リソースを追加・更新するワークフロー
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をする」ことです
そのために今回の実装では以下のようなアーキテクチャを(泣く泣く)採用しています
- 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ファイルとして実行しています
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の実行
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ドルの請求が来た時にはもう泣いた
次はもっとリソース毎のコストを意識したい
-
Terraformが全くDRYじゃない
security_group.tfを作成しているのに各リソースファイルでセキュリティグループの定義をしていたり、moduleをうまく使えていないところが多々ある -
デプロイ時の自動DB MIgrateの方法が全然いいアーキテクチャじゃない
-
力尽きてALBのDNSに名前解決を施していない
以上のように、課題が非常に多く残る個人開発になってしまったが、初めて触る技術が多くすごくワクワクしながら取り組めたので、いい経験になったと思う