コンテナとオーケストレーション — デプロイの自動化
「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
)
endAuto 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)を学ぶ。