注目イベント!
アドベントカレンダー2024開催中!
一年を締めくくる特別なイベント、アドベントカレンダーを今年も開催しています!
初心者からベテランまで楽しめる内容で、毎日新しい技術トピックをお届けします。
詳細はこちらから!
event banner

IaCでSales Support Systemのインフラ構築

| 19 min read
Author: tadashi-nakamura tadashi-nakamuraの画像

はじめに

#

営業支援システム(Sales Support System)で導入した API Gateway+CloudMap+ECS(Fargate)の Terraform での構築手順を紹介します。

背景

#

以下に上げる保守性の問題から AWS のコンテナ環境を EKS から ECS へ移行することにしました。
なお、Fargate については継続使用としています。

  • AWS のサービス毎のコストで EKS が最も多く、ECS に移行することで当時検討していた SaaS の利用料金が捻出できるぐらいの差がある。
  • EKS の基盤の Kubernetes の更新が最長でも 1 年ごとに強制される。
  • EKS 上のミドルウェア(HELM)の更新通知は当然ながら AWS からはされないため、自分たちで確認する必要がある。
  • Kubernetes の更新頻度が高いため、キャッチアップが間に合わない。
    • Kubernetes 自体も含め、α 版や Β 版の API が多く、また、当然の如く非互換の更新もある。
  • EKS 利用でよくあげられる理由である Kubernetes のノウハウが SSS 開発チームにない。
    • そもそも AWS 自体に不慣れなメンバでそちらのキャッチアップで手一杯になっていたというのも。
EKS vs ECS

EKS と ECS の主な要素の比較表です。

要素 ECS EKS
コントロールプレーン ECS AWS マネージド
データプレーン EC2/Fargate EC2/Fargate
他の AWS サービスとの親和性 高い 低い
機能 より少ない より豊富
k8s 用の各種ツール 利用不可 利用可
定義ファイル タスク定義 マニフェスト
料金 無料 0.1USD/hr
リリースサイクル なし 約3か月
サポート期限 なし 約1年
最小実行単位 Task Pod
クラスター内通信 Route53+CloudMap Service
クラスター外通信/インバウンド 別途構築 Ingress
環境変数
Secret
Cron タスク定義の範囲外 マニフェストで定義
定義ファイルの CICD なし GitOps
スケジュールタスク あり なし
クラスター作成速度 約 2 秒 約 5 分から 10 分

CloudMap vs ALB vs NLB

#

AWS API Gateway は別途利用決定していたので、API Gateway の後段の構成について、以下の 3 つの AWS サービスについて調査および実際に実装して比較しました。

  1. CloudMap を利用する方法

CloudMap版のシステム構成図

  1. Application Load Balancer(以下、ALB)を利用する方法

ALB版のシステム構成図

  1. Network Load Balancer(以下、NLB)を利用する方法

NLB版のシステム構成図

以下に実際に比較した内容を示します[1]
◯、△、× で 3 点、2 点、1 点として単純にポイントを算出して最高得点となったものを採用しました。

サービス コスト ノウハウ 機能 サービス間通信 統合 ポイント 結果
CloudMap × 〇 Microservices 向け Mapper 〇 おそらく可能 △ HTTP 12 採用!
ALB × 〇 レイヤー 7。機能が NLB に比べ豊富 △ 不明? △ HTTP 11
NLB × レイヤー 4。高パフォーマンス要求向け △ 不明? 〇 REST/HTTP 11

何はともあれ、以上により、SSS では

  • AWS API Gateway
  • AWS CloudMap
  • AWS ECS on Fargate

という構成が選択されました。

構築の前提事項

#

VPC やサブネット、Cognito の経由で Google の SSO 認証などは既存のインフラとしました。
そのため、ここで紹介する構築手順では以下のことを前提としています。

  • 以下のものが構築・準備されていること
    • VPC
    • プライベートサブネット
    • Cognito
    • Google をフェデレーテッド ID プロバイダとするユーザープール

