👾

Supabase,Vercel,AWS(terraform)でGoとNextのアプリをデプロイしてみた

2025/02/13に公開

個人開発をしていた自分向け&Playgroudなアプリをデプロイしてみたので、それまでに行ったことを記録したいと思います。

前提

自力で色々頑張っているつもりですが、普通に間違ってる設定とかあると思いますのでご了承ください

ざっくりな構成

細かい部分は省略していますが、ざっくりとした構成は以下のようになっています。

簡易構成図

あんまりこういう図を書いたことがないので書き方がよくわかんなかったです。
雰囲気が伝わったらいいなぁ。

Supabaseはipv6ですが、今回はそこまで設定するのが辛かったため暫定ですがipv4で接続する可能なTransaction poolerで接続するようにしています。
よくないんでしょうけどね...

構築

ここからそれぞれの構築で行った設定などを記載していきます。
Terraformは不慣れなときに切り出しをするとカオスになると思ったので、全部ベタ書きにしています。ご了承ください。

Supabase, Vercelは正直やることが殆どないのでほとんど割愛します。
AWSへ(から)の疎通部分だけ記載したいと思います。

Terraform実行用のアカウント作成

これは過去に記事にしていたので、そちらを記載しておきます。
terraformでterraform実行ユーザや環境をセットアップする for AWS

上記ではTerraformに必要な権限を可能な限り絞って、一つずつ丁寧にRoleに追加する方法を取りました。
ただ色々とやっていて思ったのですが、Adminより1つだけ弱い限りなくAdminみたいな権限が存在するはずなのでそっちを使っちゃったほうが楽かもしれません。

