2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TerraformでWebアプリ開発 - 3. Terraformでインフラ構築.ver2

Last updated at Posted at 2025-06-25

前回の記事に続き、第3回はTerraformでネットワーク周りのAWSリソースを作成します。

目次

  1. 環境構築
  2. Terraformでインフラ構築.ver1
  3. Terraformでインフラ構築.ver2 ←ココ
  4. Terraformでインフラ構築.ver3
  5. おまけ

3.Terraformでインフラ構築.ver2

このセクションでは以下を行います。

  1. VPC/IGWの作成
  2. ドメイン設定とALB
  3. Fargateの構築

実装範囲はこの辺です。

image.png

3-1. VPC/IGWの作成

VPCやセキュリティグループなどは、envs/prod/appと同階層にnetworkディレクトリを作成してそこで管理します。以下のように各種ファイルを作成します。

envs/prod/network/main/backend.tf
terraform {
  backend "s3" {
    bucket = "tfapp-tfstate"
    key    = "test/prod/network/main_v1.5.0.tfstate"
    region = "ap-northeast-1"
  }
}

シンボリックリンクを作成します。
envs/prod/network/mainに移動して、以下を実行します。

$ ln -fs ../../provider.tf provider.tf
$ ln -fs ../../shared_locals.tf shared_locals.tf

VPCの作成
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/vpc.tf
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${local.name_prefix}-main"
  }
}

後述のvariables.tfで定義したcidrを当てられるようにしておきます。
また、プライベートホストゾーンでの名前解決を有効にしておきます。

IGW(インターネットゲートウェイ)の作成
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/internet_gateway.tf
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = aws_vpc.this.tags.Name
  }
}

リージョン名の取得
data.tfでaws_regionを使うことで、リージョン名を取得できます。この値は後述のsubnet.tfで利用します。
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/data.tf
data "aws_region" "current" {}

サブネットの作成
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/subnet.tf
resource "aws_subnet" "public" {
  for_each = var.azs

  availability_zone = "${data.aws_region.current.name}${each.key}"
  cidr_block        = each.value.public_cidr

  # パブリックサブネット = true
  map_public_ip_on_launch = true
  vpc_id                  = aws_vpc.this.id

  tags = {
    Name = "${aws_vpc.this.tags.Name}-public-${each.key}"
  }
}

resource "aws_subnet" "private" {
  for_each = var.azs

  availability_zone       = "${data.aws_region.current.name}${each.key}"
  cidr_block              = each.value.private_cidr
  map_public_ip_on_launch = false
  vpc_id                  = aws_vpc.this.id

  tags = {
    Name = "${aws_vpc.this.tags.Name}-private-${each.key}"
  }
}

サブネット用のルートテーブル作成
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/route_table.tf
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${aws_vpc.this.tags.Name}-public"
  }
}

resource "aws_route" "internet_gateway_public" {
  gateway_id             = aws_internet_gateway.this.id
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route_table_association" "public" {
  for_each = var.azs

  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public[each.key].id
}


resource "aws_route_table" "private" {
  for_each = var.azs

  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${aws_vpc.this.tags.Name}-private-${each.key}"
  }
}

resource "aws_route" "nat_gateway_private" {
  for_each = var.enable_nat_gateway ? var.azs : {}

  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.this[var.single_nat_gateway ? keys(var.azs)[0] : each.key].id
  route_table_id         = aws_route_table.private[each.key].id
}

resource "aws_route_table_association" "private" {
  for_each = var.azs

  route_table_id = aws_route_table.private[each.key].id
  subnet_id      = aws_subnet.private[each.key].id
}

NATゲートウェイの作成
NATゲートウェイは作成後に0.062USD/hour従量課金されます。
これ以降は特定のリソースを作成するときに、コストが発生します。そのため、いざデプロイが必要な時以外はリソースを作成せず、実行コマンドによって、作成可否を制御できるようにしておきます。
実際には、terraform apply時にvariableの変数の値に応じて作成可否 / どの程度作成するかを制御できるようにしておきます。

前述したtfコードにも、各所にvariablesで定義された値を取得するように記述してきました。
NATゲートウェイの制御設定も合わせてvariables.tfに設定します。

envs/prod/network/main/variables.tf
variable "vpc_cidr" {
  type    = string
  default = "172.31.0.0/16"
}

variable "azs" {
  type = map(object({
    public_cidr  = string
    private_cidr = string
  }))

  default = {
    a = {
      public_cidr  = "172.31.0.0/20"
      private_cidr = "172.31.48.0/20"
    },

    c = {
      public_cidr  = "172.31.16.0/20"
      private_cidr = "172.31.64.0/20"
    }
  }
}

variable "enable_nat_gateway" {
  type    = bool
  default = true
}