実際には更に RDS や DynamoDB などミドルウェアや S3 などのストレージなどもそのまま利用しました。

ECS の構築

#

この章は AWS のチュートリアル[2]の内容をベースに Terraform で再現しています。

ECS クラスターの作成

#

まずは、ECS クラスターから作成します。
ECS クラスターの Terraform コードは以下のようになります。
ここで、Fargate 起動タイプ用の ECS クラスターとするため、capacity_providers"FARGATE"を指定しています。
なお、local.となっているのは Terraform のローカル変数で定義されている値です。

main.tf
resource "aws_ecs_cluster" "this" {
  name = local.ecs_cluster_name
}

resource "aws_ecs_cluster_capacity_providers" "this" {
  cluster_name       = aws_ecs_cluster.this.name
  capacity_providers = ["FARGATE"]
}

IAM ロールの作成

#

ECS 上でアプリケーションを実行するため、以下の 2 種類の IAM ロールを作成する必要があります。

  • ECS タスク実行ロール
    • 定義されたタスクを実行する際に必要となる権限のロール
  • ECS タスクロール
    • 定義されたアプリケーションが実行する際に必要となる権限のロール

ECS タスク実行ロール

#

ECS タスク実行ロールの Terraform コードは以下のようになります。
一般的なユースケースに必要な権限は AWS 管理のポリシーAmazonECSTaskExecutionRolePolicyに定義されているので、このポリシーをアタッチしています。
このポリシーでは、ECR からイメージをプルするための権限と CloudWatch へのログ出力のための権限が定義されています。
SSS では更にメトリックスデータの出力のための権限をインラインポリシーで定義しています。

ポリシーの種別

ポリシーは管理ポリシーとインラインポリシーがありますが、AWS は「管理ポリシー」の利用を推奨しています。

管理ポリシーとインラインポリシーのいずれかを選択する

main.tf
resource "aws_iam_role" "ecs_task_exec" {
  name               = local.ecs_task_execution_role_name
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume_role_policy.json
}

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

