ECS+Aurorasqlをterraformで構築
main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
}
data "aws_availability_zones" "available" {
state = "available"
}
# ==================== Variables ====================
variable "project" {
default = "admin-system"
}
variable "environment" {
default = "production"
}
variable "aws_region" {
default = "ap-northeast-1"
}
variable "vpc_cidr" {
default = "10.0.0.0/16"
}
variable "az_count" {
default = 2
}
variable "container_port" {
description = "nginx(フロント)が待ち受けるポート"
default = 80
}
variable "php_fpm_port" {
description = "app(php-fpm)が待ち受けるポート"
default = 9000
}
variable "app_cpu" {
default = "512"
}
variable "app_memory" {
default = "1024"
}
variable "app_desired_count" {
default = 2
}
variable "db_name" {
default = "admin_system"
}
variable "db_master_username" {
default = "admin"
}
variable "domain_name" {
description = "ACM証明書用ドメイン。未使用ならnull"
default = null
}
variable "nginx_ecr_repository_url" {
description = "既存nginx用ECRリポジトリURL"
default = null
}
variable "app_ecr_repository_url" {
description = "既存app(php-fpm)用ECRリポジトリURL"
default = null
}
variable "nginx_image" {
description = "初回デプロイ用の暫定nginxイメージ"
default = "public.ecr.aws/docker/library/nginx:1.28-alpine"
}
variable "container_image" {
description = "初回デプロイ用の暫定app(php-fpm)イメージ"
default = "public.ecr.aws/docker/library/php:8.3-fpm-alpine"
}
# ==================== Networking ====================
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.project}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project}-igw" }
}
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.project}-public-${count.index}" }
}
resource "aws_subnet" "private_app" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.project}-private-app-${count.index}" }
}
resource "aws_subnet" "private_db" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.project}-private-db-${count.index}" }
}
resource "aws_eip" "nat" {
count = var.az_count
domain = "vpc"
tags = { Name = "${var.project}-nat-eip-${count.index}" }
}
resource "aws_nat_gateway" "main" {
count = var.az_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "${var.project}-nat-${count.index}" }
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.project}-public-rt" }
}
resource "aws_route_table_association" "public" {
count = var.az_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = { Name = "${var.project}-private-rt-${count.index}" }
}
resource "aws_route_table_association" "private_app" {
count = var.az_count
subnet_id = aws_subnet.private_app[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
resource "aws_route_table_association" "private_db" {
count = var.az_count
subnet_id = aws_subnet.private_db[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# ==================== Security Groups ====================
resource "aws_security_group" "alb" {
name_prefix = "${var.project}-alb-"
vpc_id = aws_vpc.main.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"]
}
lifecycle { create_before_destroy = true }
tags = { Name = "${var.project}-alb-sg" }
}
resource "aws_security_group" "ecs_service" {
name_prefix = "${var.project}-ecs-"
vpc_id = aws_vpc.main.id
ingress {
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle { create_before_destroy = true }
tags = { Name = "${var.project}-ecs-sg" }
}
resource "aws_security_group" "aurora" {
name_prefix = "${var.project}-aurora-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.ecs_service.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle { create_before_destroy = true }
tags = { Name = "${var.project}-aurora-sg" }
}
# ==================== Aurora MySQL (Serverless v2) ====================
resource "aws_db_subnet_group" "aurora" {
name = "${var.project}-aurora-subnet-group"
subnet_ids = aws_subnet.private_db[*].id
tags = { Name = "${var.project}-aurora-subnet-group" }
}
resource "aws_rds_cluster" "aurora" {
cluster_identifier = "${var.project}-aurora"
engine = "aurora-mysql"
engine_mode = "provisioned"
engine_version = "8.0.mysql_aurora.3.08.0"
database_name = var.db_name
master_username = var.db_master_username
db_subnet_group_name = aws_db_subnet_group.aurora.name
vpc_security_group_ids = [aws_security_group.aurora.id]
manage_master_user_password = true
backup_retention_period = 7
preferred_backup_window = "17:00-18:00"
preferred_maintenance_window = "sun:18:00-sun:19:00"
storage_encrypted = true
deletion_protection = true
serverlessv2_scaling_configuration {
min_capacity = 0.5
max_capacity = 4
}
}
resource "aws_rds_cluster_instance" "aurora_instances" {
count = 2
identifier = "${var.project}-aurora-${count.index}"
cluster_identifier = aws_rds_cluster.aurora.id
instance_class = "db.serverless"
engine = aws_rds_cluster.aurora.engine
engine_version = aws_rds_cluster.aurora.engine_version
}
locals {
db_secret_arn = aws_rds_cluster.aurora.master_user_secret[0].secret_arn
}
# ==================== ECR ====================
resource "aws_ecr_repository" "nginx" {
count = var.nginx_ecr_repository_url == null ? 1 : 0
name = "${var.project}-nginx"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_repository" "app" {
count = var.app_ecr_repository_url == null ? 1 : 0
name = "${var.project}-app"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "nginx" {
count = var.nginx_ecr_repository_url == null ? 1 : 0
repository = aws_ecr_repository.nginx[0].name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "直近20世代のみ保持"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 20
}
action = { type = "expire" }
}]
})
}
resource "aws_ecr_lifecycle_policy" "app" {
count = var.app_ecr_repository_url == null ? 1 : 0
repository = aws_ecr_repository.app[0].name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "直近20世代のみ保持"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 20
}
action = { type = "expire" }
}]
})
}
locals {
nginx_ecr_repository_url = var.nginx_ecr_repository_url != null ? var.nginx_ecr_repository_url : try(aws_ecr_repository.nginx[0].repository_url, null)
app_ecr_repository_url = var.app_ecr_repository_url != null ? var.app_ecr_repository_url : try(aws_ecr_repository.app[0].repository_url, null)
}
# ==================== IAM & Secrets & Logging ====================
data "aws_iam_policy_document" "ecs_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_task_execution" {
name = "${var.project}-ecs-task-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "ecs_task_execution_secrets" {
name = "${var.project}-secrets-access"
role = aws_iam_role.ecs_task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = [local.db_secret_arn, aws_secretsmanager_secret.app_key.arn]
}]
})
}
resource "aws_iam_role" "ecs_task" {
name = "${var.project}-ecs-task"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
resource "random_id" "app_key_bytes" {
byte_length = 32
}
resource "aws_secretsmanager_secret" "app_key" {
name = "${var.project}/${var.environment}/app-key"
}
resource "aws_secretsmanager_secret_version" "app_key" {
secret_id = aws_secretsmanager_secret.app_key.id
secret_string = "base64:${random_id.app_key_bytes.b64_std}"
lifecycle {
ignore_changes = [secret_string]
}
}
resource "aws_cloudwatch_log_group" "nginx" {
name = "/ecs/${var.project}/nginx"
retention_in_days = 30
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project}/app"
retention_in_days = 30
}
# ==================== ECS Cluster & Task Definition & Service ====================
resource "aws_ecs_cluster" "main" {
name = "${var.project}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 100
}
}
resource "aws_ecs_task_definition" "app" {
family = "${var.project}-app"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.app_cpu
memory = var.app_memory
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([
{
name = "nginx"
image = var.nginx_image
essential = true
portMappings = [
{ containerPort = var.container_port, protocol = "tcp" }
]
dependsOn = [
{ containerName = "app", condition = "START" }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.nginx.name
awslogs-region = var.aws_region
awslogs-stream-prefix = "nginx"
}
}
},
{
name = "app"
image = var.container_image
essential = true
portMappings = [
{ containerPort = var.php_fpm_port, protocol = "tcp" }
]
environment = [
{ name = "APP_ENV", value = var.environment },
{ name = "DB_CONNECTION", value = "mysql" },
{ name = "DB_HOST", value = aws_rds_cluster.aurora.endpoint },
{ name = "DB_PORT", value = "3306" },
{ name = "DB_DATABASE", value = var.db_name },
{ name = "DB_USERNAME", value = var.db_master_username },
]
secrets = [
{ name = "APP_KEY", valueFrom = aws_secretsmanager_secret.app_key.arn },
{ name = "DB_PASSWORD", valueFrom = "${local.db_secret_arn}:password::" }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = var.aws_region
awslogs-stream-prefix = "app"
}
}
}
])
}
resource "aws_ecs_service" "app" {
name = "${var.project}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.app_desired_count
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private_app[*].id
security_groups = [aws_security_group.ecs_service.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "nginx"
container_port = var.container_port
}
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
depends_on = [aws_lb_listener.http]
lifecycle {
ignore_changes = [task_definition]
}
}
# ==================== ALB ====================
resource "aws_lb" "main" {
name = "${var.project}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
enable_deletion_protection = true
}
resource "aws_lb_target_group" "app" {
name = "${var.project}-tg"
port = var.container_port
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/up"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 15
matcher = "200"
}
}
resource "aws_acm_certificate" "app" {
count = var.domain_name != null ? 1 : 0
domain_name = var.domain_name
validation_method = "DNS"
lifecycle { create_before_destroy = true }
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
dynamic "default_action" {
for_each = var.domain_name != null ? [1] : []
content {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
dynamic "default_action" {
for_each = var.domain_name == null ? [1] : []
content {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
}
resource "aws_lb_listener" "https" {
count = var.domain_name != null ? 1 : 0
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.app[0].arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
# ==================== Autoscaling ====================
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 6
min_capacity = 2
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "ecs_cpu" {
name = "${var.project}-cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 60
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
# ==================== Outputs ====================
output "alb_dns_name" {
value = aws_lb.main.dns_name
}
output "nginx_ecr_repository_url" {
value = local.nginx_ecr_repository_url
}
output "app_ecr_repository_url" {
value = local.app_ecr_repository_url
}
output "aurora_endpoint" {
value = aws_rds_cluster.aurora.endpoint
}
output "aurora_reader_endpoint" {
value = aws_rds_cluster.aurora.reader_endpoint
}
output "ecs_cluster_name" {
value = aws_ecs_cluster.main.name
}
output "ecs_service_name" {
value = aws_ecs_service.app.name
}
output "db_credentials_secret_arn" {
value = local.db_secret_arn
}
Dockerfile
FROM php:8.3-fpm
WORKDIR /var/www/html
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
autoconf \
gcc \
make \
&& docker-php-ext-install \
pdo_mysql \
zip
RUN pecl install opentelemetry \
&& docker-php-ext-enable opentelemetry
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
CMD ["php-fpm"]
Dockerfile
FROM nginx:1.28-alpine
WORKDIR /var/www/html
COPY ./docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY . /var/www/html
EXPOSE 80
.docker/nginx/default.conf
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
}
location ~ /\.ht {
deny all;
}
}
.github/workflows/laravel-deploy.yml
name: Laravel Deploy to ECS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsTerraformRole
aws-region: ap-northeast-1
# ====================== Build & Push to ECR ======================
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build & Push Docker Image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: admin-system-app
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
# ====================== Update ECS Task Definition ======================
- name: Update ECS Task Definition
run: |
aws ecs register-task-definition \
--family admin-system-app \
--requires-compatibilities FARGATE \
--network-mode awsvpc \
--cpu "512" \
--memory "1024" \
--execution-role-arn $(aws iam get-role --role-name admin-system-ecs-task-execution --query 'Role.Arn' --output text) \
--task-role-arn $(aws iam get-role --role-name admin-system-ecs-task --query 'Role.Arn' --output text) \
--container-definitions '[
{
"name": "app",
"image": "${{ steps.login-ecr.outputs.registry }}/admin-system-app:${{ github.sha }}",
"essential": true,
"portMappings": [{"containerPort": 80, "protocol": "tcp"}],
"environment": [
{"name": "APP_ENV", "value": "production"},
{"name": "DB_CONNECTION", "value": "mysql"}
],
"secrets": [
{"name": "APP_KEY", "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:YOUR_ACCOUNT:secret:admin-system/production/app-key-XXXX"},
{"name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:...:password::"}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/admin-system",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "app"
}
}
}
]' \
--query 'taskDefinition.taskDefinitionArn' --output text > taskdef_arn.txt
- name: Update ECS Service
run: |
aws ecs update-service \
--cluster admin-system-cluster \
--service admin-system-service \
--task-definition $(cat taskdef_arn.txt) \
--desired-count 2