variable "single_nat_gateway" {
  type    = bool
  default = true
}

VPCのCIDRは172.31.0.0/16としています。すでに使用済みであれば別のCIDRを指定してください。
以下にCIDRの仕組みや割り当てなど紹介した記事があったので参考にしてみてください。

enable_nat_gatewayの値で作成可否を制御します。
single_nat_gatewayの値でどの程度作成するかを制御します。

実際にNATゲートウェイを作成せずにリソース展開をするときは、

$ terraform apply -var='enable_nat_gateway=false'

とすれば作成されません。(課金回避)

続けて、ElasticIPを作成します。

Elastic IPの作成
NATゲートウェイに固定のパブリックIDアドレスを付与するために、Elastic IPを作成します。
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/eip.tf
resource "aws_eip" "nat_gateway" {
  for_each = var.enable_nat_gateway ? local.nat_gateway_azs : {}

  tags = {
    Name = "${aws_vpc.this.tags.Name}-nat-gateway-${each.key}"
  }
}

enable_nat_gatewayがtrueの時はlocalsで定義されたnat_gateway_azsを参照するようにします。
envs/prod/network/mainで以下のファイルを作成します。

envs/prod/network/main/locals.tf
locals {
  nat_gateway_azs = var.single_nat_gateway ? {
    keys(var.azs)[0] = values(var.azs)[0]
  } : var.azs
}

上記のように設定することで、single_nat_gateway=trueである場合は1つ作成し、そうでければvariablesのAZ分だけ作成するようにしています。

最後に、NATゲートウェイを定義するためにnat_gateway.tfを作成します。

envs/prod/network/main/nat_gateway.tf
resource "aws_nat_gateway" "this" {
  for_each      = var.enable_nat_gateway ? local.nat_gateway_azs : {}
  allocation_id = aws_eip.nat_gateway[each.key].id
  subnet_id     = aws_subnet.public[each.key].id

  tags = {
    Name = "${aws_vpc.this.tags.Name}-${each.key}"
  }
}

eip.tfやプライベートサブネットと同様にNATゲートウェイが作成される数を制御しています。
allocation_idにはNATゲートウェイに紐付けるElasticIPのidを指定します。
また、subnet_idには、NATゲートウェイを紐づけるサブネットidを指定します。上記ではパブリックサブネットを紐付けます。

セキュリティグループの作成
セキュリティグループを2つ作成します。
1つはVPC外のHTTP/HTTPSリクエストを許可するもの、もう1つはVPC内のリソース同士の通信を許可するものとして作成します。

envs/prod/network/main/security_group.tf
resource "aws_security_group" "web" {
  name   = "${aws_vpc.this.tags.Name}-web"
  vpc_id = aws_vpc.this.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${aws_vpc.this.tags.Name}-web"
  }
}

resource "aws_security_group" "vpc" {
  name   = "${aws_vpc.this.tags.Name}-vpc"
  vpc_id = aws_vpc.this.id

  ingress {
    from_port = 0
    to_port   = 0
    protocol  = "-1"
    self      = true
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${aws_vpc.this.tags.Name}-vpc"
  }
}

ingressは指定ブロックのリソースに対するインバウンド通信の許可設定です。
egressはセキュリティグループが付けられたリソースからのアウトバウンド通信の許可設定です。
selfをtrueにすると、このセキュリティグループ自身がついたリソースからの通信が許可できます。

outputs.tfの作成
後述のALBとターゲットグループの作成において、設定したサブネットIDとセキュリティグループのID、VPCのIDが必要になります。
別のディレクトリから参照できるように、前もってoutputs.tfを作成します。

envs/prod/network/main/outputs.tf
output "security_group_web_id" {
  value = aws_security_group.web.id
}

output "security_group_vpc_id" {
  value = aws_security_group.vpc.id
}

output "subnet_public" {
  value = aws_subnet.public
}

output "subnet_private" {
  value = aws_subnet.private
}

output "vpc_this_id" {
  value = aws_vpc.this.id
}

ここまでリソースを作成すれば、envs/prod/network/main/で初期化しておきます。

$ terraform init

デプロイ実行前に、変数指定によってnat_gatewayの作成制御ができているか確認します。
はじめに通常実行した場合を確認します。
想定ではnat_gatewayリソースが作成されているはずです。

$ terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_eip.nat_gateway["a"] will be created
  + resource "aws_eip" "nat_gateway" {
      + allocation_id        = (known after apply)
      + association_id       = (known after apply)
      + carrier_ip           = (known after apply)
      + customer_owned_ip    = (known after apply)
      + domain               = (known after apply)
      + id                   = (known after apply)
      + instance             = (known after apply)
      + network_border_group = (known after apply)
      + network_interface    = (known after apply)
      + private_dns          = (known after apply)
      + private_ip           = (known after apply)
      + public_dns           = (known after apply)
      + public_ip            = (known after apply)
      + public_ipv4_pool     = (known after apply)
      + tags                 = {
          + "Name" = "test-prod-main-nat-gateway-a"
        }
      + tags_all             = {
          + "Env"    = "prod"
          + "Name"   = "test-prod-main-nat-gateway-a"
          + "System" = "test"
        }
      + vpc                  = (known after apply)
    }

  # aws_internet_gateway.this will be created
  + resource "aws_internet_gateway" "this" {
      + arn      = (known after apply)
      + id       = (known after apply)
      + owner_id = (known after apply)
      + tags     = {
          + "Name" = "test-prod-main"
        }
      + tags_all = {
          + "Env"    = "prod"
          + "Name"   = "test-prod-main"
          + "System" = "test"
        }
      + vpc_id   = (known after apply)
    }

  # aws_nat_gateway.this["a"] will be created
  + resource "aws_nat_gateway" "this" {
      + allocation_id        = (known after apply)
      + id                   = (known after apply)
      + network_interface_id = (known after apply)
      + private_ip           = (known after apply)
      + public_ip            = (known after apply)
      + subnet_id            = (known after apply)
   .
   .
   .
   Plan: 20 to add, 0 to change, 0 to destroy.
   Changes to Outputs:
   + security_group_vpc_id = (known after apply)
   .
   .
   .

nat_gateway,epi,nat_gateway_private(a,c)が作成(合計4つ)されていて、合計20のリソースが作成されるようです。
次にterraform plan -var='enable_nat_gateway=false'を実行し、nat_gatewayとそれに関連するリソースが作成されない状態になることを確認します。

$ terraform plan -var='enable_nat_gateway=false'
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_internet_gateway.this will be created
  + resource "aws_internet_gateway" "this" {
      + arn      = (known after apply)
      + id       = (known after apply)
      + owner_id = (known after apply)
      + tags     = {
          + "Name" = "test-prod-main"
        }
      + tags_all = {
          + "Env"    = "prod"
          + "Name"   = "test-prod-main"
          + "System" = "test"
        }
      + vpc_id   = (known after apply)
    }

  # aws_route.internet_gateway_public will be created
  .
  .
  .
  Plan: 16 to add, 0 to change, 0 to destroy.
  
  Changes to Outputs:
  + security_group_vpc_id = (known after apply)
  .
  .

nat_gateway,eip、nat_gateway_private(a,c)が作成されておらず、リソースも16個に減ることが確認できればOKです。

リソース作成は以下で紹介のうちどちらかを実行します。


// 全てのリソースを作っても良いなら
$ terraform apply

// 費用を抑えたいなら
$ terraform apply -var='enable_nat_gateway=false'

もし費用を発生させたくない場合はterraform apply -var='enable_nat_gateway=false'を実行します。実際にマネコンにいってnat_gatewayが作成されていないことを確認しても良いでしょう。

image.png

3-2. ドメイン設定とALB

ドメイン取得と設定については以下を参照してください。

DNS設定やALBは、envs/prod/appと同階層にroutingディレクトリを作成してそこで管理することにします。
ここでは、取得したドメイン(例:tfapp.net)にちなんで、tfapp_netを作成し、このドメインに関連するリソースはこのディレクトリ以下で管理することにします。
envs/prod/routing/tfapp_netで以下のファイルを作成します。

envs/prod/routing/tfapp_net/backend.tf
terraform {
  backend "s3" {
    bucket = "tfapp-tfstate"
    key    = "test/prod/routing/tfapp_net_v1.5.0.tfstate"
    region = "ap-northeast-1"
  }
}

envs/prod/routing/tfapp_netに移動して、以下を実行します。

$ ln -fs ../../provider.tf provider.tf
$ ln -fs ../../shared_locals.tf shared_locals.tf

backend.tfなどを作成したのでterraform initを実行します。

ホストゾーンの設定
envs/prod/routing/tfapp_netで以下のファイルを作成します。
取得したドメインを利用せずにサブドメインを使ってルーティング設定を行います。
(ドメインがtfapp.net、サブドメインがsubdomainである時は以下のように設定する)

envs/prod/routing/tfapp_net/route53.tf
data "aws_route53_zone" "this" {
    name = "tfapp.net"
}

resource "aws_route53_record" "alb_record" {
    count = var.enable_alb ? 1 : 0
    zone_id = data.aws_route53_zone.this.zone_id
    name    = "subdomain"
    type    = "A"

    alias {
        name                   = aws_lb.this[0].dns_name
        zone_id                = aws_lb.this[0].zone_id
        evaluate_target_health = true
    }
}

証明書の発行
ACM(AWS Certificae Manager)で証明書の発行を行います。
ACM発行手順は以下のサイトを参考にすると良いでしょう。

数分待って、マネジメントコンソール > ACMの画面で証明書の状況が「発行済み」、検証欄が「成功」になっていればOKです。
マネコンで作成したACMの情報を取得します。
envs/prod/routing/tfapp_netで以下のファイルを作成します。

envs/prod/routing/tfapp_net/acm.tf
data "aws_acm_certificate" "wildcard_cert" {
  domain   = "tfapp.net"
  statuses = ["ISSUED"]
  most_recent = true
}

数分でapplyが完了し、マネジメントコンソール > ACMの画面で証明書の状況が「発行済み」、検証欄が「成功」になっていればOKです。

ALB設定
後述のFargateの前段に配置するALBの設定を行います。
最初にALBのアクセスログ保存用のS3バケットを作成します。
各種ログに関するコードは、envs/prod/appと同階層にlogディレクトリを作成し、そこで管理することにします。

envs/prod/log/albで以下のファイルを作成します。

envs/prod/log/alb/backend.tf
terraform {
  backend "s3" {
    bucket = "tfapp-tfstate"
    key    = "test/prod/log/alb_v1.5.0.tfstate"
    region = "ap-northeast-1"
  }
}

envs/prod/log/albに移動して、以下を実行します。

$ ln -fs ../../provider.tf provider.tf
$ ln -fs ../../shared_locals.tf shared_locals.tf

ログ排出用のS3バケットを定義します。

envs/prod/log/alb/s3.tf
resource "aws_s3_bucket" "this" {
  bucket = "tf-test-${local.name_prefix}-alb-log"

  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }

  lifecycle_rule {
    enabled = true
    expiration {
      days = 90
    }
  }

  tags = {
    Name = "tf-test-${local.name_prefix}-alb-log"
  }
}

