GitHub Actions のジョブ用 VM の起動を 23 秒から 7 秒にした記録
GitHub Actions のジョブ 1 つのために新しい VM を起動すると、Skipbox のホストでは、ジョブを受け付けてからランナーのプロセスが動き出すまで 22.9 秒かかっていました。いまは 7.25 秒です。この記事は、消えた 15 秒がどこにあったか、それぞれをどう測ったか、試して効かなかったものは何かの記録です。
Skipbox は、月 6,000 円の定額で実行時間が無制限の GitHub Actions ランナーです。ジョブは、ジョブのあとも残るアカウント専用のマシンで動き、新しい VM が要るジョブには 7 秒で VM が用意されます。移行は runs-on を 1行書き換えるだけです。
何を、どのマシンで測ったか
Skipbox のホストでは、Incus 経由で KVM の VM を動かし、ストレージは ZFS です。ランナーのイメージは、停止した VM とそのスナップショット。新しい VM が要るジョブには、スナップショットの ZFS クローンを作って起動し、ゲストエージェント経由で just-in-time ランナーの設定を渡し、ジョブを 1 つ実行したら、ディスクごと削除します。
ホストのデーモンは、ジョブを受け付けた時刻を 0 として、起動の各ステップが終わった時刻をミリ秒でログに出します(startMs: cloned、started、agent)。以下は、16 スレッドの Intel i7-7820X のホストと、4 vCPU・8 GB の VM で測った数字です。
| 受け付けからランナーの開始まで(各 3 回) | |
|---|---|
| 変更前 | 22.9 秒(21.7〜24.2) |
| 変更後 | 7.25 秒(7.1〜8.1) |
内訳です。それぞれ単独で測っています。
| 変更 | 測った値 | 削れた時間 |
|---|---|---|
| OVMF をやめて SeaBIOS に | VM の開始からエージェントの最初の応答まで(3 回の中央値): 13.7 → 8.4 秒 | 5.3 秒 |
カーネルのコマンドラインに quiet | 8.4 → 6.5 秒 | 1.9 秒 |
| initrd なしで起動 | 6.5 → 5.1 秒 | 1.4 秒 |
| イメージを最初からクローンと同じ大きさに | クローン 1.5 → 0.1 秒、最初の起動 2.35 → 1.8 秒 | 1.9 秒 |
| dockerd ではなく Docker のソケットを待つ | dockerd が立ち上がるのはエージェントの 3.6 秒後 | 3.6 秒 |
| ゲストエージェントへの問い合わせを 1 秒おきから 100 ミリ秒おきに | 平均 0.5 秒、最大 1 秒 | 0.5 秒 |
足すと 14.6 秒。通しで測った差 15.65 秒の、ほぼすべてです。
ファームウェア: GRUB にたどり着くまでの 5.3 秒
Incus は、何も指定しなければ VM を OVMF とセキュアブートで起動します。このホストでは、OVMF が GRUB に制御を渡すまで 5.3 秒かかっていました。SeaBIOS なら 0.5 秒です。切り替えはインスタンスの設定 2 つ、security.csm=true と security.secureboot=false です。
いちばん大きく削れたのがここで、セキュリティに関わるのもここだけです。SeaBIOS にすると、ゲストの起動の過程で署名は検証されません。署名の検証が防ぐのは、ゲストのブートローダーやカーネルが差し替えられることです。CI のジョブ用 VM でそれができる立場にいるのは、ジョブ自身です。ジョブはもともと自分の VM の中で root を持っていて、sudo で何でもできます。そして VM もディスクも、ジョブが終われば消えます。ある利用者の VM をほかの利用者の VM から隔てているのは、KVM と、ホストのブリッジにある VM ごとのファイアウォールのチェーンで、どちらもゲストのファームウェアには頼っていません。イメージはどちらのファームウェアでも起動でき、ホスト側のフラグ 1 つで UEFI に戻せます。
静かなカーネルと、initrd なしの起動
ジョブ用 VM はジョブのたびに 1 回起動するだけで、シリアルコンソールを読む人はいません。quiet をつけただけで、中央値は 8.4 秒から 6.5 秒になりました。
initrd の役目は、ルートファイルシステムを見つけるためのドライバーを読み込むことです。Ubuntu の generic カーネルには virtio-scsi と ext4 が組み込まれていて、クローンはどれもイメージと同じパーティション UUID を持っています。だから root=PARTUUID=… を渡せば、カーネルが直接 / をマウントします。これでさらに 1.4 秒。
気づかないうちに元に戻らないよう、歯止めを 2 つ置いています。Ubuntu の GRUB は、initrd なしの起動がパニックすると、次の起動では自動で initrd を使います(GRUB_FORCE_PARTUUID)。安全な仕組みですが、これが働くと 1.4 秒が黙ってすべてのジョブに戻ってきます。そこでイメージのビルドでは、テスト用のクローンを実際に起動し、dmesg に initramfs を展開した行があればビルドを失敗させます。
あとで広げるくらいなら、最初からその大きさで作る
イメージはもっと小さいディスクで作っていて、クローンを起動するたびにプランの大きさまで広げていました。これがジョブ 1 つあたり 1.9 秒です。クローンに 0.1 秒ではなく 1.5 秒、最初の起動に 1.8 秒ではなく 2.35 秒かかっていました。いまは、いちばん小さいプランのディスク(80 GB)と同じ大きさでイメージを作っています。ボリュームはシンプロビジョニングなので、イメージが実際に使うのは書き込んだ分だけです。Incus はクローンを縮められないので、イメージはいちばん小さいプランに合わせ、大きいプランのクローンは起動時に広げます。
Docker はソケットがあれば足りる
起動スクリプトは、ランナーを始める前に docker.service を待っていました。dockerd が立ち上がるのは、ゲストエージェントの 3.6 秒後です。ところが Docker のソケットは、起動の早い段階から systemd が待ち受けています。dockerd より先に接続したクライアントは、dockerd が立ち上がるまで待つだけです。そこで、待つのは docker.socket と DNS だけにしました(待ち時間には上限があります)。dockerd は、ランナーが立ち上がっている間に準備を終えます。
100 ミリ秒おきに聞く
ランナーの設定は、ゲストエージェント経由で VM に書き込みます。エージェントは、ゲストが起動するまで応答しません。ホストは 1 秒おきに再試行していたので、エージェントが立ち上がってから平均 0.5 秒待っていました。エージェントがいない間、Incus は書き込みを 20 ミリ秒で断ります。100 ミリ秒おきに聞いても負担はありません。VM の最初の 30 秒を過ぎたら、1 秒おきに戻します。
起動の話ではないが: ジョブと一緒に消えるディスクの fsync
測っているうちに、VM の中でいちばん遅いのは起動ではないとわかりました。ゲストの fsync が毎回、ホストの ZFS がインテントログをコミットするのを待っていたのです。ジョブごとに作る VM のディスクは、ジョブと一緒に消えます。停電に耐える必要のあるデータは 1 つもありません。そこで起動スクリプトは、ランナーを始める前に / を nobarrier で再マウントします。
4 vCPU・8 GB の VM での、3 回の中央値です。
| 変更前 | 変更後 | |
|---|---|---|
| fsync つきの 4k ランダム書き込み | 毎秒 227 回 | 毎秒 2,505 回 |
yarn install(キャッシュから node_modules 1 GB) | 12.92 秒 | 9.99 秒(23% 短縮) |
docker pull node:22 | 19.12 秒 | 15.45 秒(19% 短縮) |
| redis の Docker ビルド | 92.89 秒 | 87.21 秒 |
make -j4、sysbench cpu | 変わらず | 変わらず |
これをするのは、ジョブごとに作る VM だけです。専用のマシンにはジョブをまたいで残るキャッシュが入っているので、ext4 のバリアは有効のままです。
測ったうえで採らなかったもの:
- VM のボリュームに ZFS の
sync=disabled。 効果は同じでした(毎秒 2,901 回、10.10 秒、16.17 秒、87.99 秒)。ただし root でのzfsの呼び出しが要り、ホストのデーモンのサンドボックスにはその権限がありません。ゲスト側の再マウントなら、ホストには何も要りません。 - ゲストのメモリを huge pages に。
makeは 52.41 秒で、使わないときの 51.72 秒と変わりません。 /にnoatime、discardなし。yarn installは 12.84 秒で、もとの 12.92 秒と変わりません。
借りているクラウドのサーバーでも、同じ探し物をした
Skipbox のホストが埋まっているとき、ジョブはスナップショットのイメージから起動したクラウドのサーバーで動きます。こちらにも、別の場所に無駄な待ちがありました。4 vCPU の Intel の共有サーバーで、ジョブのスクリプト(サーバーの user data)が動き出すのは、カーネルの開始から 28.1 秒後でした。cloud-init がスクリプトを受け取っていたのは 19.3 秒、Docker が立ち上がったのは 22.2 秒の時点です。それでも cloud-init は、user data を最後のステージで、ほかのすべてが終わってから実行します。
いまのイメージには小さな systemd ユニットがあり、cloud-init がスクリプトを書き出した直後に実行します。スクリプトは、先に削除したファイルをファイルディスクリプター経由で読むので、cloud-init の最後のステージが同じものをもう一度実行することはありません。ほかに、SSH のホスト鍵を 3 種類から 1 種類に減らし(全体が待たされる経路で 2.0 秒。このサーバーにログインする人はいません)、snapd、apport、rsyslog と十数個の保守用タイマーを起動時に動かすのをやめました。
このイメージで起動した 4 vCPU の AMD の共有サーバーでは、カーネルの開始から 8〜14 秒でジョブのスクリプトが動き出します。
時計が止まった夜
ホストの 1 台は、P コアと E コアが混ざった CPU(Core Ultra 7 265F)のデスクトップ PC です。夜中にイメージのビルドを回していたとき、カーネルがこう書き残しました。
tsc: Marking TSC unstable due to KVM discovered backwards TSC
clocksource: Switched to clocksource acpi_pm
KVM は、vCPU が別のホスト CPU に移るときに TSC を比べます。種類の違うコアに移った先で前より小さい値を読み、TSC を unstable と判定しました。HPET は起動時にカーネルが無効にしていたので("HPET dysfunctional in PC10")、残ったクロックソースは acpi_pm だけ。それがこのマザーボードではほとんど進まず、30 分で uptime が 1 秒ほどしか進みませんでした。sleep は返らない。sync は固まる。新しい SSH ログインは拒否される。ping だけは返ってくる。マシンの前にいる人が再起動するしかありませんでした。
効かなかったもの: tsc=reliable は clocksource の watchdog を止めるだけで、KVM のこのチェックを切るスイッチはありません。
効いたものは 2 段階です。
- まともに動くフォールバックを用意する。 BIOS でパッケージ C ステートの上限を C8 にすると、HPET が生き残ります。それでも KVM は TSC を落としました。負荷をかけながら 8 vCPU の VM の起動と停止を 20 回くり返すループの、11 回目です。ただし今度は時計が HPET に移り、時間は進み続けました。
- vCPU をピン留めする。 KVM がこのチェックをするのは、vCPU が移動したときだけです。vCPU を 1 つずつホストの CPU に固定して同じループを回すと、40 回、TSC のまま、ログに新しい行は出ませんでした。
ホストのデーモンは、クロックソースが tsc で、かつ hpet が使える状態でなければジョブを受けません。TSC を落としたホストは自分で受け付けを止め、ジョブはほかのマシンに回ります。Skipbox のホストは、どれも vCPU をピン留めしています。
いちばん速い起動は、起動しないこと
7 秒は、新しい VM が要るジョブの話です。書き込み権限のない人のコード(フォークからのプルリクエストなど)、専用のマシンが使用中のときに来たジョブ、そして毎回まっさらな VM を選んだアカウントのジョブ。それ以外のジョブは、何も起動しません。前のジョブの Docker のレイヤー、パッケージのキャッシュ、ツールがディスクに残った、アカウント専用のマシンで動きます。
分単位で効くのは、こちらです。同じワークフローを、GitHub の ubuntu-latest(2 vCPU。プライベートリポジトリに割り当てられるランナー)と、使い続けている Skipbox S(4 vCPU)で動かした中央値です。
GitHub ubuntu-latest | Skipbox S | ||
|---|---|---|---|
| excalidraw のインストール、ビルド、テスト | 613 秒 | 450 秒 | 27% 短縮 |
| セットアップ(チェックアウト、Node、キャッシュの復元) | 52 秒 | 7 秒 | |
| テスト | 484 秒 | 354 秒 | |
| umami のマルチステージの Docker ビルド | 263 秒 | 188 秒 | 29% 短縮 |
セットアップが 52 秒から 7 秒になるのは、ダウンロードして展開するキャッシュのアーカイブがないからです。Node はツールキャッシュに、yarn のキャッシュはディスクにあります。Docker のビルドは、デーモン自身のレイヤーキャッシュをそのまま使い、書き出しも読み込みもしません。Skipbox 側のワークフローには、キャッシュのステップが 1 つもありません。
定額だから、マシンを起動したままにできる
使われていないマシンは無駄に見えます。ところがコンピューティングの課金の仕組みでは、そのほうが安くつきます。定額が成り立つのはそのためです。
借りているクラウドのサーバーは、起動した 1 時間ごとに、1 時間分がまるごと課金されます。3 分のジョブのためにサーバーを 1 台立てると、60 分ぶん払うことになります。自分たちの CI のある 1 日では、平均 2.5 分のジョブ 44 件が 44 台のサーバーで動き、課金は 44 サーバー時間でした。同じ 1 日を、「サーバーを、その 1 時間のうちに来る同じアカウントの次のジョブのために残す」として計算し直すと、13〜14 サーバー時間。約 70% 減ります。もう 1 つ。1 か月は 730 時間です。ジョブごとにサーバーを立てる方式で短いジョブを月に 730 件より多く動かすアカウントは、止まらないマシン 1 台より多くの時間に課金されます。
つまり、使う側にとっていちばん速いマシン(キャッシュがディスクにあり、起動するものがない)は、動かす側にとってもいちばん安いマシンです。Skipbox が自前のホストを先に使うのは、時間ごとの請求がないから。借りているサーバーは、あふれた分の受け皿です。だから Skipbox の料金は、分ではなくマシンにつきます。
| 月の実行時間(4 vCPU) | GitHub | Skipbox S | 毎月の差額 |
|---|---|---|---|
| 10,000分 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 | 6,000 円 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 |
| 20,000分 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 | 6,000 円 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 |
| 30,000分 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 | 6,000 円 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 |
| 常時稼働 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 | 6,000 円 | 参考値:2026年11月10日の為替レート・1ドル=158.22円。米ドルの料金を、このレートで円に換算した概算です。 |
フル稼働なら GitHub の 1/12 の費用です。GitHub の額は、GitHub Team に含まれる無料分を引いたあとの請求です。自分のチームの分数で計算するなら、トップページの料金計算に入れてください。ログインは要りません。
移行は 1行
jobs:
build:
runs-on: skipbox # ubuntu-latest から書き換える
- GitHub で Skipbox にログインする
- Skipbox の GitHub App をインストールして、リポジトリを選ぶ
runs-onを書き換えて push する
試す
カード不要で、7日間かジョブの合計 3,000分まで無料です。いちばん遅いワークフローを runs-on: skipbox に替えて 2回動かし、2回目のステップの時間を比べてください。
よくある質問
Skipbox で、ジョブ用の新しい VM の起動にはどれくらいかかる?
Skipbox のホストでは、ホストがジョブを受け付けてからランナーのプロセスが動き出すまで 7.25 秒です。以前は 22.9 秒でした。そもそも多くのジョブは何も起動しません。ジョブのあとも残る、アカウント専用のマシンで動くからです。
CI のジョブ用 VM を、UEFI のセキュアブートではなく SeaBIOS で起動して安全?
ジョブ用 VM なら安全です。セキュアブートが防ぐのはゲストのブートローダーやカーネルの差し替えですが、CI のジョブはもともと自分の VM の中で root を持ち、VM はジョブが終われば消えます。Skipbox では、VM どうしを隔てるのは KVM とホスト側の VM ごとのファイアウォールで、どちらもゲストのファームウェアに頼りません。
ZFS の上の VM で fsync が遅いのはなぜ? CI ではどう直す?
ゲストの fsync が毎回、ホストの ZFS がインテントログをコミットするのを待つからです。ジョブごとに作る VM のディスクはジョブと一緒に消えるので、Skipbox は ext4 をバリアなしでマウントします。yarn install は 23%、docker pull は 19% 速くなりました。ジョブのあとも残すマシンでは、バリアは有効のままです。
P コアと E コアが混ざった CPU で、KVM が TSC を unstable にするのはなぜ?
KVM は vCPU が別のホスト CPU に移るときに TSC を比べ、種類の違うコアの間では前より小さい値を読むことがあるからです。vCPU を 1 つずつホストの CPU に固定すれば、このチェックは走りません。Skipbox の起動と停止のループは、固定したあと 40 回、TSC のまま回りました。
自分のジョブを Skipbox で動かすには?
ワークフローで、runs-on を skipbox に書き換えるだけです。カード不要で、7日間かジョブの合計 3,000分まで無料で試せます。
2026年11月12日 更新