この記事で得られること

  • セルフホストランナー環境で起きる問題と対策パターン
  • 共有Docker daemonでのリソース競合の防ぎ方
  • 誕生日のパラドックスに殺されるポート割り当ての話
  • 「動いていたCIが突然壊れる」原因の調査手法

はじめに

第2回で、WebAuthnやMailpitを使ったE2Eテスト自動化について書いた。テスト自体は問題なく動く。問題はCIインフラの方だった。

Saruは4つのフロントエンド × 4つのバックエンドAPIを持つ。E2Eテストは各ポータルごとに独立して実行し、さらにクロスポータルテスト(ポータル間の連携テスト)もある。これらを並列実行すると、7つ以上のジョブが同時に走る。

当初はGitHub-hostedランナーを使っていたが、E2Eテストにはデータベース、Keycloak、メールサーバーが必要で、Dockerコンテナを大量に起動する。GitHub-hostedでは毎回のセットアップに時間がかかり、並列実行のコスト効率も悪い。

そこでセルフホストランナーに移行した。その判断は正しかったが、新しい種類の問題が大量に発生した。

本記事では、セルフホストCI環境で遭遇した問題と、その解決策を時系列で記録する。同じ構成を採用する人の参考になれば幸いだ。

1. Docker Desktop/WSL2が不安定すぎた

最初の構成

最初はWindows上のDocker Desktop(WSL2バックエンド)でランナーを動かしていた。構成はシンプルだ:

Windows Host
  └─ WSL2
      └─ Docker Desktop
          └─ GitHub Actions Runner × N

問題は「コンテナがランダムに死ぬ」こと。E2Eテスト中にPostgreSQLコンテナが突然消えたり、Keycloakが応答しなくなったりする。docker inspectするとExit Code: 137(SIGKILL)で殺されている。

原因を追うと、Docker Desktop/WSL2の仮想化レイヤーが問題だった:

コンテナ → Docker Engine → WSL2 → Hyper-V → Windows

WSL2自体がHyper-Vの軽量VMとして動いており、そこにさらにDockerが乗る。メモリプレッシャーが高まるとWSL2がOOM Killerを発動し、Dockerコンテナが無差別に殺される。

Hyper-V VMへの移行

解決策として、WSL2を経由せず直接Hyper-V上にUbuntu VMを立てた:

コンテナ → Docker Engine → Ubuntu VM → Hyper-V → Windows
項目
VM名saru-ci-runner
OSUbuntu 24.04
vCPU16
メモリ64GB
ディスク200GB
ネットワークExternal Switch(ブリッジ)

Hyper-V VMの方が仮想化レイヤーが少なく、メモリ管理もWSL2より安定している。WSL2は動的メモリ割り当てで「ホストと共有」する(デフォルトでホストRAMの50%または8GBまで)が、Hyper-V VMは固定メモリを割り当てるため、OOM Killerに殺されるリスクが低い。

この上に15台のGitHub Actions Runnerをsystemdサービスとして配置した:

1
2
3
4
# saru-hyperv-1 〜 saru-hyperv-15
for i in $(seq 1 15); do
  sudo systemctl status actions.runner.ko-chan-saru.saru-hyperv-$i
done

15台のランナーが1つのDocker daemonを共有する。この「共有」が後に多くの問題を引き起こす。

2. 誕生日のパラドックスによるポート衝突

問題

E2Eテストでは、各ジョブが独自のPostgreSQL、Keycloak、フロントエンド、バックエンドを起動する。ポートが衝突しないように、GitHub ActionsのRUN_IDからポート番号を計算していた:

1
2
3
# 当初の実装(問題あり)
PORT_OFFSET=$(( RUN_ID % 3000 ))
POSTGRES_PORT=$(( 10000 + PORT_OFFSET ))

一見うまくいきそうだが、15台のランナーが1つのDocker daemonを共有しているため、同時に走るジョブのポートが衝突する可能性がある。