resource "aws_s3_bucket_policy" "this" {
  bucket = aws_s3_bucket.this.id
  policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Principal" : {
            "AWS" : "arn:aws:iam::${data.aws_elb_service_account.current.id}:root"
          },
          "Action" : "s3:PutObject",
          "Resource" : "arn:aws:s3:::${aws_s3_bucket.this.id}/*"
        },
        {
          "Effect" : "Allow",
          "Principal" : {
            "Service" : "delivery.logs.amazonaws.com"
          },
          "Action" : "s3:PutObject",
          "Resource" : "arn:aws:s3:::${aws_s3_bucket.this.id}/*",
          "Condition" : {
            "StringEquals" : {
              "s3:x-amz-acl" : "bucket-owner-full-control"
            }
          }
        },
        {
          "Effect" : "Allow",
          "Principal" : {
            "Service" : "delivery.logs.amazonaws.com"
          },
          "Action" : "s3:GetBucketAcl",
          "Resource" : "arn:aws:s3:::${aws_s3_bucket.this.id}"
        }
      ]
    }
  )
}

S3バケットポリシーのPrincipalで使用していたデータソースaws_elb_service_accountを宣言します。
こうすることで、リージョンごとに管理されているAWSアカウントID(自身のIDとはまた別のELB管理ID)をハードコードしないようにすみます。

envs/prod/log/alb/data.tf
data "aws_elb_service_account" "current" {}

また、ALBを作成するときにS3バケットIDが必要になるので、以下のファイルを作成して外部から参照できるようにしておきます。

envs/prod/log/alb/outputs.tf
output "s3_bucket_this_id" {
  value = aws_s3_bucket.this.id
}

こうすることで、terraform_remote_stateというデータソースによって、別のディレクトリから値を参照することができます。

ここまで定義を作成すれば、terraform applyを実行してリソースを作成してください。

$ terraform init
$ terraform apply

ALBの作成
サブネットやセキュリティグループ、ログ用のS3を作成した時に、それぞれoutputs.tfを作成しました。
ALB作成時に、これら異なるディレクトリで作成したリソースのIDを参照する必要があります。
各ディレクトリで定義されたtfstateにoutputs.tfがあれば、apply時にtfstateに含めることができます。またterraform_remote_stateを利用することで、outputs.tfの値を読み取ることができます。

envs/prod/routing/tfapp_net/にファイルを作成、読み取れるようにします。

envs/prod/routing/tfapp_net/data.tf
data "terraform_remote_state" "network_main" {
  backend = "s3"

  config = {
    bucket = "tfapp-tfstate"
    key    = "${local.system_name}/${local.env_name}/network/main_v1.5.0.tfstate"
    region = "ap-northeast-1"
    profile = var.profile
  }
}