正直このTerraformにアタッチしてる権限だけでも強奪されたら致命的なので、あんまり変わらんよな...という気がします。(気持ちは悪いけど

ドメインを設定してTLSのための証明書を取得する

まずはドメインを設定していきます。
別にこの順番で設定する必要があるわけではないので、順番は気にしないでください。

data "terraform_remote_state" "setup" {
  backend = "s3"

  config = {
    bucket = "myapp-terraformstate"
    key    = "setup/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

terraform {
  required_version = ">= 1.10"

  backend "s3" {
    bucket  = "myapp-terraformstate"
    key     = "domain/terraform.tfstate"
    encrypt = true
    region  = "ap-northeast-1"
  }
}

provider "aws" {
  region = "ap-northeast-1"

  assume_role {
    role_arn = data.terraform_remote_state.setup.outputs.terraform_execute_role_arn
  }
}

# ACM用
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

locals {
  domain = "your-domain.net"
}

##### zones
resource "aws_route53_zone" "main" {
  name          = local.domain
  force_destroy = false
}
resource "aws_route53_record" "main_ns" {
  name    = aws_route53_zone.main.name
  zone_id = aws_route53_zone.main.id
  type    = "NS"
  records = [
    aws_route53_zone.main.name_servers[0],
    aws_route53_zone.main.name_servers[1],
    aws_route53_zone.main.name_servers[2],
    aws_route53_zone.main.name_servers[3],
  ]
  ttl = 86400
}

# API用サブドメイン
resource "aws_route53_zone" "myapp_api" {
  name = "myapp-api.${aws_route53_zone.main.name}"
}
resource "aws_route53_record" "myapp_api_ns" {
  zone_id = aws_route53_zone.main.zone_id
  name    = aws_route53_zone.myapp_api.name
  type    = "NS"
  ttl     = 86400
  records = aws_route53_zone.myapp_api.name_servers
}

# lb用サブドメイン
resource "aws_route53_zone" "myapp_lb" {
  name = "myapp-lb.${aws_route53_zone.main.name}"
}
resource "aws_route53_record" "myapp_lb_ns" {
  zone_id = aws_route53_zone.main.zone_id
  name    = aws_route53_zone.myapp_lb.name
  type    = "NS"
  ttl     = 86400
  records = aws_route53_zone.myapp_lb.name_servers
}

resource "aws_acm_certificate" "your-domain_cf" { # CloudFront用
  provider = aws.us_east_1

  domain_name       = "*.${local.domain}"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
  depends_on = [aws_route53_zone.main]
}
resource "aws_acm_certificate" "your-domain_lb" { # ALB用
  domain_name       = "*.${local.domain}"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
  depends_on = [aws_route53_zone.main]
}

# 検証用のレコード定義
resource "aws_route53_record" "cert_validation_cf" {
  for_each = {
    for dvo in aws_acm_certificate.your-domain_cf.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  allow_overwrite = true

  zone_id = aws_route53_zone.main.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}
resource "aws_route53_record" "cert_validation_lb" {
  for_each = {
    for dvo in aws_acm_certificate.your-domain_lb.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  allow_overwrite = true

  zone_id = aws_route53_zone.main.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}


# 検証完了までの待機
resource "aws_acm_certificate_validation" "your-domain_cf" {
  provider                = aws.us_east_1
  certificate_arn         = aws_acm_certificate.your-domain_cf.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation_cf : record.fqdn]
}
resource "aws_acm_certificate_validation" "your-domain_lb" {
  certificate_arn         = aws_acm_certificate.your-domain_lb.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation_lb : record.fqdn]
}

補足

  • 外部からCloudFrontへの通信のために必要な証明書を作成
    • CloudFrontはリージョンがなく設定上us-east-1の指定が必要なので、そのリージョンにACMを作成
  • CloudFrontとALBの通信のために必要な証明書を作成
    • ALBはリージョンが任意なので、今回構築するap-northeast-1にACMを作成
  • ドメインを管理しているので、証明書の検証にはドメインを利用
  • 設定をミスってたときに面倒だなぁという気持ちからTTLは短く設定(その後に長くし忘れ)

ACMをus-east-1に作成するのとかがAWSやCloudFrontのことをよくわかってないと忘れがちのようです。
僕も知りませんでした。まぁでも確かにCDNにリージョンってなんなの? という話ですよねぇ。

ネットワークを構築する

VPCやSubnet, InternetGatewayを作成していきます。
あまり複雑なことはしておらず、ネットワークとしてはPublicになっておりin, outに制限をかけていません。
またALBを使いたいのでsubnetを2つ用意しています。

########## Network
# VPCの作成
resource "aws_vpc" "myapp" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
}

# Subnetsの作成
resource "aws_subnet" "public_myapp_a" {
  vpc_id            = aws_vpc.myapp.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${local.region}a"
}
resource "aws_subnet" "public_myapp_c" {
  vpc_id            = aws_vpc.myapp.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "${local.region}c"
}

# インターネットGateway
resource "aws_internet_gateway" "myapp" {
  vpc_id = aws_vpc.myapp.id
}

# Route Table
resource "aws_route_table" "public_myapp" {
  vpc_id = aws_vpc.myapp.id

  route {
    cidr_block = "0.0.0.0/0" # IPv4の全ての通信
    gateway_id = aws_internet_gateway.myapp.id
  }
}

# Route Tableの関連付け
resource "aws_route_table_association" "public_myapp_a" {
  subnet_id      = aws_subnet.public_myapp_a.id
  route_table_id = aws_route_table.public_myapp.id
}
resource "aws_route_table_association" "public_myapp_c" {
  subnet_id      = aws_subnet.public_myapp_c.id
  route_table_id = aws_route_table.public_myapp.id
}

private subnetを利用していない理由

今回private subnetを利用しておらずネットワークをpublicにしています。
このようになったのは以下のような紆余曲折がありました。

  1. private subnet + VPC Endpointで設定するがSupabaseに繋げられないことに気づく
  2. Nat Gatewayを追加することを考えるが、NatがあるならVPC Endpointが不要なことに気づく
  3. Nat Gateway値段を調べたら個人で使うには高すぎて絶望する
  4. ECSへの通信をCloudFront <---> ALB <---> ECSという経路のみ許可すればpublicでもセーフなのでprivate subnetを諦める

本来であれば設定ミスの可能性や「なにか」があっては困るのでprivateにしておきたいのですが、個人で小さいものなのと 設定ミスさえなければ この方針でも安全なはずなので妥協しました。
お仕事で構築する方はNat GatewayとVPC Endpointとかを使いましょう。

通信費用なども考えるとNat一本よりVPC Endpointも一緒に使ったほうが安いんだっけ...?

APIサーバ用のCloudFront/ALB設定

この辺からしんどくなってきます。
僕はしんどかったです。慣れた人には簡単みたいですが...

ちょっと長いのでざっくり方針

  • ALBはCloudFrontからの通信しか受け付けないようにする
  • www --> CloudFront ---> ALBのそれぞれの経路をhttpsで接続する

ちょっとした補足はコードに書いていこうと思います。

########## CloudFront / ALB ##########
# ALBが受け付けるCloudFrontからの通信を識別するための情報を用意
data "aws_ec2_managed_prefix_list" "cloudfront" {
  name = "com.amazonaws.global.cloudfront.origin-facing"
}

data "aws_ssm_parameter" "cloudfront_header_value" {
  name = "/CloudFront/ALBCustomeHeader"
}

########## ALB
# ALBのセキュリティグループ
resource "aws_security_group" "myapp_alb" {
  vpc_id = aws_vpc.myapp.id

  # cloudfront --> albはhttpsのみなので443のみを許可する
  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
  }
  # ALBから外向きの通信は制限する必要はないので、制限をしない
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# ALBのリソース定義
resource "aws_lb" "myapp" {
  name                       = "myapp"
  internal                   = false
  load_balancer_type         = "application"
  security_groups            = [aws_security_group.myapp_alb.id]
  subnets                    = [aws_subnet.public_myapp_a.id, aws_subnet.public_myapp_c.id]
  enable_deletion_protection = false
}

# ターゲットグループの定義
resource "aws_lb_target_group" "myapp" {
  name        = "myapp-${substr(uuid(), 0, 6)}" # 名前が重複すると作り直しなどの際にどうしようもなくなるのでランダムに生成
  target_type = "ip"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.myapp.id

  health_check {
    enabled             = true
    interval            = 30
    path                = "/health"
    protocol            = "HTTP"
    healthy_threshold   = 5
    unhealthy_threshold = 2
    timeout             = 5
    matcher             = "200"
  }

  lifecycle {
    create_before_destroy = true
  }
}

# リスナーの設定
data "aws_acm_certificate" "your-domain_lb" {
  provider = aws.ap-northeast-1
  domain   = "*.your-domain.net"
}
resource "aws_lb_listener" "myapp" {
  load_balancer_arn = aws_lb.myapp.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = data.aws_acm_certificate.your-domain_lb.arn # 作成したALB用の証明書を設定

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "Not found"
      status_code  = "404"
    }
  }
}