誕生日のパラドックスと同じ構造だ。3000通りのポートオフセットがあるとき、5つの並行ジョブがあれば衝突確率は約0.33%(1 - 3000!/(3000^5 × 2995!))。些細に見えるが、1日に何十回もCIが走ると、週に数回は衝突する。そしてポートが衝突すると「コンテナは起動したがサービスに到達できない」という不可解なエラーになる。

解決策:RUNNER_NAME-based allocation

ランダム性のあるRUN_IDではなく、ランナー名から決定論的にポートを割り当てる方式に変更した:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# ランナー名からランナー番号を抽出(例: saru-hyperv-7 → 7)
if [[ "${RUNNER_NAME}" =~ saru-hyperv-([0-9]+) ]]; then
  RUNNER_NUM=${BASH_REMATCH[1]}
else
  RUNNER_NUM=$(( (RUN_ID % 15) + 1 ))
fi

# 各ランナーに200ポートのブロックを割り当て
RUNNER_BLOCK=$((RUNNER_NUM * 200))
PORTAL_OFFSET=$((PORTAL_INDEX * 10))

# フロントエンド/バックエンド: 20000 + (RUNNER_NUM × 200) + (PORTAL_INDEX × 10) + {0,1,2,3}
BASE_PORT=20000
OFFSET=$((RUNNER_BLOCK + PORTAL_OFFSET))
PORTAL_PORT=$((BASE_PORT + OFFSET + 1))
API_PORT=$((BASE_PORT + OFFSET + 2))

# インフラ: 30000 + (RUNNER_NUM × 1000) + {0,100,200,...} + PORTAL_INDEX
BASE_INFRA_PORT=30000
INFRA_RUNNER_BLOCK=$((RUNNER_NUM * 1000))
POSTGRES_PORT=$((BASE_INFRA_PORT + INFRA_RUNNER_BLOCK + PORTAL_INDEX))
KEYCLOAK_PORT=$((BASE_INFRA_PORT + INFRA_RUNNER_BLOCK + 100 + PORTAL_INDEX))

ポイントは「各ランナーは同時に1つのジョブしか実行しない」という制約を利用していること。ランナー番号で一意にポートブロックが決まるため、衝突は原理的に発生しない:

ランナーフロントエンド範囲インフラ範囲
saru-hyperv-120200〜2031331000〜31510
saru-hyperv-220400〜2051332000〜32510
saru-hyperv-1523000〜2311345000〜45510

すべてのポートが65535以内に収まることも確認済み。

3. docker system pruneが他ジョブのコンテナを殺す

問題

CIジョブの最後にDockerのクリーンアップを入れていた:

1
2
3
# ⚠️ これが問題だった
- name: Cleanup
  run: docker system prune -f

docker system prune停止中のコンテナをすべて削除する。15台のランナーが1つのDocker daemonを共有しているため、あるジョブのクリーンアップが、別のジョブがまさに使っているコンテナを破壊する。

特に厄介なのが、コンテナの起動直後のタイミング。Docker ComposeやRunで起動した直後、ヘルスチェックが通る前にpruneが走ると、起動中のコンテナが「停止中」と判断されて消される。

解決策:ターゲット指定のクリーンアップ

1
2
3
4
5
# ✅ 安全なクリーンアップ
# docker system prune、docker container prune は使用禁止
# 並行ジョブのコンテナを破壊するため
# 24時間以上経過したダングリングイメージのみ削除
docker image prune -f --filter "until=24h" 2>/dev/null || true

コンテナの削除は、自分のRUN_IDに紐づくものだけを対象にする。永続コンテナ(saru-postgres-integ等)を誤って消さないよう、名前パターンでフィルタする:

1
2
3
4
5
6
7
8
# 自ジョブのコンテナのみ削除(永続コンテナは保護)
RUN_ID="${{ github.run_id }}"
for container in $(docker ps -a --format '{{.Names}}' \
  | { grep "^saru-" | grep -v "saru-postgres-integ\|saru-keycloak-dev\|saru-mailpit-dev" || true; }); do
  if [[ ! "$container" =~ "${RUN_ID}" ]]; then
    docker rm -f "$container" 2>/dev/null || true
  fi
