GitHub ActionsとECS Run TaskでDB操作を自動化する

CI/CDシリーズ:

CI/CDシリーズの3本目。今回はデプロイ以外の運用自動化、具体的にはDB操作のワークフロー化の話。

背景: なぜ運用タスクをワークフロー化するのか

デプロイ以外にも繰り返し発生する運用タスクがある。マイグレーションの適用、マスタデータの投入、テストデータの投入、データパッチの適用など。

これらを手動でSSHやローカルからDB接続して作業する運用はセキュリティ的にちょっと厳しいところ。それにVPC内のリソースへのアクセスにも制約がある。今回はAurora Serverless v2を使ってて、当然のようにプライベートサブネットに配置しているので、ローカルから直接接続するのは難しいし、Bastion、いわゆる踏み台サーバーを用意してそれを経由するかだが、それもよ愛したくない。いずれにしても面倒だし危険性もある。

というわけでこれらの運用タスクもGitHub Actionsのワークフローとして整備した。

DB操作ワークフロー (db-operation.yml)

workflow_dispatchによる手動実行。GitHubのUIから操作の種類・環境・パラメータを選択して実行する形式にしている。ワークフローの入力定義はこんな感じ。

on:
  workflow_dispatch:
    inputs:
      operation:
        description: '実行するDB操作'
        required: true
        type: choice
        options:
          - migrate
          - seed-master
          - seed-dev
          - patch
          - custom-script
          - shell
      environment:
        description: '対象環境'
        required: true
        type: choice
        options:
          - dev
          - prod
      script_path:
        description: 'custom-script 時のシェルコマンド (例: TARGET_EMAIL=user@example.com node dist/scripts/set-admin-role.js)'
        required: false
        type: string
      shell_command:
        description: 'shell 時のコマンド (例: npx prisma migrate status)'
        required: false
        type: string
      ref:
        description: 'ビルド元ブランチ/タグ (未指定時はデプロイ済みイメージを使用)'
        required: false
        type: string
        default: ''
      confirm_prod:
        description: '本番実行の確認 (prod の場合 "yes" と入力)'
        required: false
        type: string

サポートする操作

用意している操作は6種類。

migrate はPrismaマイグレーションの適用。内部的にはまずensure-schema.jsでスキーマの存在確認をして、次にresolve-failed-migrations.jsで失敗マイグレーションを回復し、それからprisma migrate deployを実行する。この3段階の順序が大事で、スキーマが存在しない場合や前回のマイグレーションが中途半端に失敗している場合にも対応できるようにしている。具体的には、_prisma_migrationsテーブルにfinished_at IS NULLのレコードが残っていると次回のmigrate deployがエラー終了してしまうので、その状態を前段で解決してから本体に進む形。実はこれは後から追加した。このワークフローで運用し始めてから一度そんな状況になってしまい、にっちもさっちも行かなくなって困ったので追加した。

seed-master はマスタデータの投入。プランや問題データなどプロダクトの基本となるデータで、全環境共通で使う。まあでも運用始まったらあんまり使わない。

seed-dev は開発用テストデータの投入。これはdev環境のみ実行可能にしていて、prodでは入力バリデーションでブロックされる。間違ってprod環境にテストデータを入れてしまう事故を防ぐため。

patch はデータパッチの適用。patch-runner.jsがパッチスクリプトを順次実行する。マイグレーションではなくデータの修正が必要な場合に使う。これも用意したものの後述のカスタムスクリプトで処理することのほうが多くなっててあまり使われていない。

custom-script は任意のTypeScriptスクリプトをシェルコマンドとして実行するもの。例えばTARGET_EMAIL=user@example.com node dist/scripts/set-admin-role.jsみたいな形で、特定のユーザーに管理者権限を付与するとか、一回きりのデータ修正とか、そういう用途。パッチ当てみたいな用途ではなんだかんだでこっちのほうが使うこと多い。

shell は任意のシェルコマンドの実行。npx prisma migrate statusでマイグレーションの状態確認をしたいときとかに使う。だが、これもカスタムスクリプトで処理すること多くなってしまった。

安全策

prod環境での操作にはいくつかの安全策を設けている。入力バリデーションは用途ごとに独立したステップとして並べていて、それぞれ if: の条件で発火する形。

