CI環境でDockerがエフェメラルポート競合により失敗・ハングする様子と、開発者が示すリトライ設計のフロー図。GitHub Actions上でポート変更により問題を解決し、最終的に成功する過程、E2Eテストのプロセスを示す。

はじめに

CI でローカル DB(Docker ベースのバックエンドスタック)を起動すると、ごくまれに「ポートが既に使用されています」というエラーで落ちることがあります。
さらに厄介なのは、エラーにすらならず、起動プロセスがそのままハングするケースです。

再実行すれば通るので放置されがちですが、原因を辿ると設定したポート番号が Linux の ephemeral port レンジと重なっていました。
この記事では、原因の整理と、非ゼロ終了とハングの両方を拾うリトライスクリプトの設計、そして実装時にハマりやすい点をコード付きで紹介します。

こんな人におすすめ

  • CI でたまにだけ起動に失敗する Docker 系ステップに悩んでいる方
  • 「再実行すると通る」flaky な失敗を、握りつぶさずに緩和したい方
  • 複数の workflow に散らばった shell スクリプトを composite action にまとめたい方
  • Node.js で子プロセスのタイムアウトと後始末を正しく書きたい方

原因は ephemeral port との衝突

Linux では、アウトバウンド通信の送信元ポートとして OS が自動的に割り当てる範囲があります。
これが ephemeral port レンジで、目安として 32768〜60999 あたりです(/proc/sys/net/ipv4/ip_local_port_range で確認できます)。

cat /proc/sys/net/ipv4/ip_local_port_range
# 32768	60999

設定ファイルで指定したポートがこの範囲に入っていると、次のような競合が起こり得ます。

  1. Docker がポートを bind しようとする
  2. その直前に、同じマシン上の別プロセス(docker pull や npm install など)が、たまたま同じ番号を一時ポートとして使う
  3. bind が失敗する

発生はタイミング次第なので、再現性が低く、まれにしか起きません。
恒久対策は「ポート番号を範囲の外へ振り直す」ことですが、ローカル開発環境の設定にも波及します。
そこでまず、リトライによる緩和策を入れました。

非ゼロ終了とハングを両方拾うリトライ

ポイントは、失敗を 2 種類に分けて扱うことです。
bind 競合は、必ずしもプロセスを異常終了させません。CLI が応答不能になることもあります。
そのため、spawn したプロセスに試行ごとのタイムアウトを設け、タイムアウト自体も失敗として扱います。

function spawnWithTimeout(command, args, { timeoutMs, stdio }) {
  return new Promise((resolve) => {
    const startedAt = Date.now()
    let child
    try {
      // detached: true でプロセスグループを分け、タイムアウト時に
      // 起動したサブプロセスごと畳めるようにする。
      child = spawn(command, args, { stdio, cwd: repoRoot, detached: true })
    } catch (error) {
      resolve({ outcome: 'failed', exitCode: 1, durationMs: Date.now() - startedAt })
      return
    }

    const killGroup = (signal) => {
      try {
        process.kill(-child.pid, signal)
      } catch {
        child.kill(signal)
      }
    }

    let timedOut = false
    const timer = setTimeout(() => {
      timedOut = true
      killGroup('SIGTERM')
      setTimeout(() => killGroup('SIGKILL'), 10_000)
    }, timeoutMs)

    child.on('close', (code) => {
      clearTimeout(timer)
      resolve(timedOut
        ? { outcome: 'timeout', exitCode: null, durationMs: Date.now() - startedAt }
        : { outcome: code === 0 ? 'success' : 'failed', exitCode: code ?? 1, durationMs: Date.now() - startedAt })
    })
  })
}

後始末は SIGTERM から始め、一定時間後も残っていれば SIGKILL を送る二段階にしています。
Docker CLI のように子プロセスをさらに起動する場合があるため、process.kill(-pid) でプロセスグループ単位に kill します。

バックグラウンド起動と完了待ちを分ける

起動に時間がかかるため、起動(バックグラウンド)と完了待ちを別 step に分けました。
workflow 側は composite action の呼び出しに置き換えています。

- name: Start local Supabase (background)
  uses: ./.github/actions/start-supabase-background
  with:
    args: --ignore-health-check -x studio

- name: Wait for Supabase to be ready
  run: node scripts/supabase-start-with-retry.mjs wait

これで、複数の workflow にコピーされていたインライン shell が 1 か所に集約され、実装の二重化が解消されます。
同じ理由で、事前クリーンアップとリトライ内の掃除も、ラベルフィルタで対象コンテナを絞る処理を共有しました。

別プロセス間の受け渡しには sentinel file(終了コードを書くファイル)を使います。ここに落とし穴があります。

export async function readExitCodeFile(exitPath, readText) {
  const raw = await readText(exitPath).catch(() => null)
  if (raw === null || raw.trim() === '') return null // まだ完了していない
  const parsed = Number(raw.trim())
  return Number.isFinite(parsed) ? parsed : 1
}

echo $? > file は、ファイル作成から書き込みまでの間に一瞬だけ空になり得ます。
Number('') は 0 なので、素朴に読むと「起動成功」と誤認します。空文字や空白のみは「未完了」として明示的に弾く必要があります。

リトライを silent にしない

リトライで自動復旧すると、同じ問題が積み重なっていても誰も気づけません。
CI の Job Summary と ::warning:: annotation に、何回目の試行で、どんな失敗で、どれだけ待って復旧したかを記録します。

echo "::warning::supabase start recovered on attempt 2 (timeout after 180s)"

こうしておけば後から頻度を追え、恒久対策に着手する判断材料になります。

つまづきやすいポイント

  • 待ち側のタイムアウトが短すぎる: 起動側の「タイムアウト+強制終了の猶予+リトライごとの後片付け」の最悪時間より短いと、復旧する直前に待ち側が諦めます。待ち時間は数式で算出し、単体テストで固定すると安全です。
  • 設定値が呼び出し元でズレる: 起動側と待ち側が別プロセスで同じ環境変数を参照する場合、片方にだけ値を置くと待ち時間がドリフトします。デフォルト値はリトライ処理側に固定し、呼び出し元での上書きを禁止する運用が有効です。
  • プロセス単体だけ kill する: 子プロセスが残ってポートを掴み続けると、次の試行も失敗します。プロセスグループ単位で kill してください。
  • ハングを失敗として数えない: 終了コードだけを見ていると、ハングは永遠に待ち続けます。

まとめ

CI のポート競合は、設定値が ephemeral port レンジと重なることで、まれに起きます。
緩和策として、非ゼロ終了とハングの両方を失敗として扱い、プロセスグループごと後始末するリトライが有効です。
sentinel file の空文字や待ち時間の計算といった境界条件を押さえ、リトライの発生は必ず記録に残してください。
恒久対策(ポート番号の振り直し)は、記録した頻度を見ながら判断するのが現実的です。