Go x Next.js(SPA) な環境をTerraformでAWSに構築してみます!
バックエンドのGoには、ECS Fargateで。
フロントエンドのSPAには、CloudFront・S3を使用して静的ホスティングをしてます。
また、System エージェントをインストールすることでFargateコンテナ内に接続できるようにしています。
はじめに
連載記事でこの環境を構築していきます。
本記事では、ECSクラスター・SSMアクティベーション・ECSサービスの作成を行います!
フォルダ構成とか書き方、モージュル化などは、【ネットワーク環境構築】terraform AWS環境構築 第1回この記事とほぼ同じなので気になる方は御覧ください!
全体のソースコード:github
環境は以下です。
OS | Cataline 10.15.6 |
Terraform | 0.14.4 |
Go | 1.16.3 |
React | 17.0.2 |
基本構文などこちらにまとめてますので、よかったらみてください!
AWS Terraform 基本コード まとめ
連載一覧
- Go x Next.js(SPA) をTerraformでさっさと構築 1/3
- Go x Next.js(SPA) をTerraformでさっさと構築 2/3
- Go x Next.js(SPA) をTerraformでさっさと構築 3/3 ←ここ
やること
以下の定義と作成をします。
ECSクラスター
【ECS Fargate(nginx)実行】terraform AWS環境構築 第4回
ここのECSクラスターの作成とほぼ同じです。
./main.tf
module "network" { source = "./network" app_name = var.app_name } module "acm" { source = "./acm" domain = var.domain } module "spa" { source = "./spa" app_name = var.app_name domain = var.domain acm_id = module.acm.acm_id } module "subdomain_acm" { source = "./subdomain_acm" domain = var.domain } module "elb" { source = "./elb" app_name = var.app_name vpc_id = module.network.vpc_id public_subnet_ids = module.network.public_subnet_ids sub_acm_id = module.subdomain_acm.sub_acm_id domain = var.domain } module "rds" { source = "./rds" app_name = var.app_name db_name = var.db_name db_user = var.db_user db_password = var.db_password vpc_id = module.network.vpc_id alb_security_group_id = module.elb.alb_security_group_id private_subnet_ids = module.network.private_subnet_ids } # 追記 module "ecs_cluster" { source = "./ecs_cluster" app_name = var.app_name }
./ecs_cluster/main.tf
resource "aws_ecs_cluster" "this" { name = "${var.app_name}-cluster" }
./ecs_cluster/variables.tf
variable "app_name" {}
./ecs_cluster/outputs.tf
output "cluster_name" { value = aws_ecs_cluster.this.name }
SSMアクティベーション
Fargateコンテナ内にSession Managerでログインできるようにアクティベーションを作成します。※Fargateは外部サーバーとしてみなすので、アクティベーションを作成する必要があります。
簡単に説明すると、以前インスタンスやコンテナにセキュアに接続するにはSSHポートなど開けて対応するのですがSession Managerを使用すればポート開けずに接続できるため、SSH接続するよりセキュアにできる。というものです。
以下のサイトが参考になりました。
https://tech.smartcamp.co.jp/entry/fargate-with-session-manager
https://qiita.com/pocari/items/3f3d77c80893f9f1e132
https://enokawa.hatenablog.jp/entry/2019/09/05/104545
./main.tf
module "network" { source = "./network" app_name = var.app_name } module "acm" { source = "./acm" domain = var.domain } module "spa" { source = "./spa" app_name = var.app_name domain = var.domain acm_id = module.acm.acm_id } module "subdomain_acm" { source = "./subdomain_acm" domain = var.domain } module "elb" { source = "./elb" app_name = var.app_name vpc_id = module.network.vpc_id public_subnet_ids = module.network.public_subnet_ids sub_acm_id = module.subdomain_acm.sub_acm_id domain = var.domain } module "rds" { source = "./rds" app_name = var.app_name db_name = var.db_name db_user = var.db_user db_password = var.db_password vpc_id = module.network.vpc_id alb_security_group_id = module.elb.alb_security_group_id private_subnet_ids = module.network.private_subnet_ids } module "ecs_cluster" { source = "./ecs_cluster" app_name = var.app_name } # 追記 module "ssm_activation" { source = "./ssm_activation" app_name = var.app_name }
IAMロールの作成
SSM アクティベーションの作成前に、IAMロールの作成をしておきます。
./ssm_activation/main.tf
module "ssm_ec2_run_role" { source = "../iam_role" name = "ssm-ec2-run-role" identifier = "ssm.amazonaws.com" policy_arn = [ data.aws_iam_policy.ssm_managed_instance_core.arn, data.aws_iam_policy.ssm_directory_service_access.arn, data.aws_iam_policy.cloud_watch_agent_server_policy.arn, aws_iam_policy.update_ssm_service_policy.arn, ] } locals { account_id = data.aws_caller_identity.user.account_id } resource "aws_iam_policy" "update_ssm_service_policy" { name = "update-ssm-service" policy = data.template_file.update_ssm_service_policy.rendered }
./ssm_activation/variables.tf
variable "app_name" {}
./ssm_activation/data.tf
data "aws_iam_policy" "ssm_managed_instance_core" { arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } data "aws_iam_policy" "ssm_directory_service_access" { arn = "arn:aws:iam::aws:policy/AmazonSSMDirectoryServiceAccess" } data "aws_iam_policy" "cloud_watch_agent_server_policy" { arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" } data "aws_caller_identity" "user" {} data "template_file" "update_ssm_service_policy" { template = file("ssm_activation/update_ssm_service_policy.json") vars = { account_id = local.account_id } }
./ssm_activation/update_ssm_service_policy.json
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ssm:GetServiceSetting" ], "Resource": "*" }, { "Effect": "Allow", "Action": [ "ssm:ResetServiceSetting", "ssm:UpdateServiceSetting" ], "Resource": "arn:aws:ssm:ap-northeast-1:${account_id}:servicesetting/ssm/managed-instance/activation-tier" } ] }
- AmazonSSMManagedInstanceCore:マネージドインスタンスで AWS Systems Manager サービスの主要機能を使用できるようにします。
- AmazonSSMDirectoryServiceAccess:マネージドインスタンスによるドメインへの結合リクエストに対して、代わりに SSM エージェント が AWS Directory Service にアクセスできるようにします。
- CloudWatchAgentServerPolicy:CloudWatch エージェント がマネージドインスタンスで実行できるようにします。このコマンドでは、インスタンスの情報を読み込み、CloudWatchに書き込めるようにします。
- update_ssm_service_policy:アドバンストインスタンス層の有効化をできるようします。
SSM アクティベーションの作成
コンテナログインのためのアクティベーションを作成しましょう。
./ssm_activation/main.tf
module "ssm_ec2_run_role" { source = "../iam_role" name = "ssm-ec2-run-role" identifier = "ssm.amazonaws.com" policy_arn = [ data.aws_iam_policy.ssm_managed_instance_core.arn, data.aws_iam_policy.ssm_directory_service_access.arn, data.aws_iam_policy.cloud_watch_agent_server_policy.arn, aws_iam_policy.update_ssm_service_policy.arn, ] } locals { account_id = data.aws_caller_identity.user.account_id } resource "aws_iam_policy" "update_ssm_service_policy" { name = "update-ssm-service" policy = data.template_file.update_ssm_service_policy.rendered } # 追記 resource "aws_ssm_activation" "this" { name = "${var.app_name}-ssm" iam_role = module.ssm_ec2_run_role.iam_role_name registration_limit = "5" }
- aws_ssm_activation ;SSM アクティベーション
- name :Name
- iam_role:IAMロール
- registration_limit:コンテナ・インスタンス登録制限 (max: 1000)
./ssm_activation/outputs.tf
output "ssm_agent_code" { value = aws_ssm_activation.this.activation_code } output "ssm_agent_id" { value = aws_ssm_activation.this.id }
SSMにコンテナを登録するために、Agent CodeとAgent IDをアウトプット変数として定義しておきます。
[terraform] $ terraform plan
作成されるリソースの確認。
[terraform] $ terraform apply
リソースの作成。
「ハイブリッドアクティベーション」を選択すると、アクティベーションが作成されていると思います。
ECSサービス
amazon-ssm-agentをインストールしたGoコンテナを作成し、ECS Fargate デプロイします。
【ECS Fargate(rails + nginx)実行】terraform AWS環境構築 第7回
こことほぼ同じです。
./main.tf
module "network" { source = "./network" app_name = var.app_name } module "acm" { source = "./acm" domain = var.domain } module "spa" { source = "./spa" app_name = var.app_name domain = var.domain acm_id = module.acm.acm_id } module "subdomain_acm" { source = "./subdomain_acm" domain = var.domain } module "elb" { source = "./elb" app_name = var.app_name vpc_id = module.network.vpc_id public_subnet_ids = module.network.public_subnet_ids sub_acm_id = module.subdomain_acm.sub_acm_id domain = var.domain } module "rds" { source = "./rds" app_name = var.app_name db_name = var.db_name db_user = var.db_user db_password = var.db_password vpc_id = module.network.vpc_id alb_security_group_id = module.elb.alb_security_group_id private_subnet_ids = module.network.private_subnet_ids } module "ecs_cluster" { source = "./ecs_cluster" app_name = var.app_name } module "ssm_activation" { source = "./ssm_activation" app_name = var.app_name } # 追記 module "ecs_go" { source = "./ecs_go" app_name = var.app_name db_name = var.db_name db_user = var.db_user db_password = var.db_password db_host = module.rds.db_address ssm_agent_code = module.ssm_activation.ssm_agent_code # SSM Agent Code ssm_agent_id = module.ssm_activation.ssm_agent_id # SSM Agent ID vpc_id = module.network.vpc_id http_listener_arn = module.elb.http_listener_arn https_listener_arn = module.elb.https_listener_arn alb_security_group_id = module.elb.alb_security_group_id cluster_name = module.ecs_cluster.cluster_name public_subnet_ids = module.network.public_subnet_ids }
./terraform.tfvars
domain = "<your domain>" db_name = "go-next_db" db_user = "root" db_password = "password"
./variables.tf
variable "aws_region" { type = string default = "ap-northeast-1" } variable "aws_profile" { type = string default = "default" description = "AWS CLI's profile" } variable "app_name" { type = string default = "go-next" } variable "domain" {} variable "db_name" {} variable "db_user" {} variable "db_password" {}
./ecs_go/main.tf
locals { account_id = data.aws_caller_identity.user.account_id } resource "aws_iam_policy" "ecs_task_execution" { name = "ecs-task-execution" policy = data.aws_iam_policy_document.ecs_task_execution.json } module "ecs_task_execution_role" { source = "../iam_role" name = "ecs-task-execution" identifier = "ecs-tasks.amazonaws.com" policy_arn = [aws_iam_policy.ecs_task_execution.arn] } resource "aws_ecs_task_definition" "this" { family = "${var.app_name}-task" cpu = 256 memory = 2048 network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] container_definitions = data.template_file.container_definitions.rendered execution_role_arn = module.ecs_task_execution_role.iam_role_arn } resource "aws_cloudwatch_log_group" "this" { count = length(var.app_names) name = "/ecs/go-next/${var.app_names[count.index]}" } resource "aws_lb_target_group" "this" { name = "${var.app_name}-tg" vpc_id = var.vpc_id port = 80 protocol = "HTTP" target_type = "ip" health_check { port = 80 path = "/health" } } resource "aws_lb_listener_rule" "http_rule" { listener_arn = var.http_listener_arn action { type = "forward" target_group_arn = aws_lb_target_group.this.id } condition { path_pattern { values = ["*"] } } } resource "aws_lb_listener_rule" "https_rule" { listener_arn = var.https_listener_arn action { type = "forward" target_group_arn = aws_lb_target_group.this.id } condition { path_pattern { values = ["*"] } } } resource "aws_ecs_service" "this" { name = "${var.app_name}-service" launch_type = "FARGATE" desired_count = 1 cluster = var.cluster_name task_definition = aws_ecs_task_definition.this.arn network_configuration { security_groups = [var.alb_security_group_id] subnets = var.public_subnet_ids assign_public_ip = true } load_balancer { target_group_arn = aws_lb_target_group.this.arn container_name = "nginx" container_port = 80 } }
./ecs_go/variables.tf
variable "app_name" {} variable "vpc_id" {} variable "db_user" {} variable "db_password" {} variable "db_host" {} variable "db_name" {} variable "app_names" { default = ["nginx", "go-next_api"] } variable "http_listener_arn" {} variable "https_listener_arn" {} variable "alb_security_group_id" {} variable "cluster_name" {} variable "public_subnet_ids" {} variable "ssm_agent_code" {} variable "ssm_agent_id" {}
./ecs_go/data.tf
data "aws_caller_identity" "user" {} data "aws_iam_policy" "ecs_task_execution_role_policy" { arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } #「AmazonECSTaskExecutionRolePolicy」ロールを継承したポリシードキュメントの定義 data "aws_iam_policy_document" "ecs_task_execution" { source_json = data.aws_iam_policy.ecs_task_execution_role_policy.policy statement { effect = "Allow" actions = ["ssm:GetParameters", "kms:Decrypt"] resources = ["*"] } } data "template_file" "container_definitions" { template = file("./ecs_go/container_definitions.json") vars = { account_id = local.account_id db_host = var.db_host db_user = var.db_user db_password = var.db_password db_name = var.db_name ssm_agent_code = var.ssm_agent_code ssm_agent_id = var.ssm_agent_id } }
./ecs_go/container_definitions.json
[ { "name": "nginx", "image": "${account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/nginx:latest", "essential": true, "memory": 128, "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "nginx", "awslogs-group": "/ecs/go-next/nginx" } }, "portMappings": [ { "containerPort": 80, "hostPort": 80 } ] }, { "name": "go-next_api", "image":"${account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/go-next_api:latest", "essential": true, "memory": 1536, "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "go-next_api", "awslogs-group": "/ecs/go-next/go-next_api" } }, "command": ["go","run","main.go"], "environment": [ { "name": "RDS_HOST", "value": "${db_host}" }, { "name": "RDS_USER", "value": "${db_user}" }, { "name": "RDS_PASSWORD", "value": "${db_password}" }, { "name": "RDS_PORT", "value": "3306" }, { "name": "RDS_DB_NAME", "value": "${db_name}" }, { "name": "SSM_AGENT_CODE", "value": "${ssm_agent_code}" }, { "name": "SSM_AGENT_ID", "value": "${ssm_agent_id}" }, { "name": "FRONT_URL", "value": "<your domain>" } ], "portMappings": [ { "containerPort": 8080, "hostPort": 8080 } ] } ]
そして、以下がGoコンテナのDockerfileです。
FROM golang:1.16.3 RUN apt-get update && apt-get install -y sudo # amazon-ssm-agent インストール RUN mkdir /tmp/ssm RUN wget https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/debian_amd64/amazon-ssm-agent.deb -O /tmp/ssm/amazon-ssm-agent.deb \ && sudo dpkg -i /tmp/ssm/amazon-ssm-agent.deb \ && cp /etc/amazon/ssm/seelog.xml.template /etc/amazon/ssm/seelog.xml # 以下任意 WORKDIR /go/src/app RUN go get github.com/rubenv/sql-migrate/... COPY go.mod go.sum ./ RUN go mod download COPY . . COPY docker-entrypoint.sh / ENTRYPOINT [ "/docker-entrypoint.sh" ]
docker-entrypoint.sh
#!/bin/bash set -e # SSMにコンテナを登録 amazon-ssm-agent -register -code "${SSM_AGENT_CODE}" -id "${SSM_AGENT_ID}" -region "ap-northeast-1" # バックグラウンド実行 amazon-ssm-agent & exec "$@"
これで、コンテナが起動する際にSSMに登録されSession Managerでログインできるようになります。
今回はGoのDockerイメージを使用しているためDebianでのインストール方法を参考にしています。他のOSによってインストール方法は変わるので注意してください。
公式はこちら
[terraform] $ terraform plan
作成されるリソースの確認。
[terraform] $ terraform apply
リソースの作成。
これでECSサービス立ち上げてSSMにコンテナが登録されると思いますので見てみましょう。
「フリートマネージャー」にインスタンスが1つ増えていますね。
このインスタンスをクリックして「インスタンスアクション」から「セッションを開始」でコンテナにログインできます。
セッション開始。
ここでコマンド操作が可能になります。
セッション開始からアドバンスインスタンスティアになり従量制で料金が発生しますので注意が必要です。
料金について
正常にGoコンテナが動いているか見てみましょう。
おわり
これで、Go x Next.js(SPA)は完成です!お疲れさまでした。
SSM エージェントを使用することでコンテナの運用をやりやすくできますね!
alpineベースで作成できないのが気になりますが。。仕方がないのですかね??
また、SSM アクティベーションには登録制限がありコンテナが落ちただけではこの制限から数が減ることはありません。そのため、登録制限までコンテナの更新をしてまったらアクティベーションの再作成をする必要ができます。
その1つの対策として、アクティベーションはTerraformで作成せず、docker-entrypoint.shでアクティベーションを作成するコマンドを入力して作成する方法があります。
set -e SSM_ACTIVATION=$(aws ssm create-activation --default-instance-name "go-next-ssm" --iam-role "ssm-ec2-run-role" --registration-limit 1 --region "ap-northeast-1") export SSM_ACTIVATION_CODE=$(echo $SSM_ACTIVATION | jq -r .ActivationCode) export SSM_ACTIVATION_ID=$(echo $SSM_ACTIVATION | jq -r .ActivationId) amazon-ssm-agent -register -code $SSM_ACTIVATION_CODE -id $SSM_ACTIVATION_ID -region ap-northeast-1 exec "$@"
その他には、CloudFormationとかで対策できそうですね。(やり方はまだ調べてない。。)
とりあえず、、最後までご覧いただきありがとうございます!
何か疑問に思うことがあれば、何でもいいのでコメントくれれば精一杯答えさせていただきます。
Twitterとかフォローしてくれると嬉しいです。では、次回お会いしましょう!
コメント