data "terraform_remote_state" "log_alb" {
  backend = "s3"

  config = {
    bucket = "tfapp-tfstate"
    key    = "${local.system_name}/${local.env_name}/log/alb_v1.5.0.tfstate"
    region = "ap-northeast-1"
    profile = var.profile
  }
}

ALBは作成すれば、時間あたりの課金が発生します。
前回と同じように、余計な利用料金が発生しないように変数で作成の制御を行えるようにします。
envs/prod/routing/tfapp_net/にファイルを作成、読み取れるようにします。

envs/prod/routing/tfapp_net/variables.tf
variable "enable_alb" {
  type    = bool
  default = true
}

variable "profile" {
  type = string
  default = "default"
}

次にalb.tfを作成して、以下のようにします。

envs/prod/routing/tfapp_net/alb.tf
resource "aws_lb" "this" {
    count = var.enable_alb ? 1 : 0
    name = "${local.name_prefix}-tfapp-net"

    internal = false
    load_balancer_type = "application"

    access_logs {
      bucket = data.terraform_remote_state.log_alb.outputs.s3_bucket_this_id
      enabled = true
      prefix = "tfapp-net"
    }

    security_groups = [
        data.terraform_remote_state.network_main.outputs.security_group_web_id,
        data.terraform_remote_state.network_main.outputs.security_group_vpc_id
    ]

    subnets = [
        for s in data.terraform_remote_state.network_main.outputs.subnet_public : s.id
    ]

    tags = {
        Name = "${local.name_prefix}-tfapp-net"
    }
}

resource "aws_lb_listener" "https" {
    count = var.enable_alb ? 1 : 0

    certificate_arn = data.aws_acm_certificate.wildcard_cert.arn
    load_balancer_arn = aws_lb.this[0].arn
    port = 443
    protocol = "HTTPS"
    ssl_policy = "ELBSecurityPolicy-2016-08"

    default_action {
      type = "fixed-response"

      fixed_response {
        content_type = "text/plain"
        message_body = "Test fixed response"
        status_code = 200
      }
    }
}

resource "aws_lb_listener" "redirect_http_to_https" {
    count = var.enable_alb ? 1 : 0

    load_balancer_arn = aws_lb.this[0].arn
    port = 80
    protocol = "HTTP"

    default_action {
        type = "redirect"

        redirect {
            port = 443
            protocol = "HTTPS"
            status_code = "HTTP_301"

        }  
    }
}

今はまだ、Fargateを構築していませんが、疎通確認をするために、一時的にalbのリスナーにfixed_responseとしてテキストを返すようにしています。

ここまで完了すれば、terraform applyを行います。
ただ、今回はエイリアス指定をaws_route53_recordで行なっているため、デプロイに数分時間がかかります。辛抱強く待って、デプロイが完了すれば、ブラウザからsubdomain.tfapp.netを表示させてください。
プレーンテキストが表示されていればOKです。
確認後、余計な課金を発生させたくなければ、terraform apply -var="enable_alb=false"を行っておきましょう。
count = var.enable_alb ? 1 : 0と記述している箇所で作成の制御を行なっています。apply時にfalseを指定することでリソースそのものを作成しないようにしています。

log/albnetwork/mainの状態をterraform_remote_stateで参照しています。それぞれのconfigでprofile = var.profileを指定しており、profileはデフォルトでdefaultが指定されています。もしprofileを固定してapplyしていたのであれば、-var='profile=manager'のように変数指定してコマンドを実行しましょう。)

$ terraform init

# 疎通確認のため、一旦リソースを展開
$ terraform plan
$ terraform apply

# 疎通確認がOKであれば、 albを切っておく
$ terraform apply -var='enable_alb=false'

# profileを指定するなら以下のようにコマンドを叩けばOK
$ terraform apply -var='enable_alb=false' -var='profile=manager'

3-3. Fargateの構築

「Fargateとはなんぞや?」という方には、以下のサイトがわかりやすく説明しているので、参考にすると良いでしょう。

今回は、1クラスター1サービスを作成します。

test-prod-tfappクラスター
 L test-prod-tfappサービス

構築の流れとしては、

  • appディレクトリでECSとIAM定義
  • logディレクトリでCloudWatchログ定義

をイメージします。

ECSクラスターの作成
envs/prod/app/に以下のファイルを作成します。

envs/prod/app/ecs.tf
resource "aws_ecs_cluster" "this" {
    name = "${local.name_prefix}-${local.service_name}"

    capacity_providers = [
        "FARGATE",
        "FARGATE_SPOT"
    ]

    tags = {
        Name = "${local.name_prefix}-${local.service_name}"
    }
}

