MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第7回】Terraform の土台とネットワーク:state 管理と VPC

    前回までで、管理画面を本番用のコンテナイメージにまとめました。今回から、そのイメージを動かす AWS 側の準備に入ります。最初に作るのは、Terraform(=インフラの構成をコードで書き、その通りに作成・変更するツール)の土台と、すべての部品が乗るネットワーク(VPC)です。

    「AWS の画面で手作業で作った環境が、誰がいつ何を変えたのか分からなくなってしまった。コードで管理したいけれど、どこから始めればいいのか…」

    結論から言うと、Terraform で最初に決めるのは「state(=何を作ったかの記録)をどこに置き、同時に書き換えられないようにするか」と「ネットワークを public と private に分け、外への出口をどうするか」の2つです。ここを後から変えるのは大変なので、最初の回でまとめて固めます。

    前回(第6回:本番用コンテナイメージを作る)では、nginx と php-fpm の本番用イメージを作りました。今回はそれを動かすための VPC・サブネット・NAT Gateway・セキュリティグループを Terraform で定義します。

    なお、執筆環境には AWS アカウントを用意していないため、terraform apply(実際の作成)は行っていません。terraform validate による構文と整合性の確認と、AWS に接続しない形での plan(作成予定の一覧)までを確認しています。

    目次

    Terraform の土台とネットワークで作るもの

    今回追加するファイルは次のとおりです(すべて infra/terraform/ 配下)。

    ファイル内容
    versions.tf / .terraform.lock.hclTerraform と AWS プロバイダー(=AWS を操作するためのプラグイン)のバージョン固定
    backend.tf / backend.hcl.examplestate の保存先(S3)とロック
    providers.tfリージョンと、全リソース共通のタグ
    variables.tf / locals.tf / terraform.tfvars.example環境ごとに変える値
    network.tfVPC、サブネット、インターネットゲートウェイ、NAT Gateway、ルートテーブル
    vpc_endpoints.tfS3 のゲートウェイ型エンドポイント、インターフェイス型エンドポイント(任意)
    security_groups.tfALB・Web タスク・その他のタスク・RDS の通信の許可
    outputs.tf次回以降に使う ID の出力

    1つのディレクトリに役割ごとのファイルを並べる構成にしました(この規模ならモジュール分割は不要と判断)。検証環境(stg)と本番(prod)は、同じコードに別の変数と別の state を渡して作り分けます。

    state をS3に置き、use_lockfile でロックする

    state とは何か

    Terraform は、作ったリソースの ID や設定を state ファイルに記録し、次の実行時に「コードと実際の差分」を計算します。state を担当者のパソコンに置くと、別の人の実行で差分を正しく計算できず、二重作成や削除事故につながるため、S3 などの共有の場所に置きます。

    backend の設定:バケット名はコードに書かない

    infra/terraform/backend.tf

    # state(Terraform が管理しているリソースの記録)の保存先
    #
    # バケット名などの環境ごとの値はコードに書かず、init 時に渡す(部分設定)
    #   terraform init -backend-config=backend.hcl
    # backend.hcl の例は backend.hcl.example を参照(backend.hcl 自体は Git に入れない)
    terraform {
      backend "s3" {
        # S3 にロックファイル(<key>.tflock)を作って同時実行を防ぐ。DynamoDB のロック用テーブルは不要
        use_lockfile = true
        encrypt      = true
      }
    }

    infra/terraform/backend.hcl.example

    bucket = "example-admin-tfstate"   # 実際のバケット名に置き換える
    key    = "admin/stg/terraform.tfstate"
    region = "ap-northeast-1"

    use_lockfile = true は、Terraform 1.11 で正式な機能になった S3 のネイティブロックです。実行中は state と同じ場所にロック用のファイルを置き、他の人が同時に apply できないようにします。以前は DynamoDB のテーブルをロックに使うのが定番でしたが、1.11 のドキュメントでは DynamoDB によるロックは非推奨とされています。

    📰 出典:Terraform v1.11.x Backend Type: s3

    バケット名や state のパス(key)は、backend.hcl に書いて terraform init -backend-config=backend.hcl で渡します(部分設定)。環境ごとに key を変えれば、同じコードで stg と prod の state を分けられます。

    state 用の S3 バケットそのものは、Terraform の外で先に作ります(state を置く場所を、その state で管理することはできないためです)。AWS CLI なら次のような手順です。バージョニングを有効にしておくと、state を誤って壊したときに前の版に戻せます。

    aws s3api create-bucket --bucket <バケット名> --region ap-northeast-1 \
      --create-bucket-configuration LocationConstraint=ap-northeast-1
    aws s3api put-bucket-versioning --bucket <バケット名> --versioning-configuration Status=Enabled
    aws s3api put-public-access-block --bucket <バケット名> \
      --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

    state には、リソースの設定値がそのまま記録されます。パスワードなどの秘密情報を Terraform の変数で渡すと state にも残るため、この連載では DB のパスワードを AWS 側で生成・管理させます(第8回)。

    バージョン固定と共通タグ

    infra/terraform/versions.tf(terraform ブロック)

    terraform {
      required_version = "~> 1.11"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.97"
        }
      }
    }

    ~> 5.97 は「5.97 以上、6.0 未満」という指定です。実際に使う版は、terraform init で作られる .terraform.lock.hcl に記録され、チーム全員が同じ版を使います。筆者の環境(2025年8月時点)では AWS プロバイダー 5.100.0 が選ばれました。このファイルは Git に含めます。

    infra/terraform/providers.tf

    provider "aws" {
      region = var.region
    
      # すべてのリソースに共通のタグを付ける(費用の集計・持ち主の確認に使う)
      default_tags {
        tags = {
          Project     = var.project
          Environment = var.environment
          ManagedBy   = "terraform"
        }
      }
    }

    default_tags を付けておくと、請求画面で環境ごとの費用をタグで集計できます。ManagedBy = "terraform" は、画面から手で変更してはいけないリソースの目印にもなります。

    VPC を public と private のサブネットに分ける

    ネットワークの全体像

    場所置くものインターネットとの関係
    public サブネット(2つの AZ)ALB(ロードバランサー)、NAT Gateway直接つながる
    private サブネット(2つの AZ)ECS のタスク、RDS外から直接は届かない。外向きは NAT Gateway 経由

    AZ(アベイラビリティゾーン)は、同じリージョン内で物理的に離れたデータセンターの単位です。ALB と RDS はどちらも2つ以上の AZ のサブネットを必要とするため、最初から2つの AZ に作ります。

    サブネットの IP アドレス範囲は、VPC の 10.0.0.0/16 を cidrsubnet() 関数で /24 に分けて決めています(public は 10.0.0.0/24・10.0.1.0/24、private は 10.0.10.0/24・10.0.11.0/24)。

    NAT Gateway:1台にするか AZ ごとにするか

    infra/terraform/network.tf(NAT Gateway と private のルート)

    resource "aws_eip" "nat" {
      count  = local.nat_gateway_count
      domain = "vpc"
    
      tags = { Name = "${local.name_prefix}-nat-${var.azs[count.index]}" }
    }
    
    resource "aws_nat_gateway" "main" {
      count = local.nat_gateway_count
    
      allocation_id = aws_eip.nat[count.index].id
      subnet_id     = aws_subnet.public[count.index].id
    
      tags = { Name = "${local.name_prefix}-nat-${var.azs[count.index]}" }
    
      depends_on = [aws_internet_gateway.main]
    }
    
    resource "aws_route" "private_nat" {
      count = length(var.azs)
    
      route_table_id         = aws_route_table.private[count.index].id
      destination_cidr_block = "0.0.0.0/0"
      # NAT が1台なら全 AZ がそれを使う。AZ ごとに置いた場合は同じ AZ の NAT を使う
      nat_gateway_id = aws_nat_gateway.main[min(count.index, local.nat_gateway_count - 1)].id
    }

    locals.tf で nat_gateway_count = var.single_nat_gateway ? 1 : length(var.azs) としており、変数1つで台数を切り替えられます。

    • 1台(single_nat_gateway = true、既定):費用を抑えられます。その代わり、NAT Gateway のある AZ に障害が起きると、もう一方の AZ のタスクも外に出られなくなります。
    • AZ ごと(false):1つの AZ の障害でも外に出られます。NAT Gateway は台数分の時間課金がかかります。

    検証環境は1台、本番は業務の止まってよい時間に応じて判断する、というのが一般的な考え方です。

    📰 出典:AWS NAT ゲートウェイ(Amazon VPC ユーザーガイド)

    NAT Gateway と VPC エンドポイントを比較する

    private サブネットの ECS タスクは、起動時にイメージの取得(ECR)、ログの送信(CloudWatch Logs)、秘密情報の取得(Secrets Manager・SSM Parameter Store)のために AWS のサービスへ接続します。その経路は2通りあります。

    経路仕組み費用の構造向いている場面
    NAT Gatewayインターネット経由で AWS のサービスや外部 API に出る台数×時間+通信量外部の API(決済・メールなど)も使う。構成を単純にしたい
    VPC エンドポイント(インターフェイス型)VPC 内から AWS のサービスへ直接つなぐエンドポイント数×AZ 数×時間+通信量外に出る通信をなくしたい。イメージの取得などの通信量が多い
    VPC エンドポイント(ゲートウェイ型、S3)ルートテーブルで S3 への通信を直接つなぐ追加料金なし常に作っておいてよい

    infra/terraform/vpc_endpoints.tf(抜粋)

    resource "aws_vpc_endpoint" "s3" {
      vpc_id            = aws_vpc.main.id
      service_name      = "com.amazonaws.${var.region}.s3"
      vpc_endpoint_type = "Gateway"
      route_table_ids   = aws_route_table.private[*].id
    
      tags = { Name = "${local.name_prefix}-s3" }
    }
    
    locals {
      # ECS on Fargate がイメージ取得・ログ送信・シークレット取得に使うサービスと、キュー(SQS)
      interface_endpoint_services = var.enable_interface_endpoints ? toset([
        "ecr.api",
        "ecr.dkr",
        "logs",
        "secretsmanager",
        "ssm",
        "sqs",
      ]) : toset([])
    }

    ECR のイメージ本体は S3 から取得されるため、S3 のゲートウェイ型エンドポイントは常に作り、NAT Gateway を通る通信量を減らしています。インターフェイス型は enable_interface_endpoints で切り替えられるようにしました。6つのサービス×2つの AZ 分の時間課金がかかるため、この連載の既定は「NAT Gateway 1台+S3 ゲートウェイ型」です。

    📰 出典:Amazon ECR インターフェイス VPC エンドポイント(AWS PrivateLink)

    セキュリティグループは「相手のグループ」で許可する

    セキュリティグループ(=リソースごとの通信の許可リスト)は、IP アドレスではなく「どのセキュリティグループからの通信か」で許可します。ECS のタスクは起動のたびに IP アドレスが変わるためです。

    グループ受け付ける通信出ていく通信
    albインターネットから 80・443web へ 8080
    web(nginx+php-fpm)alb から 8080443(AWS の API など)、rds へ 3306
    app(worker・scheduler・マイグレーション)なし443、rds へ 3306
    rdsweb と app から 3306なし

    infra/terraform/security_groups.tf(web タスクと RDS の部分)

    # web タスク(nginx+php-fpm):ALB からの 8080 番だけを受け付ける
    resource "aws_security_group" "web" {
      name        = "${local.name_prefix}-web"
      description = "ECS web tasks: from ALB only"
      vpc_id      = aws_vpc.main.id
    
      tags = { Name = "${local.name_prefix}-web" }
    }
    
    resource "aws_vpc_security_group_ingress_rule" "web_from_alb" {
      security_group_id            = aws_security_group.web.id
      description                  = "From ALB"
      referenced_security_group_id = aws_security_group.alb.id
      ip_protocol                  = "tcp"
      from_port                    = 8080
      to_port                      = 8080
    }
    
    resource "aws_vpc_security_group_ingress_rule" "rds_from_tasks" {
      for_each = {
        web = aws_security_group.web.id
        app = aws_security_group.app.id
      }
    
      security_group_id            = aws_security_group.rds.id
      description                  = "MySQL from ECS ${each.key} tasks"
      referenced_security_group_id = each.value
      ip_protocol                  = "tcp"
      from_port                    = 3306
      to_port                      = 3306
    }

    待ち受けを 8080 番にしているのは、第6回で nginx を非 root で動かすようにしたためです。ルールは aws_security_group の中に書かず、aws_vpc_security_group_ingress_rule / egress_rule という1ルール1リソースの書き方にしました。AWS プロバイダーのドキュメントでも、aws_security_group の中にルールを書く方法は避け、こちらを使うことが勧められています。ルールごとに説明やタグを付けられ、追加・削除が他のルールに影響しにくくなります。

    📰 出典:Terraform AWS Provider 5.100.0 aws_security_group

    動作確認の方法

    Terraform も Docker のイメージで実行します(infra/terraform で実行)。

    docker run --rm -v $PWD:/w -w /w hashicorp/terraform:1.11.4 fmt -check -recursive
    docker run --rm -v $PWD:/w -w /w hashicorp/terraform:1.11.4 init -backend=false
    docker run --rm -v $PWD:/w -w /w hashicorp/terraform:1.11.4 validate

    init -backend=false は、S3 の state に接続せずにプロバイダーだけをダウンロードする指定で、AWS アカウントがなくても構文とリソースの整合性を確認できます。筆者の環境では次の結果でした。

    • fmt -check は差分なし、validate は Success! The configuration is valid.
    • .terraform.lock.hcl に AWS プロバイダー 5.100.0 が記録された
    • 確認用に、backend をローカルに差し替え、AWS に接続しない設定(ダミーの認証情報と認証チェックの省略)を一時的に加えて plan を実行したところ、既定の変数で Plan: 35 to add。single_nat_gateway = false と enable_interface_endpoints = true にすると NAT Gateway が2台、インターフェイス型エンドポイントが6つ増えて Plan: 43 to add になった。environment = "dev" のように許可していない値は、変数の検証でエラーになった

    この plan は AWS に問い合わせていないため、AZ 名が実在するか、アカウントの上限に収まるかなどは確認できていません。読者の AWS アカウントでは、backend.hcl と terraform.tfvars を用意して、terraform init -backend-config=backend.hcl、terraform plan で作成予定を確認してから apply してください。

    つまずきやすい点・セキュリティ上の注意

    • state・tfvars を Git に入れない:.gitignore に *.tfstate、*.tfvars(例のファイルを除く)、backend.hcl を入れています。state にはリソースの詳細な設定が入ります。
    • 画面から手で変更しない:Terraform で作ったリソースを AWS の画面で変更すると、次の plan で元に戻す差分が出ます。緊急対応で変更した場合は、必ずコードにも反映します。
    • NAT Gateway は作った瞬間から課金される:検証が終わった環境は terraform destroy で削除するか、使わない時間帯の扱いを決めておきます。
    • サブネットの IP 範囲は後から変えにくい:社内ネットワークや他の VPC と接続する予定があれば、重ならない範囲を最初に決めます。
    • VPC フローログなどの監査の仕組みは省略:本番では、通信の記録(VPC フローログ)の要否もセキュリティ要件として検討します。

    発注者向けメモ:AWS の費用は「常にかかるもの」と「使った分」に分かれる

    AWS の月額費用は、大きく2種類に分けて考えると見積もりが読みやすくなります。

    • 時間課金で常に発生するもの:NAT Gateway、ALB、RDS、Fargate のタスク、インターフェイス型 VPC エンドポイント。利用者がいない夜間も費用がかかります
    • 使った分だけのもの:通信量、ログの量と保存期間、S3 の保存量

    今回の NAT Gateway のように、「1台にするか AZ ごとにするか」で費用と障害への強さが変わる部品があります。検証環境と本番で構成を分けるのは一般的ですが、その判断の根拠(どれくらい止まってよいか)は発注者側が決める必要があります。

    • インフラをコードで管理しているか:Terraform などのコードが納品物に含まれるか
    • state の置き場所と権限:誰の AWS アカウントに、誰が変更できる形で置くか
    • 検証環境と本番の違い:どこを省略し、それによってどんなリスクを受け入れるか

    開発会社には、次のように確認してみてください。

    • 「AWS の構成はコードで管理していますか?そのコードと state は、契約終了後にこちらで引き継げますか?」
    • 「検証環境と本番で構成が違う部分はどこですか?それぞれの月額の目安を分けて出せますか?」
    • 「1つのアベイラビリティゾーンで障害が起きたとき、システムはどうなりますか?」
    • 「画面から手作業で変更した場合、コードとのずれをどうやって防いでいますか?」

    まとめと次回予告

    第7回では、Terraform の土台とネットワークを定義しました。

    • state は S3 に置き、Terraform 1.11 の use_lockfile でロックする。バケット名は部分設定で渡し、コードに書かない
    • .terraform.lock.hcl でプロバイダーの版を固定し、default_tags で全リソースにタグを付ける
    • VPC を public と private のサブネット(2つの AZ)に分け、NAT Gateway の台数を変数で切り替える
    • S3 はゲートウェイ型エンドポイントを常に作り、インターフェイス型は必要に応じて有効にする
    • セキュリティグループは相手のグループで許可し、RDS には ECS のタスクからだけ接続できるようにする

    次回は「RDS for MySQL と秘密情報:パスワードをコードに書かない」です。今回作った private サブネットに RDS for MySQL 8.4 を置き、DB のパスワードを AWS に管理させる方法と、APP_KEY などの秘密情報の置き場所を扱います。

    この連載の記事一覧

    この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次