servicesにイメージ名を1行書けば、そのジョブの間だけPostgreSQLやRedisが立ち上がります。ところが実際に組むと、ローカルでは通るテストがCIだけ接続拒否で落ちる、ヘルスチェックを足したのに起動を待ってくれない、ジョブをコンテナで動かすように変えた途端localhostで届かなくなる、という詰まり方をするのが実情です。原因のほとんどは記法ではなく、ジョブをどこで走らせているかで接続先が変わる規則と、ランナーが待つ条件そのものを取り違えているところにあります。この記事では、ネットワークの作られ方、接続先の切り替え、起動待ちの設計、そしてdocker composeへ寄せるべき境目までを扱いました。パイプライン全体の考え方はCI/CDとは?仕組みとパイプラインの判断基準、土台になるビルドとテストの設定はGitHub Actionsでビルド・自動テストを設定する方法を先に押さえてください。
まとめ:サービスコンテナで先に決める4つの前提
第一に、走らせる場所がLinuxに限られます。GitHubホステッドならUbuntuランナー、セルフホストならDockerの入ったLinuxマシンが条件です。macOSやWindowsのランナーを指定した時点で、servicesは使えません。
第二に、接続先はジョブの走らせ方で変わります。ジョブ自体をコンテナで動かすなら、サービスのラベル名がそのままホスト名になり、ポートの記述は不要になりました。ランナーの上で直接動かすなら、portsでホストへ公開したうえでlocalhostか127.0.0.1を指す必要があります。
第三に、起動待ちはヘルスチェックがあるときにだけ働きます。ランナーの実装はdocker inspectで健全性の状態を読み、イメージがHEALTHCHECKを持たず--health-cmdも書かれていなければ、待たずに次のステップへ進む作りです。公式のPostgreSQLイメージもRedisイメージもHEALTHCHECKを持たないため、書き忘れれば起動待ちは成立しません。
第四に、servicesが受け取るのはイメージだけです。Dockerfileからのビルドも、サービス同士の起動順の宣言も、--networkの指定もできません。ここがdocker composeとの分かれ目になります。
servicesが作る使い捨てのDockerネットワークとLinux限定という前提
最初に、ランナーが裏で何をしているかを揃えます。ここを飛ばすと、この後の接続先の話も起動待ちの話も場当たりの暗記になってしまうためです。
ジョブごとに作られるネットワークとジョブ完了時に破棄されるデータの範囲
公式リファレンスは、servicesについて「ランナーがDockerネットワークを自動的に作成し、サービスコンテナのライフサイクルを管理する」と説明しています。ランナーの実装を見ると、その正体はgithub_network_に一意な識別子を連ねた名前のネットワークで、ジョブに属するコンテナはすべて同じネットワークへ参加する仕組みです。生成された名前はjob.container.networkのコンテキストから参照できます。
作られたコンテナはジョブの完了時に破棄されます。裏を返せば、テストが書き込んだデータも作ったテーブルも次のジョブへは残りません。毎回まっさらな状態から始まる代わりに、マイグレーションの適用やシードの投入はワークフロー側の手順として明示する必要が出てきます。テスト同士がDBの状態を共有できない前提は、結合テストの組み方そのものに効いてきます。テスト階層の切り分けはE2Eテストのベストプラクティスと結合テストとの違いで整理しました。
Linuxランナー限定と合成アクションの中では作れないという制約
公式の注記は明快です。Dockerコンテナアクション、ジョブコンテナ、サービスコンテナのいずれかを使うワークフローは、Linuxランナーでなければなりません。GitHubホステッドではUbuntuランナー、セルフホストではDockerを導入したLinuxマシンが条件になります。runs-onのラベル指定そのものはruns-onの記法と-latest更新への備えにまとめてあります。
もうひとつ、見落としやすい制約です。公式は「合成アクションの内部でサービスコンテナを作って使うことはできない」と明記しています。複数リポジトリで同じテスト基盤を共有したくなると、servicesごと合成アクションへ切り出したくなりますが、この方向は塞がれています。共有する場合の選択肢は、呼び出し可能ワークフロー側へ寄せるか、servicesのブロックだけを各リポジトリに残す形です。
コンテナジョブとランナー直走行で変わる接続先とポート指定の扱い
詰まりの最大の発生源がここです。同じYAMLでも、ジョブをコンテナで動かすかどうかで正しい接続先が入れ替わります。
コンテナジョブではラベル名がホスト名になりportsが要らない
ジョブ自体をcontainerで動かすと、ジョブとサービスは同じユーザー定義ブリッジネットワークに入ります。同一ネットワーク上のコンテナは互いに全ポートを見せ合うため、公式は「サービスコンテナのポートを設定する必要はない」と説明しています。接続先は、ワークフローで付けたラベル名がそのままホスト名になる仕組みです。servicesの直下にpostgresと書いたなら、ホスト名はpostgresです。
jobs:
test:
runs-on: ubuntu-24.04
container: node:22-bookworm-slim
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v5
- run: npm ci
- run: npm test
env:
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
この形では、ホスト側へポートは一切出ていません。外へ露出しない分だけ、同じランナー上で複数ジョブが動いてもポートが取り合いになりません。
ランナー直走行ではportsで公開してlocalhostへつなぐ
containerを書かずにランナーの上で直接テストを走らせる場合、事情が反転します。公式が言うとおり、サービスのポートは既定ではジョブ側へ出ていないため、portsでDockerホストへ公開しなければ届きません。公開したあとはlocalhostまたは127.0.0.1の該当ポートで接続します。内部的には--publishが使われています。
jobs:
test:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:7.4
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v5
- run: npm test
env:
POSTGRES_HOST: 127.0.0.1
REDIS_HOST: 127.0.0.1
ラベル名で引こうとして名前解決に失敗するのは、たいていこちら側の書き方です。逆に、コンテナジョブなのにlocalhostと書くと、ジョブコンテナ自身を指してしまい接続が拒否されます。
ホスト側のポート番号を省いた場合の自動割り当てと安全な受け取り方
コンテナ側のポートだけを書いてホスト側を省くと、空いているポートが自動で割り当てられます。番号はjob.servicesのコンテキストへ入るため、テスト側はそこから読み取ります。並列実行でポートの取り合いが起きる構成では、固定番号よりこちらのほうが安全です。
| portsの値 | 意味 |
|---|---|
| 8080:80 | コンテナのTCP80をホスト8080へ |
| 8080:80/udp | コンテナのUDP80をホスト8080へ |
| 8080/udp | ホスト側の空きポートを自動割当 |
services:
redis:
image: redis:7.4
ports:
- 6379/tcp
steps:
- run: npm test
env:
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
接続先の違いを環境変数へ寄せてコンテナとランナーの両方を通す
ホスト名の分岐をテストコードへ埋め込むと、ローカル開発とCIで別の分岐が増えていきます。接続情報はstepsのenvで1か所に寄せ、テストコードは環境変数だけを読む形にしておくのが扱いやすい形です。コンテナジョブへ移すときも、書き換えるのはYAMLの2行で済みます。
手元でワークフローを再現したいときはGitHub Actionsをローカル実行するactと再現できる範囲を参照してください。actはservicesの解釈に癖があるため、環境変数へ寄せておくと、CIとローカルで差分が出たときの切り分けが速くなります。
ヘルスチェックが無ければ待たないという挙動と待ち時間の決まり方
ここが本題です。「起動を待ってくれない」という症状の大半は、待つ条件を満たしていないだけで、揺らぎでもタイミングの運でもありません。
HEALTHCHECKを持たないイメージでは待たずに次へ進む
ランナーはサービスを起動したあと、健全性の状態をdocker inspectで問い合わせます。このとき使う書式は、Healthcheckの設定がある場合にだけ状態を出力するものです。設定が無ければ返り値は空文字になり、実装はそこで「ヘルスチェックを持たないコンテナ」と判断して合格扱いで抜けます。つまり、待ち時間はゼロです。
公式のPostgreSQLイメージ、Redisイメージ、MySQLイメージは、いずれもDockerfileにHEALTHCHECK命令を持ちません。したがってoptionsで--health-cmdを与えない限り、ランナーはDBの準備完了を一切待たずにステップへ進みます。ローカルでは通るテストがCIだけ接続拒否で落ちるとき、まず疑うのはここです。
待ちの打ち切りを決めるのはintervalとretriesという分担
ヘルスチェックを与えると、ランナーは状態がstartingである間だけ待ち続けます。待機の間隔は2秒から始まり、2秒ずつ伸びて最大32秒で頭打ちになる指数バックオフです。注意したいのは、ランナー側にはこのループの打ち切り条件が無い点になります。
打ち切りを決めているのはDocker側の設定です。--health-intervalの間隔で--health-cmdを実行し、--health-retriesの回数だけ連続で失敗した時点で状態がunhealthyへ変わり、そこでランナーの待機ループが抜けて失敗と判定されます。公式例の10秒間隔・5回なら、猶予はおよそ50秒です。初期化に時間のかかるイメージを使うなら、回数を増やすか--health-start-periodで最初の猶予そのものを伸ばします。
起動に失敗したときサービスコンテナのジョブログのどこを見るか
ランナーはサービスの起動確認を「Waiting for all services to be ready」という折りたたみグループにまとめます。この中に「service is healthy.」が出ていれば合格、「service is starting, waiting N seconds before checking again.」が並んでいれば待機中です。
失敗した場合は、そのサービス専用のグループが開き、コンテナの標準出力が丸ごと吐き出されます。DB自身が出した初期化エラーはここにあります。続いて「Failed to initialize container」とイメージ名が出て、最後に「One or more containers failed to start.」でジョブが落ちる流れです。ワークフローの構文ではなくイメージ側の設定ミスであることが多いため、この折りたたみを開かずにYAMLを直し始めると遠回りになります。
PostgreSQLのpg_isreadyが通ってもつながらない時間帯の切り分け
ヘルスチェックを正しく書いたのに、それでもごく低い確率で接続拒否が出ることがあります。原因は待ち方ではなく、何を「準備完了」と見なしているかのずれです。
初期化中の一時サーバがソケットだけを開けている時間帯と判定リスク
PostgreSQL公式イメージの起動スクリプトは、データディレクトリが空のときに初期化処理へ入ります。このとき起動されるのは本番の待ち受けではなく、初期化専用の一時サーバです。スクリプトは待ち受けアドレスを空にする指定を付けてサーバを立ち上げ、初期化SQLを流し終えたところで一度停止させてから、あらためて本来の設定で起動し直します。
待ち受けアドレスが空の間、開いているのはUNIXドメインソケットだけで、TCPの5432番は閉じています。ところがpg_isreadyを引数なしで実行すると、既定ではそのローカルソケットへ問い合わせるため、TCPが閉じている時間帯でも成功が返り得る仕組みです。ヘルスチェックの初回がこの窓に当たると、Dockerはコンテナをそのままhealthyと判定し、ランナーは次のステップへ進み、テストのTCP接続だけが拒否されます。
health-cmdをTCP接続で書き直して判定を実態へ寄せる
対処は、ヘルスチェックの中身をテストと同じ経路へ揃えることです。pg_isreadyに接続先ホストとポートとユーザーを明示すれば、判定はTCP経由になり、一時サーバの窓を踏み抜かなくなります。あわせて初回猶予を置いておくと、初期化SQLが長いイメージでも安定します。
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd "pg_isready -h 127.0.0.1 -p 5432 -U postgres" --health-interval 5s --health-timeout 5s --health-retries 12 --health-start-period 10s
MySQLでも同じ性質の二段起動が起きるため、mysqladmin pingにホスト指定を足す形が無難でした。Redisは初期化フェーズを持たないぶん素直で、redis-cli pingで足ります。
アプリ側の接続リトライとヘルスチェックを併せた二段構えにする
ヘルスチェックだけで揺れを消し切ろうとすると、猶予を長く取りすぎてジョブ時間が伸びます。実務では、ヘルスチェックで「おおむね上がった」ところまで押さえ、テスト起動時の初回接続に数回のリトライを持たせる二段構えが安定的です。マイグレーション実行を最初のステップに置き、そこで数回リトライさせれば、後続のテスト本体は接続済みの前提で書けます。
この形なら、DBの起動が遅れた分は最初のステップが吸収し、テストコードには待機処理が漏れ出しません。テストが落ちたときに「DBが上がっていない」のか「アサーションが違う」のかを、ログの位置だけで切り分けられるようになります。
docker composeとservicesの使い分けと採用条件
「composeを捨ててservicesへ寄せる」と単純化すると、あとで戻す判断が要ります。どちらにも届かない範囲があるため、境目を先に引いておきます。
servicesでは届かない自前ビルドとサービス間の依存順の制御範囲
servicesが受け取るのはレジストリ上のイメージ名だけです。Dockerfileからその場でビルドする指定はありません。テスト対象のアプリ自体をコンテナとして立てたいなら、先にイメージを作ってレジストリへ置くか、ジョブを分ける形になります。イメージを作る側の設計はGitHub ActionsでDockerイメージをビルドする手順とpush先の設計にまとめてあります。
サービス同士の起動順を宣言する仕組みもありません。すべてのサービスが同時に起動し、ランナーは全部の健全性が揃うのを待つだけです。DBが上がってからマイグレーション用コンテナを走らせる、といった段取りは表現できません。optionsはdocker createのオプションを通せますが、公式が明示的に警告しているとおり--networkだけは通りません。ネットワーク構成そのものはランナーの管理下にあるからです。
ubuntuランナーに同梱のdocker composeを持ち込む場面
GitHubホステッドのUbuntuイメージにはDocker Composeが同梱されています。つまり、ステップの中でdocker compose upを呼ぶ選択肢は最初から使用可能です。ローカル開発ですでにcomposeファイルを運用していて、CIでも同じ定義をそのまま動かしたい場合は、二重管理を避けられるぶんこちらが有利になります。
自前ビルドが要る、サービス間の依存順が要る、あるいは3つ以上のミドルウェアが絡んで定義が育っている。この3つのいずれかに当てはまるなら、composeへ寄せた設計のほうが読みやすくなりました。
servicesを選んでよい3条件とcomposeへ移す場面の判断基準
採用してよいのは次の3条件がそろうときです。第一に、必要なミドルウェアが公開イメージのまま使えること。第二に、起動順の制御が不要で、健全性の確認だけで足りること。第三に、その定義がCI専用で、ローカル開発と共有する必要がないこと。この3つが満たされるなら、composeファイルを持ち込むよりYAML内で完結するservicesのほうが、依存が減って壊れにくくなります。
逆に、テスト対象のイメージを毎回ビルドする、サービスが4つ5つと増える、ローカルとCIで同じ定義を共有したい、といった場面では見送りです。servicesのまま無理に押し切ると、optionsへの長い一行とentrypointの上書きが積み上がり、composeファイル1枚より読みにくい塊になっていきます。マトリックスで複数バージョンのDBを回す構成についてはマトリックスビルドの組み合わせ展開とinclude・excludeの調整が参考資料です。引き渡し後もこの土台を壊さず運用できるかどうかまで含めて設計したい場合は、保守運用・内製化支援で実際のパイプラインごと引き取っています。
よくある質問
macOSやWindowsのランナーでサービスコンテナは使えますか?
使えません。公式の注記は、サービスコンテナ・ジョブコンテナ・Dockerコンテナアクションのいずれかを使うならLinuxランナーが必要だと明記しています。GitHubホステッドではUbuntuランナー、セルフホストではDockerを入れたLinuxマシンが条件です。macOS向けのビルドとDB付きテストを同居させたいときは、ジョブを分けてテスト側だけUbuntuへ置く形になります。
サービスコンテナ自身のログはどこで確認できますか?
起動に失敗した場合は、ジョブログの中に「Service container … failed.」という折りたたみグループが現れ、そこへコンテナの出力が丸ごと展開されます。正常に起動したときはこのグループが出ないため、稼働中のクエリログなどを見たいなら、ステップとしてdocker logsを呼ぶ形を足します。
合成アクションの中にservicesを書けますか?
書けません。公式は、合成アクションの内部でサービスコンテナを作成して使うことはできないと明記しています。複数リポジトリでテスト基盤を共有したいなら、呼び出し可能ワークフロー側へ切り出すか、servicesのブロックだけは各ワークフローに残す構成にします。
プライベートレジストリのイメージを指定できますか?
指定可能です。credentialsにusernameとpasswordを書けば、レジストリへ認証したうえでイメージを取得します。公式によれば、この指定はDocker Hubのレート制限を緩める用途にも使えます。認証を通しておくと、匿名取得の上限に当たってジョブがまとめて落ちる事故を避けられる仕組みです。
services:
db:
image: ghcr.io/octocat/testdb:latest
credentials:
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
同じジョブでDBを2つ立てるとポートが競合しませんか?
ジョブをコンテナで動かす構成なら、そもそもホストへポートを出さないため競合しません。ランナー直走行でも、ホスト側の番号を書かずにコンテナ側だけ指定すれば空きポートが自動で割り当てられ、番号はjob.servicesのコンテキストから取得できます。固定番号での公開は、同一ランナー上に同じポートを使う構成が同居しない場合に限って使う形が安全です。
関連記事
- GitHub Actionsでビルド・自動テストを設定する方法|CI/CDワークフローの作り方:サービスコンテナを載せる前段のワークフロー設計がまとまっています。
- GitHub ActionsでDockerイメージをビルドする|push先とキャッシュ・マルチアーキの設計:自前イメージをservicesへ渡すための作り方です。
- GitHub Actionsをローカル実行するact|導入とイメージ選択・再現できる範囲:CIとローカルの差分を切り分けたいときの手段です。
- E2E(エンドツーエンド)テストのベストプラクティス|結合テストとの違いとツール選定:DB付きテストをどの階層へ置くかの判断材料です。
- CI/CDとは?仕組み・パイプライン・導入すべき企業の判断基準を解説:パイプライン全体からテスト基盤を見直す入口です。