- name: Validate production confirmation
  if: inputs.environment == 'prod' && inputs.confirm_prod != 'yes'
  run: |
    echo "::error::本番環境への操作には confirm_prod に 'yes' と入力してください"
    exit 1

- name: Validate seed-dev is not run on prod
  if: inputs.operation == 'seed-dev' && inputs.environment == 'prod'
  run: |
    echo "::error::seed-dev は本番環境では実行できません"
    exit 1

- name: Validate custom-script has script_path
  if: inputs.operation == 'custom-script' && inputs.script_path == ''
  run: |
    echo "::error::custom-script には script_path の指定が必要です"
    exit 1

- name: Validate shell has shell_command
  if: inputs.operation == 'shell' && inputs.shell_command == ''
  run: |
    echo "::error::shell には shell_command の指定が必要です"
    exit 1

加えてGitHub Environmentsのprotection rulesにより、prod環境への操作には別途承認が必要。つまりワークフローを実行しようとしても承認者がApproveしないと先に進まない、二重のゲートを設けている形。

custom-scriptshellは事実上任意コマンドの実行を許してしまうので、それ自体は危ういと言えば危うい。ここは「ワークフローの実行権限自体がリポジトリのwrite権限と承認者ロールに紐づいている」「prod操作は二重ゲートを通る」という前提で運用していて、便利さを取った形になっている。組織やチームの規模次第ではこの2つは外して、明示的なoperationだけに限定したほうが安全だとは思う。

refパラメータはビルド元ブランチを指定できる。これが何に使えるかというと、マージ前のブランチに含まれる新しいマイグレーションやスクリプトをdev/prod環境で事前テストしたいときだ。ref未指定時はデプロイ済みのタスク定義(とそのイメージ)をそのまま使うので追加ビルドが不要。

実装としては「Determine image」の単一ステップにせず、if: inputs.ref != '' の条件付きステップを並べる構造にしている。ref指定時はチェックアウト→ECRログイン→ビルド&プッシュ→一時タスク定義の登録、未指定時はデプロイ済みタスク定義の取得、最後に分岐の結果を1つのoutputに集約するステップを置く。

# ref 指定時: 指定ブランチからイメージをビルドして一時タスク定義を作成
- name: Checkout source (for custom build)
  if: inputs.ref != ''
  uses: actions/checkout@v4
  with:
    ref: ${{ inputs.ref }}

- name: Login to Amazon ECR
  if: inputs.ref != ''
  id: login-ecr
  uses: aws-actions/amazon-ecr-login@v2

- name: Build and push temporary image
  if: inputs.ref != ''
  id: build-image
  env:
    ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
    IMAGE_TAG: db-op-${{ github.run_id }}
  run: |
    IMAGE=$ECR_REGISTRY/${{ vars.ECR_REPOSITORY }}:$IMAGE_TAG
    docker build -t $IMAGE -f backend/Dockerfile .
    docker push $IMAGE
    echo "image=$IMAGE" >> $GITHUB_OUTPUT

- name: Create temporary task definition
  if: inputs.ref != ''
  id: temp-task-def
  run: |
    # デプロイ済みタスク定義をベースに、イメージだけ差し替える
    DEPLOYED=$(aws ecs describe-services \
      --cluster ${{ vars.ECS_CLUSTER }} --services ${{ vars.ECS_SERVICE }} \
      --query 'services[0].taskDefinition' --output text)
    aws ecs describe-task-definition --task-definition "$DEPLOYED" \
      --query taskDefinition > /tmp/base.json
    jq --arg image "${{ steps.build-image.outputs.image }}" \
      '.containerDefinitions[0].image = $image
       | del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
             .compatibilities, .registeredAt, .registeredBy, .deregisteredAt)' \
      /tmp/base.json > /tmp/temp.json
    ARN=$(aws ecs register-task-definition \
      --cli-input-json file:///tmp/temp.json \
      --query 'taskDefinition.taskDefinitionArn' --output text)
    echo "task_def_arn=$ARN" >> $GITHUB_OUTPUT

