この記事で得られること
- セルフホストランナー環境で起きる問題と対策パターン
- 共有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 |
| OS | Ubuntu 24.04 |
| vCPU | 16 |
| メモリ | 64GB |
| ディスク | 200GB |
| ネットワーク | External Switch(ブリッジ) |
Hyper-V VMの方が仮想化レイヤーが少なく、メモリ管理もWSL2より安定している。WSL2は動的メモリ割り当てで「ホストと共有」する(デフォルトでホストRAMの50%または8GBまで)が、Hyper-V VMは固定メモリを割り当てるため、OOM Killerに殺されるリスクが低い。
この上に15台のGitHub Actions Runnerをsystemdサービスとして配置した:
| |
15台のランナーが1つのDocker daemonを共有する。この「共有」が後に多くの問題を引き起こす。
2. 誕生日のパラドックスによるポート衝突
問題
E2Eテストでは、各ジョブが独自のPostgreSQL、Keycloak、フロントエンド、バックエンドを起動する。ポートが衝突しないように、GitHub ActionsのRUN_IDからポート番号を計算していた:
| |
一見うまくいきそうだが、15台のランナーが1つのDocker daemonを共有しているため、同時に走るジョブのポートが衝突する可能性がある。
誕生日のパラドックスと同じ構造だ。3000通りのポートオフセットがあるとき、5つの並行ジョブがあれば衝突確率は約0.33%(1 - 3000!/(3000^5 × 2995!))。些細に見えるが、1日に何十回もCIが走ると、週に数回は衝突する。そしてポートが衝突すると「コンテナは起動したがサービスに到達できない」という不可解なエラーになる。
解決策:RUNNER_NAME-based allocation
ランダム性のあるRUN_IDではなく、ランナー名から決定論的にポートを割り当てる方式に変更した:
| |
ポイントは「各ランナーは同時に1つのジョブしか実行しない」という制約を利用していること。ランナー番号で一意にポートブロックが決まるため、衝突は原理的に発生しない:
| ランナー | フロントエンド範囲 | インフラ範囲 |
|---|---|---|
| saru-hyperv-1 | 20200〜20313 | 31000〜31510 |
| saru-hyperv-2 | 20400〜20513 | 32000〜32510 |
| … | … | … |
| saru-hyperv-15 | 23000〜23113 | 45000〜45510 |
すべてのポートが65535以内に収まることも確認済み。
3. docker system pruneが他ジョブのコンテナを殺す
問題
CIジョブの最後にDockerのクリーンアップを入れていた:
| |
docker system pruneは停止中のコンテナをすべて削除する。15台のランナーが1つのDocker daemonを共有しているため、あるジョブのクリーンアップが、別のジョブがまさに使っているコンテナを破壊する。
特に厄介なのが、コンテナの起動直後のタイミング。Docker ComposeやRunで起動した直後、ヘルスチェックが通る前にpruneが走ると、起動中のコンテナが「停止中」と判断されて消される。
解決策:ターゲット指定のクリーンアップ
| |
コンテナの削除は、自分のRUN_IDに紐づくものだけを対象にする。永続コンテナ(saru-postgres-integ等)を誤って消さないよう、名前パターンでフィルタする:
| |
教訓: 共有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には不十分だった。
解決策
| |
--shm-size=256mを指定することで、PostgreSQLの共有メモリ領域を十分に確保する。特に並行テスト(-parallel 2以上)ではPostgreSQLの共有バッファが多く必要になり、64MBでは足りない。
この問題は「たまに起きる」タイプで、テスト負荷が高いときだけ再現する。原因特定に丸1日かかった。
OOM Killerが原因かどうかはdocker inspectで確認できる:
| |
5. 永続PostgreSQLコンテナパターン
問題
当初、各ジョブがPostgreSQLコンテナを起動・停止していた。しかし:
- コンテナの起動に毎回10〜15秒かかる
- 停止時にポートが即座に解放されず、次回の起動で衝突する
- コンテナのライフサイクル管理が複雑(停止忘れ、ゾンビコンテナ等)
解決策:永続コンテナ + ジョブ別データベース
セクション4の--shm-size=256mと組み合わせ、コンテナは常駐させ、ジョブごとに一時データベースを作成・削除する方式に変更した:
| |
ポイントは3つ:
--restart=unless-stopped: VMが再起動してもコンテナが自動復帰- ジョブIDをデータベース名に使用: 並行ジョブ間の干渉を防ぐ
- Staleデータベースの掃除: 失敗したジョブが残したゴミを定期的に除去
6. psqlのTCP接続を強制する理由
問題
PostgreSQLコンテナ内でpsqlを実行する際、接続方法を指定しないとUnixソケットが使われる。しかし、PostgreSQLは初回起動時にinitdb→再起動のサイクルがあり、再起動中にUnixソケットが一瞬消える:
| |
-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/16〜172.31.0.0/16の範囲から/16サブネットを割り当てるため、作成可能なネットワーク数は約30個に制限される。15台のランナーが同時にE2Eテストを走らせ、各ジョブが複数ネットワークを作ると、このプールを使い切る。
解決策
| |
各ジョブの開始時に未使用の古いネットワークを削除する。docker network pruneではなく、名前でフィルタして「接続中のコンテナが0のもの」だけを削除する。
8. OTP競合の防止
問題
E2EテストではOTP(ワンタイムパスワード)認証をテストする。各E2Eジョブは同じMailpitインスタンスを共有しており、MailpitのAPIでメールを検索してOTPコードを取得する。
問題は、複数ジョブが同じメールアドレス(例: system-admin@saru.local)で同時にログインすると、Mailpitに同じ宛先のOTPメールが複数届くこと。タイムスタンプフィルタリングである程度防げるが、ミリ秒単位の競合は防ぎきれない。
解決策:ジョブ別メールアドレス
| |
各ジョブが異なるシステム管理者アカウント(メールアドレス)を使うことで、OTPメールの取得が競合しない。バックエンドのシードデータに複数のシステム管理者を登録しておく。
9. hashFiles構文の落とし穴
問題
GitHub ActionsのhashFiles()で動的パスを使おうとしてハマった:
| |
hashFiles()の引数にはリテラル文字列しか使えない。式の評価はされない。
解決策
| |
format()関数で先にパスを組み立て、その結果をhashFiles()に渡す。これはGitHub Actionsのドキュメントには載っておらず、コミュニティの議論(#25718)で見つけた。
10. マイグレーション往復テスト
CIでは毎回「マイグレーションの往復」をテストしている:
| |
これにより:
- Downマイグレーションが壊れていないことを保証
- 「Up→Downしたら二度とUpできない」というパターンを検出
- 本番でロールバックが必要になった時の安全性を担保
11. 失敗時の自動診断
CIが失敗したときに原因を特定しやすくするため、ジョブのif: failure()ステップで診断情報を収集している:
| |
これがあるだけで、「CIが落ちた → ログを見る → 原因がわからない → 再実行して祈る」のループから解放される。
12. ディスク管理
15台のランナーが同じ200GBディスクを共有するため、ディスク管理は重要だ:
| |
各ジョブはnode_modulesのインストール、フロントエンドのビルド、Playwrightブラウザのキャッシュ等で約2GBを消費する。8ジョブ同時に走ると16GB、加えてDockerイメージやビルドキャッシュで、200GBはあっという間に埋まる。
定期的なクリーンアップスクリプトも用意している:
| 対象 | 保持期間 |
|---|---|
| CI artifacts | 7日 |
| Runner _temp | 1日 |
| .turbo cache | 7日 |
| node_modules | 3日 |
| Go build cache | 14日 |
| Docker images | 30日 |
| 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-size、max_connectionsは明示指定 |
セルフホストCIは、GitHub-hostedにはない柔軟性とスピードをもたらす。しかし「インフラを自分で管理する」ことの複雑さも引き受けることになる。
ソロ開発では、CIが壊れたら自分で直すしかない。だからこそ、問題が起きたら「なぜ」を追求し、再発しない仕組みを作ることが重要だった。ここで紹介した対策はすべて、実際の障害から生まれたものだ。