data "aws_iam_policy_document" "ecs_task_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "cloud_watch_policy" {
  statement {
    actions   = ["cloudwatch:PutMetricData"]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "ecs_task_exec_cloud_watch_policy" {
    name   = "${local.prefix}-cloud-watch-policy"
    role   = aws_iam_role.ecs_task_exec.id
    policy = data.aws_iam_policy_document.cloud_watch_policy.json

}

resource "aws_iam_role_policies_exclusive" "ecs_task_exec" {
  role_name = aws_iam_role.ecs_task_exec.name

  policy_names = [
    aws_iam_role_policy.ecs_task_exec_cloud_watch_policy.name
  ]
}

ECS タスクロール

#

続いて、タスクロールですが、このロールはアプリケーション実行のためのものなので、アプリケーションの内容に応じて定義する必要があります。
DynamoDB などを利用するアプリケーションを実行する場合は、このロールに DyanmoDB へのアクセスのための権限を付与する必要があります。
今回利用するアプリケーションは静的なページを返すだけの単純な Web アプリケーションなので、実行に際して追加するものはありません。
ここではサンプルとしてタスク実行ロールと同じポリシーを付与しています。

ecs_task.tf
resource "aws_iam_role" "mz_dev_app" {
  name               = "${local.app_name}-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume_role_policy.json
}

resource "aws_iam_role_policy" "cloud_watch_log_policy" {
  name   = "${local.app_name}-cloud-watch-log-policy"
  role   = aws_iam_role.mz_dev_app.id
  policy = data.aws_iam_policy_document.cloud_watch_policy.json
}

resource "aws_iam_role_policies_exclusive" "mz_dev_app" {
  role_name = aws_iam_role.mz_dev_app.name
  policy_names = [
    aws_iam_role_policy.cloud_watch_log_policy.name
  ]
}

ECS タスク定義の作成

#

ECS 上で動作するアプリケーションのメインとなる設定が ECS タスク定義になります。
ECS タスク定義ではアプリケーションに対する様々な設定か定義できます[3]

  • 起動タイプ
  • 使用する Docker イメージ
  • メモリと CPU の要件
  • OS
  • Docker ネットワーキングモード
  • ...

起動タイプには"FARGATE"を指定しています。Fargate 起動タイプにすると以下の設定が制限されます。

  • ネットワークモードがawsvpcであること。
  • コンテナ定義(container_definitions)の
    • ポートマッピング(portMappings)のhostPortが空白かcontainerPortと同じであること。
    • ログ構成仕様(logConfiguration)の
      • logDriverが以下のいずれかであること。
        • awslogs
        • splunk
        • awsfirelens
      • awslogs-stream-prefixが必須であること。

他にも制限事項がありますが、基本的には Fargate の動作を制限させないためのもののようです。
ログ構成仕様はawslogsログドライバーを使用して、コンテナログを CloudWatch Logs に送信するように設定しています。
ECS タスク定義で指定されているcpumemoryはタスクに含まれる全コンテナの合計になります。コンテナごとにも定義が可能ですが、それぞれの合計値が ECS タスク定義の設定値を超えないようにする必要があります。

ecs_task.tf
resource "aws_ecs_task_definition" "mz_dev_app" {
  family                = "${local.prefix}-site"

  container_definitions = <<EOF
[
    {
        "name": "${local.app_name}",
        "image": "public.ecr.aws/docker/library/httpd:latest",
        "portMappings": [
            {
                "containerPort": 80,
                "hostPort": 80,
                "protocol": "tcp"
            }
        ],
        "essential": true,
        "entryPoint": [
            "sh",
            "-c"
        ],
        "command": [
            "/bin/sh -c \"echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #333;} </style> </head><body> <div style=color:white;text-align:center> <h1>Amazon ECS Sample App</h1> <h2>Congratulations!</h2> <p>Your application is now running on a container in Amazon ECS.</p> </div></body></html>' >  /usr/local/apache2/htdocs/index.html && httpd-foreground\""
        ],
        "logConfiguration": {
            "logDriver": "awslogs",
            "options": {
                "awslogs-group": "${aws_cloudwatch_log_group.mz_dev_app.name}",
                "awslogs-region": "ap-northeast-1",
                "awslogs-stream-prefix": "${local.app_name}"
            }
        }
    }
]
EOF

  execution_role_arn       = aws_iam_role.ecs_task_exec.arn
  task_role_arn            = aws_iam_role.mz_dev_app.arn

  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.ecs_task.cpu
  memory                   = var.ecs_task.memory
}

以下は ECS タスク定義で定義したアプリケーションに対する CloudWatch のロググループの定義になります。

ecs_task.tf
resource "aws_cloudwatch_log_group" "mz_dev_app" {
  name              = "/aws/ecs/fargate/${local.app_name}"
  retention_in_days = var.log_retention_in_days
}

ECS サービスの作成

#

ECS タスクをスタンドアロンでも起動が可能ですが、通常は ECS サービスから起動されます。なお、ECS タスクをスタンドアロンで起動するのは、バッチプロセスなど、何らかの処理を実行して停止するアプリケーションの場合になります。

ECS サービスは実行するタスクに関する設定を定義します。

  • どのクラスター上で実行するのか
  • いくつタスクを起動するのか
  • ネットワーク環境(実行するサブネットやセキュリティグループなど)
  • サービスレジストリとの接続設定
  • デプロイ時のエラー対応(サーキットブレーカー)
  • ...

基本的にはデプロイの実行に関するものや ECS タスクをどこで実行するのかといった実行に必要な情報を定義します。
ECS タスク定義では「何をどのように動かすか」を定義し、ECS サービスでは「どこでどのように動かすか」を定義することになります。

なお、service_registryはサービスレジストリである CloudMap に対するサービスの登録に関する情報を設定しています。

ecs_service.tf
resource "aws_ecs_service" "mz_dev_app" {
  name                 = local.app_name
  cluster              = aws_ecs_cluster.this.id
  task_definition      = aws_ecs_task_definition.mz_dev_app.arn
  desired_count        = var.ecs_service.desired_count
  force_new_deployment = true
  launch_type          = "FARGATE"

  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.ecs.id]
  }

  service_registries {
    registry_arn   = aws_service_discovery_service.mz_dev_app.arn
    container_name = local.app_name
    container_port = 80
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }
}