起動タイプに"FARGATE"を設定します。
"FARGATE_SPOT"を当てることで中断可能性のあるものは7割引の料金でFargateを利用できるので、こちらも設定します。

タスク実行ロールの作成
タスクを実行させるにはタスク実行専用のIAMロールが必要になります。
envs/prod/app/に以下のファイルを作成します。

envs/prod/app/iam.tf
 # ECSタスク実行ロール
resource "aws_iam_role" "ecs_task_execution" {
    name = "${local.name_prefix}-${local.service_name}-ecs-task-execution"
    assume_role_policy = jsonencode(
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "ecs-tasks.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        }
    )

    tags = {
      Name = "${local.name_prefix}-${local.service_name}-ecs-task-execution"
    }
}

data "aws_iam_policy" "ecs_task_execution" {
    arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
    role = aws_iam_role.ecs_task_execution.name
    policy_arn = data.aws_iam_policy.ecs_task_execution.arn
}


# ECS SSMパラーメータReadロール
resource "aws_iam_policy" "ssm" {
    name = "${local.name_prefix}-${local.service_name}-ssm"
    policy = jsonencode(
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": [
                        "ssm:GetParameters",
                        "ssm:GetParameter"
                    ],
                    "Resource": "arn:aws:ssm:${data.aws_region.current.id}:${data.aws_caller_identity.self.account_id}:parameter/${local.system_name}/${local.env_name}/*"
                }
            ]
        }
    )

    tags = {
      Name = "${local.name_prefix}-${local.service_name}-ssm"
    }
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution_ssm" {
    role = aws_iam_role.ecs_task_execution.name
    policy_arn = aws_iam_policy.ssm.arn
}

# ECS Execロール(コンテナ調査用)
resource "aws_iam_role" "ecs_task" {
    name = "${local.name_prefix}-${local.service_name}-ecs-task"
    assume_role_policy = jsonencode(
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "ecs-tasks.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        }
    )

    tags = {
      Name = "${local.name_prefix}-${local.service_name}-ecs-task"
    }
}

resource "aws_iam_role_policy" "ecs_task_ssm" {
    name = "ssm"
    role = aws_iam_role.ecs_task.id

    policy = jsonencode(
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": [
                        "ssmmessages:CreateControlChannel",
                        "ssmmessages:CreateDataChannel",
                        "ssmmessages:OpenControlChannel",
                        "ssmmessages:OpenDataChannel",
                    ],
                    "Resource": "*"
                }
            ]
        }
    )
}

iamには、タスク実行ロール、パラメータ読み取りロール、コンテナの中に入ることができるロール(for ECS Exec)ための権限を定義しています。
リソースのリージョン、自身のアカウントIDが必要なので、data.tfを作成して参照できるようにします。

envs/prod/app/data.tf
data "aws_caller_identity" "self" {}

data "aws_region" "current" {}

ここまでコードを記述すれば、envs/prod/app/でapplyを実行します。

$ terraform apply

CloudWatchLogグループの作成
nginxとphpコンテナそれぞれのCloudWatchロググループを作成します。
envs/prod/log/app/を新規にディレクトリ作成して、以下のファイルを作成します。

envs/prod/log/app/backend.tf
terraform {
  backend "s3" {
    bucket = "tfapp-tfstate"
    key    = "test/prod/log/tfapp_v1.5.0.tfstate"
    region = "ap-northeast-1"
  }
}
envs/prod/log/app/locals.tf
locals {
  service_name = "tfapp"
}

envs/prod/log/appに移動して、以下を実行します。

$ ln -fs ../../provider.tf provider.tf
$ ln -fs ../../shared_locals.tf shared_locals.tf

ここで一度、terraform initを実行します。
続いて、それぞれのコンテナのロググループを作成します。

envs/prod/log/app/cloudwatch_log.tf
resource "aws_cloudwatch_log_group" "nginx" {
    name = "/ecs/${local.name_prefix}-${local.service_name}/nginx"

    retention_in_days = 90
}

resource "aws_cloudwatch_log_group" "php" {
    name = "/ecs/${local.name_prefix}-${local.service_name}/php"

    retention_in_days = 90
}

ここまで完了すれば、一度planを実行しリソースを追加リソースを確認、applyを実行します。

$ terraform plan
$ terraform apply

パラメータストアの値を作成
タスクの各コンテナは起動時にパラーメータストアを参照して環境変数として取り込むことができます。
今回だとphpのLaravelでは環境変数APP_KEYが必要ですが、このような機密性の高い情報をソースコードで下手書きで管理するのは良くないので、代わりにパラメータストアで暗号化して登録します。

# APP_KEYの生成方法
$ php artisan key:generate --show

base64:xxxxxxxxxxxxxxxxxxxxxxxxxxx

