「外部サービスの設定ドリフトをNightly CIで検知」するワークフローを説明する図。中央のキャラクターが虫眼鏡で変化を指し示し、背景にはドリフト検知、ロール分離、応答ルーティングのプロセスが連携して描かれている。CloudflareやWoodpeckerのアイコンも見える。

はじめに

Cloudflare Hyperdrive のような外部クラウドサービスのリソース設定は、git にもアプリのコードにも残りません。
ダッシュボードでの手動操作やリソースの再作成で、設定がサイレントに変わってしまうことがあります。
その結果、「以前直したはずの本番障害が、原因不明のまま再発する」という厄介な状況が起こります。

この記事では、実運用しているサービスで nightly の設定ドリフト検知ワークフローに「外部サービス設定の assert」ステップを足した際の設計を紹介します。
終了コードの使い分け、シークレット未投入でも壊れない graceful skip、Issue の upsert といった、他の外部サービスにも流用できるパターンを中心に説明します。

こんな人におすすめ

  • Cloudflare・Supabase・Stripe など、コード管理できない外部設定に不安がある方
  • nightly の CI で「設定のずれ」を監視したい方
  • 通知や Issue 起票を含む CI シェルスクリプトを堅牢に書きたい方
  • 監視対象を段階的に有効化できる仕組みを作りたい方

設計方針:判定とCI連携を分ける

最初に決めたのは役割の分離です。

  • Node.js スクリプト:純粋な判定ロジックだけを持つ(例:assertCachingDisabled)
  • Shell スクリプト:終了コードを受け取り、通知と Issue 起票を担当する

判定ロジックを Node.js 側に閉じ込めると、ユニットテストをそちらに集中できます。
Shell 側は分岐とコマンド呼び出しだけになり、見通しが良くなります。

独立ステップとして CI に追加する

Woodpecker のワークフローに、既存の設定ドリフト検知とは別のステップとして追加します。

# .woodpecker/nightly-config-drift.yml
  external-service-drift:
    image: ci-image:node22
    pull: false
    # 他ステップの結果に依存せず実行する(関心事が別なため独立)
    depends_on: []
    environment:
      GH_TOKEN:
        from_secret: github_token
      GH_REPO: example-org/example-repo
      CLOUDFLARE_ACCOUNT_ID:
        from_secret: cloudflare_account_id    # 未設定でも graceful skip
      CLOUDFLARE_API_TOKEN:
        from_secret: cloudflare_api_token
      DISCORD_WEBHOOK_URL:
        from_secret: discord_nightly_webhook_url
    commands:
      - bash scripts/check-external-service-drift.sh

ポイントは depends_on: [] です。
既存の設定ドリフト検知が失敗しても、このステップは独立して実行されます。
2つは別々の関心事なので、片方の失敗で他方の通知が止まらないようにしています。

終了コードを3段階に使い分ける

「本物のインフラ障害」と「要対応の設定ずれ」は、重大度も対応者の動きも違います。
そこで終了コードを次のように分けました。

終了コード 意味 pipeline 通知
0 問題なし、または graceful skip 緑 なし
3 ドリフト検出 緑のまま Discord 通知と Issue 起票(人手で triage)
その他 API 呼び出し失敗など 赤 インフラ障害として可視化
# scripts/check-external-service-drift.sh
node "${SCRIPT_DIR}/check-drift.mjs" 2>&1 | tee "$output_file"
node_exit=${PIPESTATUS[0]}

case "$node_exit" in
  0)
    # OK または graceful skip
    exit 0
    ;;
  3)
    # drift 検出 → 通知・起票して pipeline は緑のまま
    ;;
  *)
    # API 失敗等の回復不能なエラー → pipeline を赤にして可視化
    exit 1
    ;;
esac

$? ではなく PIPESTATUS[0] を使っているのは、tee にパイプすると $? が tee 側の終了コードになるためです。
Node.js 側の終了コードを取りたいときは、この書き方が必要です。

シークレット未投入でも壊れない graceful skip

監視対象を増やすたびに、全員へシークレットの投入を求めるのは現実的ではありません。
そこでシークレットは任意扱いにし、未設定なら判定スクリプトがスキップを返す設計にしました。
これで本番監視を段階的に有効化できます。

エッジケースのテストも重要です。
CLOUDFLARE_ACCOUNT_ID はあるが CLOUDFLARE_API_TOKEN がない、というシナリオを意図的にテストに書きました。
片方だけ設定されている状態でも、AND 条件でスキップしないと本当の graceful skip になりません。

Issue は upsert にして積み上げを防ぐ

ドリフトが何回かの nightly で連続して検出されると、同じ Issue が毎晩増えてしまいます。
そこで open な Issue があればコメントを追記し、なければ新規起票する upsert にしました。

existing=$(
  gh issue list \
    --repo "$GH_REPO" \
    --label infra \
    --state open \
    --search "Cache config drift" \
    --limit 1 \
    --json number \
    --jq '.[0].number // empty'
)

if [ -n "$existing" ]; then
  gh issue comment "$existing" --repo "$GH_REPO" --body-file "$body_file" || true
else
  gh issue create \
    --repo "$GH_REPO" \
    --label infra \
    --label security \
    --title "Cache config drift detected on ${date_utc}" \
    --body-file "$body_file" || true
fi

// empty は、値が null のときに空文字列を返す jq のイディオムです。
--jq '.[0].number' だけだと null という文字列が返り、bash の -n 判定を通り抜けてしまいます。

つまづきやすいポイント

  • tee を挟んだときに $? で終了コードを取ってしまい、Node.js 側の失敗を見逃す
  • jq の null が文字列として返り、存在しない Issue にコメントしようとして失敗する
  • シークレットを片方だけ判定して、片方欠けの状態で API を呼びエラーになる
  • ドリフト検出を赤にしてしまい、本物のインフラ障害と区別がつかなくなる

まとめ

外部サービスの設定は、コード管理できない「見えない技術的負債」になりやすい領域です。
nightly で assert を回し、判定ロジックと CI 連携を分離し、終了コードで重大度を使い分けることで、現実的な監視になります。
graceful skip と Issue の upsert を組み合わせれば、段階的に有効化でき、通知も溢れません。
同じ型は Supabase や Stripe など、他の外部サービスの設定にも応用できます。