mybook

Stage 9: DevOps と CI/CD — デプロイの自動化

「動く」から「届ける」へ

「マイさん、最近デプロイが怖くて……毎回手でコマンドを叩いて、ミスしそうで。」

「それは設計の問題だ。デプロイは再現可能で、自動化されて、安全でなければならない。」

「DevOpsはツールではなく文化。開発(Development)と運用(Operations)の壁をなくして、コードを書いた人が安全にデプロイできるようにする考え方。CI/CDはそれを実現するパイプライン。」

CI/CDパイプラインの構造

Loading diagram...

GitHub Actionsで始めるCI

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true
      - name: RuboCop
        run: bundle exec rubocop --format github
      - name: Brakeman
        run: bundle exec brakeman --no-pager -w2
 
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app_test
        ports: ["5432:5432"]
        options: --health-cmd pg_isready --health-interval 10s
 
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
 
    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
      REDIS_URL: redis://localhost:6379/1
 
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true
      - name: Setup database
        run: bundle exec rails db:schema:load
      - name: Run RSpec
        run: bundle exec rspec --format progress --format RspecJunitFormatter --out tmp/test-results.xml
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: tmp/test-results.xml
 
  build:
    name: Build Docker Image
    runs-on: ubuntu-latest
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1
      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/myapp:$IMAGE_TAG .
          docker push $ECR_REGISTRY/myapp:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/myapp:$IMAGE_TAG" >> $GITHUB_OUTPUT

Dockerfileのベストプラクティス

# Dockerfile
# ベースイメージ: 公式Rubyイメージ
FROM ruby:3.3-slim AS base
 
# セキュリティ: 非rootユーザーで実行
RUN groupadd -r app && useradd -r -g app app
 
WORKDIR /app
 
# 依存のインストール(キャッシュ活用)
FROM base AS dependencies
 
RUN apt-get update -qq && apt-get install -y \
    build-essential \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*
 
COPY Gemfile Gemfile.lock ./
RUN bundle config set without "development test" \
    && bundle install --jobs 4 --retry 3
 
# 本番イメージ
FROM base AS production
 
COPY --from=dependencies /usr/local/bundle /usr/local/bundle
COPY --chown=app:app . .
 
# assets:precompile
ENV RAILS_ENV=production SECRET_KEY_BASE=dummy
RUN bundle exec rails assets:precompile
 
USER app
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

ECSへのデプロイ

# .github/workflows/deploy.yml
name: Deploy to ECS
 
on:
  workflow_run:
    workflows: [CI]
    types: [completed]
    branches: [main]
 
jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    environment: staging
 
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1
 
      - name: Download task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition myapp-staging \
            --query taskDefinition > task-definition.json
 
      - 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: app
          image: ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}
 
      - name: Run DB migrations
        run: |
          aws ecs run-task \
            --cluster myapp-staging \
            --task-definition ${{ steps.task-def.outputs.task-definition }} \
            --overrides '{"containerOverrides":[{"name":"app","command":["bundle","exec","rails","db:migrate"]}]}'
 
      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: myapp-staging
          cluster: myapp-staging
          wait-for-service-stability: true
 
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [deploy-staging]
    environment: production  # 承認が必要なgithub environment
 
    steps:
      # ステージングと同様の手順
      - name: Deploy to Production ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: myapp-production
          cluster: myapp-production
          wait-for-service-stability: true

INFO

GitHub Environmentsの承認フロー

environment: production を設定し、GitHubリポジトリのSettings > Environmentsで承認者を設定すると、本番デプロイ前に手動承認ゲートが追加されます。

ブルーグリーンデプロイ

「ダウンタイムなしでデプロイするためのパターン。」

Loading diagram...
# ECSでのブルーグリーンデプロイ設定
# CodeDeployと組み合わせて実現
 
aws deploy create-deployment \
  --application-name myapp \
  --deployment-group-name myapp-prod \
  --revision '{
    "revisionType": "AppSpecContent",
    "appSpecContent": {
      "content": "{\"version\":1,\"Resources\":[{\"TargetService\":{\"Type\":\"AWS::ECS::Service\",\"Properties\":{\"TaskDefinition\":\"arn:aws:ecs:...\",\"LoadBalancerInfo\":{\"ContainerName\":\"app\",\"ContainerPort\":3000}}}}]}"
    }
  }'

インフラ as Code (IaC)