# リスナールールの設定
resource "aws_lb_listener_rule" "myapp" {
  listener_arn = aws_lb_listener.myapp.arn
  priority     = 10

  # ALBからECSへは素直に通信を通してほしいのでforward
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.myapp.arn
  }

  # CloudFrontから埋め込んだ情報をヘッダーに持つもののみを許可する
  condition {
    http_header {
      http_header_name = "X-Custom-Header" # CloudFrontからのリクエストヘッダーを指定
      values           = [data.aws_ssm_parameter.cloudfront_header_value.value]
    }
  }
}

# ドメイン情報を参照
data "aws_route53_zone" "myapp_api" {
  name = "myapp-api.your-domain.net"
}
data "aws_route53_zone" "myapp_api_lb" {
  name = "myapp-lb.your-domain.net"
}

# 証明書を当てるためにCF, LBそれぞれにドメインを設定
resource "aws_route53_record" "myapp_api_cf" {
  zone_id = data.aws_route53_zone.myapp_api.zone_id
  name    = data.aws_route53_zone.myapp_api.name
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.myapp_api.domain_name
    zone_id                = aws_cloudfront_distribution.myapp_api.hosted_zone_id
    evaluate_target_health = false
  }
}
resource "aws_route53_record" "myapp_api_lb" {
  zone_id = data.aws_route53_zone.myapp_api_lb.zone_id
  name    = data.aws_route53_zone.myapp_api_lb.name
  type    = "A"
  alias {
    name                   = aws_lb.myapp.dns_name
    zone_id                = aws_lb.myapp.zone_id
    evaluate_target_health = false
  }
}