ECS のセキュリティグループの作成

#

ECS で動作するアプリケーション用のセキュリティグループを定義します。
インプットルールでは Web アプリケーションへアクセスするため、 TCP で 80 番ポートに許可を与えています。
アウトプットルールはすべてを許可しています。
実際のルールは ECS クラスター利用するアプリケーションに合わせる必要があります。

main.tf
resource "aws_security_group" "ecs" {
  name   = local.ecs_security_group_name
  vpc_id = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name  = "${local.prefix}-ecs-security-group"
  }
}

CloudMap の構築

#

API Gateway とアプリケーションの仲立ちをするための CloudMap を構築します。

プライベート DNS 名前空間の作成

#

SSS のサービス間連携のためにプライベート DNS 名前空間を定義します。

プライベート DNS 名前空間の名称

プライベート DNS 名前空間の名称は、サービス間連携(サービスコネクト)で利用されます。
そのため、RFC で規定されている DNS 名 および URL の文字数および文字種の制限に従っている必要があります。
SSS では_を使っていたのですが、ライブラリのバージョンアップでチェックが厳しくなったためか、URL 取得でNullPointerExceptionが発生するようになってしまいました。
結局、エラーを解消するためにプライベート DNS 名前空間の名称を修正することになりました。

main.tf
resource "aws_service_discovery_private_dns_namespace" "this" {
  name = local.service_discovery_dns_namespace
  vpc  = var.vpc_id
}

管理コンソールでは以下のように確認できます。

プライベートDNS名前空間の管理コンソールイメージ

アプリケーションの CloudMap サービスの作成

#

アプリケーションの ECS サービスが CloudMap で検索できるように、 CloudMap の名前空間に対して CloudMap サービスを定義します。

DNS レコードにはサービス検出の場合はタイプがASRVである必要があります。
SSS では Java のサービスと Python のサービスでポートが異なっているため、ポート指定ができるSRVを指定しています。
また、 AWS で推奨されている HealthCheckCustomConfig を Amazon ECS サービスのサービス検出により管理されるコンテナレベルのヘルスチェックを使用しています[4]

ecs_service.tf
resource "aws_service_discovery_service" "mz_dev_app" {
  name         = local.app_name
  namespace_id = aws_service_discovery_private_dns_namespace.this.id

  dns_config {
    namespace_id = aws_service_discovery_private_dns_namespace.this.id

    dns_records {
      ttl  = 300
      type = "SRV"
    }
  }

  health_check_custom_config {
    failure_threshold = 1
  }
}

API Gateway の構築

#

最後にシステムの入口となる API Gateway を構築します。

HTTP API の作成

#

まずは API Gateway の基本要素として種別と CORS 設定[5]を定義します。
SSS では以下の理由で種別は HTTP API としました。

  • JWT 認証をサポートしている
  • CloudMap との統合をサポートしている
  • 最小限の機能で構成されている
  • 低価格である
REST API vs HTTP API

AWS API Gateway で RESTful API を提供する場合は REST API と HTTP API の 2 つがあります。
基本的には HTTP API の方が最小構成で低価格ですが、JWT についてはなぜか REST API は AWS Lambda を使って検証する必要があります。
バックエンドとの統合についても ALB や CloudMap が未対応など、実際に使う AWS サービス構成に応じて選択する必要があります。

REST API と HTTP API のどちらかを選択する

API Gateway の CORS を設定するだけでプリフライト OPTIONS リクエストのレスポンスをするようになります[6]
なお、HTTP ステータスコードは 204 を返します。

