はじめに
こんにちは、株式会社TechoesのR(入社1年目の初心者エンジニア)と申します!
この連載では「Laravel開発環境をローカルからAWS ECS Fargateへ」をテーマに、環境構築からクラウド上での実行、パフォーマンス改善までを段階的に学んでいきます。
第1回では、Docker DesktopとLaravel Sailを使ったローカル環境の構築を紹介しました。今回は、PHPとNginxのコンテナを用意し、AWS ECS FargateでLaravelを起動して、画面と確認用APIの応答を検証した内容をまとめます。
今回のゴールと検証範囲
この記事の目的は、第1回で構築したLaravelのローカル環境をもとに、ECS向けのDockerイメージを作成し、AWS ECS Fargateで画面・確認用APIが応答するまでの手順を紹介することです。
Laravel Sailとの構成の違い、専用のDockerfileを用意する理由を説明し、費用の確認、ログ確認、構築中の問題と対応、検証後の停止・リソース削除までをまとめます。
- Laravelの初期画面が表示される
- ヘルスチェック用の /up がHTTP 200を返す
- 確認用APIがJSONを返す
- コンテナのログをCloudWatch Logsで確認できる
- 検証後に不要なAWSリソースを削除できる
今回の構成は、実データを扱わないHTTPの動作確認用です。そのまま本番環境に利用するための構成ではありません。
今回はDB接続を必要としない動作確認までを対象とし、RDSの構築・LaravelからのDB接続は次回に扱います。
HTTPS化、ElastiCache、認証機能、ジョブワーカー、自動デプロイ、負荷試験は今回の対象外です。HTTPS化・自動デプロイ・パフォーマンス改善は、今後の連載で段階的に検証します。
構築するAWS環境の全体像
ブラウザ
↓ HTTP :80
ALB
↓ :80(ALBからの通信のみ許可)
ECS Fargateのタスク
├─ Nginx
└─ PHP-FPM / Laravel
↑ Nginxから127.0.0.1:9000で接続
ECR → コンテナイメージの保存
Secrets Manager → APP_KEYの管理
CloudWatch Logs ← コンテナのログ
既存の案件とは分離したVPCを作成し、2つのアベイラビリティゾーンにパブリックサブネットを配置しました。ALBは両方のサブネットを使用しますが、Fargateのタスクは1つです。アプリケーション自体を複数台にした構成ではありません。
今回のタスクにはパブリックIPを付与し、インターネットゲートウェイ経由でECRなどへアクセスさせています。NAT Gatewayは作成していません。タスクの80番ポートはALBのセキュリティグループからのみ許可し、PHP-FPMの9000番を外部公開しない設定にしました。
ECSのawsvpcモードでは、同じタスク内のコンテナがlocalhostで通信できます。そのため、AWSではNginxからPHP-FPMへの接続先を127.0.0.1:9000にしています。AWS公式:awsvpcネットワーク
検証時の環境
| 項目 | 検証した内容 |
|---|---|
| ローカル | Windows / Docker Desktop / PowerShell |
| PHP・PHP-FPM | 8.5.10 |
| Laravel | 13.29.0 |
| Nginx | 1.30.5 |
| AWSリージョン | 東京(ap-northeast-1) |
| Fargate | Linux / x86_64 |
| タスクのCPU・メモリ | 0.5 vCPU / 1 GB |
| タスク数 | 1 |
| ログ保持期間 | 7日 |
バージョンは検証時点のものです。Dockerイメージのタグは将来更新されるため、同じタグから常に同じバージョンが取得できるとは限りません。継続運用では、バージョンやダイジェストの固定も検討します。
事前準備と費用の確認
用意したものは、Laravelプロジェクト、起動済みのDocker Desktop、AWS CLI、および検証用AWSアカウントです。
会社のAWSを使う場合は、作成してよいリソース、利用期間、権限、費用の扱いを事前に確認します。既存サービスやお客様の環境を検証先と取り違えないことも重要です。
以下のコマンドで、接続しているアカウントを確認しました。
aws sts get-caller-identity --profile default --region ap-northeast-1
今回の構成ではFargateだけでなく、ALB、パブリックIPv4、ECR、ログ、Secrets Managerなども課金対象となり得ます。検証前に料金を確認し、終了後の削除までを作業範囲に含めました。
8時間動かした場合の費用の目安
今回の構成を東京リージョンで8時間利用した場合の計算例です。実際の請求額ではなく、2026年9月確認の単価を使った概算です。無料枠・割引・税は含めません。
前提はLinux/x86_64、0.5 vCPU・1 GBのFargateタスク1つ、ALB1台、パブリックIPv4がALB用2個とタスク用1個です。タスクの入れ替えによる一時的な増加やALBの拡張は含めていません。
| 課金対象 | 何に対して課金されるか | 8時間の計算例(USD) |
|---|---|---|
| FargateのCPU | 割り当てたvCPU数と実行時間 | 0.05056 × 0.5 × 8 = 約0.202 |
| Fargateのメモリ | 割り当てたGB数と実行時間 | 0.00553 × 1 × 8 = 約0.044 |
| ALB本体 | ALBを利用している時間 | 0.0243 × 8 = 約0.194 |
| パブリックIPv4 | IPアドレス数と利用時間 | 0.005 × 3 × 8 = 0.120 |
| 上記の小計 | CPU・メモリ・ALB本体・IPv4 | 約0.56 USD |
FargateはCPU使用率ではなく、タスクに割り当てたCPU・メモリと課金対象時間で計算します。Linuxではイメージのダウンロード開始からタスク終了までを秒単位(最低1分)で課金します。ALB本体は1時間未満の端数も課金されるため、実際の作成・削除時刻によって差が出ます。Fargate料金、ALB料金、IPv4料金
このほかに、次の従量料金・保管料金があります。
| サービス・項目 | 追加料金の考え方 |
|---|---|
| ALBのLCU | 接続数・処理バイト数・ルール評価などの使用量。例として平均1 LCUなら、0.008 × 8 = 0.064 USDを追加 |
| ECR | 保存したイメージの容量と保存期間。タスクを止めても、イメージを残せば保管料金が続く |
| CloudWatch Logs | ログの取り込み量、保存量・期間、検索などの使用量 |
| Secrets Manager | シークレットの保管期間とAPI呼び出し回数 |
| データ転送 | 転送先や通信量に応じた料金 |
上記の小計に平均1 LCU分を加える例では約0.63 USDで、1 USD未満です。 ただし、ECR・ログ・シークレット・データ転送などは別途加算されるため、全体が必ず1 USD未満になるという意味ではありません。これらの量は今回の請求実績として測定していないため、合計額を断定していません。
RDS・ElastiCache・NAT Gatewayは今回作成していないため、この構成の計算には含めていません。タスクだけを停止してもALBや保存系の料金は残るので、検証終了後は不要なリソースを削除します。
参考:ECR料金、CloudWatch料金、Secrets Manager料金
PHP・NginxのDockerイメージを作成する
Laravel SailとECS向けのコンテナ構成の違い
SailもDockerを使っています。今回は「SailからDockerへ変換する」のではなく、ローカル開発向けの構成とは別に、ECSで実行するための構成を用意します。
Laravel Sailは、Docker Composeで定義した開発用コンテナを、簡単なコマンドで操作するためのツールです。PHPやMySQL、Redisを使った開発環境を手元で動かしやすくしてくれます。Laravel公式:Sail
一方、今回のECS環境では、ソースコードを含むイメージを事前に作り、ECRへ登録してから起動します。手元のPCのファイルをそのまま参照して動かすのではなく、配布するイメージと実行時の設定を分けて管理します。
| 観点 | ローカル開発用のSail | 今回のECS向け構成 |
|---|---|---|
| 目的 | 手元でコードを編集し、動作確認しやすくする | 作成済みのイメージをAWS上で実行する |
| ソースコード | PCの作業ファイルをコンテナへマウントする構成 | ビルド時に必要なコードをイメージへ含める |
| 起動・管理 | SailのコマンドとDocker Compose | ECSのタスク定義・サービス |
| Webリクエストの処理 | Sailの開発用アプリコンテナ | NginxからPHP-FPMへ処理を渡す |
| DB | ローカルのMySQLコンテナなど | 今回は接続しない。RDSは次回に扱う |
ECS向けに専用のDockerfileを用意する理由
Dockerfileはイメージの作成手順、Composeはローカルでの複数コンテナの起動設定、ECSタスク定義はAWS上での実行設定です。DockerfileだけでComposeやECSの設定をすべて置き換えるわけではありません。
今回、Sailの開発用構成をそのまま使わず、専用のDockerfileを用意した理由は次のとおりです。
-
実行に使うファイルと依存関係を明確にするため
必要なソースコードをイメージへ含め、Composerの開発用依存パッケージは除外します。ローカルの.envやキャッシュを持ち込まないようにします。 -
NginxとPHP-FPMの役割を分けるため
NginxがHTTPリクエストを受け、PHP-FPMがLaravelの処理を担当する構成を、個別のイメージとして定義します。 -
環境ごとの設定を起動時に渡すため
APP_KEYなどの秘密情報をイメージへ埋め込まず、AWS側で管理してコンテナへ渡します。 -
同じイメージを使って動作を確認するため
ローカルで確認したイメージをECRへ登録し、ECSでも実行することで、どの成果物を配布したかを追いやすくします。
ローカル開発では引き続きSailを使います。Sailをプロジェクトから削除する作業ではありません。また、今回のDockerfileにもビルドツールなどは残っているため、最小サイズの本番イメージに仕上げたわけではありません。
本番運用では、実行に不要なツールの除去、脆弱性への対応、HTTPS、権限、監視なども別途検討します。今回の目的は、その前段階としてECS上でLaravelが動くことを確認することです。
PHP・Nginxの設定と確認用ルートの作成
ここから、AWS向けに用意したDockerfileと設定ファイルを紹介します。
Laravelプロジェクトの下に、次のファイルを配置します。
infra/ecs/
├─ Dockerfile
├─ Dockerfile.dockerignore
├─ default.conf.template
├─ health-route.php
├─ compose.yaml
└─ stack.yaml
Dockerfile
1つのDockerfileから、PHP用とNginx用の2つのイメージを作成します。
FROM php:8.5-fpm-bookworm AS php
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libzip-dev unzip \
&& docker-php-ext-install -j2 mbstring pdo_mysql zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --no-interaction --prefer-dist
COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY public ./public
COPY resources ./resources
COPY routes ./routes
COPY artisan ./artisan
RUN rm -f bootstrap/cache/*.php \
&& mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs \
&& composer dump-autoload --no-dev --classmap-authoritative --no-scripts \
&& php artisan package:discover --ansi \
&& chown -R www-data:www-data storage bootstrap/cache
COPY infra/ecs/health-route.php /tmp/health-route.php
RUN cat /tmp/health-route.php >> routes/web.php && rm /tmp/health-route.php
ENV APP_ENV=production APP_DEBUG=false LOG_CHANNEL=stderr SESSION_DRIVER=array CACHE_STORE=array QUEUE_CONNECTION=sync
USER www-data
EXPOSE 9000
CMD ["php-fpm", "-F"]
FROM nginx:stable-alpine AS nginx
COPY --from=php /var/www/html/public /var/www/html/public
COPY infra/ecs/default.conf.template /etc/nginx/templates/default.conf.template
ENV PHP_UPSTREAM=127.0.0.1:9000
EXPOSE 80
今回のポイントは以下です。
- 開発用の依存パッケージを含めないよう、Composerに--no-devを指定する
- ローカルのキャッシュファイルを持ち込まない
- APP_DEBUG=falseにする
- Laravelのログを標準エラーへ出す
- APP_KEYはビルド時に埋め込まず、実行時に渡す
DBを使わない確認のため、セッションとキャッシュはarray、キューはsyncにしています。セッション・キャッシュは永続化されず、ジョブも非同期処理にはなりません。
また、このDockerfileは今回の初期画面と疎通確認に必要なファイルを対象にしています。Migration用のdatabaseディレクトリやフロントエンド資産のビルドなどは、アプリケーションの要件に応じて追加が必要です。
ビルドに含めるファイルを絞る
Dockerfile.dockerignoreを次の内容にしました。
**
!composer.json
!composer.lock
!artisan
!app/**
!bootstrap/**
!config/**
!public/**
!resources/**
!routes/**
!infra/ecs/**
bootstrap/cache/*.php
**/.env*
**/*.pem
**/*.key
.envや秘密鍵をイメージへ含めないための設定です。秘密情報を別の名前で保存している場合は、それも対象にする必要があります。
Nginxの設定
default.conf.templateには以下を設定しました。
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
server_tokens off;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param HTTP_PROXY "";
fastcgi_pass ${PHP_UPSTREAM};
}
location ~ \.php$ { return 404; }
location ~ /\. { deny all; }
}
公開ディレクトリはLaravelのpublicにします。PHP_UPSTREAMは、Nginxコンテナの起動時にテンプレートへ反映されます。
確認用のルート
health-route.phpは、ビルド時にroutes/web.phpへ追記するための断片として用意しました。元の学習用routes/web.phpは変更していません。追記用のため、先頭にPHP開始タグは付けていません。
// routes/web.phpへ追記するコード
\Illuminate\Support\Facades\Route::get('/api/learning-health', function () {
return response()->json([
'status' => 'ok',
'service' => 'laravel-learning',
'release' => 'v1',
]);
});
パスに/apiを付けていますが、今回はroutes/web.phpに登録した疎通確認用ルートです。本格的なAPIルーティングや認証の設計は別途行います。
ローカルでコンテナの動作を確認する
compose.yamlを用意します。
services:
php:
image: laravel-learning-php:v1
build:
context: ../..
dockerfile: infra/ecs/Dockerfile
target: php
environment:
APP_KEY: ${LEARNING_APP_KEY:?Set LEARNING_APP_KEY for this shell}
networks: [learning]
nginx:
image: laravel-learning-nginx:v1
build:
context: ../..
dockerfile: infra/ecs/Dockerfile
target: nginx
environment:
PHP_UPSTREAM: php:9000
ports: ["127.0.0.1:8088:80"]
depends_on: [php]
networks: [learning]
networks:
learning:
ローカルのComposeでは接続先をphp:9000にします。AWSの同一タスク内通信で使う127.0.0.1:9000との違いに注意します。
以下はLaravelプロジェクトのルートで実行したPowerShellコマンドです。
docker build --platform linux/amd64 --target php -t laravel-learning-php:v1 -f infra/ecs/Dockerfile .
docker build --platform linux/amd64 --target nginx -t laravel-learning-nginx:v1 -f infra/ecs/Dockerfile .
$learningBytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($learningBytes)
$env:LEARNING_APP_KEY = 'base64:' + [Convert]::ToBase64String($learningBytes)
docker compose -p laravel-learning -f infra/ecs/compose.yaml up -d --no-build
Invoke-RestMethod http://127.0.0.1:8088/api/learning-health
作成したAPP_KEYは画面や記事へ出力しません。
ローカルでは初期画面、/up、/api/learning-healthのすべてでHTTP 200を確認しました。Nginxの設定テストも成功し、PHPイメージに.envがないことも確認しました。
CloudFormationでAWSの検証環境を作成する
AWS側の設定はCloudFormationのstack.yamlにまとめました。CloudFormationは、AWSリソースの構成をファイルで定義し、まとめて作成・削除するためのサービスです。
設定した主な内容
| 対象 | 設定 |
|---|---|
| VPC | 既存環境とは別の専用VPC |
| サブネット | 異なる2つのAZに配置 |
| ALB | インターネット向け、HTTP 80番 |
| ターゲットグループ | ターゲットタイプip、ヘルスチェック/up |
| ECS | Fargate、awsvpc、Linux/x86_64 |
| タスク | PHPとNginxの2コンテナ、0.5 vCPU / 1 GB |
| 起動数 | 最初は0、イメージ登録後に1へ変更 |
| Secrets Manager | APP_KEYを生成して保存 |
| タスク実行ロール | ECRからの取得、ログ出力、対象APP_KEYの取得 |
| ログ | PHPとNginxでプレフィックスを分け、保持7日 |
APP_KEYはSecrets Managerで生成し、ECSタスク定義のsecretsからコンテナへ渡しました。値をDockerfileや記事へ直接書かないようにします。
ECR・ログ・APP_KEYにはRetainを設定したため、スタックを削除しても残る構成でした。この設定は、後片付けの際にも確認が必要です。
次が使用したテンプレートです(一部のYAML表記を短く整理しています)。新規作成時はExistingAppKeyArnを空にします。同名のリソースがないこと、指定するAZが利用できることを事前に確認してください。ALBはHTTPで外部公開する設定なので、機密情報や認証情報を送らない検証に限定します。
stack.yaml(クリックして展開)
AWSTemplateFormatVersion: '2010-09-09'
Description: Isolated Laravel learning environment - HTTP smoke testing only
Parameters:
ZoneA: {Type: 'AWS::EC2::AvailabilityZone::Name', Default: ap-northeast-1a}
ZoneB: {Type: 'AWS::EC2::AvailabilityZone::Name', Default: ap-northeast-1c}
ExistingAppKeyArn: {Type: String, Default: ''}
ImageTag: {Type: String, Default: v1}
DesiredCount: {Type: Number, Default: 0, AllowedValues: [0, 1]}
Conditions:
CreateStorage: !Equals [!Ref ExistingAppKeyArn, '']
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.87.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags: [{Key: Name, Value: laravel-learning}]
Gateway:
Type: AWS::EC2::InternetGateway
GatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref Gateway}
SubnetA:
Type: AWS::EC2::Subnet
Properties: {VpcId: !Ref Vpc, CidrBlock: 10.87.1.0/24, AvailabilityZone: !Ref ZoneA}
SubnetB:
Type: AWS::EC2::Subnet
Properties: {VpcId: !Ref Vpc, CidrBlock: 10.87.2.0/24, AvailabilityZone: !Ref ZoneB}
Routes:
Type: AWS::EC2::RouteTable
Properties: {VpcId: !Ref Vpc}
InternetRoute:
Type: AWS::EC2::Route
DependsOn: GatewayAttachment
Properties:
RouteTableId: !Ref Routes
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref Gateway
RouteAssociationA:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: {SubnetId: !Ref SubnetA, RouteTableId: !Ref Routes}
RouteAssociationB:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: {SubnetId: !Ref SubnetB, RouteTableId: !Ref Routes}
AlbSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: HTTP access to learning demo
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0}
TaskSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Only ALB can reach the learning task
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !Ref AlbSecurityGroup
PhpRepository:
Condition: CreateStorage
Type: AWS::ECR::Repository
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RepositoryName: laravel-learning-php
ImageTagMutability: IMMUTABLE
ImageScanningConfiguration: {ScanOnPush: true}
NginxRepository:
Condition: CreateStorage
Type: AWS::ECR::Repository
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RepositoryName: laravel-learning-nginx
ImageTagMutability: IMMUTABLE
ImageScanningConfiguration: {ScanOnPush: true}
Logs:
Condition: CreateStorage
Type: AWS::Logs::LogGroup
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties: {LogGroupName: /ecs/laravel-learning, RetentionInDays: 7}
AppKey:
Condition: CreateStorage
Type: AWS::SecretsManager::Secret
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Name: laravel-learning-app-key
GenerateSecretString: {PasswordLength: 32, ExcludePunctuation: true}
ExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: {Service: ecs-tasks.amazonaws.com}
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
Policies:
- PolicyName: ReadLearningAppKey
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !If [CreateStorage, !Ref AppKey, !Ref ExistingAppKeyArn]
Cluster:
Type: AWS::ECS::Cluster
Properties: {ClusterName: laravel-learning}
Task:
Type: AWS::ECS::TaskDefinition
Properties:
Family: laravel-learning
Cpu: '512'
Memory: '1024'
NetworkMode: awsvpc
RequiresCompatibilities: [FARGATE]
RuntimePlatform: {CpuArchitecture: X86_64, OperatingSystemFamily: LINUX}
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
ContainerDefinitions:
- Name: php
Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/laravel-learning-php:${ImageTag}'
Essential: true
Secrets:
- Name: APP_KEY
ValueFrom: !If [CreateStorage, !Ref AppKey, !Ref ExistingAppKeyArn]
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !If [CreateStorage, !Ref Logs, /ecs/laravel-learning]
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: php
- Name: nginx
Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/laravel-learning-nginx:${ImageTag}'
Essential: true
DependsOn: [{ContainerName: php, Condition: START}]
PortMappings: [{ContainerPort: 80, Protocol: tcp}]
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !If [CreateStorage, !Ref Logs, /ecs/laravel-learning]
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: nginx
Alb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
DependsOn: GatewayAttachment
Properties:
Name: laravel-learning
Scheme: internet-facing
Type: application
Subnets: [!Ref SubnetA, !Ref SubnetB]
SecurityGroups: [!Ref AlbSecurityGroup]
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !Ref Vpc
TargetType: ip
Protocol: HTTP
Port: 80
HealthCheckPath: /up
HealthCheckIntervalSeconds: 15
HealthyThresholdCount: 2
TargetGroupAttributes:
- {Key: deregistration_delay.timeout_seconds, Value: '30'}
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref Alb
Port: 80
Protocol: HTTP
DefaultActions: [{Type: forward, TargetGroupArn: !Ref TargetGroup}]
Service:
Type: AWS::ECS::Service
DependsOn: [Listener, InternetRoute, RouteAssociationA, RouteAssociationB]
Properties:
Cluster: !Ref Cluster
ServiceName: laravel-learning
TaskDefinition: !Ref Task
DesiredCount: !Ref DesiredCount
LaunchType: FARGATE
HealthCheckGracePeriodSeconds: 60
DeploymentConfiguration:
DeploymentCircuitBreaker: {Enable: true, Rollback: true}
MinimumHealthyPercent: 100
MaximumPercent: 200
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: ENABLED
Subnets: [!Ref SubnetA, !Ref SubnetB]
SecurityGroups: [!Ref TaskSecurityGroup]
LoadBalancers:
- {ContainerName: nginx, ContainerPort: 80, TargetGroupArn: !Ref TargetGroup}
Outputs:
Url: {Value: !Sub 'http://${Alb.DNSName}'}
PhpRepositoryUri: {Value: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/laravel-learning-php'}
NginxRepositoryUri: {Value: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/laravel-learning-nginx'}
TargetGroupArn: {Value: !Ref TargetGroup}
初回作成では、アプリのイメージをまだECRへ登録していないためDesiredCountを0にします。
aws cloudformation validate-template --template-body file://infra/ecs/stack.yaml --profile default --region ap-northeast-1
aws cloudformation create-stack --stack-name laravel-learning-runtime --template-body file://infra/ecs/stack.yaml --capabilities CAPABILITY_IAM --parameters ParameterKey=DesiredCount,ParameterValue=0 ParameterKey=ImageTag,ParameterValue=v1 --profile default --region ap-northeast-1
aws cloudformation wait stack-create-complete --stack-name laravel-learning-runtime --profile default --region ap-northeast-1
CAPABILITY_IAMは、このテンプレートによるIAMリソース作成を了承する指定です。テンプレートの権限設定を確認してから実行します。
今回の実作業では初回作成時にエラーがあり、保持されたECRなどを再利用して再作成しました。詳しくは「構築中に発生した問題と対応」で説明します。
ECRにDockerイメージを登録する
ECRには、PHP用とNginx用のリポジトリを用意しました。以下は認証済みのアカウントIDを取得して登録先を組み立てる例です。最初に表示されたアカウントが承認された検証先と一致していることを確認してください。
$learningAccount = aws sts get-caller-identity --query Account --output text --profile default --region ap-northeast-1
$learningRegistry = "$learningAccount.dkr.ecr.ap-northeast-1.amazonaws.com"
aws ecr get-login-password --profile default --region ap-northeast-1 | docker login --username AWS --password-stdin $learningRegistry
if ($LASTEXITCODE -ne 0) { throw 'ECRへのログインに失敗しました' }
foreach ($learningComponent in @('php', 'nginx')) {
$learningImage = "laravel-learning-${learningComponent}:v1"
docker tag $learningImage "$learningRegistry/$learningImage"
if ($LASTEXITCODE -ne 0) { throw 'タグ付けに失敗しました' }
docker push "$learningRegistry/$learningImage"
if ($LASTEXITCODE -ne 0) { throw 'イメージの登録に失敗しました' }
}
今回はv1タグを使用し、2つのイメージの登録成功を確認しました。ECRのタグは上書き禁止にしたため、更新時にはv2など別のタグを使う運用を想定しています。
ECRへのpushはイメージの保存です。これだけでECSの稼働内容が自動的に更新されるわけではありません。AWS公式:ECRへのイメージ登録
ECS FargateでLaravelを起動する
イメージの登録後、CloudFormationのDesiredCountを1へ更新しました。ほかのパラメーターは現在の値を引き継ぎます。
aws cloudformation update-stack --stack-name laravel-learning-runtime --use-previous-template --capabilities CAPABILITY_IAM --parameters ParameterKey=ExistingAppKeyArn,UsePreviousValue=true ParameterKey=DesiredCount,ParameterValue=1 ParameterKey=ImageTag,UsePreviousValue=true ParameterKey=ZoneA,UsePreviousValue=true ParameterKey=ZoneB,UsePreviousValue=true --profile default --region ap-northeast-1
aws cloudformation wait stack-update-complete --stack-name laravel-learning-runtime --profile default --region ap-northeast-1
ECSのサービスは指定したタスク数を維持します。タスク定義に書いたイメージをもとに、PHPとNginxのコンテナが起動しました。
ここで確認したのは次の3点です。
- CloudFormationがUPDATE_COMPLETEになっている
- PHPとNginxがRUNNINGになっている
- ALBのターゲットがhealthyになっている
NginxはPHPコンテナのSTARTを待つ設定ですが、起動順序だけでHTTP応答の正常性が保証されるわけではありません。ヘルスチェックと実際のアクセスまで確認しました。
ALB経由で画面・APIの応答を確認する
ECSのタスクがRUNNING、ALBのターゲットがhealthyになった後、CloudFormationの出力からUrlを確認しました。
aws cloudformation describe-stacks --stack-name laravel-learning-runtime --query 'Stacks[0].Outputs' --profile default --region ap-northeast-1
結果のうち、OutputKeyがUrlの項目にあるOutputValueがアクセス先です。以下では、そのURLをPowerShellの変数に入れます。実際のURLは環境ごとに異なります。
# <OutputValueのURL>を、取得したhttp://から始まるURLへ置き換える
$learningUrl = '<OutputValueのURL>'
$learningUrl = $learningUrl.TrimEnd('/')
ブラウザで表示内容を確認する
ブラウザのアドレスバーへ、次のURLを入力します。
- 初期画面:
<OutputValueのURL>/ - 確認用API:
<OutputValueのURL>/api/learning-health
初期画面でLaravelのページ、確認用APIでJSONが表示されることを確認します。今回設定したのはHTTPのみなので、URLの先頭は http:// のまま使い、https:// へ変更しないでください。
PowerShellでHTTP 200とJSON応答を確認する
検証時はPowerShellからアクセスし、HTTPステータスも確認しました。以下は検証時の確認方法を、変数を使って整理したコマンド例です。
# 初期画面
$pageResponse = Invoke-WebRequest -UseBasicParsing -Uri "$learningUrl/" -TimeoutSec 20
$pageResponse.StatusCode
# ヘルスチェック
$healthResponse = Invoke-WebRequest -UseBasicParsing -Uri "$learningUrl/up" -TimeoutSec 20
$healthResponse.StatusCode
# 確認用API
$apiResponse = Invoke-WebRequest -UseBasicParsing -Uri "$learningUrl/api/learning-health" -TimeoutSec 20
$apiResponse.StatusCode
$apiResponse.Content
StatusCodeはそれぞれ200、APIのContentは次の内容になることを確認します。
{"status":"ok","service":"laravel-learning","release":"v1"}
| 確認先 | 確認方法 | 検証時の結果 |
|---|---|---|
| / | ブラウザで画面表示、PowerShellでStatusCodeを確認 | 初期画面、HTTP 200 |
| /up | PowerShellでStatusCodeを確認 | HTTP 200 |
| /api/learning-health | PowerShellでStatusCodeとContentを確認 | HTTP 200、上記のJSON応答 |
画面の表示だけでHTTPステータスを判断せず、コマンドの結果も確認します。コマンドがエラーになった場合は、成功とは扱わず、ALBのターゲット状態やECSのログを確認します。
なお、/upと今回の固定JSONのAPIはDB接続を検証していません。また、検証用リソースは後片付けで削除済みなので、この記事の手順で確認するには稼働中の検証環境が必要です。
CloudWatch Logsでログを確認する
PHPとNginxの両方にawslogsを設定し、CloudWatch Logsへ出力しました。Laravel側はLOG_CHANNEL=stderrにしています。
aws logs tail /ecs/laravel-learning --since 5m --format short --profile default --region ap-northeast-1
ログでは、PHP-FPMの起動完了、Nginxの起動、ALBからのヘルスチェック、画面とAPIへのアクセスを確認できました。
構築中に発生した問題と対応
OPcacheの追加インストールでビルドが失敗した
最初はdocker-php-ext-installにopcacheも指定していましたが、今回使用したPHP 8.5イメージではOPcacheが標準搭載されていたため、追加ビルドが失敗しました。
重複したインストール指定を外すとビルドでき、php -vでもOPcacheを確認できました。古いDockerfileの設定をそのまま流用せず、使用するイメージに含まれる機能を確認する必要があると分かりました。
AZの自動取得でCloudFormationが失敗した
2つ目のAZを自動取得する処理で、次のエラーが発生しました。
Fn::Select cannot select nonexistent value at index 1
Fn::GetAZsの結果から2番目の要素を選ぼうとしていましたが、その要素を取得できませんでした。Fn::GetAZsの結果にはデフォルトサブネットの有無による条件があるため、常に期待した数のAZが返るとは限りません。AWS公式:Fn::GetAZs
利用可能なAZを確認し、今回のテンプレートではap-northeast-1aとap-northeast-1cをパラメーターとして明示しました。
aws ec2 describe-availability-zones --query 'AvailabilityZones[].ZoneName' --profile default --region ap-northeast-1
ロールバック後も一部のリソースが残った
初回のスタック作成は自動でロールバックしましたが、Retainを指定したECR、ロググループ、APP_KEYは残りました。
同じ名前で作り直すと競合するため、修正後はこれらを再利用して別の稼働用スタックを作成しました。ExistingAppKeyArnを指定した場合に保存系リソースを新規作成しない条件分岐を設け、既存のリポジトリ名とロググループ名を参照しています。
このパラメーターはAPP_KEYだけを指定すれば何でも復旧できる仕組みではなく、ECRとロググループも所定の名前で存在することが前提です。
イメージスキャンは未確認のまま残った
v1タグを指定したスキャン結果の取得ではScanNotFoundExceptionとなりました。原因調査と脆弱性の評価は今回完了していません。
コンテナが起動できたことと、脆弱性がないことは別です。 本番運用前にはスキャン結果の確認と対応が必要です。
検証環境の停止・削除と課金の注意点
最初はDesiredCountを0に変更し、FargateタスクのSTOPPEDを確認しました。ただし、この時点ではALBなどが残っていました。
そこで、記事用の構築ファイルと検証記録をローカルへ保存したうえで、次の順番で後片付けを行いました。
- 対象のアカウントと検証用スタックのリソース一覧を確認する
- 稼働用スタックを削除し、DELETE_COMPLETEを確認する
- 検証用ALBと専用VPCが残っていないことを確認する
- Retainで残ったECRの2リポジトリとイメージを削除する
- 検証用ロググループを削除する
- APP_KEYを7日間の復旧猶予付きで削除予約する
ECRイメージとログの削除は取り消せないため、必要な記録が保存されていることを確認してから実行しました。削除コマンドの一括コピーによる事故を避けるため、この記事では対象を確認する手順を中心にしています。
APP_KEYは削除予約中は利用できず、復旧猶予期間内なら復旧できます。削除予約されたシークレットの保管料金は発生しません。AWS公式:シークレットの削除
感想・まとめ
今回、ローカルで動かしたコンテナをECRへ登録し、ECS Fargate上でもLaravelの画面とAPIが応答するところまで確認できました。
最初はAWSのサービス名や設定項目が多く感じましたが、イメージを保存するECR、コンテナを実行するECS Fargate、入口となるALB、ログを確認するCloudWatch Logsという役割に分けると、流れを整理しやすくなりました。
また、DockerのビルドエラーやCloudFormationのロールバックを経験し、成功した手順だけでなく、失敗したときに何が残るかも確認する必要があると学びました。
環境構築では、動作確認だけでなく、費用と後片付けまでを考えることが大切だと感じています。
次回の記事でやること:RDSの構築・DB接続
次回はRDSを作成し、ECS Fargate上のLaravelからデータベースへ接続するところまでを扱う予定です。
- RDSの構築と、ECSから接続するためのネットワーク設定
- DB接続情報とパスワードの管理
- LaravelからのDB接続確認
- Migrationの実行と簡単なデータの読み書き
- 検証後の後片付けと費用の確認
HTTPS化、ElastiCache、自動デプロイ、応答時間やSQL実行回数の測定・改善は、その後の連載で段階的に検証します。