########## APIサーバ用CloudFront設定
data "aws_acm_certificate" "your-domain_cf" {
  provider = aws.us-east-1
  domain   = "*.your-domain.net"
}
resource "aws_cloudfront_distribution" "myapp_api" {
  aliases             = [data.aws_route53_zone.myapp_api.name]
  enabled             = true
  is_ipv6_enabled     = true # 指定しているが、使われてないのでfalseでもいい
  default_root_object = "index.html"
  price_class         = "PriceClass_200" # 日本以外から使われる予定のないサービスなので安めのプランを指定

  origin {
    domain_name = aws_route53_record.myapp_api_lb.fqdn # ALBのDNS名
    origin_id   = aws_route53_record.myapp_api_lb.name

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1", "TLSv1.1", "TLSv1.2", "SSLv3"]
    }

	# ALBからCloudFront用の通信を識別するための情報を付与
    custom_header {
      name  = "X-Custom-Header" # CloudFrontに追加するカスタムヘッダーを指定
      value = data.aws_ssm_parameter.cloudfront_header_value.value
    }
  }

  # APIサーバなのでキャッシュはさせていない
  default_cache_behavior {
    allowed_methods  = ["HEAD", "OPTIONS", "GET", "PUT", "POST", "DELETE", "PATCH"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = aws_route53_record.myapp_api_lb.name

    forwarded_values {
      query_string = true

      cookies {
        forward = "all"
      }
    }

    viewer_protocol_policy = "https-only"
    min_ttl                = 0
    default_ttl            = 0
    max_ttl                = 0
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  # CloudFront用に作成した証明書を利用
  viewer_certificate {
    acm_certificate_arn            = data.aws_acm_certificate.your-domain_cf.arn
    ssl_support_method             = "sni-only"
    cloudfront_default_certificate = false
  }

  retain_on_delete = false
}

補足

細かい部分の設定については正直完璧に把握してコントロールしているとは言い難いです。
先述していますが、やりたいことは通信経路の制限です。

各ネットワークがpublicになってしまっているので経路を絞ることで安全性を担保しようとしています。

subnetがprivateになっていないためALBからECSへの通信がhttpなのが少し気がかりですが、果たして...
VPC内部ではあるので大丈夫だと思っているんですが...

APIサーバのECS構築

続いてECSの構築をしていきます。
ECSはFargateを利用しています。
こちらもそこそこな量なので、コード内にコメントで補足します。

########## ECS
## ECSクラスター
resource "aws_iam_service_linked_role" "ecs" {
  aws_service_name = "ecs.amazonaws.com"
}

resource "aws_ecs_cluster" "myapp" {
  name = "myapp"
}

resource "aws_ecs_service" "myapp" {
  name            = "myapp"
  cluster         = aws_ecs_cluster.myapp.id
  task_definition = aws_ecs_task_definition.myapp.arn
  desired_count   = "1" # ALBを使ってる意味がないが、勉強用個人利用なので1で十分
  launch_type     = "FARGATE"

  enable_execute_command = true # デバッグを頻繁に行う状態であればtrue。不要になったらfalseでよい

  # ローリングデプロイ用
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200
  deployment_controller {
    type = "ECS" # default
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.myapp.arn
    container_name   = "myapp-api"
    container_port   = "8080"
  }

  network_configuration {
    subnets = [
      aws_subnet.public_myapp_a.id,
      aws_subnet.public_myapp_c.id,
    ]
    security_groups = [
      aws_security_group.ecs.id,
    ]
    assign_public_ip = true # 外部を通信するためにIPを付与
  }
}

resource "aws_security_group" "ecs" {
  name   = "myapp_ecs"
  vpc_id = aws_vpc.myapp.id
}

# ECSで稼働するアプリは8080で稼働しているため8080を許可
resource "aws_security_group_rule" "ecs_ingress_http_myip" {
  from_port                = "8080"
  to_port                  = "8080"
  protocol                 = "tcp"
  type                     = "ingress"
  security_group_id        = aws_security_group.ecs.id
  source_security_group_id = aws_security_group.myapp_alb.id
}
resource "aws_security_group_rule" "ecs_egress_all_all" {
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  security_group_id = aws_security_group.ecs.id
  type              = "egress"
  cidr_blocks       = ["0.0.0.0/0"]
}

## ECS Task用のRoleを作成・設定
data "aws_iam_policy_document" "myapp_ecs_assumerole" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}
resource "aws_iam_role" "myapp_ecs_task" {
  name               = "myapp-ecs-task"
  assume_role_policy = data.aws_iam_policy_document.myapp_ecs_assumerole.json
}
# containerにあるアプリから必要な権限を付与する
resource "aws_iam_policy" "myapp_ecs_task" {
  name = "myapp-ecs-task"
  policy = jsonencode({

    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Action" : [
          "s3:ListBucket",
          "s3:GetObject",
          "s3:GetObjectAcl",
          "s3:PutObject",
          "s3:PutObjectAcl",
          "s3:ReplicateObject",
          "s3:DeleteObject"

        ],
        "Effect" : "Allow",
        "Resource" : [
          "arn:aws:s3:::myapp/*"
        ]
      }
    ],
  })
}
resource "aws_iam_role_policy_attachment" "myapp_ecs_task_role" {
  role       = aws_iam_role.myapp_ecs_task.name
  policy_arn = aws_iam_policy.myapp_ecs_task.arn
}


## ECS 実行用のroleを作成・設定
resource "aws_iam_role" "myapp_ecs_exec" {
  name               = "myapp-ecs-exec"
  assume_role_policy = data.aws_iam_policy_document.myapp_ecs_assumerole.json
}
resource "aws_iam_policy" "myapp_ecs_exec" {
  name = "myapp-ecs-exec"
  policy = jsonencode({

    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Action" : [
          "ecr:GetAuthorizationToken",
          "ecr:BatchCheckLayerAvailability",
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
          "logs:CreateLogGroup",
          "ssm:GetParameter",
          "ssm:GetParameters",
          "secretsmanager:GetSecretValue",
          "kms:Decrypt"
        ],
        "Resource" : "*"
      },
    ],
  })
}
resource "aws_iam_role_policy_attachment" "myapp_ecs_exec_role" {
  role       = aws_iam_role.myapp_ecs_exec.name
  policy_arn = aws_iam_policy.myapp_ecs_exec.arn
}