apigw.tf
resource "aws_apigatewayv2_api" "this" {
  name          = "${local.prefix}-api-gateway"

  protocol_type = "HTTP"

  cors_configuration {
    allow_origins     = var.allow_origins
    allow_headers     = ["authorization", "origin", "content-type", "accept", "x-requested-with"]
    allow_methods     = ["GET", "POST", "DELETE", "PUT"]
    allow_credentials = true
    max_age           = var.cors_max_age
  }
}

ステージの作成

#

AWS API Gateway では、API のライフサイクル(バージョンや環境の違い)を扱う論理的な要素としてステージがあります。
SSS では REST API を使って UI とバックエンドの通信していますが、API を公開する予定がないため、API バージョン管理は特に行っていません。
また、環境は AWS アカウントおよびドメイン名が異なっています。
これらの理由により、ステージに関してはデフォルト($default)のみを利用しています。

ステージはデプロイする必要がありますが、デフォルトのみのため、自動デプロイを有効にしています。

他にはログに関する設定をしています。
access_log_settingsformatで出力する項目を指定しています。

apigw.tf
resource "aws_apigatewayv2_stage" "this" {
  name        = "$default"

  api_id      = aws_apigatewayv2_api.this.id
  auto_deploy = true

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_gateway.arn
    format          = jsonencode({
      requestId      = "$context.requestId"
      ip             = "$context.identity.sourceIp"
      requestTime    = "$context.requestTime"
      httpMethod     = "$context.httpMethod"
      routeKey       = "$context.routeKey"
      path           = "$context.path"
      status         = "$context.status"
      protocol       = "$context.protocol"
      responseLength = "$context.responseLength"
      errMsg         = "$context.integrationErrorMessage"
    })
  }
}

以下は API Gateway の$defaultステージに対する CloudWatch のロググループの定義になります。

apigw.tf
resource "aws_cloudwatch_log_group" "api_gateway" {
  name              = "/aws/api-gateway/mz-dev"
  retention_in_days = var.log_retention_in_days
}
#

後ほど作成する HTTP API ルートから VPC 内のプライベートリソースに接続するプライベート統合を作成するため、VPC リンクを作成します。

apigw.tf
resource "aws_apigatewayv2_vpc_link" "this" {
  name               = "${local.prefix}-vpc-link"
  security_group_ids = [var.default_security_group_id]
  subnet_ids         = var.private_subnet_ids
}

統合の作成

#

HTTP API ルートをバックエンドのサービスと接続するための統合を作成します。

HTTP API のプライベート統合の場合、integration_typeHTTP_PROXYのみとなります。
CloudMap サービス検出を使用した統合となるのでアプリケーションの CloudMap サービスをintegration_uriに指定します。
integration_methodはサンプルアプリケーションの場合、GETのみでも大丈夫ですが、一般的にはANYとするのでANYとしています。
VPC リンク経由での接続となるので connection_typeVPC_LINKconnection_idには先に定義した VPC リンクの ID を指定します。

ecs_service.tf
resource "aws_apigatewayv2_integration" "mz_dev_app" {
  api_id             = aws_apigatewayv2_api.this.id

  integration_type   = "HTTP_PROXY"
  integration_uri    = aws_service_discovery_service.mz_dev_app.arn
  integration_method = "ANY"

  connection_type    = "VPC_LINK"
  connection_id      = aws_apigatewayv2_vpc_link.this.id
}

オーサライザーの作成

#

Cognito と連携して JWT 認証[7]する既存の仕組みを利用して JWT オーサライザーを作成します。
JWT を利用するので、authorizer_typeは当然JWTとなります。
jwt_configurationには Cognito のユーザープールクライアント ID とユーザープールエンドポイントを指定します。

apigw.tf
resource "aws_apigatewayv2_authorizer" "jwt_authorizer" {
  name             = "${local.prefix}-jwt-authorizer"

  api_id           = aws_apigatewayv2_api.this.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]

  jwt_configuration {
    audience = var.user_pool_client_ids
    issuer   = "https://${var.cognito_user_pool_endpoint}"
  }
}

