Tech Learning Daily

2026-09-06 (Sun) — 第 52 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Dev 🌿 基礎 ⏱ 約 7 分

GitHub Actions の jobs は、なぜ書いた順ではなく一斉に走り出すのか?

workflow.yml に buildtestdeploy の 3 つの jobs を上から順に書いたはずなのに、Actions タブを開くとなぜか同時に走り出している——そんな画面を見て戸惑ったことはないだろうか。実は GitHub Actions は yaml に書かれた順序を一切見ておらず、jobs は既定ですべて並列に投げられる。

🎯 3 行まとめ
  • jobs は yaml に書いた順序と無関係に既定で並列実行される。順序を決める唯一の手段は needs キーワードで、これが依存関係グラフそのものになる。
  • 各 job は毎回まっさらな使い捨ての仮想マシン上で動く(public リポジトリは 4 vCPU・16 GB RAM・14 GB SSD、private リポジトリはその半分)。前の job で作ったファイルは、明示的に artifact や cache で渡さない限り一切残らない。
  • actions/cachekey の完全一致がなければ restore-keys の前方一致にフォールバックするが、キャッシュは現在のブランチとデフォルトブランチ(PR ならベースブランチも)にしか及ばず、兄弟ブランチ間では共有されない。

「上から順」という思い込みと needs グラフ

1 つの jobs: ブロックに複数の job を並べて書くと、上から下へ逐次実行されるように見える。しかし GitHub Actions のスケジューラが見ているのは記述順ではなく、各 job が持つ needs の有無だけだ。needs を書かなければ、その job はワークフローの開始と同時にすぐ実行可能な状態になる。

jobs.<job_id>.needs に他の job の ID を指定すると、指定した job が成功するまで自分は待機する。これは「順番に並べる」のではなく「有向グラフの辺を張る」ことに近い。needs: [build, lint] のように複数指定すれば、両方の完了を待ってから実行される。逆に needs を持たない job 同士は、たとえ隣り合って書かれていても互いの完了を待たない。

🍱 たとえるなら

job は人材派遣会社から毎回呼ぶ派遣スタッフに近い。呼ぶたびに真新しい机と道具一式を持った別人が来て、前の日にその机に置いた資料は跡形もなく消えている。yaml に書いた job の並び順は単なる依頼リストの見た目にすぎず、実際に「誰が誰の成果物を待ってから着手するか」を決めるのは、あなたが明示的に渡した引き継ぎメモ(needs)だけだ。

ランナーは毎回使い捨てのVM

GitHub がホストする標準ランナー(ubuntu-latest など)は、シングル vCPU 版を除き job ごとに新しく起動される仮想マシンだ。public リポジトリでは 4 vCPU・16 GB RAM、private リポジトリではその半分の 2 vCPU・8 GB RAM が割り当てられ、いずれもディスクは 14 GB の SSD に共通する。job が終わればこの VM は破棄され、次の job は別のまっさらな VM で始まる。

同じワークフロー内の job 同士であっても、ファイルシステムの中身や環境変数は自動的には引き継がれない。前段の job でビルドした成果物を後段で使いたければ、actions/upload-artifact / download-artifact で明示的に受け渡すか、次に説明するキャッシュの仕組みに頼る必要がある。何もしなければ、後段の job は毎回ゼロからやり直す。

┌───────────────────────────────────────┐
│ needs グラフ(fan-out→fan-in)        │
│                                       │
│            ┌─────────┐                │
│            │  build  │                │
│            └────┬────┘                │
│       needs:build │                   │
│      ┌────────────┴──────────┐        │
│      ▼                        ▼       │
│┌──────────┐            ┌─────────┐    │
││   lint   │            │  test   │    │
│└────┬─────┘            └────┬────┘    │
│     │   needs:[lint,test]   │         │
│     └────────────┬──────────┘         │
│                  ▼                    │
│            ┌─────────┐                │
│            │ deploy  │                │
│            └─────────┘                │
└───────────────────────────────────────┘

キャッシュは「完全一致」と「前方一致」の二段構え

actions/cache はまず key に指定した文字列(cache key)との完全一致を探す。ヒットすれば path に指定したディレクトリへそのまま復元する「キャッシュヒット」だ。一致しなければ、続けて restore-keys に列挙した文字列を前方一致で順番に試し、最初に見つかった候補を復元する。key にロックファイルのハッシュ(hashFiles())を含めておくと、依存関係が変わった瞬間だけ新しいキャッシュキーになる、という設計が典型的だ。

