AWS CDK + ecspresso で Laravel App をデプロイするまで
やること
- Laravel をセットアップする
- セットアップした Laravel アプリケーションを Docker Compose を使ってローカルで起動・表示できるようにする
- AWS CDK + ecspresso を使って Laravel アプリケーションを AWS ECS 環境にデプロイする
やらないこと
- Laravel アプリケーションの作り込み
何はともあれ composer create-project
コマンドを叩いて Laravel アプリケーションをセットアップする。
$ composer create-project laravel/laravel aws-cdk-ecspresso-laravel-example-2024
セットアップが終わったら作成されたディレクトリに移動し、とりあえず php artisan serve
してみる。
$ cd aws-cdk-ecspresso-laravel-example-2024/
$ php artisan serve
INFO Server running on [http://127.0.0.1:8000].
Press Ctrl+C to stop the server
表示された URL にアクセスすると、Laravel の初期ページが表示される。最近のはかっこいいなぁ。
ここから以下を準備して php artisan serve
ではなく Docker Compose を使ってローカル開発環境が動くようにする。
-
nginx
のコンテナイメージ(Dockerfile
) -
php-fpm
のコンテナイメージ(Dockerfile
) docker-compose.yaml
まずは nginx
のコンテナイメージを用意する。プロジェクトルートに infra
ディレクトリを新たに作成し、以下のファイルを作成していく。
infra/docker/nginx
├── Dockerfile
└── assets
├── app.conf.template
├── nginx.conf
└── nginx.gzip.conf
まずは Dockerfile
から。nginx
のイメージをそのまま使ってもいいんだけど unprivileged かつ distroless なイメージを使いたいので以下のようなカンジに。設定ファイル類も独自に用意するのでベースイメージには含まれないようにしている。
#
# nginx-resources
#
FROM nginxinc/nginx-unprivileged:1.27-bookworm AS nginx-resources
ARG TIMEZONE="Asia/Tokyo"
USER root
RUN mkdir -p /opt/var/cache/nginx && \
cp -a --parents /usr/lib/nginx /opt && \
cp -a --parents /usr/share/nginx /opt && \
cp -a --parents /var/log/nginx /opt && \
cp -aL --parents /var/run /opt && \
cp -a --parents /etc/nginx /opt && \
cp -a --parents /etc/passwd /opt && \
cp -a --parents /etc/group /opt && \
cp -a --parents /usr/sbin/nginx /opt && \
cp -a --parents /usr/sbin/nginx-debug /opt && \
cp -a --parents /lib/$(uname -m)-linux-gnu/ld-* /opt && \
cp -a --parents /lib/$(uname -m)-linux-gnu/libz.so.* /opt && \
cp -a --parents /lib/$(uname -m)-linux-gnu/libc* /opt && \
cp -a --parents /lib/$(uname -m)-linux-gnu/libdl* /opt && \
cp -a --parents /lib/$(uname -m)-linux-gnu/libpthread* /opt && \
cp -a --parents /lib/$(uname -m)-linux-gnu/libcrypt* /opt && \
cp -a --parents /usr/lib/$(uname -m)-linux-gnu/libssl.so.* /opt && \
cp -a --parents /usr/lib/$(uname -m)-linux-gnu/libcrypto.so.* /opt && \
cp -a --parents /usr/lib/$(uname -m)-linux-gnu/libpcre2-8.so.* /opt && \
cp -a /etc/passwd /opt/etc/passwd && \
cp -a /etc/group /opt/etc/group && \
cp -a /usr/share/zoneinfo/${TIMEZONE} /opt/etc/localtime && \
rm -rf /opt/etc/nginx/*.conf && \
rm -rf /opt/etc/nginx/conf.d/*.conf
#
# nginx-config
#
FROM nginx-resources AS nginx-config
ARG APP_DOMAIN_NAME="localhost"
COPY infra/docker/nginx/assets/app.conf.template /tmp/
RUN echo "$APP_DOMAIN_NAME" && \
envsubst '$APP_DOMAIN_NAME' < /tmp/app.conf.template > /opt/etc/nginx/conf.d/app.conf
#
# nginx-base
#
FROM gcr.io/distroless/base-debian12:nonroot AS nginx-base
COPY /opt /
COPY infra/docker/nginx/assets/*.conf /etc/nginx/
USER www-data
EXPOSE 8080 8443
CMD ["nginx"]
#
# nginx-dev
#
FROM nginx-base AS nginx-dev
COPY /opt/etc/nginx/conf.d/ /etc/nginx/conf.d
続いて nginx
の設定ファイルを用意する。infra/docker/nginx/assets
以下に nginx.conf
を以下の通り作成。
daemon off;
worker_processes auto;
error_log /dev/stderr notice;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
proxy_temp_path /tmp/proxy_temp;
client_body_temp_path /tmp/client_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/nginx.gzip.conf;
include /etc/nginx/conf.d/*.conf;
}
続いて gzip 関連の設定を記述する nginx.gzip.conf
を以下の通り作成する。
gzip on;
gzip_buffers 16 8k;
gzip_comp_level 6;
gzip_disable "msie6" "Mozilla/4";
gzip_http_version 1.0;
gzip_min_length 2048;
gzip_proxied any;
gzip_static always;
gzip_vary on;
gzip_types
text/css
text/plain
text/javascript
application/javascript
application/json
application/x-javascript
application/xml
application/xml+rss
application/xhtml+xml
application/x-font-ttf
application/x-font-opentype
application/vnd.ms-fontobject
image/svg+xml
image/x-icon
application/rss+xml
application/atom_xml;
最後に設定ファイル(のテンプレート) app.conf.template
を用意する。
server {
listen 8080;
server_name $APP_DOMAIN_NAME;
charset utf-8;
index index.php;
root /app/public;
add_header Referrer-Policy no-referrer always;
add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains; preload';
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
error_page 404 /index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php-fpm/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
nginx
関連の準備はこれで整った。
続いて php-fpm
のコンテナイメージを用意する。ファイル構成はこんなカンジ。
infra/docker/app
├── Dockerfile
└── assets
├── app-base
│ └── app.ini
├── app-cli-base
│ └── app-cli.ini
├── app-cli-dev
│ └── app-cli-dev.ini
├── app-server-base
│ ├── app-server.ini
│ └── www.conf
└── app-server-dev
└── app-server-dev.ini
まずは Dockerfile
から。
#
# app-base
#
FROM php:8.3.12-fpm-bookworm AS app-base
ARG TIMEZONE="Asia/Tokyo"
ENV TZ=${TIMEZONE} \
LANG=ja_JP.UTF-8 \
LANGUAGE=ja_JP:jp \
LC_ALL=ja_JP.UTF-8
RUN set -eux; \
apt-get update; \
### Install Runtime Dependencies
apt-get install -y --no-install-recommends \
libfreetype6 \
libjpeg62-turbo \
libpng16-16 \
libzip4 \
locales \
zlib1g \
; \
### Install Build Dependencies
savedAptMark="$(apt-mark showmanual)"; \
apt-get install -y --no-install-recommends \
libfreetype6-dev \
libjpeg-dev \
libpng-dev \
libzip-dev \
zlib1g-dev \
; \
### Install PHP Extensions
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j$(nproc) \
gd \
opcache \
pcntl \
pdo_mysql \
zip \
; \
yes "" | pecl install apcu; docker-php-ext-enable apcu; \
### Setup Locales
locale-gen ja_JP.UTF-8; \
localedef -f UTF-8 -i ja_JP ja_JP; \
### Cleanup
apt-mark auto '.*' > /dev/null; \
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
rm -rf /usr/local/etc/php-fpm.d/zz-docker.conf
WORKDIR /app
#
# app-cli-base
#
FROM app-base AS app-cli-base
COPY infra/docker/app/assets/app-cli-base/app-cli.ini /usr/local/etc/php/conf.d/
ENTRYPOINT docker-php-entrypoint
CMD ["php"]
#
# app-server-base
#
FROM app-base AS app-server-base
COPY infra/docker/app/assets/app-server-base/app-server.ini /usr/local/etc/php/conf.d/
COPY infra/docker/app/assets/app-server-base/www.conf /usr/local/etc/php-fpm.d/
VOLUME /var/run/php-fpm
CMD ["php-fpm"]
#
# app-cli-dev
#
FROM app-cli-base AS app-cli-dev
COPY infra/docker/app/assets/app-cli-dev/app-cli-dev.ini /usr/local/etc/php/conf.d/
RUN set -eux; yes "" | pecl install xdebug; docker-php-ext-enable xdebug
#
# app-server-dev
#
FROM app-server-base AS app-server-dev
COPY infra/docker/app/assets/app-server-dev/app-server-dev.ini /usr/local/etc/php/conf.d/
RUN set -eux; yes "" | pecl install xdebug; docker-php-ext-enable xdebug
続いてすべてのイメージにおける共通の設定を記述する app.ini
を追加する。
expose_php = Off
max_file_uploads = 100
post_max_size = 20M
[date]
date.timezone = Asia/Tokyo
続いて CLI 向けの設定を記述する app-cli.ini
を追加する。
memory_limit = 2G
続いて開発環境の CLI 向けに Xdebug の設定を記述する app-cli-dev.ini
を追加する。
[xdebug]
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = host.docker.internal
xdebug.client_port = 9000
xdebug.idekey = aws-cdk-ecspresso-laravel-example-2024
サーバー向けには PHP の設定ファイルである app-server.ini
と PHP-FPM の設定ファイルとなる www.conf
を追加する。
memory_limit = 512M
[global]
daemonize = no
error_log = /proc/self/fd/2
events.mechanism = epoll
[www]
listen = /var/run/php-fpm/php-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
access.log = /dev/null
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
user = www-data
group = www-data
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.process_idle_timeout = 10s
pm.max_requests = 0
pm.status_path = /status
最後に開発環境のサーバー向け設定ファイルである app-server-dev.ini
を追加。CLI 同様 Xdebug の設定を記述する。
[xdebug]
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = host.docker.internal
xdebug.client_port = 9000
xdebug.idekey = aws-cdk-ecspresso-laravel-example-2024
これで php-fpm
の準備も完了。
docker-compose.yaml
を作成し、nginx
と php-fpm
を docker compose
で起動できるようにする。
---
volumes:
app-data:
services:
app:
build:
context: .
dockerfile: infra/docker/app/Dockerfile
target: app-server-dev
image: aws-cdk-ecspresso-laravel-example-2024/app-server-dev:latest
restart: always
volumes:
- .:/app
- app-data:/var/run/php-fpm
nginx:
build:
context: .
dockerfile: infra/docker/nginx/Dockerfile
target: nginx-dev
image: aws-cdk-ecspresso-laravel-example-2024/nginx-dev:latest
restart: always
ports:
- "80:8080"
volumes:
- .:/app
- app-data:/var/run/php-fpm
ポイントは2点。
- プロジェクトのルートディレクトリを双方の
/app
にマウントして開発環境のファイルにアクセスできるようにする -
app-data
というボリュームを共有し、PHP-FPM の UNIX ソケットを共有することでnginx
とphp-fpm
が TCP/IP ではなく UNIX ソケット経由で通信できるようにする
最後に .env
ファイルを用意する。
APP_AWS_ACCOUNT=************
APP_AWS_REGION=ap-northeast-1
APP_STACK_NAME=ExampleLaravelAppStack
APP_HOSTED_ZONE_NAME=example.com
APP_DOMAIN_NAME=app.example.com
APP_LOG_BUCKET_NAME=example-laravel-app-alb-log-storage
APP_ECS_CLUSTER_NAME=example-laravel-app-cluster
APP_ECS_SERVICE_NAME=example-laravel-app-service
.env
を読み込みつつ docker compose
を実行したいので Task を使う。Taskfile.yaml
を次の通り作成。
version: 3
dotenv:
- .env
- infra/aws/.env
tasks:
#
# Docker tasks
#
docker:compose:build:
cmds:
- docker compose build
docker:compose:up:
cmds:
- docker compose up -d
docker:compose:down:
cmds:
- docker compose down
ここまで来たら task docker:compose:up
を実行することでコンテナが立ち上がり、ブラウザから http://localhost
にアクセスすると Laravel の初期画面が表示されるはずだ。
続いて AWS CDK を使ってインフラの準備を行う。まずは AWS CDK プロジェクトを追加する。やり方は色々あると思うけど今回は npm
の workspace 機能を使って、プロジェクトルート以下に AWS CDK のプロジェクトを追加する方針を採る。
最初に infra/aws
ディレクトリを作成し、そこで cdk init
を実行する。
$ mkdir infra/aws
$ cd infra/aws
$ npx cdk init --language typescript
(略)
cdk init
のタイミングで npm install
が行われ infra/aws/node_modules
ディレクトリが作成されるが、これを削除し、npm
のワークスペース機能を使うようにしていく。まずはルートディレクトリにある package.json
に以下の行を追加する。
"type": "module",
+ "workspaces": ["infra/aws"],
"scripts": {
続いて、infra/aws/node_modules
を削除した上でルートディレクトリ上で改めて npm install
を実行する。
$ cd ../../
$ rm -rf infra/aws/node_modules
$ npm install
次に、前回同様、vitest
や biome
, rimraf
などを導入していく。
$ npm uninstall jest ts-jest @types/jest -w infra/aws
$ npm install --save-dev husky lint-staged rimraf vitest
$ npm install --save-dev --save-exact @biomejs/biome
$ npm install --save dotenv -w infra/aws
$ npx husky init
$ npx biome init --jsonc
諸々の設定は前回を参考に行っていく。
npx lint-staged
---
'*.{js,ts,json,jsonc}':
- npm run check
{
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
"vcs": {
// Biome の VCS 統合を有効にする
"enabled": true,
"clientKind": "git",
// .gitignore に記述されたファイルを無視する
"useIgnoreFile": true
},
"files": {
"include": ["infra/aws/bin/**/*.ts", "infra/aws/lib/**/*.ts", "infra/aws/test/**/*.ts", "*.json", "*.jsonc"],
"ignore": ["**/*.d.ts"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"organizeImports": {
"enabled": true
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
}
}
"scripts": {
"build": "tsc",
- "watch": "tsc -w",
- "test": "jest",
- "cdk": "cdk"
+ "cdk": "cdk",
+ "clean": "rimraf {bin,lib}/*.{d.ts,js}",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
+ "watch": "tsc -w"
},
"scripts": {
- "dev": "vite",
"build": "vite build",
+ "check": "biome check --write",
+ "check:dry-run": "biome check",
+ "dev": "vite",
"prepare": "husky"
},
これで AWS CDK を扱う準備は完了。
AWS CDK を用いてスタックの定義を行う。ポイントは次の通り。
- ECS のタスク/サービスは
ecspresso
を使ってデプロイするため AWS CDK では ECS クラスタの定義までとする。 - 煩雑な ALB 周りを自作の
Construct
にまとめる。
import * as cdk from 'aws-cdk-lib'
import * as certificationManager from 'aws-cdk-lib/aws-certificatemanager'
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as targets from 'aws-cdk-lib/aws-route53-targets'
import type * as s3 from 'aws-cdk-lib/aws-s3'
import { Construct } from 'constructs'
const HTTP = 80
const HTTPS = 443
export interface ApplicationLoadBalancerProps {
domainName: string
hostedZone: route53.IHostedZone
logBucket: s3.IBucket
vpc: ec2.IVpc
}
export class ApplicationLoadBalancer extends Construct {
public readonly targetGroupArn: string
public readonly securityGroup: ec2.ISecurityGroup
constructor(scope: Construct, id: string, props: ApplicationLoadBalancerProps) {
super(scope, id)
const { domainName, hostedZone, vpc } = props
// ACM: Certificate
const certificate = new certificationManager.Certificate(this, 'Certificate', {
domainName,
validation: certificationManager.CertificateValidation.fromDns(hostedZone),
})
// Security Group
const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', { vpc })
securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(HTTP))
securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(HTTPS))
// Application Load Balancer
const alb = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
internetFacing: true,
securityGroup,
vpc,
})
alb.logAccessLogs(props.logBucket)
// ALB: Target Group
const targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
port: HTTP,
stickinessCookieDuration: cdk.Duration.days(1),
targetType: elbv2.TargetType.IP,
vpc,
})
// ALB: HTTPS Listener
alb.addListener('HttpsListener', {
certificates: [certificate],
defaultTargetGroups: [targetGroup],
port: HTTPS,
protocol: elbv2.ApplicationProtocol.HTTPS,
})
// ALB: HTTP Listener
const httpListener = alb.addListener('HttpListener', {
defaultTargetGroups: [targetGroup],
port: HTTP,
protocol: elbv2.ApplicationProtocol.HTTP,
})
// ALB: HTTP Listener Rule - Redirect to HTTPS
new elbv2.ApplicationListenerRule(this, 'HttpListenerRule', {
action: elbv2.ListenerAction.redirect({
permanent: true,
port: HTTPS.toString(),
protocol: elbv2.ApplicationProtocol.HTTPS,
}),
conditions: [elbv2.ListenerCondition.pathPatterns(['*'])],
listener: httpListener,
priority: 1,
})
// Route 53: A/AAAA Record to ALB
const recordProps: route53.ARecordProps & route53.AaaaRecordProps = {
recordName: props.domainName,
target: route53.RecordTarget.fromAlias(new targets.LoadBalancerTarget(alb)),
zone: hostedZone,
}
new route53.ARecord(this, 'ARecord', recordProps)
new route53.AaaaRecord(this, 'AaaaRecord', recordProps)
// Assign to class properties
this.targetGroupArn = targetGroup.targetGroupArn
this.securityGroup = securityGroup
}
}
そして用意した Construct を用いて次の通り LaravelAppStack
を定義する。参考にした記事では ecspresso に ARN 等を渡すために SSM Parameter を使っているが、Cloudformation には Output という機能があるため、それを使うようにした。
import * as cdk from 'aws-cdk-lib'
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as ecr from 'aws-cdk-lib/aws-ecr'
import * as ecs from 'aws-cdk-lib/aws-ecs'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as logs from 'aws-cdk-lib/aws-logs'
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as s3 from 'aws-cdk-lib/aws-s3'
import type { Construct } from 'constructs'
import { ApplicationLoadBalancer } from './constructs/application-load-balancer'
interface LaravelAppStackProps extends cdk.StackProps {
domainName: string
hostedZoneName: string
ecr: {
repositories: Array<{
id: string
repositoryName: string
}>
}
ecs: {
clusterName: string
serviceName: string
}
s3: {
logBucketName: string
}
vpc: {
cidr: string
}
}
export class LaravelAppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: LaravelAppStackProps) {
super(scope, id, props)
// Route 53: Hosted Zone
const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
domainName: props.hostedZoneName,
})
// S3: Bucket for ALB access logs
const logBucket = new s3.Bucket(this, 'LogBucket', {
bucketName: props.s3.logBucketName,
lifecycleRules: [{ expiration: cdk.Duration.days(365) }],
})
// VPC
const vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr(props.vpc.cidr),
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
cidrMask: 24,
name: 'Public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 24,
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
],
})
const vpcSubnetIds = vpc.privateSubnets.map(subnet => subnet.subnetId)
if (vpcSubnetIds.length !== 2) {
throw new Error('Unexpected number of private subnets')
}
// ALB
const alb = new ApplicationLoadBalancer(this, 'Alb', {
domainName: props.domainName,
hostedZone,
logBucket,
vpc,
})
// ECR: Repositories
for (const { id, repositoryName } of props.ecr.repositories) {
new ecr.Repository(this, `Ecr${id}`, {
repositoryName,
lifecycleRules: [
{
description: 'hold 10 images',
maxImageCount: 10,
},
],
})
}
// ECS: Cluster
new ecs.Cluster(this, 'EcsCluster', {
clusterName: props.ecs.clusterName,
vpc,
})
// IAM: Role for ECS
const ecsTaskExecutionRole = new iam.Role(this, 'EcsTaskExecutionRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess')],
})
// CloudWatch: Log Groups for ECS
new logs.LogGroup(this, 'EcsNginxLogGroup', {
logGroupName: `/ecs/${props.ecs.serviceName}/nginx`,
retention: logs.RetentionDays.TEN_YEARS,
})
new logs.LogGroup(this, 'EcsAppServerLogGroup', {
logGroupName: `/ecs/${props.ecs.serviceName}/app-server`,
retention: logs.RetentionDays.TEN_YEARS,
})
new logs.LogGroup(this, 'EcsAppBatchLogGroup', {
logGroupName: `/ecs/${props.ecs.serviceName}/app-batch`,
retention: logs.RetentionDays.TEN_YEARS,
})
// Security Group: for ECS
const ecsSecurityGroup = new ec2.SecurityGroup(this, 'EcsSecurityGroup', { vpc })
ecsSecurityGroup.addIngressRule(alb.securityGroup, ec2.Port.tcp(8080))
// Cloudformation Outputs
new cdk.CfnOutput(this, 'PrivateSubnetAz1', {
value: vpcSubnetIds[0],
})
new cdk.CfnOutput(this, 'PrivateSubnetAz2', {
value: vpcSubnetIds[1],
})
new cdk.CfnOutput(this, 'EcsSecurityGroupId', {
value: ecsSecurityGroup.securityGroupId,
})
new cdk.CfnOutput(this, 'AlbTargetGroupArn', {
value: alb.targetGroupArn,
})
new cdk.CfnOutput(this, 'EcsTaskExecutionRoleArn', {
value: ecsTaskExecutionRole.roleArn,
})
}
}
コードが長すぎて Zenn の文字数制限に引っかかってしまったため続きは次のコメントへ。
前回の続き。まずはテストから。
import * as cdk from 'aws-cdk-lib'
import { Match, Template } from 'aws-cdk-lib/assertions'
import { beforeAll, describe, it } from 'vitest'
import { LaravelAppStack } from '../lib/laravel-app-stack'
describe('LaravelAppStack', () => {
let template: Template
beforeAll(() => {
const app = new cdk.App()
const stack = new LaravelAppStack(app, 'TestStack', {
env: {
account: '123456789012',
region: 'ap-northeast-1',
},
domainName: 'app.example.org',
hostedZoneName: 'example.org',
ecr: {
repositories: [
{ id: 'foo', repositoryName: 'laravel-app/foo' },
{ id: 'bar', repositoryName: 'laravel-app/bar' },
{ id: 'baz', repositoryName: 'laravel-app/baz' },
],
},
ecs: {
clusterName: 'test-laravel-app-cluster',
serviceName: 'test-laravel-app-service',
},
s3: {
logBucketName: 'example-log-storage',
},
vpc: {
cidr: '192.168.0.0/16',
},
})
template = Template.fromStack(stack)
})
it('has a S3 bucket for ALB access logs', () => {
template.resourceCountIs('AWS::S3::Bucket', 1)
})
it('has a S3 bucket with the specified name', () => {
template.hasResourceProperties('AWS::S3::Bucket', {
BucketName: 'example-log-storage',
})
})
it('has a VPC', () => {
template.resourceCountIs('AWS::EC2::VPC', 1)
})
it('has a VPC with the specified CIDR', () => {
template.hasResourceProperties('AWS::EC2::VPC', {
CidrBlock: '192.168.0.0/16',
})
})
describe('VPC', () => {
it('has a NAT gateway', () => {
template.resourceCountIs('AWS::EC2::NatGateway', 1)
})
it('has a Internet Gateway', () => {
template.resourceCountIs('AWS::EC2::InternetGateway', 1)
})
it('has 4 subnets', () => {
template.resourceCountIs('AWS::EC2::Subnet', 4)
})
describe('Availability Zone A', () => {
it('has a public subnet', () => {
template.hasResourceProperties('AWS::EC2::Subnet', {
AvailabilityZone: 'dummy1a',
MapPublicIpOnLaunch: true,
})
})
it('has a private subnet', () => {
template.hasResourceProperties('AWS::EC2::Subnet', {
AvailabilityZone: 'dummy1a',
MapPublicIpOnLaunch: false,
})
})
})
describe('Availability Zone B', () => {
it('has a public subnet', () => {
template.hasResourceProperties('AWS::EC2::Subnet', {
AvailabilityZone: 'dummy1b',
MapPublicIpOnLaunch: true,
})
})
it('has a private subnet', () => {
template.hasResourceProperties('AWS::EC2::Subnet', {
AvailabilityZone: 'dummy1b',
MapPublicIpOnLaunch: false,
})
})
})
})
it('has 2 security groups', () => {
template.resourceCountIs('AWS::EC2::SecurityGroup', 2)
})
it('has a security group for the Application Load Balancer', () => {
const [vpcId] = Object.keys(template.findResources('AWS::EC2::VPC'))
template.hasResourceProperties('AWS::EC2::SecurityGroup', {
GroupDescription: 'TestStack/Alb/SecurityGroup',
SecurityGroupIngress: [
Match.objectLike({
FromPort: 80,
IpProtocol: 'tcp',
ToPort: 80,
}),
Match.objectLike({
FromPort: 443,
IpProtocol: 'tcp',
ToPort: 443,
}),
],
VpcId: { Ref: vpcId },
})
})
it('has a security group for the ECS', () => {
const [vpcId] = Object.keys(template.findResources('AWS::EC2::VPC'))
template.hasResourceProperties('AWS::EC2::SecurityGroup', {
GroupDescription: 'TestStack/EcsSecurityGroup',
SecurityGroupEgress: [
Match.objectLike({
CidrIp: '0.0.0.0/0',
}),
],
VpcId: { Ref: vpcId },
})
})
it('should allow traffic from the Application Load Balancer to the ECS', () => {
const securityGroups = template.findResources('AWS::EC2::SecurityGroup')
const [albSecurityGroupId] = Object.keys(securityGroups).filter(
ref => securityGroups[ref].Properties.GroupDescription === 'TestStack/Alb/SecurityGroup',
)
const [ecsSecurityGroupId] = Object.keys(securityGroups).filter(
ref => securityGroups[ref].Properties.GroupDescription === 'TestStack/EcsSecurityGroup',
)
template.hasResourceProperties('AWS::EC2::SecurityGroupIngress', {
FromPort: 8080,
ToPort: 8080,
GroupId: { 'Fn::GetAtt': [ecsSecurityGroupId, 'GroupId'] },
SourceSecurityGroupId: { 'Fn::GetAtt': [albSecurityGroupId, 'GroupId'] },
})
})
it('has an ACM certificate for the Application Load Balancer', () => {
template.resourceCountIs('AWS::CertificateManager::Certificate', 1)
})
it('has an ACM certificate with the specified domain name', () => {
template.hasResourceProperties('AWS::CertificateManager::Certificate', {
DomainName: 'app.example.org',
})
})
it('has an Application Load Balancer', () => {
template.resourceCountIs('AWS::ElasticLoadBalancingV2::LoadBalancer', 1)
})
it('has an Application Load Balancer that is internet-facing', () => {
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
Scheme: 'internet-facing',
})
})
it('has an Application Load Balancer with the public subnets', () => {
const subnets = template.findResources('AWS::EC2::Subnet', {
Properties: {
MapPublicIpOnLaunch: true,
},
})
const subnetIds = Object.keys(subnets).map(ref => ({ Ref: ref }))
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
Subnets: subnetIds,
})
})
it('has an Application Load Balancer with the security group', () => {
const securityGroup = template.findResources('AWS::EC2::SecurityGroup', {
Properties: {
GroupDescription: 'TestStack/Alb/SecurityGroup',
},
})
const [securityGroupId] = Object.keys(securityGroup)
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
SecurityGroups: [{ 'Fn::GetAtt': [securityGroupId, 'GroupId'] }],
})
})
it('has a target group', () => {
template.resourceCountIs('AWS::ElasticLoadBalancingV2::TargetGroup', 1)
})
it('has a target group with the specified properties', () => {
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::TargetGroup', {
Port: 80,
Protocol: 'HTTP',
TargetType: 'ip',
})
})
it('has 2 Listeners', () => {
template.resourceCountIs('AWS::ElasticLoadBalancingV2::Listener', 2)
})
it('has an HTTPS Listener', () => {
const certificates = template.findResources('AWS::CertificateManager::Certificate')
const [certificateArn] = Object.keys(certificates)
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::Listener', {
Certificates: [{ CertificateArn: { Ref: certificateArn } }],
Port: 443,
Protocol: 'HTTPS',
})
})
it('has an HTTP Listener', () => {
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::Listener', {
Port: 80,
Protocol: 'HTTP',
})
})
it('should redirect HTTP to HTTPS', () => {
const listeners = template.findResources('AWS::ElasticLoadBalancingV2::Listener', {
Properties: {
Protocol: 'HTTP',
},
})
const [listenerArn] = Object.keys(listeners)
template.hasResourceProperties('AWS::ElasticLoadBalancingV2::ListenerRule', {
Actions: [
{
RedirectConfig: {
Protocol: 'HTTPS',
StatusCode: 'HTTP_301',
},
Type: 'redirect',
},
],
Conditions: [
{
Field: 'path-pattern',
PathPatternConfig: {
Values: ['*'],
},
},
],
ListenerArn: { Ref: listenerArn },
Priority: 1,
})
})
it('has 2 RecordSets', () => {
template.resourceCountIs('AWS::Route53::RecordSet', 2)
})
it('has an A RecordSet', () => {
template.hasResourceProperties('AWS::Route53::RecordSet', {
Name: 'app.example.org.',
Type: 'A',
})
})
it('has an AAAA RecordSet', () => {
template.hasResourceProperties('AWS::Route53::RecordSet', {
Name: 'app.example.org.',
Type: 'AAAA',
})
})
it('has the number of ECR repositories that is specified by the props', () => {
template.resourceCountIs('AWS::ECR::Repository', 3)
})
it.each([['laravel-app/foo'], ['laravel-app/bar'], ['laravel-app/baz']])(
'has an ECR repository with the specified name (%s)',
repositoryName => {
template.hasResourceProperties('AWS::ECR::Repository', {
RepositoryName: repositoryName,
})
},
)
it('has an ECS Cluster', () => {
template.resourceCountIs('AWS::ECS::Cluster', 1)
})
it('has an ECS Cluster with the specified name', () => {
template.hasResourceProperties('AWS::ECS::Cluster', {
ClusterName: 'test-laravel-app-cluster',
})
})
it('has an IAM Role', () => {
template.resourceCountIs('AWS::IAM::Role', 1)
})
it('has an IAM Role for ECS Task Execution', () => {
template.hasResourceProperties('AWS::IAM::Role', {
AssumeRolePolicyDocument: Match.objectLike({
Statement: [
{
Action: 'sts:AssumeRole',
Effect: 'Allow',
Principal: { Service: 'ecs-tasks.amazonaws.com' },
},
],
}),
ManagedPolicyArns: [
Match.objectLike({
'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':iam::aws:policy/PowerUserAccess']],
}),
],
})
})
it('has 3 Log Groups', () => {
template.resourceCountIs('AWS::Logs::LogGroup', 3)
})
it('has a Log Group for nginx', () => {
template.hasResourceProperties('AWS::Logs::LogGroup', {
LogGroupName: '/ecs/test-laravel-app-service/nginx',
RetentionInDays: 3653,
})
})
it('has a Log Group for app-server', () => {
template.hasResourceProperties('AWS::Logs::LogGroup', {
LogGroupName: '/ecs/test-laravel-app-service/app-server',
RetentionInDays: 3653,
})
})
it('has a Log Group for app-batch', () => {
template.hasResourceProperties('AWS::Logs::LogGroup', {
LogGroupName: '/ecs/test-laravel-app-service/app-batch',
RetentionInDays: 3653,
})
})
it('has an Output for the first subnet ID', () => {
const subnets = template.findResources('AWS::EC2::Subnet', {
Properties: {
MapPublicIpOnLaunch: false,
},
})
const subnetIds = Object.keys(subnets)
template.hasOutput('PrivateSubnetAz1', {
Value: { Ref: subnetIds[0] },
})
})
it('has an Output for the second subnet ID', () => {
const subnets = template.findResources('AWS::EC2::Subnet', {
Properties: {
MapPublicIpOnLaunch: false,
},
})
const subnetIds = Object.keys(subnets)
template.hasOutput('PrivateSubnetAz2', {
Value: { Ref: subnetIds[1] },
})
})
it('has an Output for the security group ID', () => {
const securityGroups = template.findResources('AWS::EC2::SecurityGroup', {
Properties: {
GroupDescription: 'TestStack/EcsSecurityGroup',
},
})
const [securityGroupId] = Object.keys(securityGroups)
template.hasOutput('EcsSecurityGroupId', {
Value: { 'Fn::GetAtt': [securityGroupId, 'GroupId'] },
})
})
it('has an Output for the target group ARN', () => {
const targetGroups = template.findResources('AWS::ElasticLoadBalancingV2::TargetGroup')
const [targetGroupArn] = Object.keys(targetGroups)
template.hasOutput('AlbTargetGroupArn', {
Value: { Ref: targetGroupArn },
})
})
it('has an Output for the ECS Task Execution Role ARN', () => {
const roles = template.findResources('AWS::IAM::Role')
const [roleArn] = Object.keys(roles)
template.hasOutput('EcsTaskExecutionRoleArn', {
Value: { 'Fn::GetAtt': [roleArn, 'Arn'] },
})
})
})
続いて infra/aws/bin/laravel-app.ts
を以下の通り作成する。
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import * as dotenv from 'dotenv'
import { LaravelAppStack } from '../lib/laravel-app-stack'
// Load environment variables from .env file
dotenv.config()
const account = process.env.APP_AWS_ACCOUNT ?? process.env.CDK_DEFAULT_ACCOUNT
const region = process.env.APP_AWS_REGION ?? process.env.CDK_DEFAULT_REGION
const stackName = process.env.APP_STACK_NAME ?? 'ExampleLaravelAppStack'
const app = new cdk.App()
new LaravelAppStack(app, stackName, {
env: {
account,
region,
},
hostedZoneName: process.env.APP_HOSTED_ZONE_NAME ?? 'example.com',
domainName: process.env.APP_DOMAIN_NAME ?? 'app.example.com',
ecr: {
repositories: [
{ id: 'Nginx', repositoryName: 'aws-cdk-ecspresso-laravel-example-2024/nginx-prod' },
{ id: 'AppCli', repositoryName: 'aws-cdk-ecspresso-laravel-example-2024/app-cli-prod' },
{ id: 'AppServer', repositoryName: 'aws-cdk-ecspresso-laravel-example-2024/app-server-prod' },
],
},
ecs: {
clusterName: process.env.APP_ECS_CLUSTER_NAME ?? 'example-laravel-app-cluster',
serviceName: process.env.APP_ECS_SERVICE_NAME ?? 'example-laravel-app-service',
},
s3: {
logBucketName: process.env.APP_LOG_BUCKET_NAME ?? 'example-log-bucket',
},
vpc: {
cidr: '192.168.0.0/16',
},
})
cdk.json
の修正も忘れずに。
- "app": "npx ts-node --prefer-ts-exts bin/aws.ts",
+ "app": "npx ts-node --prefer-ts-exts bin/laravel-app.ts",
AWS CDK プロジェクトの package.json
に cdk
関連のスクリプトを追加する。
"cdk": "cdk",
+ "cdk:deploy": "cdk deploy",
+ "cdk:destroy": "cdk destroy",
+ "cdk:diff": "cdk diff",
+ "cdk:synth": "cdk synth",
"clean": "rimraf {bin,lib}/*.{d.ts,js} {bin,lib}/**/*.{d.ts,js}",
最後に Taskfile.yaml
を編集し、AWS CDK 関連のタスクを追加する。
docker:compose:down:
cmds:
- docker compose down
+ #
+ # AWS CDK tasks
+ #
+ cdk:deploy:
+ cmds:
+ - npm run cdk:deploy -w infra/aws
+ cdk:destroy:
+ cmds:
+ - npm run cdk:destroy -w infra/aws
+ cdk:diff:
+ cmds:
+ - npm run cdk:diff -w infra/aws
+ cdk:synth:
+ cmds:
+ - npm run cdk:synth -w infra/aws
これで CDK の準備は整った。
参考にした記事
ECS を使ってコンテナを立ちあげるためには、当然ながら今回用意したコンテナイメージを ECS が pull
できるようにする必要がある。
もちろん Docker Hub を使ってもいいんだけど、今回は AWS CDK で用意した ECR に push
していく。
まずは本番用イメージをビルドできるように各種 Dockerfile
にそれぞれ次の通り追記していく。
#
# nginx-dev
#
FROM nginx-base AS nginx-dev
COPY /opt/etc/nginx/conf.d/ /etc/nginx/conf.d/
+
+#
+# nginx-prod
+#
+FROM nginx-base AS nginx-prod
+
+COPY /opt/etc/nginx/conf.d/ /etc/nginx/conf.d/
+
+COPY public/ /app/public/
#
# app-server-dev
#
FROM app-server-base AS app-server-dev
COPY infra/docker/app/assets/app-server-dev/app-server-dev.ini /usr/local/etc/php/conf.d/
RUN set -eux; yes "" | pecl install xdebug; docker-php-ext-enable xdebug
+
+#
+# app-cli-prod
+#
+FROM app-cli-base AS app-cli-prod
+
+RUN mkdir -p /app
+RUN mv /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
+
+COPY /usr/bin/composer /usr/bin/
+COPY composer.* /app/
+RUN composer install --no-dev --no-autoloader --no-progress --no-scripts
+
+COPY . /app
+RUN composer dump-autoload --no-dev --optimize
+
+RUN chown -R www-data:www-data /app
+
+#
+# app-server-prod
+#
+FROM app-server-base AS app-server-prod
+
+RUN mkdir -p /app
+RUN mv /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
+
+COPY /usr/bin/composer /usr/bin/
+COPY composer.* /app/
+RUN composer install --no-dev --no-autoloader --no-progress --no-scripts
+
+COPY . /app
+RUN composer dump-autoload --no-dev --optimize
+
+RUN chown -R www-data:www-data /app
これらをビルドするために長いコマンドを書いてもいいんだけど、docker-compose.yaml
形式の YAML ファイルを作成し docker compose build
からビルドするようにすると可読性が高く、管理しやすくなる。
今回はビルド用に docker-compose-build.yaml
を作成することにする。
---
services:
app-cli:
build:
context: .
dockerfile: infra/docker/app/Dockerfile
target: app-cli-prod
image: aws-cdk-ecspresso-laravel-example-2024/app-cli-prod:latest
platform: linux/amd64
app-server:
build:
context: .
dockerfile: infra/docker/app/Dockerfile
target: app-server-prod
image: aws-cdk-ecspresso-laravel-example-2024/app-server-prod:latest
platform: linux/amd64
nginx:
build:
context: .
dockerfile: infra/docker/nginx/Dockerfile
target: nginx-prod
args:
- TIMEZONE=Asia/Tokyo
- APP_DOMAIN_NAME=${APP_DOMAIN_NAME}
image: aws-cdk-ecspresso-laravel-example-2024/nginx-prod:latest
platform: linux/amd64
Taskfile.yaml
を編集し、task docker:build
で本番用のイメージをビルドできるようにする。
tasks:
#
# Docker tasks
#
+ docker:build:
+ cmds:
+ - docker compose -f docker-compose-build.yaml --env-file=infra/aws/.env build
docker:compose:build:
cmds:
- docker compose build
つづく。
Taskfile.yaml
を編集し、ECR にプッシュできるようにする。
dotenv:
- .env
- infra/aws/.env
+
+vars:
+ AWS_REGION: '{{.APP_AWS_REGION}}'
+ AWS_ECR_HOST: '{{.APP_AWS_ACCOUNT}}.dkr.ecr.{{.APP_AWS_REGION}}.amazonaws.com'
+ DOCKER_IMAGE_PREFIX: aws-cdk-ecspresso-laravel-example-2024
+ DOCKER_IMAGES:
+ - app-cli-prod
+ - app-server-prod
+ - nginx-prod
+ DATE:
+ sh: date '+%Y%m%d%H%M%S'
+ REVISION:
+ sh: git rev-parse --short HEAD
+ TAG: '{{.DATE}}-{{.REVISION}}'
cdk:synth:
cmds:
- npm run cdk:synth -w infra/aws
+ #
+ # AWS ECR tasks
+ #
+ aws:ecr:login:
+ cmds:
+ - aws ecr get-login-password --region {{.APP_AWS_REGION}} | docker login --username AWS --password-stdin {{.AWS_ECR_HOST}}
+ aws:ecr:push:
+ deps:
+ - docker:build
+ cmds:
+ - task: aws:ecr:login
+ - for: { var: DOCKER_IMAGES }
+ cmd: docker tag {{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:{{.TAG}}
+ - for: { var: DOCKER_IMAGES }
+ cmd: docker tag {{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest
+ - for: { var: DOCKER_IMAGES }
+ cmd: docker push {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:{{.TAG}}
+ - for: { var: DOCKER_IMAGES }
+ cmd: docker push {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest
これにより task aws:ecr:push
を実行すると以下の処理が行われる。
- 現在日時 + Git のリビジョン(短縮形)からタグを決定(生成)する。
-
docker:build
タスクを実行し、Docker イメージをビルドする。 -
aws:ecr:login
タスクを実行し、ECR にログインする。 - 各イメージそれぞれについて ECR 向けに 1. で決定したタグおよび latest でタグ付けする。
- タグ付けしたイメージを ECR にプッシュする。
ECR の準備(≒コンテナイメージの準備)はこれで完了。次から ecspresso
の設定を行う。
続いて ecspresso
の設定ファイルを作成していく。必要となるのは次の3つ。
-
ecspresso
設定ファイル(YAML or Jsonnet) - タスク定義ファイル(JSON or Jsonnet)
- サービス定義ファイル(JSON or Jsonnet)
まずは ecspresso
の設定ファイルを記述していく。
---
# ECS クラスターを配置する AWS リージョン
region: "{{ env `APP_AWS_REGION` `ap-northeast-1` }}"
# ECS クラスター名
cluster: "{{ must_env `APP_ECS_CLUSTER_NAME` }}"
# ECS サービス名
service: example-laravel-app-service
# ECS サービス定義ファイル
service_definition: infra/aws/ecspresso/ecs-service-definition.json
# ECS タスク定義ファイル
task_definition: infra/aws/ecspresso/ecs-service-task-definition.json
# プラグイン
plugins:
- name: cloudformation
{{ env ... }}
や {{ must_env ... }}
は ecspresso
で利用可能なテンプレート記法で、環境変数を参照することができる。
また、今回は CloudFormation の Output を参照するため cloudformation
プラグインを導入する(特に別途インストール等は不要)。
次にタスク定義ファイルを作成する。タスク定義ファイルは Amazon ECS タスク定義パラメータを JSON または Jsonnet 形式で記述する。
今回は Jsonnet 形式を選択するが、{{ cfn_output ... }}
の中でさらに環境変数を参照したいため、envsubst
と jsonnet
を組み合わせて JSON ファイルを生成し、ecspresso
からは生成した JSON 形式のファイルを参照する方針とする。
// ECR ホスト
local ecrHost = '${APP_AWS_ACCOUNT}.dkr.ecr.${APP_AWS_REGION}.amazonaws.com';
// コンテナイメージの共通プレフィクス
local imagePrefix = ecrHost + '/aws-cdk-ecspresso-laravel-example-2024';
// コンテナイメージ名を返す関数
local image (name) = imagePrefix + '/' + name + ':{{ env `TAG` `latest` }}';
// ログ設定を返す関数
local logConfiguration (prefix) = {
logDriver: 'awslogs',
options: {
'awslogs-group': '/ecs/${APP_ECS_SERVICE_NAME}/' + prefix,
'awslogs-region': '${APP_AWS_REGION}',
'awslogs-stream-prefix': prefix,
},
};
// See https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_definition_parameters.html
{
family: '${APP_ECS_CLUSTER_NAME}-service-task-definition',
cpu: '256',
memory: '1024',
networkMode: 'awsvpc',
requiresCompatibilities: ['FARGATE'],
executionRoleArn: '{{ cfn_output `${APP_STACK_NAME}` `EcsTaskExecutionRoleArn` }}',
containerDefinitions: [
{
name: 'app-server',
image: image('app-server-prod'),
essential: true,
logConfiguration: logConfiguration('app-server'),
},
{
name: 'nginx',
image: image('nginx-prod'),
essential: true,
logConfiguration: logConfiguration('nginx'),
portMappings: [
{
containerPort: 8080,
protocol: 'tcp',
},
],
dependsOn: [
{
containerName: 'app-server',
condition: 'START',
},
],
volumesFrom: [
{
sourceContainer: 'app-server',
readOnly: true,
},
],
},
],
}
つづいてサービス定義ファイルを作成する。こちらは Amazon ECS サービス定義パラメータの内容をタスク定義ファイル同様、JSON or Jsonnet 形式で記述する。こちらも {{ cfn_output ... }}
の中で環境変数を参照するため、タスク定義ファイルと同様の戦略を採る。
{
launchType: 'FARGATE',
platformFamily: 'LINUX',
platformVersion: '1.4.0',
serviceName: '${APP_ECS_SERVICE_NAME}',
desiredCount: 2,
networkConfiguration: {
awsvpcConfiguration: {
subnets: [
'{{ cfn_output `${APP_STACK_NAME}` `PrivateSubnetAz1` }}',
'{{ cfn_output `${APP_STACK_NAME}` `PrivateSubnetAz2` }}',
],
securityGroups: ['{{ cfn_output `${APP_STACK_NAME}` `EcsSecurityGroupId` }}'],
assignPublicIp: 'DISABLED',
},
},
loadBalancers: [
{
containerName: 'nginx',
containerPort: 8080,
targetGroupArn: '{{ cfn_output `${APP_STACK_NAME}` `AlbTargetGroupArn` }}',
},
],
enableExecuteCommand: false,
}
合計3つの設定ファイルを用意したら Taskfile.yaml
に ecspresso
関連のタスクを定義していく。
aws:ecr:push:
deps:
- docker:build
cmds:
- task: aws:ecr:login
- for: { var: DOCKER_IMAGES }
cmd: docker tag {{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:{{.TAG}}
- for: { var: DOCKER_IMAGES }
cmd: docker tag {{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest
- for: { var: DOCKER_IMAGES }
cmd: docker push {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:{{.TAG}}
- for: { var: DOCKER_IMAGES }
cmd: docker push {{.AWS_ECR_HOST}}/{{.DOCKER_IMAGE_PREFIX}}/{{.ITEM}}:latest
+ #
+ # AWS ECS tasks
+ #
+ aws:ecs:setup:
+ vars:
+ FILES:
+ - infra/aws/ecspresso/ecs-service-definition
+ - infra/aws/ecspresso/ecs-service-task-definition
+ cmds:
+ - for: { var: FILES }
+ cmd: envsubst < {{.ITEM}}.jsonnet | jsonnet -o {{.ITEM}}.json -
+ aws:ecs:verify:
+ deps:
+ - aws:ecs:setup
+ cmds:
+ - ecspresso verify
+ aws:ecs:diff:
+ deps:
+ - aws:ecs:setup
+ cmds:
+ - ecspresso diff
+ aws:ecs:deploy:dry-run:
+ deps:
+ - aws:ecs:setup
+ cmds:
+ - ecspresso deploy --dry-run
+ aws:ecs:deploy:
+ deps:
+ - aws:ecr:push
+ - aws:ecs:setup
+ env:
+ TAG: '{{.TAG}}'
+ cmds:
+ - ecspresso deploy
つづく。
Taskfile.yaml
に定義したタスクを実行していく。
まず task aws:ecs:setup
を実行するとタスク定義ファイル・サービス定義ファイルそれぞれについて Jsonnet 形式のファイルから JSON ファイルが生成される。このとき envsubst
ならびに jsonnet
コマンドを用いているので、もしインストールされていなければインストールしておく。
# envsubst は gettext パッケージの一部
$ brew install gettext jsonnet
task aws:ecs:setup
コマンドを実行し、生成される JSON ファイルの中身を確認する。
{
"containerDefinitions": [
{
"essential": true,
"image": "************.dkr.ecr.ap-northeast-1.amazonaws.com/aws-cdk-ecspresso-laravel-example-2024/app-server-prod:{{ env `TAG` `latest` }}",
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/example-laravel-app-service/app-server",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "app-server"
}
},
"name": "app-server"
},
{
"dependsOn": [
{
"condition": "START",
"containerName": "app-server"
}
],
"essential": true,
"image": "************.dkr.ecr.ap-northeast-1.amazonaws.com/aws-cdk-ecspresso-laravel-example-2024/nginx-prod:{{ env `TAG` `latest` }}",
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/example-laravel-app-service/nginx",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "nginx"
}
},
"name": "nginx",
"portMappings": [
{
"containerPort": 8080,
"protocol": "tcp"
}
],
"volumesFrom": [
{
"readOnly": true,
"sourceContainer": "app-server"
}
]
}
],
"cpu": "256",
"executionRoleArn": "{{ cfn_output `ExampleLaravelAppStack` `EcsTaskExecutionRoleArn` }}",
"family": "example-laravel-app-cluster-service-task-definition",
"memory": "1024",
"networkMode": "awsvpc",
"requiresCompatibilities": [
"FARGATE"
]
}
{
"desiredCount": 2,
"enableExecuteCommand": false,
"launchType": "FARGATE",
"loadBalancers": [
{
"containerName": "nginx",
"containerPort": 8080,
"targetGroupArn": "{{ cfn_output `ExampleLaravelAppStack` `AlbTargetGroupArn` }}"
}
],
"networkConfiguration": {
"awsvpcConfiguration": {
"assignPublicIp": "DISABLED",
"securityGroups": [
"{{ cfn_output `ExampleLaravelAppStack` `EcsSecurityGroupId` }}"
],
"subnets": [
"{{ cfn_output `ExampleLaravelAppStack` `PrivateSubnetAz1` }}",
"{{ cfn_output `ExampleLaravelAppStack` `PrivateSubnetAz2` }}"
]
}
},
"platformFamily": "LINUX",
"platformVersion": "1.4.0",
"serviceName": "example-laravel-app-service"
}
つづく。
task aws:ecs:verify
を実行する。これは ecspresso verify
を実行することで、ECS が依存する各種リソースが存在するかどうかをチェックするコマンド。
対象の AWS アカウントに自身の認証情報で直接アクセスできる場合はそのまま実行すれば OK。もし Assume Role が必要な環境である場合、ecspresso
自身は Assume Role や MFA の機能を持たないため、同じ作者が開発している aswarp
を使うのがオススメ。
Homebrew を使っている場合は brew install fujiwara/tap/aswrap
でインストールできる。
# Assume Role しない場合
$ task aws:ecs:verify
# Assume Role が必要な場合
$ env AWS_PROFILE=**** aswrap task aws:ecs:verify
いずれも次のようなメッセージが表示されれば OK。
20XX/XX/XX 12:34:56 example-laravel-app-service/example-laravel-app-cluster Verify OK!
つづく。
task aws:ecs:diff
を実行すると、ecspresso diff
を実行し、デプロイ済のタスク定義と現在のタスク定義(≒これからデプロイするタスク定義)との差分を確認できる。
# Assume Role しない場合
$ task aws:ecs:diff
# Assume Role が必要な場合
$ env AWS_PROFILE=**** aswrap task aws:ecs:diff
現時点ではまだ何もデプロイしていないのでこれから登録されるタスク定義の内容が表示されるはず。
task aws:ecs:deploy:dry-run
を実行すると、ecspresso deploy --dry-run
を実行する。これは ecspresso deploy
を実行した時に何が行われるのかを確認するために利用できる。
# Assume Role しない場合
$ task aws:ecs:deploy:dry-run
# Assume Role が必要な場合
$ env AWS_PROFILE=**** aswrap task aws:ecs:deploy:dry-run
task aws:ecs:deploy
を実行すると、いよいよデプロイが行われる。
# Assume Role しない場合
$ task aws:ecs:deploy
# Assume Role が必要な場合
$ env AWS_PROFILE=**** aswrap task aws:ecs:deploy
デプロイが終われば .env
で APP_DOMAIN_NAME
に設定したドメイン名にアクセスすることで、ローカルと同じ Laravel の初期画面が表示されるはず。
おつかれさまでした。