全部詰め込んだ サンプルコード を作成しました。とても長いので、要点だけを解説していきます。
これまで作ってきた VPC と パブリックホストゾーン を利用しています。
バグや改善点などがあれば、お気軽に教えてください。
工夫
- アプリ側とインフラ側のコードを完全に分離する。これによって、インフラの担当とアプリの担当で切り分けが簡単になる。
- docker の二段階ビルドをして、docker image を軽量にする。
- Cognito では初期ユーザーを作成できるようにする。それ以降も、ユーザー部分を変えれば、差分検出によってユーザーが追加変更される。
- ECS Exec を可能にすることで、デバッグが容易になる。
-
terraform apply時に、アプリをビルドしプッシュする自動化。 - VPC Endpoint で、ネットワークを閉域にする。
アプリ側のコードの解説
Dockerfile の他段階ビルド
FROM rust:1.89 AS builder
WORKDIR /build
COPY . .
RUN cargo build --release
FROM rust:1.89
WORKDIR /app
COPY --from=builder /build/target/release/app .
RUN chmod +x ./app
CMD ["./app"]
docker のビルドを 1 つ目で行い、そこからビルドされたアーティファクトだけをとりだしています。2 つ目のイメージもとを rust:1.89 にする必要がなかったかもしれません。cargo あたりはいらない気がしています。
Terraform 側のコードの解説
分量が多いので、基本的なところよりも応用的なところの解説をしていきます。基本的なところの解説は需要があればするかもしれないです。
ALB -> の通信で 443 ポートを全開放する
resource "aws_vpc_security_group_egress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
from_port = 443
to_port = 443
ip_protocol = "tcp"
# TODO!: limit to cognito security group, but this is impossible.
cidr_ipv4 = "0.0.0.0/0"
}
Cognito の authenticate は、以下の流れで通信しています。
- リクエスト元 → ALB にアクセス
- ALB → Cognito Hosted UI にリダイレクト(認証要求)
- ユーザーが Cognito で認証を行う
- Cognito → リクエスト元に OAuth トークンをリダイレクトで返す(ブラウザにセット)
- リクエスト元 → トークン付きで ALB に再リクエスト
- ALB がトークンを検証し、ECS にリクエストを転送
- ECS → ALB → リクエスト元 にレスポンスを返す
つまり、途中で ALB -> Cognito があるので、その分の egress rule が必要です。この部分をうまく扱う方法がわからず、毎回 0.0.0.0/0 としています。
いい方法ないですかね。
http リクエストのリダイレクト
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = 443
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
HTTP で ALB にリクエストが来た時に、HTTPS にリダイレクトをしています。これによって、HTTPS の認証フローを HTTP リクエスト時にも強要できます。
リスナールール
resource "aws_lb_listener_rule" "http" {
listener_arn = aws_lb_listener.https.arn
priority = 100
dynamic "action" {
for_each = var.need_authenticate ? [0] : []
content {
type = "authenticate-cognito"
authenticate_cognito {
scope = "openid"
user_pool_arn = aws_cognito_user_pool.this[0].arn
user_pool_client_id = aws_cognito_user_pool_client.this[0].id
user_pool_domain = aws_cognito_user_pool_domain.this[0].domain
}
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.http.arn
}
condition {
path_pattern {
values = ["/*"]
}
}
}
変数に応じて、 authenticate-cognito をつけるかどうかを動的に決めています。
実は、ALB -> ECS (target group) の通信は、HTTP です。名前にもそれが反映されています。
cognito user pool domain
resource "aws_cognito_user_pool_domain" "this" {
count = var.need_authenticate ? 1 : 0
domain = "auth.${var.sub_domain}"
certificate_arn = aws_acm_certificate.cognito[0].arn
user_pool_id = aws_cognito_user_pool.this[0].id
depends_on = [aws_route53_record.alb, aws_acm_certificate_validation.cognito]
}
依存関係に aws_route53_record.alb が必要です。 auth.<ドメイン> の形になっているので、 <ドメイン> の A レコードがないとuser pool domain が作成できません。
ECS タスク定義
resource "aws_ecs_task_definition" "this" {
family = "${var.prefix}-task-def"
execution_role_arn = aws_iam_role.ecs_task_exec.arn
task_role_arn = aws_iam_role.ecs_task.arn
cpu = 512
memory = 1024
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
container_definitions = jsonencode([
{
name = "${var.prefix}-ecs-container"
image = "${aws_ecr_repository.app.repository_url}:${data.aws_ecr_image.app.image_tag}@${data.aws_ecr_image.app.image_digest}"
cpu = 512
memory = 1024
portMappings = [
{
protocol = "tcp"
containerPort = var.app_port
hostPort = var.app_port
}
]
linuxParameters = {
initProcessEnabled = true
}
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-create-group = "true"
awslogs-group = "${var.prefix}/ecs"
awslogs-region = var.aws_region
awslogs-stream-prefix = "app"
}
}
}
])
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
tags = local.tags
}
タスク実行ロールとタスクロールは両方とも必要です。前者は、タスクの起動時に ECR などとからイメージをとってくる必要があります。後者は、タスク自体の操作に必要なポリシーをつける必要があります。
また、linuxParameters として initProcessEnabled を有効にする必要があります。これは特に、ECS Exec を有効化する時に必要です。なぜ必要かがあまりわかっていないです。なんでですか??
タスクロール
resource "aws_iam_role_policy" "ecs_task" {
name = "${var.prefix}-ecs-task-policy"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"logs:DescribeLogGroups",
"logs:CreateLogStream",
"logs:DescribeLogStrams",
"logs:PutLogEvents",
]
Effect = "Allow"
Resource = "*"
},
{
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
Effect = "Allow"
Resource = "*"
}
]
})
}
タスクからログを流すために、ログ関連のポリシーが入っています。
後者は、ECS Exec のために必要です。仕組みとしては、ssmMessage が中継元として使われる感じなので、それに関するポリシーが必要になるみたいです。
docker イメージのプッシュの自動化
resource "null_resource" "push_app_image" {
triggers = {
app_code_change = data.archive_file.app.output_base64sha256
script_hash = sha1(file("${path.module}/push_image.sh"))
}
provisioner "local-exec" {
command = <<EOT
chmod +x ${path.module}/push_image.sh
${path.module}/push_image.sh ${aws_ecr_repository.app.repository_url} ${var.aws_region} ${path.module}
EOT
}
depends_on = [aws_ecr_repository.app]
}
トリガーとして、実行するスクリプトとアプリケーション側のコードを設定してあります。これによって、再デプロイが正しく行われます。command の内容を変更したときに、再実行してくれませんでした。仕方なく、trigger に入れました。
VPC Endpoint
resource "aws_vpc_endpoint" "s3" {
count = var.use_nat_gateway ? 0 : 1
vpc_id = module.vpc.vpc_id
service_name = "com.amazonaws.${var.aws_region}.s3"
route_table_ids = [module.vpc.private_route_table_id]
vpc_endpoint_type = "Gateway"
tags = local.tags
}
resource "aws_vpc_endpoint" "interface" {
for_each = var.use_nat_gateway ? toset([]) : toset(
concat(
[
"ecr.api",
"ecr.dkr",
"logs",
"sts",
],
var.enable_execute_command ? [
"ssm",
"ssmmessages",
"ec2messages",
] : []
)
)
vpc_id = module.vpc.vpc_id
service_name = "com.amazonaws.${var.aws_region}.${each.value}"
vpc_endpoint_type = "Interface"
subnet_ids = module.vpc.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoint[0].id]
private_dns_enabled = true
tags = local.tags
}
resource "aws_security_group" "vpc_endpoint" {
count = var.use_nat_gateway ? 0 : 1
name = "${var.prefix}-vpc-endpoint-sg"
vpc_id = module.vpc.vpc_id
tags = local.tags
}
このようになっています。S3 はゲートウェイ型で、それ以外はインタフェース型となっています。思ったよりたくさんのインタフェース型 Endpoint が必要でした。
ゲートウェイ型は、 route table への追加が必要ですが、そこも Terraform が自動で行ってくれました。
インタフェース型は、ENI を裏側で作成しているはずですがそれらの管理もとても簡単でした。ENI のためにセキュリティグループが必要でした。
これらは難しいと思って NAT Gateway を使っていましたが、とても便利です。インタフェース型で、どれが必要かを見極めるのが難しいです。そこだけです。