done

教訓: 共有Docker daemon環境ではdocker system pruneは禁じ手。スコープを絞った削除を徹底する。

4. PostgreSQLの共有メモリ不足で静かにクラッシュ

問題

CIでPostgreSQLコンテナが起動直後にクラッシュする現象が発生した。pg_isreadyは一瞬成功するが、直後のpsqlコマンドで「container is not running」エラーになる。

✓ pg_isready -U test → 成功
✗ psql -U test -c "CREATE DATABASE ..." → container is not running

これは非常に紛らわしい。PostgreSQLはinitdb後に内部的に再起動するため、pg_isreadyが通ったタイミングがその再起動の直前だと、次のコマンド実行時にはプロセスが存在しない。

しかし本当の原因は別にあった。Dockerのデフォルト/dev/shmサイズ(64MB)がPostgreSQLには不十分だった。

解決策

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
docker run -d \
  --name saru-postgres-ci \
  --shm-size=256m \  # ← これが重要
  --restart=unless-stopped \
  -e POSTGRES_USER=test \
  -e POSTGRES_PASSWORD=test \
  -p 15432:5432 \
  --health-cmd "pg_isready -U test" \
  postgres:16-alpine \
  postgres -c max_connections=200

--shm-size=256mを指定することで、PostgreSQLの共有メモリ領域を十分に確保する。特に並行テスト(-parallel 2以上)ではPostgreSQLの共有バッファが多く必要になり、64MBでは足りない。

この問題は「たまに起きる」タイプで、テスト負荷が高いときだけ再現する。原因特定に丸1日かかった。

OOM Killerが原因かどうかはdocker inspectで確認できる:

1
2
docker inspect "${CONTAINER}" --format '{{.State.OOMKilled}}'
# true ならメモリ不足が原因

5. 永続PostgreSQLコンテナパターン

問題

当初、各ジョブがPostgreSQLコンテナを起動・停止していた。しかし:

  • コンテナの起動に毎回10〜15秒かかる
  • 停止時にポートが即座に解放されず、次回の起動で衝突する
  • コンテナのライフサイクル管理が複雑(停止忘れ、ゾンビコンテナ等)

解決策:永続コンテナ + ジョブ別データベース

セクション4の--shm-size=256mと組み合わせ、コンテナは常駐させ、ジョブごとに一時データベースを作成・削除する方式に変更した:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# PostgreSQLコンテナが未起動なら起動(初回のみ)
- name: Start persistent PostgreSQL
  run: |
    POSTGRES_CONTAINER="saru-postgres-integ"
    if ! docker ps --format '{{.Names}}' | grep -qx "${POSTGRES_CONTAINER}"; then
      docker run -d \
        --name "${POSTGRES_CONTAINER}" \
        --shm-size=256m \
        --restart=unless-stopped \
        -e POSTGRES_USER=test \
        -p 15432:5432 \
        postgres:16-alpine
    fi

    # このジョブ用のデータベースを作成
    DB_NAME="integ_${{ github.run_id }}"
    docker exec "${POSTGRES_CONTAINER}" \
      psql -U test -h 127.0.0.1 -c "CREATE DATABASE \"${DB_NAME}\" OWNER test;"

# ジョブ終了時にデータベースだけ削除
- name: Cleanup database
  if: always()
  run: |
    docker exec saru-postgres-integ \
      psql -U test -h 127.0.0.1 \
      -c "DROP DATABASE IF EXISTS \"integ_${{ github.run_id }}\";"

    # 過去の失敗ジョブで残った古いデータベースも掃除
    STALE_DBS=$(docker exec saru-postgres-integ psql -U test \
      -d postgres -h 127.0.0.1 -tAc \
      "SELECT datname FROM pg_database WHERE datname LIKE 'integ_%';")
    for DB in $STALE_DBS; do
      docker exec saru-postgres-integ psql -U test \
        -d postgres -h 127.0.0.1 \
        -c "DROP DATABASE IF EXISTS \"${DB}\";"
    done