# lib/tasks/infrastructure.rake
namespace :infra do
  desc "Create RDS snapshot before migration"
  task snapshot_before_migration: :environment do
    rds = Aws::RDS::Client.new(region: "ap-northeast-1")
    snapshot_id = "pre-migration-#{Time.current.strftime('%Y%m%d%H%M%S')}"
 
    rds.create_db_snapshot(
      db_instance_identifier: ENV.fetch("RDS_INSTANCE_ID"),
      db_snapshot_identifier: snapshot_id
    )
 
    puts "スナップショット作成中: #{snapshot_id}"
    rds.wait_until(:db_snapshot_available, db_snapshot_identifier: snapshot_id)
    puts "スナップショット完了: #{snapshot_id}"
  end
end
# CloudFormation / ECSタスク定義
AWSTemplateFormatVersion: "2010-09-09"
Resources:
  AppTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: myapp
      NetworkMode: awsvpc
      RequiresCompatibilities: [FARGATE]
      Cpu: "512"
      Memory: "1024"
      ExecutionRoleArn: !GetAtt ECSExecutionRole.Arn
      ContainerDefinitions:
        - Name: app
          Image: !Sub "${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:latest"
          PortMappings:
            - ContainerPort: 3000
          Environment:
            - Name: RAILS_ENV
              Value: production
          Secrets:
            - Name: SECRET_KEY_BASE
              ValueFrom: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/myapp/prod/SECRET_KEY_BASE"
            - Name: DATABASE_URL
              ValueFrom: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/myapp/prod/DATABASE_URL"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: /ecs/myapp
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: app
          HealthCheck:
            Command: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
            Interval: 30
            Timeout: 5
            Retries: 3

秘密情報の管理

# 環境変数は直接コードに書かない
# config/credentials.yml.enc を使う(Rails標準)
# または AWS Parameter Store / Secrets Manager
 
# AWS Parameter Storeからの読み込み
class SecretsManager
  def self.fetch(key)
    @cache ||= {}
    @cache[key] ||= begin
      ssm = Aws::SSM::Client.new(region: "ap-northeast-1")
      ssm.get_parameter(name: key, with_decryption: true).parameter.value
    end
  end
end
 
# config/initializers/secrets.rb
if Rails.env.production?
  ENV["STRIPE_API_KEY"] = SecretsManager.fetch("/myapp/prod/STRIPE_API_KEY")
  ENV["SENDGRID_API_KEY"] = SecretsManager.fetch("/myapp/prod/SENDGRID_API_KEY")
end

WARNING

絶対にやってはいけないこと

  • APIキーやパスワードをコードに直書き
  • .envファイルをGitにコミット
  • 本番DBのパスワードをSlackに投稿

秘密情報は必ずAWS Parameter Store、Secrets Manager、またはGitHub Secretsで管理しましょう。

モニタリングとアラート

# config/initializers/cloudwatch.rb
# アプリのカスタムメトリクスをCloudWatchに送信
class MetricsPublisher
  def self.record(namespace:, metric_name:, value:, unit: "Count")
    return unless Rails.env.production?
 
    Thread.new do
      Aws::CloudWatch::Client.new(region: "ap-northeast-1").put_metric_data(
        namespace: namespace,
        metric_data: [{
          metric_name: metric_name,
          value: value,
          unit: unit,
          timestamp: Time.current
        }]
      )
    rescue StandardError => e
      Rails.logger.error("CloudWatch metric failed: #{e.message}")
    end
  end
end
 
# 使用例
MetricsPublisher.record(
  namespace: "MyApp/Orders",
  metric_name: "OrdersCreated",
  value: 1
)

Stage 9 のまとめ

「デプロイは恐怖であってはならない。ルーチンであるべきだ。」マイが言った。

「自動化されていれば、毎日デプロイできる。毎日デプロイできれば、変更が小さくなる。変更が小さければ、問題が起きても影響が少ない。これがDevOpsのサイクル。」

段階目標ツール
CI(継続的インテグレーション)常にテストが通る状態GitHub Actions, RSpec
CD(継続的デリバリー)常にデプロイ可能な状態ECS, CodeDeploy
監視問題を素早く発見CloudWatch, DataDog
秘密情報管理認証情報を安全にSSM Parameter Store

「最後のステージは分散システム。ここまで来たヒロシならきっと理解できる。」

ヒロシはGitHub Actionsのワークフローを設定しながら、初めて「デプロイが楽しい」と感じた。