## タスク定義
resource "aws_ecs_task_definition" "myapp" {
  family                   = "myapp-api-task"
  cpu                      = "256" # 1/4 vCPU
  memory                   = "512"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.myapp_ecs_exec.arn
  task_role_arn            = aws_iam_role.myapp_ecs_task.arn
  track_latest             = true

  # arm64でビルドしたシステムを動かしているためコチラを指定
  runtime_platform {
    operating_system_family = "LINUX"
    cpu_architecture        = "ARM64"
  }

  container_definitions = jsonencode([{
    name      = "myapp-api"
    image     = "your-ecr-repo-image"
    cpu       = 128
    account   = local.account_id
    regiont   = local.region
    essential = true

    linuxParameters = {
      initProcessEnabled = true
    }

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-region        = local.region
        awslogs-stream-prefix = "api"
        awslogs-group         = "/ecs/myapp/prod/api"
      }
    }

    portMappings = [
      {
        hostPort      = 8080
        protocol      = "tcp"
        containerPort = 8080
      }
    ]

    mountPoints = []
    volumesFrom = []
    # ecs execの場合
    # readonlyRootFilesystem = false
    readonlyRootFilesystem = true

    environment = [
      { name = "秘密じゃない環境変数", value = "値" },
    ]

    secrets = [
      { name = "秘密な環境変数", valuefrom = module.data_ssm.ssm_arns[local.ssm_names.app_etld] }, # moduleは後述
    ]
  }])
}

resource "aws_cloudwatch_log_group" "myapp_api" {
  name              = "/ecs/myapp/prod/api"
  retention_in_days = 30
}

# ECS autoscaling
## 個人利用のためECSを特定の時間のみ起動した状態にしたいため、その以下は設定
resource "aws_appautoscaling_target" "ecs_autoscaling_target" {
  service_namespace  = "ecs"
  scalable_dimension = "ecs:service:DesiredCount"

  resource_id = "service/${aws_ecs_cluster.myapp.name}/${aws_ecs_service.myapp.name}"

  min_capacity = 1
  max_capacity = 1
}

# 20:30にタスクを起動する
resource "aws_appautoscaling_scheduled_action" "up-ecs-task-scheduled-action" {

  name               = "up-ecs-task-scheduled-action"
  service_namespace  = aws_appautoscaling_target.ecs_autoscaling_target.service_namespace
  resource_id        = aws_appautoscaling_target.ecs_autoscaling_target.resource_id
  scalable_dimension = aws_appautoscaling_target.ecs_autoscaling_target.scalable_dimension
  schedule           = "cron(30 20 ? * * *)"
  timezone           = "Asia/Tokyo"

  scalable_target_action {
    max_capacity = aws_appautoscaling_target.ecs_autoscaling_target.max_capacity
    min_capacity = aws_appautoscaling_target.ecs_autoscaling_target.min_capacity
  }
}

# 1時00分にタスクを落とす
resource "aws_appautoscaling_scheduled_action" "down-ecs-task-scheduled-action" {

  name               = "down-ecs-task-scheduled-action"
  service_namespace  = aws_appautoscaling_target.ecs_autoscaling_target.service_namespace
  resource_id        = aws_appautoscaling_target.ecs_autoscaling_target.resource_id
  scalable_dimension = aws_appautoscaling_target.ecs_autoscaling_target.scalable_dimension
  schedule           = "cron(1 00 ? * * *)"
  timezone           = "Asia/Tokyo"

  scalable_target_action {
    min_capacity = 0
    max_capacity = 0
  }
}

補足

  • システムのデプロイはローリングデプロイで行う前提で設定している
  • 夜の間だけシステムを使えれば良いのでautoscalingのスケジューラを利用してインスタンスを起動・停止している
  • ECSにはExec RoleとTask Roleがある
    • ExecはECS Taskの起動などECS実行に必要な権限
    • Task RoleはTask側で必要になる権限
ssm参照用のmodule定義

ECSタスクの秘密情報を全部data属性で書くと気が狂いそうだったのでmoduleにしています。
通常みんなもっとmoduleを利用していると思うので記述不要かと思いますが、参考程度に記載します。

### 呼び出し側
locals {
  ssm_names = {
    db_host = "/hogehoge",
  }
}
module "data_ssm" {
  source = "../modules/ssm"
  names = [
    local.ssm_names.db_host,
  ]
}
# { name = "秘密な環境変数", valuefrom = module.data_ssm.ssm_arns[local.ssm_names.db_host] },