# ref 未指定時: デプロイ済みタスク定義をそのまま使う
- name: Get deployed task definition
  if: inputs.ref == ''
  id: get-task-def
  run: |
    ARN=$(aws ecs describe-services \
      --cluster ${{ vars.ECS_CLUSTER }} --services ${{ vars.ECS_SERVICE }} \
      --query 'services[0].taskDefinition' --output text)
    echo "task_def_arn=$ARN" >> $GITHUB_OUTPUT

# どちらの分岐でもARNを単一の出力に集約する
- name: Resolve task definition ARN
  id: resolve-task-def
  run: |
    if [ -n "${{ inputs.ref }}" ]; then
      echo "task_def_arn=${{ steps.temp-task-def.outputs.task_def_arn }}" >> $GITHUB_OUTPUT
    else
      echo "task_def_arn=${{ steps.get-task-def.outputs.task_def_arn }}" >> $GITHUB_OUTPUT
    fi

実行結果はCloudWatch Logsに記録され、ワークフローのJob Summaryにも出力される。

ECS Run Taskパターン

すべてのDB操作はECS Run Taskとして実行される。以前に書いたentrypoint.shを使って、containerOverridesDB_COMMANDを注入することで同一イメージで複数の操作に対応する。

サブネットとセキュリティグループは固定値を埋め込まず、ECSサービスの設定から動的に取得する。これで dev/prod ごとに値を分岐する必要がなくなる。custom-script / shell は任意コマンドを sh -c 経由で渡したいので、operation の種類に応じて containerOverrides のJSONを別ステップで組み立ててから run-task に渡している。

# サブネット/SG は ECS サービスの設定から動的取得(環境ごとの分岐不要)
- name: Get network configuration
  id: get-network
  run: |
    INFO=$(aws ecs describe-services \
      --cluster ${{ vars.ECS_CLUSTER }} --services ${{ vars.ECS_SERVICE }} \
      --query 'services[0].networkConfiguration.awsvpcConfiguration')
    echo "subnets=$(echo "$INFO" | jq -r '.subnets | join(",")')" >> $GITHUB_OUTPUT
    echo "security_groups=$(echo "$INFO" | jq -r '.securityGroups | join(",")')" >> $GITHUB_OUTPUT

# operation の種類に応じて containerOverrides を組み立てる
- name: Prepare container overrides
  id: overrides
  env:
    OPERATION: ${{ inputs.operation }}
    SCRIPT_PATH: ${{ inputs.script_path }}
    SHELL_COMMAND: ${{ inputs.shell_command }}
  run: |
    if [ "$OPERATION" = "custom-script" ]; then
      # script_path に env をインラインで渡せるよう sh -c で実行
      OVERRIDES=$(jq -n --arg path "$SCRIPT_PATH" \
        '{containerOverrides: [{name: "app", command: ["sh", "-c", $path]}]}')
    elif [ "$OPERATION" = "shell" ]; then
      OVERRIDES=$(jq -n --arg cmd "$SHELL_COMMAND" \
        '{containerOverrides: [{name: "app", command: ["sh", "-c", $cmd]}]}')
    else
      # 標準操作は DB_COMMAND 環境変数で entrypoint.sh の case を切り替える
      OVERRIDES=$(jq -n --arg op "$OPERATION" \
        '{containerOverrides: [{name: "app", environment: [{name: "DB_COMMAND", value: $op}]}]}')
    fi
    echo "json<<EOF" >> $GITHUB_OUTPUT
    echo "$OVERRIDES" >> $GITHUB_OUTPUT
    echo "EOF" >> $GITHUB_OUTPUT

