mybook

コンテナとオーケストレーション — デプロイの自動化

「1ヶ月前まで、デプロイは金曜の深夜にしかできなかった」

アオイが苦笑いする。「今は1日10回デプロイできてる。コンテナのおかげだね」

マイクロサービスとコンテナは切り離せない関係にある。


Rails サービスの Docker 化

# Dockerfile(商品サービス例)
FROM ruby:3.3-slim AS base
 
# 必要なシステムパッケージ
RUN apt-get update && apt-get install -y \
  build-essential \
  libpq-dev \
  curl \
  && rm -rf /var/lib/apt/lists/*
 
WORKDIR /app
 
# Gemのインストール(キャッシュ活用のため先にコピー)
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true && \
    bundle config set --local without 'development test' && \
    bundle install --jobs 4 --retry 3
 
# アプリケーションコードをコピー
COPY . .
 
# アセットのプリコンパイル(必要な場合)
RUN SECRET_KEY_BASE=dummy bundle exec rails assets:precompile 2>/dev/null || true
 
# 非rootユーザーで実行(セキュリティ)
RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN chown -R appuser:appuser /app
USER appuser
 
EXPOSE 3000
 
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
# config/puma.rb
workers ENV.fetch('WEB_CONCURRENCY', 2).to_i
max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5).to_i
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
threads min_threads_count, max_threads_count
 
bind "tcp://0.0.0.0:#{ENV.fetch('PORT', 3000)}"
environment ENV.fetch('RAILS_ENV', 'production')
 
# Graceful shutdown: 進行中リクエストを完了してから停止
on_worker_shutdown do
  ActiveRecord::Base.connection_pool.disconnect!
end

マルチステージビルド

# 本番用の最適化されたDockerfile
FROM ruby:3.3-slim AS builder
 
RUN apt-get update && apt-get install -y build-essential libpq-dev
 
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true && \
    bundle config set --local without 'development test' && \
    bundle install
 
COPY . .
 
# ビルドステージでのみ必要なものをここで処理
RUN bundle exec bootsnap precompile --gemfile app/ lib/
 
# 本番イメージ(小さく)
FROM ruby:3.3-slim AS production
 
RUN apt-get update && apt-get install -y libpq5 curl && \
    rm -rf /var/lib/apt/lists/*
 
WORKDIR /app
 
# ビルドステージからGemとアプリだけコピー
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /app /app
 
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
 
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

ECS/Fargate タスク定義

{
  "family": "shopnova-product-service",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::xxxx:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::xxxx:role/shopnova-product-service-role",
  "containerDefinitions": [
    {
      "name": "product-service",
      "image": "xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/product-service:latest",
      "essential": true,
      "portMappings": [
        { "containerPort": 3000, "protocol": "tcp" }
      ],
      "environment": [
        { "name": "RAILS_ENV", "value": "production" },
        { "name": "WEB_CONCURRENCY", "value": "2" },
        { "name": "RAILS_MAX_THREADS", "value": "5" }
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:xxxx:secret:shopnova/product/database-url"
        },
        {
          "name": "SECRET_KEY_BASE",
          "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:xxxx:secret:shopnova/secret-key-base"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/shopnova/product-service",
          "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": 60
      }
    }
  ]
}

GitHub Actions CI/CD パイプライン

# .github/workflows/deploy-product-service.yml
name: Deploy Product Service
 
on:
  push:
    branches: [main]
    paths:
      - 'services/product-service/**'
      - '.github/workflows/deploy-product-service.yml'
 
env:
  AWS_REGION: ap-northeast-1
  ECR_REPOSITORY: shopnova/product-service
  ECS_SERVICE: shopnova-product-service
  ECS_CLUSTER: shopnova-cluster
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: product_test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: ruby/setup-ruby@v1
        with:
          working-directory: services/product-service
          bundler-cache: true
 
      - name: Run tests
        working-directory: services/product-service
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/product_test
          RAILS_ENV: test
        run: |
          bundle exec rails db:create db:migrate
          bundle exec rspec
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::xxxx:role/github-actions-deploy
          aws-region: ${{ env.AWS_REGION }}
 
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
 
      - name: Build and push Docker image
        working-directory: services/product-service
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build \
            --cache-from $ECR_REGISTRY/$ECR_REPOSITORY:latest \
            --tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
            --tag $ECR_REGISTRY/$ECR_REPOSITORY:latest \
            --target production \
            .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
 
      - name: Update ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: .aws/task-definitions/product-service.json
          container-name: product-service
          image: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
 
      - name: Deploy to ECS with Blue/Green
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          deployment-controller: CODE_DEPLOY
          codedeploy-appspec: .aws/appspec.yml
          wait-for-service-stability: true
 
      - name: Run database migrations
        run: |
          aws ecs run-task \
            --cluster $ECS_CLUSTER \
            --task-definition shopnova-product-service-migration \
            --launch-type FARGATE \
            --network-configuration "awsvpcConfiguration={subnets=[subnet-xxxx],securityGroups=[sg-xxxx]}" \
            --overrides '{"containerOverrides":[{"name":"product-service","command":["bundle","exec","rails","db:migrate"]}]}'

Blue/Green デプロイ

# .aws/appspec.yml
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: <TASK_DEFINITION>
        LoadBalancerInfo:
          ContainerName: product-service
          ContainerPort: 3000
        PlatformVersion: LATEST
 
Hooks:
  - BeforeAllowTraffic: ValidateNewDeployment
  - AfterAllowTraffic: RunSmokeTests
# lambda/validate-deployment/handler.rb(デプロイ前のバリデーション)
def handler(event:, context:)
  deployment_id = event['DeploymentId']
  lifecycle_event_hook_id = event['LifecycleEventHookExecutionId']
 
  codedeploy = Aws::CodeDeploy::Client.new
 
  begin
    # 新しいコンテナのヘルスチェック
    response = HTTP.timeout(5).get('http://new-target-group/health')
 
    if response.status.ok?
      status = 'Succeeded'
    else
      status = 'Failed'
    end
  rescue StandardError
    status = 'Failed'
  end
 
  codedeploy.put_lifecycle_event_hook_execution_status(
    deployment_id: deployment_id,
    lifecycle_event_hook_execution_id: lifecycle_event_hook_id,
    status: status
  )
end

Auto Scaling の設定

# ECS Service Auto Scaling
ProductServiceAutoScaling:
  Type: AWS::ApplicationAutoScaling::ScalableTarget
  Properties:
    MaxCapacity: 20
    MinCapacity: 2
    ResourceId: service/shopnova-cluster/shopnova-product-service
    ScalableDimension: ecs:service:DesiredCount
    ServiceNamespace: ecs
    RoleARN: !Sub arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com
 
# CPU使用率に基づくスケーリング
CpuScalingPolicy:
  Type: AWS::ApplicationAutoScaling::ScalingPolicy
  Properties:
    PolicyName: product-service-cpu-scaling
    PolicyType: TargetTrackingScaling
    ScalingTargetId: !Ref ProductServiceAutoScaling
    TargetTrackingScalingPolicyConfiguration:
      PredefinedMetricSpecification:
        PredefinedMetricType: ECSServiceAverageCPUUtilization
      TargetValue: 60.0  # CPU 60% でスケールアウト
      ScaleInCooldown: 300
      ScaleOutCooldown: 60
 
# リクエスト数に基づくスケーリング
RequestScalingPolicy:
  Type: AWS::ApplicationAutoScaling::ScalingPolicy
  Properties:
    PolicyName: product-service-request-scaling
    PolicyType: TargetTrackingScaling
    ScalingTargetId: !Ref ProductServiceAutoScaling
    TargetTrackingScalingPolicyConfiguration:
      PredefinedMetricSpecification:
        PredefinedMetricType: ALBRequestCountPerTarget
        ResourceLabel: !Sub
          - app/${ALBFullName}/targetgroup/${TargetGroupFullName}
          - ALBFullName: !GetAtt ProductServiceALB.LoadBalancerFullName
            TargetGroupFullName: !GetAtt ProductServiceTargetGroup.TargetGroupFullName
      TargetValue: 500  # タスクあたり500リクエスト/秒でスケールアウト

Fargate Spot でコスト削減

{
  "capacityProviderStrategy": [
    {
      "capacityProvider": "FARGATE",
      "weight": 1,
      "base": 2
    },
    {
      "capacityProvider": "FARGATE_SPOT",
      "weight": 4
    }
  ]
}

INFO

Fargate Spot は通常のFargateより最大70%安い。ただしスポットインスタンスは中断される可能性があるため、ステートレスなサービスにのみ使用する。データベース処理中や重要なトランザクション処理には使わない。


まとめ

コンテナ化のベストプラクティス:
  ✓ マルチステージビルドで軽量イメージ
  ✓ 非rootユーザーで実行
  ✓ ヘルスチェックを必ず実装
  ✓ 環境変数はSecrets Managerから取得
  ✓ Graceful shutdownの実装

ECS/Fargate のベストプラクティス:
  ✓ Blue/Greenデプロイで無停止リリース
  ✓ Auto Scalingでリクエスト量に自動対応
  ✓ Fargate Spotでコスト最適化
  ✓ CloudWatch Logsでログ集約

次章では、この複雑なマイクロサービス環境を「見える化」する可観測性(Observability)を学ぶ。