Go x Next.js(SPA) をTerraformでさっさと構築 3/3

AWS

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 基本コード まとめ

連載一覧

やること

以下の定義と作成をします。

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は外部サーバーとしてみなすので、アクティベーションを作成する必要があります。

公式SSMについて

簡単に説明すると、以前インスタンスやコンテナにセキュアに接続するには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とかフォローしてくれると嬉しいです。では、次回お会いしましょう!

コメント

タイトルとURLをコピーしました