Docker による「環境の同一性」の担保:ローカル開発から AWS Fargate への一気通貫設計
Dockerを使ってローカル開発環境からAWS Fargateまで環境の同一性を担保する設計。「自分のPCでは動くのに」問題を根本的に解消するコンテナ設計の実践。
はじめに:「自分のPCでは動く」の構造的な原因
「ローカルでは動くのに本番で動かない」は、環境差異に起因する。原因を列挙すると、その多さに気づく。
環境差異の発生源:
ランタイムの差異
ローカル: Node.js 18.12.0
本番サーバー: Node.js 16.14.0(サーバー担当者が古いまま)
OS / glibc の差異
ローカル: macOS (arm64)
本番: Amazon Linux 2 (x86_64)
→ ネイティブモジュール (sharp, bcrypt等) がクラッシュ
環境変数の差異
ローカル: .env ファイルで管理
本番: 設定し忘れた変数がある
依存パッケージの差異
ローカル: npm install でインストール済み
本番: node_modules が存在しない or バージョン不一致
ファイルパスの差異
ローカル: ./uploads/ が存在する
本番: EFS / S3 が未マウント
Dockerは「アプリケーションの実行に必要な全てをイメージに封じ込める」ことで、この問題を根本から解決する。「テストしたイメージをそのまま本番に持っていく」——これが環境同一性の本質だ。
Part 1:Dockerfile の設計原則
Dockerfileの書き方次第で、イメージのサイズ・セキュリティ・ビルド時間が大きく変わる。
マルチステージビルドで本番イメージを最小化する
# Dockerfile(Node.js APIサーバーの例)
# ── Stage 1: ビルドステージ ──────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# package.json と lock ファイルを先にコピーしてキャッシュを活用
# ソースコードが変わっても依存関係が変わらなければ npm ci はスキップされる
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# ── Stage 2: 実行ステージ ──────────────────────────
FROM node:20-alpine AS runner
# セキュリティ: root以外のユーザーで実行する
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# ビルドステージから必要なものだけをコピー
# node_modules全体ではなく本番依存のみ持ち込む
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./package.json
USER appuser
EXPOSE 3000
# CMD ではなく ENTRYPOINT + CMD で起動コマンドを分離
ENTRYPOINT ["node"]
CMD ["dist/index.js"]
マルチステージビルドの効果:
シングルステージ(全部入り):
イメージサイズ: ~800MB
含まれるもの: devDependencies, ソースコード, TypeScriptコンパイラ
マルチステージ(必要なものだけ):
イメージサイズ: ~150MB
含まれるもの: 本番コード + 本番依存のみ
攻撃対象: 大幅に減少(不要なツールが存在しない)
.dockerignore の設定
# .dockerignore
node_modules
.git
.env*
*.md
*.log
dist
coverage
.DS_Store
.dockerignore がないと、COPY . . でローカルの node_modules(数百MB)がビルドコンテキストに含まれてしまい、ビルドが著しく遅くなる。
Part 2:Docker Compose でローカル環境を再現する
本番と同じサービス構成をローカルで立ち上げる。
# docker-compose.yml
services:
api:
build:
context: .
target: builder # ← 開発時はビルドステージで起動(hot reload有効)
ports:
- "3000:3000"
volumes:
# ソースコードをマウント(コード変更がコンテナに即反映される)
- ./src:/app/src:ro
environment:
- NODE_ENV=development
env_file:
- .env.local # ローカル専用の環境変数
command: ["npx", "tsx", "watch", "src/index.ts"]
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: localpassword
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d:ro # 初回起動時にマイグレーションを自動実行
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db_data:
# 開発時のワークフロー
docker compose up -d # バックグラウンドで全サービス起動
docker compose logs -f api # APIのログをフォロー
docker compose down -v # 停止 + ボリューム削除(クリーンな再起動に使う)
Part 3:ECR へのイメージプッシュと GitHub Actions の統合
ローカルで検証したイメージをそのまま ECR に push し、Fargate で動かす。
# .github/workflows/deploy-fargate.yml
name: Build and Deploy to Fargate
on:
push:
branches: [main]
env:
AWS_REGION: ap-northeast-1
ECR_REPOSITORY: my-app-api
permissions:
id-token: write
contents: read
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image to ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }} # コミットSHAをタグに使う(イメージの追跡可能性)
run: |
# 本番用イメージをビルド(runner ステージ)
docker build \
--target runner \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:latest \
.
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
# 後続ステップでイメージURIを参照できるようにする
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Update ECS Task Definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: api
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS Fargate
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: my-app-service
cluster: my-app-cluster
wait-for-service-stability: true # デプロイ完了まで待機してから成功とする
ポイント: github.sha をイメージタグに使うことで、「どのコミットのコードが動いているか」がECRのタグから追跡できる。latest タグだけでは障害発生時にどのバージョンか特定できない。
Part 4:ECS タスク定義の設計
{
"family": "my-app-api",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/my-app-task-role",
"containerDefinitions": [
{
"name": "api",
"image": "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/my-app-api:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "PORT", "value": "3000" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/my-app/prod/database-url"
},
{
"name": "REDIS_URL",
"valueFrom": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/my-app/prod/redis-url"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-app-api",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 10
}
}
]
}
secrets フィールドの重要性: 環境変数に直接シークレットを書かず、SSM Parameter Store の ARN を参照する。タスク起動時に ECS が自動的に値を取得してコンテナに注入する。これにより、シークレットがタスク定義のJSONやログに平文で残らない。
Part 5:Terraform で ECS インフラを管理する
# modules/ecs-fargate/main.tf
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-cluster"
setting {
name = "containerInsights"
value = "enabled" # Container Insightsでメトリクス・ログを集約
}
}
resource "aws_ecs_service" "api" {
name = "${var.project_name}-api-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.private_subnet_ids # Fargateタスクはプライベートサブネットに配置
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false # ALB経由のみアクセスを受ける
}
load_balancer {
target_group_arn = var.target_group_arn
container_name = "api"
container_port = 3000
}
# ローリングデプロイの設定
deployment_minimum_healthy_percent = 100 # デプロイ中も全タスクを維持
deployment_maximum_percent = 200 # 新旧タスクが並走する期間を許可
# デプロイ失敗時の自動ロールバック
deployment_circuit_breaker {
enable = true
rollback = true # 連続失敗でロールバックが自動実行される
}
lifecycle {
ignore_changes = [task_definition]
# task_definitionはGitHub Actionsが更新するため、Terraformの管理から外す
}
}
# オートスケーリング
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 10
min_capacity = 1
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "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 = 70.0 # CPU 70% を超えたらスケールアウト
}
}
Conclusion:「イメージの不変性」が運用の予測可能性を生む
コンテナ設計の本質は「不変性(immutability)」だ。
不変なイメージが実現すること:
開発者のPC → CI/CD環境 → ステージング → 本番
────────────────────────────────────────
全て同じイメージが動いている
「ステージングでは動いた」
= 本番でも同じコードが同じ環境で動く
「このコミットのビルドが本番に出ている」
= git log で何が動いているか追跡できる
障害が起きた
= 前のイメージタグに切り替えるだけでロールバック完了
| 比較軸 | サーバーへの直接デプロイ | Dockerコンテナデプロイ |
|---|---|---|
| 環境の同一性 | 担当者依存 | イメージで保証 |
| ロールバック | 手順書が必要 | タグ切り替えで即時 |
| スケールアウト | 新サーバーのセットアップが必要 | 同じイメージを追加起動するだけ |
| 本番との差異 | 見えない | 原理的にゼロ |
| デプロイ失敗時 | 中途半端な状態が残る | ローリングデプロイで無停止、失敗時自動ロールバック |
Dockerを「仮想化ツール」として捉えると、その価値の半分しか見えない。「実行環境をコードとして定義し、どこでも再現可能にする」——これがコンテナ化の本質的な価値だ。
この記事をシェア