### 定義側
variable "names" {
  type = list(string)
  description = "List of SSM parameter names"
}

data "aws_ssm_parameter" "params" {
  for_each = toset(var.names)
  name = each.key
  with_decryption = true
}

output "ssm_arns" {
  value = { for k, v in data.aws_ssm_parameter.params : k => v.arn }
}

ファイル配置用のS3を構築

システムから利用するS3のBucketを作成していきます。
S3って利用する側としてはシンプルなイメージですが、設定は細かくて結構複雑なんですね...

resource "aws_s3_bucket" "myapp" {
  bucket = "myapp"
}

# ファイル配置をトリガーにLambdaを起動するような設定をしているため、notificationを有効化している
resource "aws_s3_bucket_notification" "myapp" {
  bucket      = aws_s3_bucket.myapp.id
  eventbridge = true
}

resource "aws_s3_bucket_public_access_block" "myapp" {
  bucket = aws_s3_bucket.myapp.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_logging" "myapp" {
  bucket        = aws_s3_bucket.myapp.id
  target_bucket = aws_s3_bucket.myapp.id // ログ送信先バケット
  target_prefix = "logs/"                // ログ送信先Prefix

  target_object_key_format {
    simple_prefix {}
  }
}

# 特にバージョニングは必要ないので停止
resource "aws_s3_bucket_versioning" "myapp" {
  bucket = aws_s3_bucket.myapp.id

  versioning_configuration {
    status = "Disabled"
  }
}

resource "aws_s3_bucket_policy" "myapp" {
  bucket = aws_s3_bucket.myapp.id
  policy = jsonencode(
    {
      Statement = [
        {
          Sid    = "S3PolicyStmt-DO-NOT-MODIFY-...."
          Action = "s3:PutObject"
          Effect = "Allow"
          Condition = {
            StringEquals = {
              "aws:SourceAccount" = "your-account-id"
            }
          }
          Principal = {
            Service = "logging.s3.amazonaws.com"
          }
          Resource = "arn:aws:s3:::myapp/*"
        },
        {
          Sid    = "S3PolicyStmt-DO-NOT-MODIFY-...."
          Action = "s3:PutObject"
          Effect = "Allow"
          Condition = {
            StringEquals = {
              "aws:SourceArn" = aws_cloudfront_distribution.s3_image.arn # CloudFront設定は後述
            }
          }
          Principal = {
            Service = "cloudfront.amazonaws.com"
          }
          Resource = "arn:aws:s3:::myapp/*"
        },
        {
          Sid    = "Allow CloudFront"
          Action = "s3:GetObject"
          Effect = "Allow"
          Principal = {
            AWS = aws_cloudfront_origin_access_identity.s3_image.iam_arn
          }
          Resource = "${aws_s3_bucket.myapp.arn}/*"
        },
      ]
      Version = "2012-10-17"
    }
  )
}

resource "aws_s3_bucket_server_side_encryption_configuration" "myapp" {
  bucket = aws_s3_bucket.myapp.id
  rule {
    bucket_key_enabled = false

    apply_server_side_encryption_by_default {
      kms_master_key_id = null
      sse_algorithm     = "AES256"
    }
  }
}
# S3を普通に利用できるのを管理アカウントに限定
data "aws_canonical_user_id" "current" {}
resource "aws_s3_bucket_acl" "myapp" {
  bucket = aws_s3_bucket.myapp.id

  access_control_policy {
    # This is the CloudFront log delivery group
    # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html#access-logs-granting-permissions-to-cf-to-put-object-in-s3
    grant {
      grantee {
        id   = data.aws_canonical_user_id.current.id
        type = "CanonicalUser"
      }
      permission = "FULL_CONTROL"
    }
    owner {
      id = data.aws_canonical_user_id.current.id
    }
  }
}

補足

  • バケットはPrivate
  • データの参照はCloudFrontを経由して署名付きURLで行う
  • ファイルの配置はPostPolicyを利用して行う

ファイル配置はPostPolicy前提ですが、おそらくこの設定だとPresignedURLでも動くんじゃないかと思います。

S3参照用のCloudFrontを設定する

S3の画像はCloudFront経由の署名付きURLで利用するため、そこに必要な設定を行っていきます。