- name: Run DB operation
  id: run-task
  env:
    OVERRIDES_JSON: ${{ steps.overrides.outputs.json }}
  run: |
    TASK_ARN=$(aws ecs run-task \
      --cluster ${{ vars.ECS_CLUSTER }} \
      --task-definition "${{ steps.resolve-task-def.outputs.task_def_arn }}" \
      --launch-type FARGATE \
      --network-configuration "awsvpcConfiguration={subnets=[${{ steps.get-network.outputs.subnets }}],securityGroups=[${{ steps.get-network.outputs.security_groups }}],assignPublicIp=DISABLED}" \
      --overrides "$OVERRIDES_JSON" \
      --query &#39;tasks[0].taskArn&#39; --output text)

    echo "Waiting for task to complete..."
    aws ecs wait tasks-stopped --cluster ${{ vars.ECS_CLUSTER }} --tasks "$TASK_ARN"

    EXIT_CODE=$(aws ecs describe-tasks \
      --cluster ${{ vars.ECS_CLUSTER }} --tasks "$TASK_ARN" \
      --query &#39;tasks[0].containers[0].exitCode&#39; --output text)

    if [ "$EXIT_CODE" != "0" ]; then
      echo "::error::Task failed with exit code $EXIT_CODE"
      exit 1
    fi

このアプローチのメリットは、VPC内からAuroraに安全にアクセスできること。ローカルやCI環境からの直接DB接続を完全に排除できる。

entrypoint.shの実装

entrypoint.shの実際のコードも載せておく。シンプルだが運用ワークフローの基盤になっている。

#!/bin/sh
set -e

# DB_COMMAND が指定されていれば該当オペレーションを実行して終了
# 未指定なら通常のアプリ起動フローへ
if [ -n "$DB_COMMAND" ]; then
  case "$DB_COMMAND" in
    migrate)
      echo "=== Running database migration ==="
      node scripts/ensure-schema.js
      node scripts/resolve-failed-migrations.js
      node_modules/.bin/prisma migrate deploy
      ;;
    seed-master)
      echo "=== Running master data seed ==="
      node dist/prisma/seed-master.js
      ;;
    seed-dev)
      echo "=== Running dev data seed ==="
      node dist/prisma/seed-dev.js
      ;;
    patch)
      echo "=== Running data patches ==="
      node dist/prisma/patch-runner.js
      ;;
    generate-tts-cache)
      echo "=== Generating TTS audio cache ==="
      node dist/scripts/generate-tts-cache.js
      ;;
    *)
      echo "Unknown DB_COMMAND: $DB_COMMAND"
      exit 1
      ;;
  esac
  exit 0
fi

# 通常のアプリ起動フロー
# 引数があればそのまま exec(ECS Run Task の command override 用)
if [ $# -gt 0 ]; then
  exec "$@"
fi

echo "Starting application..."
exec node dist/src/main

DB_COMMANDが未設定なら外側の通常起動フローに抜けてnode dist/src/mainを起動。設定されていれば対応する処理を実行してexit 0で終わる。未知の値が来たらエラー終了させていて、サイレントに通常起動へフォールバックさせない。ECS Run Taskの場合はタスクが終了するのが正しい挙動で、ECSサービスとして動くときはプロセスが常駐するという使い分け。

ちなみにgenerate-tts-cacheというのが混じっているのが、これはDB操作とは別系統だがECS Run Task + entrypoint.shの同じパターンに乗せている別の処理。このプロダクトではTTSによって生成する音声ファイルのキャッシュを一括生成するワークフローがあって、それも同じイメージから起動している。VPC内からの実行と同一イメージの使い回しという観点で、DB操作以外のバッチ系処理を載せる先としても都合がよかった。

なお、変数名がDB_COMMANDなのにDB操作以外も入っているのは命名としてはイケてない。最初がDBマイグレーションだったのでそのまま付けて、後から他の処理を相乗りさせた経緯でこうなっている。今から作り直すならOPERATIONとかTASK_COMMANDみたいな汎用名にしたほうが素直だったとは思う。どっかで直したい。

設計判断の振り返り

ECS Run Taskパターンにしたことで、VPC内から安全にAuroraやS3にアクセスできる環境を確保しつつ、ローカルからの直接DB接続を排除できた。セキュリティとネットワーク構成の一貫性は保てたと思う。

entrypoint.shで多目的コンテナにしたのは、用途ごとにDockerイメージを分けると管理が煩雑になるし、依存関係の差分でバグが出ることもあるから。同一イメージで全部やるほうがシンプルでよさそう。

prod環境の二重ゲート(confirm_prodの入力バリデーションとGitHub Environmentsのprotection rules)については、workflow_dispatchが手動実行とはいえ、操作権限のある人なら誰でもprod環境に対して操作できてしまうので、二重にしておかないと不安だなと思った。

refパラメータでマージ前のコードからイメージをビルドして実行できる仕組みは、新しいマイグレーションやスクリプトの事前テストに重宝している。devで試してからprodに適用するという流れが自然にできるようになったのが地味によかった。

©Keisuke Nishitani, 2023   プライバシーポリシー