ポイントは3つ:

  1. --restart=unless-stopped: VMが再起動してもコンテナが自動復帰
  2. ジョブIDをデータベース名に使用: 並行ジョブ間の干渉を防ぐ
  3. Staleデータベースの掃除: 失敗したジョブが残したゴミを定期的に除去

6. psqlのTCP接続を強制する理由

問題

PostgreSQLコンテナ内でpsqlを実行する際、接続方法を指定しないとUnixソケットが使われる。しかし、PostgreSQLは初回起動時にinitdb→再起動のサイクルがあり、再起動中にUnixソケットが一瞬消える:

1
2
3
4
5
# ⚠️ Unixソケット(デフォルト): 再起動中に失敗する可能性
docker exec postgres psql -U test -c "SELECT 1"

# ✅ TCP接続: 再起動中でもリトライが効く
docker exec postgres psql -U test -h 127.0.0.1 -c "SELECT 1"

-h 127.0.0.1を付けるとTCP接続になり、接続失敗時のエラーがわかりやすくなる(ソケットファイルが見つからないという曖昧なエラーではなく、接続拒否という明確なエラーになる)。

教訓: CIスクリプトでのpsql呼び出しには必ず-h 127.0.0.1を付ける。

7. Dockerネットワークプール枯渇

問題

ある日突然、すべてのE2Eジョブが失敗し始めた。エラーメッセージ:

Error response from daemon: could not find an available,
non-overlapping IPv4 address pool among the defaults to
assign to the network

Docker Composeはプロジェクトごとにブリッジネットワークを作成する。Dockerはデフォルトで172.17.0.0/16172.31.0.0/16の範囲から/16サブネットを割り当てるため、作成可能なネットワーク数は約30個に制限される。15台のランナーが同時にE2Eテストを走らせ、各ジョブが複数ネットワークを作ると、このプールを使い切る。

解決策

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 古いネットワークを削除(現在のRUN_IDのものは保護)
RUN_ID="${{ github.run_id }}"
for net in $(docker network ls --format '{{.Name}}' | { grep "^saru-ci-" || true; }); do
  if [[ ! "$net" =~ "${RUN_ID}" ]]; then
    containers=$(docker network inspect "$net" \
      --format '{{len .Containers}}' 2>/dev/null || echo "in-use")
    if [ "$containers" = "0" ]; then
      docker network rm "$net" 2>/dev/null || true
    fi
  fi
done

各ジョブの開始時に未使用の古いネットワークを削除する。docker network pruneではなく、名前でフィルタして「接続中のコンテナが0のもの」だけを削除する。

8. OTP競合の防止

問題

E2EテストではOTP(ワンタイムパスワード)認証をテストする。各E2Eジョブは同じMailpitインスタンスを共有しており、MailpitのAPIでメールを検索してOTPコードを取得する。

問題は、複数ジョブが同じメールアドレス(例: system-admin@saru.local)で同時にログインすると、Mailpitに同じ宛先のOTPメールが複数届くこと。タイムスタンプフィルタリングである程度防げるが、ミリ秒単位の競合は防ぎきれない。

解決策:ジョブ別メールアドレス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
matrix:
  portal:
    - name: system-auth
      system_email: "system-admin@saru.local"
    - name: system-entities
      system_email: "system-entities@saru.local"
    - name: system-products
      system_email: "system-products@saru.local"
    - name: system-misc
      system_email: "system-misc@saru.local"

各ジョブが異なるシステム管理者アカウント(メールアドレス)を使うことで、OTPメールの取得が競合しない。バックエンドのシードデータに複数のシステム管理者を登録しておく。

9. hashFiles構文の落とし穴