data "aws_cloudfront_cache_policy" "s3_image" {
  name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_distribution" "s3_image" {
  # 独自ドメインは紐付けない

  # managed cache policyを利用する場合に指定する
  depends_on = [
    data.aws_cloudfront_cache_policy.s3_image,
  ]

  origin {
    domain_name = aws_s3_bucket.myapp.bucket_regional_domain_name
    origin_id   = aws_s3_bucket.myapp.id
    s3_origin_config {
      // CloudFrontからのアクセスのみ許可する
      origin_access_identity = aws_cloudfront_origin_access_identity.s3_image.cloudfront_access_identity_path
    }
  }

  enabled = true

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = aws_s3_bucket.myapp.id

    cache_policy_id = data.aws_cloudfront_cache_policy.s3_image.id

	# 必要に応じて調整してください
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400

    trusted_key_groups = [aws_cloudfront_key_group.s3_image.id]
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  # 独自ドメインを当てていないのでデフォルトの証明書を利用する
  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

# CloudFront origin access id
resource "aws_cloudfront_origin_access_identity" "s3_image" {}

# CloudFront public key
resource "aws_cloudfront_public_key" "s3_image" {
  encoded_key = file("cf_presigned_url_public.pem")
  name        = "s3-public-key"
}

# CloudFront key group
resource "aws_cloudfront_key_group" "s3_image" {
  items = [aws_cloudfront_public_key.s3_image.id]
  name  = "s3-key-group"
}

補足

S3参照用のCloudFrontにも独自ドメインを当てたかったのですが、そのドメインはVercelのNext.jsアプリにあたっていました。
真面目にやるのであれば、CloudFrontで通信を受けてファイル参照以外はVercel側に流すなどの設定を行うのだとおもいますが、そこまでは今はいいかな...ということでまだ行っていません (すみません)

署名に必要な鍵は以下のように作っています

# 秘密鍵
openssl genrsa -out cf_presigned_url_private.pem 2048

# 公開鍵
openssl rsa -pubout -in cf_presigned_url_private.pem -out cf_presigned_url_public.pem

システムから署名付きURLでデータ参照(Go)

参考程度ですが、実装を記載しておきます。

package cloudfront

import (
	"myapp/config"
	"myapp/core/file"
	"crypto/rsa"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign"
	"github.com/pkg/errors"
)

var _ file.Signer = (*ObjectSigner)(nil)

type ObjectSigner struct {
	cfg config.AWS
}

func NewObjectSigner(cfg config.AWS) ObjectSigner {
	return ObjectSigner{
		cfg: cfg,
	}
}

func (s ObjectSigner) Sign(key string) (string, error) {
	privKey, err := sign.LoadPEMPrivKeyPKCS8(s.cfg.CloudFrontPrivatePemReader())
	if err != nil {
		return "", errors.WithStack(err)
	}

	signer := sign.NewURLSigner(s.cfg.CloudFrontKeyID, privKey.(*rsa.PrivateKey))

	signedURL, err := signer.Sign(s.getFileURLFromKey(key), time.Now().Add(1*time.Hour))
	if err != nil {
		return "", errors.WithStack(err)
	}

	return signedURL, nil
}

func (s ObjectSigner) getFileURLFromKey(key string) string {
	return strings.Join([]string{s.cfg.CloudFrontURL, key}, "/")
}

// config.go
func (c AWS) CloudFrontPrivatePemReader() io.Reader {
	return strings.NewReader(strings.ReplaceAll(c.CloudFrontPrivatePemOneline, "\\n", "\n"))
}

秘密鍵はpemの中身を1行にしたものをssmに保存して、利用するときに改行文字を改行に置換して利用しています。

Vercelのドメイン設定

今回Vercelを利用するにあたって、ドメインの設定を追加しました。

# ドメイン情報を参照
data "aws_route53_zone" "myapp_front" {
  name = "your-domain.net"
}
# Google OAuth用の設定
resource "aws_route53_record" "google_oauth_txt" {
  zone_id = data.aws_route53_zone.myapp_front.zone_id
  name    = data.aws_route53_zone.myapp_front.name
  type    = "TXT"
  ttl     = "900"
  records = ["google-site-verification=hogehoge"]
}
# Vercelのnext.jsアプリ
resource "aws_route53_record" "myapp_front" {
  zone_id = data.aws_route53_zone.myapp_front.zone_id
  name    = "myapp.${data.aws_route53_zone.myapp_front.name}"
  type    = "CNAME"
  ttl     = "300"
  records = ["cname.vercel-dns.com."]
}

僕はGoogle OAuthを利用しているためその設定もしています。

Supabaseへの接続

ネットワークとしてはAWS側の設定が終わっているため、特に設定は必要ありません。
ただし注意事項があります。

Direct connectionでの接続はSupabase側がipv6での受付になっています。
そのためAWS側もipv6で書く設定を行う必要がありますが、ipv6で設定を行うためには全体的な修正が必要です。

今回はSupabase側の接続パターンとして用意されているTransaction poolerを利用することで回避しています。
Transaction pooler, Session poolerでの接続はipv4で行うことが可能です。

接続の実装(Go)

参考程度にですが、以下のような実装で接続可能です。

var handler *bun.DB
var once sync.Once

type Handler struct {
	db *bun.DB
}

func NewHandler(cfg config.Postgres) *Handler {
	once.Do(func() {
		sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(getDSN(cfg))))

		handler = bun.NewDB(sqldb, pgdialect.New())
	})

	return &Handler{
		db: handler,
	}
}