key が完全一致もせず restore-keys でも見つからなければ「キャッシュミス」として扱われ、job はゼロから依存関係をインストールする。ミスした場合でも job が成功すれば、その key で新しいキャッシュが自動的に作成される。つまり初回は必ずミスするのが正常な挙動であり、2 回目以降のビルドから効いてくる。

キャッシュはどのブランチとでも共有されるわけではない

ワークフロー実行がアクセスできるキャッシュの範囲は、現在のブランチ・デフォルトブランチ(多くは main)、そして pull request 実行であればベースブランチ(フォーク元のベースブランチも含む)に限られる。兄弟ブランチ同士や無関係な別ブランチのキャッシュは参照できない。feature ブランチで作ったキャッシュを別の feature ブランチが読めないのはこのためで、多くの場合 main ブランチで一度キャッシュを作っておくと、そこから全ブランチが読み取れる形になる。

キャッシュにはリポジトリ全体で 10 GB という既定の容量上限があり、超過すると最終アクセスが古いものから自動的に削除される。加えて 7 日間参照されなかったキャッシュも削除対象になる。「昨日まで効いていたキャッシュが急に効かなくなった」という現象は、このどちらかの掃除に引っかかっている場合がある。

💼 実務でどう出会うか

deploy job に needs: test を書き忘れると、テストの結果を待たずに本番デプロイが走ってしまう——という事故は needs の意味を「見た目の並び」と誤解しているときに起きやすい。また CI が「今日だけ遅い」と感じたら、まず疑うべきはキャッシュミスだ。依存関係のロックファイルを更新した直後や、初めて触るブランチでは key が一致せずフルインストールになるため、体感速度が一気に落ちる。

⌨️ 手を動かす(5 分)

認証不要の GitHub 公開 API を使い、実在する OSS(actions/toolkit)の CI 実行で複数 job が本当に同時刻に開始しているかを確認する。

# 直近で完了した unit-tests ワークフローの run ID を取得
RUN_ID=$(curl -s "https://api.github.com/repos/actions/toolkit/actions/workflows/unit-tests.yml/runs?status=completed&per_page=1" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['workflow_runs'][0]['id'])")

# その run に含まれる各 job の開始時刻を並べて表示
curl -s "https://api.github.com/repos/actions/toolkit/actions/runs/$RUN_ID/jobs" \
  | python3 -c "
import json,sys
for j in json.load(sys.stdin)['jobs']:
    print(j['name'], j['started_at'])
"

Build (ubuntu-latest, 20.x)Build (windows-latest, 24.x) のように OS・Node バージョンの組み合わせごとに job 名が分かれ、それぞれの started_at がほぼ同一秒であることが確認できるはずだ。これは OS やバージョンの組み合わせごとに同じ job 定義を複製して並列実行する matrix strategy によるもので、yaml 上は 1 箇所の記述にすぎなくても、実行時には互いに独立したランナー上で同時に走り出している。

🙅 よくある誤解
  • jobs は yaml に書いた順に上から実行される — 既定では全 job が同時に実行可能になり、順序を決めるのは needs だけだ。書く順序はスケジューリングに影響しない。
  • 同じワークフロー内なら前の job のファイルや環境変数がそのまま残る — 各 job は使い捨ての新しい VM で動き、artifact か cache で明示的に受け渡さない限り何も引き継がれない。
  • actions/cache を使えば他のブランチのキャッシュもいつでも使い回せる — 参照できるのは現在のブランチ・デフォルトブランチ・(PR なら)ベースブランチのみで、兄弟ブランチ間ではキャッシュヒットしない。
📖 用語ミニ辞典
needs
job が他の job の成功完了を待つように指定する GitHub Actions のキーワード。依存関係グラフの辺にあたる。
ランナー(runner)
job を実行する実体。GitHub ホストの標準ランナーは job ごとに新規起動される使い捨ての仮想マシン。
cache key
actions/cache でキャッシュを一意に識別する文字列。完全一致した場合のみキャッシュヒットになる。
restore-keys
key が完全一致しなかった場合に前方一致で順に検索するフォールバック用のキー一覧。
matrix strategy
OS やバージョンの組み合わせごとに同じ job 定義を複製して並列実行する仕組み。
🔗 もっと深く