マネジメントコンソールを開き、「AWS System Manager > パラメータストア」を開く

以下のように設定する

キーの名前:/test/prod/tfapp/APP_KEY
利用枠:標準
タイプ: 安全な文字列
KMSキーソース:現在のアカウント
KMSキーID:alias/aws/ssm
値:base64:xxxxxxxxxxxxxxxxxxxxxxxxxxx

これで設定は完了です。

タスク定義の作成
タスクはenvs/prod/app/ecs.tfで定義します。
すでに作成したecs.tfに以下を追記します。

envs/prod/app/ecs.tf
.
.
.
resource "aws_ecs_task_definition" "this" {
    family = "${local.name_prefix}-${local.service_name}"
    task_role_arn = aws_iam_role.ecs_task.arn
    network_mode = "awsvpc"
    requires_compatibilities = [
        "FARGATE",
    ]
    execution_role_arn = aws_iam_role.ecs_task_execution.arn

    memory = 512
    cpu = 256

    container_definitions = jsonencode(
        [
            {
            name = "nginx"
            image = "${module.nginx.ecr_repository_this_repository_url}:latest"

            portMappings = [
                {
                    containerPort = 80
                    protocol = "tcp"
                }
            ]

            environment = []

            secrets = []

            dependsOn = [
                {
                    containerName = "php"
                    condition = "START"
                }
            ]

            mountPoints = [
                {
                    containerPath = "/var/run/php-fpm"
                    sourceVolume = "php-fpm-socket"
                }
            ]

            logConfiguration = {
                logDriver = "awslogs"
                options = {
                    awslogs-group = "/ecs/${local.name_prefix}-${local.service_name}/nginx"
                    awslogs-region = data.aws_region.current.id
                    awslogs-stream-prefix = "ecs"
                }
            }
        },
        {
            name = "php"
            image = "${module.php.ecr_repository_this_repository_url}:latest"
            portMappings = []
            environment = []
            secrets = [
                {
                    name = "APP_KEY"
                    valueFrom = "/${local.system_name}/${local.env_name}/${local.service_name}/APP_KEY"
                }
            ]

            mountPoints = [
                {
                    containerPath = "/var/run/php-fpm"
                    sourceVolume = "php-fpm-socket"
                }
            ]

            logConfiguration = {
                logDriver = "awslogs"
                options = {
                    awslogs-group = "/ecs/${local.name_prefix}-${local.service_name}/php"
                    awslogs-region = data.aws_region.current.id
                    awslogs-stream-prefix = "ecs"
                }
            }
        }
        ]
    )

    volume {
      name = "php-fpm-socket"
    }

    tags = {
        Name = "${local.name_prefix}-${local.service_name}"
    }
}

ここまで完了して、planで確認(特にSSMで設定したキーが正しいパスであるかタスク定義のsecretsを確認)、terraform applyを実行します。

$ terraform plan
$ terraform apply

ターゲットグループの作成
固定レスポンスで返していたALBリソース箇所をターゲットグループに変更して、トラフィックをフォワードするように設定します。
envs/prod/routing/tfapp_net/alb.tfを以下のように修正します。

envs/prod/routing/tfapp_net/alb.tf
resource "aws_lb_listener" "https" {
.
.
.
    default_action {
      type             = "forward"
      target_group_arn = aws_lb_target_group.tfapp.arn
    }

#   default_action {
#     type = "fixed-response"
#     fixed_response {
#       content_type = "text/plain"
#       message_body = "Test fixed response"
#       status_code  = 200
#     }
#   }
}

# 以下追記
resource "aws_lb_target_group" "tfapp" {
  name = "${local.name_prefix}-tfapp"

  deregistration_delay = 60
  port                 = 80
  protocol             = "HTTP"
  target_type          = "ip"
  vpc_id               = data.terraform_remote_state.network_main.outputs.vpc_this_id

  health_check {
    healthy_threshold   = 2
    interval            = 30
    matcher             = 200
    path                = "/healthcheck"
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 2
  }

  tags = {
    Name = "${local.name_prefix}-tfapp"
  }
}

health_check のパスは"/healthcheck"とします。
コンテナの状態確認は、ALBがIPベースでリクエストすることで実現します。
この時、デフォルトでは"/"ですが、"/healthcheck"とすることで、前に設定したnginxのcontainer.confの下部default_server内の location = /healthcheck にマッチさせて、コンテナの状態を監視させます。
ここで作成したターゲットグループは、別ディレクトリのECSサービスでターゲットグループARNを必要とするので、outputs.tfを作成して外部ディレクトリから参照できるようにします。

envs/prod/routing/tfapp_net/outputs.tf
output "lb_target_group_tfapp_arn" {
    value = aws_lb_target_group.tfapp.arn
}