func getDSN(c config.Postgres) string {
	query := url.Values{}
	query.Add("sslmode", c.SSLMode)

	if c.CertPath != "" {
		query.Add("sslrootcert", c.CertPath)
	}

	return fmt.Sprintf(
		"postgres://%s:%s@%s:%s/%s?%s",
		c.User,
		c.Password,
		c.Host,
		c.Port,
		c.Database,
		query.Encode(),
	)
}

ssl_modeはverify-fullです。
connectionに証明書を渡す方法は色々調べたり試したのですが、cert_pathでファイルパスを指定するものしか見つけられませんでした。
鍵の文字列を直接渡せると楽なんですけどね...

dockerに同梱するなどして参照しましょう。(面倒ですね)

その他調整

前述している各システム側の修正以外にもちょいちょい修正が必要な場所がありましたので箇条書きにします。

  • PostPolicyの実装にX-Amz-Security-Tokenの考慮を追加
    • AssumeRoleをした権限でSignatureを生成する場合、SecurityTokenが必要になります。
  • Next.jsのnextconfigにimagesのremotePatterを追加
  • Cookie周りの調整

Github Actionsの設定

過去記事にも書いていますが、少し修正が入っているので記載します。

ECSの更新にtask-definition.json的なものが必要なのですが、それはterraformで管理しておりjsonは別で持ちたくありませんでした。
そのため今回はaws cliで直接引っ張ってくるようにしています。

name: Build and Push DockerImage to ECR

on: 
  push:
    branches:
      - main

env:
  AWS_ASSUME_ROLE_ARN: ${{secrets.MYAPP_ASSUME_ROLE_ARN}}
  AWS_REGION: ${{secrets.MYAPP_AWS_REGION}}
  ECR_REPOSITORY: myapp-api

jobs:
  build-and-push:
   
    runs-on: ubuntu-latest
    concurrency:
      group: ${{ github.ref }}
      cancel-in-progress: true

    # for OIDC AssumeRole
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      # https://github.com/docker/setup-qemu-action
      - name: Setup QEMU
        uses: docker/setup-qemu-action@v3

      # https://github.com/docker/setup-buildx-action
      - name: Setup Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3

      # https://github.com/aws-actions/configure-aws-credentials
      - name: Get AWS Credentials
        id: creds
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_ASSUME_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Download Supabase cert
        run: |
          aws s3 cp s3://myapp/env/prod-ca-2021.crt ./ 

      # https://github.com/docker/metadata-action
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ steps.creds.outputs.aws-account-id }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}
          tags: |
            type=raw,value=latest

      # https://github.com/aws-actions/amazon-ecr-login
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      # https://github.com/docker/build-push-action
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/arm64
          push : true
          tags: ${{ steps.meta.outputs.tags }}
          provenance: false

      # https://github.com/aws-actions/amazon-ecs-deploy-task-definition
      - name: Download task definition
        run: |
          aws ecs describe-task-definition --task-definition myapp-api-task --query taskDefinition > task-definition.json

      - name: Deploy to Amazon ECS service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: task-definition.json
          service: myapp
          cluster: myapp

あとがき

感想

恥ずかしながらまともに自力でAWSを構築するのは初めてに近くて苦労しました。
でも色々と学びがあったのでやってよかったです。

今回構築したものは本当に最低限で、本当はエラー通知の仕組みやログに関する調整とか正常系以外の動作を詰めないといけないんですが、まぁそれは追々...
あとterraformも1枚書きは厳しいので、分割して整理したいですね。dev, prodそれぞれ用意したりするときにベタ書きは普通に不便と言うか現実的ではないので。

参考にさせていただいたサイト

数多のサイトや記事を参考にしたため個別に列挙しきれないのですが、わかってる範囲で以下に記載します。
本当に助かりました...

[AWS]CloudFront(署名付きURL)+S3のコンテンツ配信をTerraformで構築してみた
【Terraformハンズオン】AWS CloudFront&ALB構成を実現しよう

Discussion