Skip to main content

GitHub Actions で Docker (OCI) Image のビルドを高速化する

イメージのビルドって時間かかりますよね。ある程度の規模のプロジェクトになると、OS レイヤへのミドルウェアのインストール、ビルドから始まり、

言語のセットアップ、プロジェクトが必要とするライブラリのインストール、Asset Precompile, etc… と、どんどん実行時間が長くなります。

イメージのビルド時間は、サービスのリリースサイクルのリードタイムに直結します。できる限り短くしたいところ。

self-hosted runner を利用してビルドを高速化できるか検証してみました。

最近 GitHub Actions でビルドすることが多いため、そちらに特化した内容です。

ビルドを速くするには

イメージのビルドが遅くなる要因として、キャッシュを活用できないことが挙げられます。

イメージをビルドする際、各コマンドで実施される変更をレイヤとして保存し、ハッシュが一致するレイヤはキャッシュが利用されます。(詳しい挙動はあまり理解はしていない。。。)

CI でイメージをビルドする際は、ローカルのレイヤキャッシュが存在しない場合が多いです。

そのため、タスクごとに新規にビルドを実行するため、遅くなります。

CI でキャッシュを効かせるためのアプローチはいくつか存在します。

例えば Google が作成し Cloud Build のバックエンドで利用されている builder である kaniko や、Uber が作成した makisu では、Registry に cache layer をプッシュ、ビルド実行時に cache を pull してくる Remote Cache という仕組みを実装しています。

程々に速くなるのですが、外部からキャッシュをダウンロードしてくる以上、ローカルキャッシュよりは遅くなります。その分ステートレスになるので運用は楽です。

以前、そのスピードで満足できずに、可用性を犠牲にして docker daemon の socket を参照してビルドする API を実装したことがあります。今考えると、k8s 上に(アクセス制限があるとはいえ)外部から docker socket にアクセスできる口を生やすという愚行でしたが、想定の速度は出ていました。

できればこの速度を実現したくてうーんと悩んでいたら、同僚(@pyama86)に「self-hosted runner をビルド用に一台だけ用意すればできそう」というアドバイスをもらったため、試してみました。

検証の前提条件

  • 大規模プロジェクトのサンプルとして Mastodon のビルドを実施します
  • 簡略化のため、イメージの Push 時間は考慮せず、ビルドの時間のみを検証します
  • ビルドの実行時間は、同条件で複数回(2~3回)実行して、10%以上の誤差がないことを確認しています

Github Actions で普通にビルドすると

以下の Workflow を記述して、実行してみます。

name: Build and Push

on:
  push:
    branches:
      - build
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    timeout-minutes: 300
    steps:
    - uses: actions/checkout@v1
    - name: Build
      run: |
        docker build -t test .

結果はこちら。15m10s かかりました。

Re-run job してみる

Re-run した job でも、ベースイメージのダウンロードから実施され、再活用されたキャッシュはありませんでした。実行結果も大きく変化はありません。

self-hosted Runner でビルドしてみる

ローカルの余っているマシンで Runner を構築し、workflow で self-hosted runner を指定します。

jobs:
  build-and-push:
    runs-on: self-hosted
    timeout-minutes: 300
    steps:
    - uses: actions/checkout@v1
    - name: Build
      run: |
        docker build -t test .

self-hosted runner のビルド時間がこちら。6m57s でした。

実行時間が速いのは、インスタンスのスペックの差だと思われます。

Re-run job してみる

全部キャッシュが効いて、1s でビルドできました。

プロジェクトを編集した場合

app/models/account.rb に適当な関数を追加したコミットを積んでビルドを回すとこんな感じでした。

COPY が走るため若干時間がかかりますが、初回ビルドより遙かに速いです。

挙動を見るに、commit hash が変更されるくらいのプッシュであっても、どうやら COPY の行は再実行されるようです。ファイルに変更ないのに。。。

このへんはレイヤのキャッシュ判定がどうなっているのか、docker build の実装を確認しないとわかりません。

デメリット

理論上可能であることはわかったのですが、この Runner を利用する場合、並列にタスクが実行できないという制限が生まれてしまいます。

プロジェクトの種類によっては、劇的にビルド速度が改善される可能性がありその差分でデメリットが吸収されそうですが、実環境上にて検証を実施し、必要であれば release build のみ利用するといったワークアラウンドを導入する必要があるかと思います。

セキュリティについての考察

概要部分で、「docker socket を外部に見せるのはセキュリティ的によくない」といった記述をしました。この方法はそれに該当するのか?という疑問ですが、それはノーです。

Actions Runner は外部からアクセスできる Attack Surface を保持していない、かつ、この方法で起動した Runner は GitHub で管理された Runner と同一のコードベースであるため、ランタイム等が適切に管理されていれば、潜在的なセキュリティリスクは存在しないと考えられます。

まとめ

「Runner を絞ってビルドする」という方法は、有効に働きそうだということが確認できました。

Remote Caching の手法を利用した builder との比較もしてみたいので、比較したら検証結果を追記します。

追記 2021/04/04

レイヤキャッシュが効くメリットはそのままに、タスクを並列できないデメリットを完全に解消する方法を検証しました。

https://www.takutakahashi.dev/actions-with-external-docker-host/