Docker による「環境の同一性」の担保:ローカル開発から AWS Fargate への一気通貫設計

|
Docker AWS Fargate ECS Container CI/CD

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を「仮想化ツール」として捉えると、その価値の半分しか見えない。「実行環境をコードとして定義し、どこでも再現可能にする」——これがコンテナ化の本質的な価値だ。

この記事をシェア

Twitter / X LinkedIn
記事一覧に戻る
🤖
Cloud Assistant
IBM Watson powered

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