問題

GitHub ActionsのhashFiles()で動的パスを使おうとしてハマった:

1
2
# ⚠️ これは動かない(文字列結合はhashFiles内で使えない)
key: ${{ runner.os }}-turbo-${{ hashFiles('apps/' + matrix.app + '/**') }}

hashFiles()の引数にはリテラル文字列しか使えない。式の評価はされない。

解決策

1
2
# ✅ format()ヘルパーを使う
key: ${{ runner.os }}-turbo-${{ matrix.app }}-${{ hashFiles(format('apps/{0}/**', matrix.app), 'packages/**', 'pnpm-lock.yaml', 'turbo.json') }}

format()関数で先にパスを組み立て、その結果をhashFiles()に渡す。これはGitHub Actionsのドキュメントには載っておらず、コミュニティの議論(#25718)で見つけた。

10. マイグレーション往復テスト

CIでは毎回「マイグレーションの往復」をテストしている:

1
2
3
4
# Up → Down 1ステップ → 再Up
DATABASE_URL="..." go run ./cmd/migrate -action up
DATABASE_URL="..." go run ./cmd/migrate -action down -steps 1
DATABASE_URL="..." go run ./cmd/migrate -action up

これにより:

  • Downマイグレーションが壊れていないことを保証
  • 「Up→Downしたら二度とUpできない」というパターンを検出
  • 本番でロールバックが必要になった時の安全性を担保

11. 失敗時の自動診断

CIが失敗したときに原因を特定しやすくするため、ジョブのif: failure()ステップで診断情報を収集している:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
- name: Diagnose PostgreSQL on failure
  if: failure()
  run: |
    # コンテナの状態
    docker inspect "${POSTGRES_CONTAINER}" --format '{{.State.Status}}'

    # アクティブ接続
    docker exec "${POSTGRES_CONTAINER}" psql -U test -h 127.0.0.1 \
      -c "SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;"

    # コンテナログ(直近30行)
    docker logs "${POSTGRES_CONTAINER}" --tail 30

    # ホストのメモリ状況
    free -h

これがあるだけで、「CIが落ちた → ログを見る → 原因がわからない → 再実行して祈る」のループから解放される。

12. ディスク管理

15台のランナーが同じ200GBディスクを共有するため、ディスク管理は重要だ:

1
2
strategy:
  max-parallel: 2  # ディスク枯渇防止のため同時実行を制限

各ジョブはnode_modulesのインストール、フロントエンドのビルド、Playwrightブラウザのキャッシュ等で約2GBを消費する。8ジョブ同時に走ると16GB、加えてDockerイメージやビルドキャッシュで、200GBはあっという間に埋まる。

定期的なクリーンアップスクリプトも用意している:

対象保持期間
CI artifacts7日
Runner _temp1日
.turbo cache7日
node_modules3日
Go build cache14日
Docker images30日
Playwright browsers保持(E2Eに必須)

まとめ:セルフホストCIで学んだこと

教訓詳細
共有リソースは衝突するDocker daemon、ポート、ネットワーク、ディスク
ランダムより決定論ポート割り当てはRUN_ID % NよりRUNNER_NUMベース
pruneは銃共有環境ではdocker system prune禁止
永続コンテナ + 一時DBコンテナのライフサイクル管理を単純化
TCPを強制psqlは-h 127.0.0.1でUnixソケットの罠を回避
失敗時に診断情報を残すif: failure()で原因特定を自動化
Dockerデフォルトを信じない--shm-sizemax_connectionsは明示指定

セルフホストCIは、GitHub-hostedにはない柔軟性とスピードをもたらす。しかし「インフラを自分で管理する」ことの複雑さも引き受けることになる。

ソロ開発では、CIが壊れたら自分で直すしかない。だからこそ、問題が起きたら「なぜ」を追求し、再発しない仕組みを作ることが重要だった。ここで紹介した対策はすべて、実際の障害から生まれたものだ。