CI 実行基盤を Namespace の GitHub Actions runner へ移した際、依存パッケージのキャッシュが当たる run と当たらない run が混ざる現象に当たった。設定を変えていない連続実行で混ざるので、最初は自分の設定ミスを疑った。結論は仕様で、対処は「キャッシュに何を置くか」の切り分けだった。
キー方式のキャッシュ(actions/cache など)を前提に組んだ CI をこの手の runner へ移すとき、暗黙に置いている前提が1つ外れる。その前提が何で、外れると何が壊れるかを書く。計測は 2026-08 時点。
結論(TL;DR)
- cache volume は前 run の内容が次 run に来ることを保証しない。並列でなく逐次実行でも hit と miss が混ざる
- キャッシュが外れて壊れるものは cache volume でなく artifacts に置く。外れても自力で収束する依存キャッシュだけを cache volume に残す
- post step の
cachedログと、見かけのディスク容量を、保存成功・保持上限として読んではいけない
前提: 置き場が2つある
Namespace は性質の違う置き場を2つ持っている。話の結論はこの2つの使い分けになるので、先に両方を出しておく。
cache volume
キーを持たないキャッシュ。tag で識別される1つのブロックボリュームを runner のローカル NVMe として直付けし、ジョブが終わってもボリュームは残り続ける。アップロードもダウンロードもないので、保存・復元に秒を払わない。整合は利用側のツール(composer install、npm ci、静的解析の result cache など)が取る前提になっている。
jobs:
test:
runs-on: namespace-profile-my-build # profile 側でボリュームを設定する
キーもフォールバックもないので、キー設計から解放される代わりに「どの run にどのボリュームが来るか」を制御する手段もない。ここが本題。
artifacts
nsc artifact upload / download は Namespace のワークスペースに属する名前付きの置き場で、GitHub Actions の artifact とは別物。runner に nsc が同梱されていて、login も secret も要らずに読み書きできる。
キーではなく名前で取る。同名への upload は黙って上書きし、存在しない名前の download はエラーで返る。ブランチや profile による分離がないので、ワークスペース内なら同じ名前をどこからでも引ける。裏を返すと名前の衝突がそのまま事故になるので、用途と版を名前に入れる(latest 相当の1つの名前で回さない)。転送は伴うが小さく、800KB のファイルで upload 60〜190ms / download 60〜80ms だった。保持は期限で決まり、--expires_in を指定しなければ無期限になる1。
なぜ actions/cache ではなく cache volume を選ぶのか
先に答えておくと、actions/cache は Namespace runner でもそのまま動く。実際こちらでも併用していて、lint 系のワークフロー(静的解析やフォーマッタの result cache)は移行後も actions/cache のままにしている。テスト実行系だけ cache volume にした理由は3つ。
- 転送を払わない。 依存 369MB の保存と復元が毎 run 消える。同じボリュームにコンテナイメージのレイヤも載るので、ベースイメージのビルドが初回 165s に対し2回目以降 4〜6s になる。レイヤキャッシュをキー方式で持つのは現実的でない
- ブランチ分離の回避運用が要らない。 GitHub Actions cache は feature ブランチ同士では読めない(自分のブランチと default ブランチのキャッシュしか読めない)。ブランチ間で共有するには default ブランチ側でキャッシュを生成する専用ワークフローを別に持つことになる。actions/cache の tips が回避策として挙げているのがこれで、cache volume にはこの制約がない
- リポジトリ 10GB の上限と LRU eviction から外れる。 ブランチが増えるほど eviction の圧力が上がり、それを下げるための削除ワークフローを持つことになる
裏返すと、転送量が小さくヒットの安定を優先したいものは actions/cache に残すのが正しい。以降の話は、この選択の代償にあたる。
症状
並列実行で複製が作られることは公式ドキュメントに書かれている。困ったのは逐次実行でも混ざる点で、以下は同じ profile・同じ tag・同じブランチで4回連続実行したときの Attach cache volume の出力。間で設定は変えていない。
| 容量 | 使用量 | 依存キャッシュ |
|---|---|---|
| 20G | 4.9M | miss |
| 25G | 5.2G | hit (369M) |
| 25G | 5.2G | hit (369M) |
| 20G | 4.9M | miss |
容量が 20G と 25G で違うのは設定値の解釈によるもので、後述する。
nsc volume list --output json で見えるボリュームは1つだけで、使用量は 5.2G のまま残っている。最終アタッチの時刻も hit した run のものから進まない。つまり miss した run はこのボリュームを掴んでいない。書き込みが失われているのではなく、届くボリュームが別実体になっている。
マウント自体は正しく張られている。マウント情報を見ると、ワークスペースの vendor はボリューム(/dev/vdi)からの bind mount で、/cache/vendor と同じ実体を rw で指している。
87 82 254:128 / /cache rw ext4 /dev/vdi
52 82 254:128 /vendor /home/runner/work/<repo>/vendor rw ext4 /dev/vdi
miss した run では /cache/vendor は存在するがサイズ 4096 の空ディレクトリで、ボリュームの使用量も 5M 前後に見える。同じ tag に 5.2G 入っているのに空、という状態になる。
切り分けで分かったこと
profile でボリュームを指定する方式とラベル方式(nscloud-cache-tag-*)、キャッシュパスの位置(ワークスペース内 / 外の絶対パス)、自前の版管理処理の有無を変えても、ミスは同じように起きた。この挙動は設定の書き方に依存しない。
そのうえで、切り分けに使っていた材料が2つとも壊れていた。ここは他の基盤でも同じ形で踏むと思う。
サンプル数。 ヒットが安定しないと分かる前は、連続2回で全ミスした構成を「この構成では効かない」と判定していた。後から見ればミスの側を引いただけで、その判定は全部無効だった。非決定的な挙動を相手にするときは、2サンプルで構成の良否を決めてはいけない。
ログ。 ジョブ末尾に出る <path>: cached を「保存された」と読んでいたが、nscloud-cache-action のソースを読むと、Linux(bind mount)では検証を一切せず無条件にこの行を出している。生存確認をしているのは macOS / Windows 経路だけで、保存の証拠として使えるものではなかった。
ベンダーに確認: 設計どおり
ベンダーに確認したところ、これは意図された挙動だった。要旨は次の通り。
- cache volume はローカル NVMe への排他書き込みで IOPS を稼ぎ、起動時のネットワーク転送をゼロにするための仕組みである
- その代償として、run が前の run のキャッシュを受け取る保証はない
- 過去の成功 run が作ったボリューム群を候補のプールと見なし、新しい run には一番新しいものを割り当てようとするが、古いものやミスに倒れることもある
- ミスするたびに候補の分布が広がり、以後のミスは減る。使い始めにミスが多いのは想定内
tag: vendor
候補プール(過去の成功 run が残した実体)
├─ 実体 1 vendor 369MB
├─ 実体 2 空
└─ …
run ごとにこのうちの1つが割り当てられる
一番新しいものを狙うが、古いものや空のものに倒れる
nsc volume list に見えるのは1つだが、実体はそれより多い。候補を複数生かしているが利用者が候補数を制御できないため課金は1つ分、という説明とも整合する。
排他書き込みという性質から素直に出てくる帰結ではある。転送ゼロを成立させるには「そのボリュームは自分だけが触る」必要があり、並行 run のために複製が増え、複製が増えれば「どれが最新か」は割り当て時の選択問題になる。公式ドキュメントも並列実行時の自動 fork とランプアップ期間の存在には触れているが、逐次実行で別実体に当たるケースの説明はない。
分かるのは割り当ての方針までで、どの実体が来るかを決めている条件、ランプアップ期間の長さ、収束後に期待できるヒット率は分からない。つまりヒット率は設計に織り込めない。 以降は「外れる前提で何を置くか」の話になる。
対処: 版に厳しいものはキャッシュに置かない
ヒット率を上げる手段はない、という前提で設計を変えた。ベンダー側の推奨もこれで、「版に厳しい要求があるデータは artifacts、依存のダウンロードキャッシュは cache volume」という切り分けになる。
| 対象 | 性質 | 置き場 |
|---|---|---|
| DB スキーマのダンプ | 版が合わないとマイグレーションが失敗して回復できない | artifacts |
| 依存パッケージ(composer / npm) | インストーラが差を吸収する | cache volume |
判定基準はキャッシュミス時の挙動で決まる。ミスしたときにツールが自力で正しい状態へ収束するなら cache volume、収束せず壊れるなら artifacts。
ダンプは名前に版を入れる(db-dump/v<N>.sql)だけで、取得が毎 run 確定で当たるようになった。外れたときのスキーマのフル構築(52s)が消えている。ブランチや profile の分離がないので、保存するジョブと取得するジョブを分けられるのも都合が良い。版を上げても旧版は別の名前として残るため、その版を取り込んでいないブランチは旧版を引き続き使える。
依存パッケージ側に残る不確定さは、外れたときに composer install の分だけ遅くなる(実測で 27s → 36s)に留まる。落ちる方向の被害はない。
落ちないので監視が要る
キャッシュが効かなくなっても、遅くなるだけでジョブは緑になる。設定値を超えて書き込んだ場合もエラーにはならず、その run の後で静かに破棄される。気づけないので使用量をメトリクスとして送っている。
分母には注意が要る。症状の表で容量が 20G と 25G に分かれていたのは、設定値が「保持上限」と「毎 run の最低空き容量」の二重の意味を持つためで、既に 5GB 入っているボリュームには out of disk を避けるため 25GB のディスクが出る。したがって statvfs で取れるディスクサイズは provision された値であって、監視の分母は自分が設定した値になる。
nsc volume list は runner の認証情報では権限がないため、ジョブ内からは環境変数で渡されるキャッシュパスを statvfs で見ている。
増える主因は distinct なコンテナイメージタグの数だった。ベースイメージを内容ハッシュでタグ付けしていると更新のたびに別タグとして積まれる。定常状態でじわじわ増えるというより、Dockerfile をいじる時期に跳ねる。
-
補足: 破棄の閾値と課金の基準
「設定値を超えると破棄」の閾値が 1 run の書き込み量なのか保持総量なのかは判別できていない。1 run の書き込みは 1〜2GB 程度なので前者なら問題にならず、後者なら現状は設定値の半分弱を使っていることになる。対応の要否が真逆になるので、判定基準が分かるまでボリュームの設定は変えていない。
なお課金も設定値基準で、実データ量でも provision されたディスクでもない。サイズを上げるのは無償ではないので、まず入れるものを減らす方が筋が良い。
置き場ごとの性質
| actions/cache | cache volume | artifacts | |
|---|---|---|---|
| 取得 | キー一致で決定的 | 非決定的。候補プールから割り当て | 名前一致で決定的 |
| 転送 | 保存・復元に時間を払う | ゼロ | 払う(800KB で往復 0.2 秒前後) |
| スコープ | ブランチ。feature 同士では読めない | workspace / profile / repository で分離 | ワークスペース。ブランチも profile も跨ぐ |
| 無効化 | キーを変えれば捨てられる | 手段がない。版マーカーを置いて自前で捨てる | 名前に版を入れる。新旧が共存する |
| 容量の頭打ち | リポジトリ 10GB・LRU で evict | 設定値超過で run 後に破棄(無通知) | 期限のみ。無指定なら無期限 |
| 並行実行 | 影響なし | 自動 fork される。tag は用途ごとに分ける | 影響なし。同名は上書き |
最後の行は運用上わりと効く。同一 tag を複数の用途が同時に使うと、fork した系統が別々に育ってコミットが取りこぼされ続ける。
まとめ
転送ゼロのキャッシュは、キーによる決定的な復元とのトレードオフになっている。cache volume は速い actions/cache ではなく、性質の違う置き場だと見るのが正しい。
そのうえで、移行時に判断が要るのは基盤固有の挙動ではなく「キャッシュが外れたときにジョブが壊れるか、遅くなるだけか」の1点だった。壊れるものはキャッシュに置かず、確定で取得できる置き場に逃がす。ヒット率が読めない基盤ではこれが唯一の設計原則になるし、キーで当たる基盤でも成り立つ話ではある。
-
CLI リファレンス に by default, artifacts do not expire と明記されている ↩