以降の操作は課金発生します
これまでは余計な課金を避けて、-var='enable_alb=false'を指定してリソースを展開していましたが、ここであえて課金を考慮してターゲットグループを作成する必要があります。理由は、後述のECSサービス定義で、ターゲットグループが作成されていないとデプロイエラーになるからです。
したがって、以降の作業とデプロイは課金考慮の上で実行してください。

ここまで定義すれば、一度applyをします。

$ terraform apply

ECSサービスの作成
envs/prod/app/のdata.tfとecs.tfを修正します。
data.tfは、先ほど設定したターゲットグループとプライベートサブネットを参照できるようにし、
ecs.tfはdata.tfで定義した値を設定します。

envs/prod/app/data.tf
data "aws_caller_identity" "self" {}

data "aws_region" "current" {}

# 以降追加
data "terraform_remote_state" "network_main" {
    backend = "s3"
    config = {
        bucket = "tfapp-tfstate"
        key    = "${local.system_name}/${local.env_name}/network/main_v1.5.0.tfstate"
        region = "ap-northeast-1"
    }
}

data "terraform_remote_state" "routing_tfapp_net" {
    backend = "s3"
    config = {
        bucket = "tfapp-tfstate"
        key    = "${local.system_name}/${local.env_name}/routing/tfapp_net_v1.5.0.tfstate"
        region = "ap-northeast-1"
    }
}
envs/prod/app/ecs.tf
.
.
.
resource "aws_ecs_service" "this" {
    name = "${local.name_prefix}-${local.service_name}"
    cluster = aws_ecs_cluster.this.arn
    capacity_provider_strategy {
      capacity_provider = "FARGATE_SPOT"
      base = 0
      weight = 1
    }

    platform_version = "1.4.0"

    task_definition = aws_ecs_task_definition.this.arn
    desired_count = var.desired_count
    deployment_minimum_healthy_percent = 100
    deployment_maximum_percent = 200

    load_balancer {
      container_name = "nginx"
      container_port = 80
      target_group_arn = data.terraform_remote_state.routing_tfapp_net.outputs.lb_target_group_tfapp_arn
    }

    health_check_grace_period_seconds = 60

    network_configuration {
      assign_public_ip = false
      security_groups = [
        data.terraform_remote_state.network_main.outputs.security_group_vpc_id
      ]
      subnets = [
        for s in data.terraform_remote_state.network_main.outputs.subnet_private : s.id
      ]
    }

    enable_execute_command = true

    tags = {
        Name = "${local.name_prefix}-${local.service_name}"
    }
}

desired_countを0にするとタスクを停止させることができます。
コマンドを通して、タスクを停止させたい場合を考慮して、variables.tfを新規に作成して定義します。

envs/prod/app/variables.tf
variable "desired_count" {
    type = number
    default = 1
}

実際にタスクを起動させたい場合は

$ terraform plan
$ terraform apply

で実行できます。

タスクを停止させたい or 不要な料金を発生させたくない場合は、

$ terraform apply -var="desired_count=0"

を実行すれば、タスクを停止してコストがかからなくなります。

Fargateを起動
起動させるには、以下が必要です。

  • ALB作成済み
  • NATゲートウェイ作成済み
  • ECRにnginxとphpのイメージが登録済み

上2つについては、課金を回避して作成していない場合は再作成する必要があります。
それぞれ、

ALB

# デプロイまでおおよそ3分程度かかる
$ cd envs/prod/routing/tfapp_net
$ terraform apply

NATゲートウェイ

# デプロイまでおおよそ3分程度かかる
$ cd envs/prod/network/main
$ terraform apply

で再作成してください。

ECRにnginxとphpのイメージがあることを確認して、

# デプロイまでおおよそ1分弱程度かかる
$ cd envs/prod/app/
$ terraform apply

を実行すれば、desired_countのdefalut=1に従ってタスクが起動します。
(タスクがアクティブになるまで少々待つ)

無事実行できれば、以下のURLからクラスターやタスクの起動状況、設定したサブドメインを叩いてアプリの状態を確認できればOKです。

image.png

確認後、余計な課金を防ぐのであれば、以下のコマンドを各ディレクトリで実行します。

ALB

$ cd envs/prod/routing/tfapp_net
$ terraform apply -var="enable_alb=false"

NATゲートウェイ

$ cd envs/prod/network/main
$ terraform apply -var='enable_nat_gateway=false'

ECRタスク

$ cd envs/prod/app
$ terraform apply -var="desired_count=0"

次回

ここまでで3.Terraformでインフラ構築.ver2 が終了しました。
次は、Terraformでインフラ構築.ver3 において、GithubActionsと連携してCI/CDを構築します。

他の記事

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?