Supabase,Vercel,AWS(terraform)でGoとNextのアプリをデプロイしてみた
個人開発をしていた自分向け&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はリージョンがなく設定上
- CloudFrontとALBの通信のために必要な証明書を作成
- ALBはリージョンが任意なので、今回構築する
ap-northeast-1
にACMを作成
- ALBはリージョンが任意なので、今回構築する
- ドメインを管理しているので、証明書の検証にはドメインを利用
- 設定をミスってたときに面倒だなぁという気持ちから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にしています。
このようになったのは以下のような紆余曲折がありました。
- private subnet + VPC Endpointで設定するがSupabaseに繋げられないことに気づく
- Nat Gatewayを追加することを考えるが、NatがあるならVPC Endpointが不要なことに気づく
- Nat Gateway値段を調べたら個人で使うには高すぎて絶望する
- 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