アプリケーションに対するルートの作成

#

最後にアプリケーション API への URL パス名と HTTP メソッドのペアに対してどのオーサライザーとどの統合にルーティングするかを定義します。

今回のサンプアプリケーションの URL パス名は/、HTTP メソッドはGETのみとなります。
以下の Terraform コードではfor_eachを使用して、複数の HTTP メソッドが定義された場合にも対応しています。
route_keyで使用しているeach.keyではvar.ecs_service.http_methodsで複数定義されている HTTP メソッドが個々に指定されます。
targetには統合、authorizer_idにはオーサライザーを指定しています。

ecs_service.tf
resource "aws_apigatewayv2_route" "mz_dev_app" {
  for_each = var.ecs_service.http_methods
  api_id             = aws_apigatewayv2_api.this.id
  route_key          = "${each.key} /{proxy+}"
  target             = "integrations/${aws_apigatewayv2_integration.mz_dev_app.id}"
  authorizer_id      = aws_apigatewayv2_authorizer.jwt_authorizer.id
  authorization_type = "JWT"
}

外部入力

#

既存の AWS リソースの ID などは Terraform のvariable[8]として定義しています。
以下に変数、型、デフォルト値、概要を示します。
型がobjectとなっている変数についての詳細は各変数の表にて同じく詳細を示してあります。

変数名 デフォルト値 概要
vpc_id string VPC ID
default_security_group_id string デフォルトセキュリティグループ ID
allow_origins list(string) 許可するオリジン
cors_max_age number 80000 CORS 最大時間(秒)
log_retention_in_days number 7 ログ保持期間(日)
cognito_user_pool_endpoint string Cognito のユーザープールのエンドポイント
user_pool_client_ids list(string) Cognito のユーザープールクライアント ID
ecs_task object ECS タスク定義に関する設定(詳細は後述)
ecs_service object ECS サービスに関する設定(詳細は後述)
private_subnet_ids list(string) プライベートサブネット ID
  • ecs_task
変数名 デフォルト値 概要
memory number 512 タスクのメモリ量
cpu number 256 タスクの仮想 CPU の値
  • ecs_service
変数名 デフォルト値 概要
desired_count number 1
http_methods set(string) ["GET"]

最後に

#

SSS で実際に構築したインフラとその IaC 実装をベースにその内容を紹介しました。
IaC で実装するとインフラが何度も作成と破棄が可能となります。
実際にこの記事を執筆する際にも、記事を作成して動作確認などをする際にだけサンプルアプリシステムを作成し、他の作業をする際には破棄をするということを行っていました。

ここで取り上げなかったローカル変数やプロバイダ設定などを含めたコードの全体はIaC で Sales Support System のインフラ構築のリポジトリから入手可能です。
そのまま試してみることももちろん、今回取り上げられなかった組み合わせを試してみたり、タスク定義を増やして複数サービスにしたりと、色々と応用されてみてはいかがでしょうか。


  1. クリティカルな非機能要件のない SSS ではあまり差が出なかったので、ちょっと恣意的な感じになっていますが・・・(汗) ↩︎

  2. AWS CLI を使用して、Fargate 起動タイプ用の Amazon ECS Linux タスクを作成する ↩︎

  3. ECS タスク定義の設定の詳細については Amazon ECS タスク定義パラメータ を参照。 ↩︎

  4. サービスの検出に関する考慮事項を参照。 ↩︎

  5. CORS の詳細はCross-Origin Resource Sharingr(CORS)を参照。 ↩︎

  6. HTTP API の場合は機能が少ないためか、残念ながら、REST API で推奨されている Mock 統合がサポートされていないようです。 ↩︎

  7. JWT に関しては豆蔵デベロッパーサイトの「基本から理解する JWT と JWT 認証の仕組み」を参照。 ↩︎

  8. variableについてはInput Variablesを参照。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。