GitHub ActionsでDockerイメージをキャッシュする

2021.12.06

吉川@広島です。

案件で、「S3やDynamoDBを含む自動テストをCIで回す」を実現するために「GitHub Actions上でLocalStackのDockerコンテナを起動してテストを実行する」という方法を採りました。

localstack/localstack: 💻 A fully functional local AWS cloud stack. Develop and test your cloud & Serverless apps offline!

ただ、実際に構築したGitHub Actions workflowの実行を見ていると、

LocalStackをGitHub Actions上で動かしてテストする | DevelopersIO

上記のコードはプルリクエストへのコード修正時に毎回Docker Hubからイメージを取得しているため、時間がかかるのとDocker Hubの制限に引っかかる可能性はあるので、複数人で並行して開発する場合で問題があればGitHub Actions上でDockerイメージをキャッシュすることを検討してください。

こちらの弊社ブログ記事で言及されているように、

  • 毎回LocalStackイメージをpullする時間をなくして高速化したい
  • DockerHubのレート制限を回避したい

この2点が気になったため手を打つことにしました。すでに上記で解決策も提示されているということで、Dockerイメージをキャッシュする方法を試してみました。

なお、今回LocalStackイメージで取り上げていますが、他のDokerイメージでも同じ方法になるかと思います。

TLDR

  • actions/cacheDockerのsaveとloadの機能を使ってイメージをキャッシュする。
  • シンプルにPublic Imageをキャッシュするだけの用途だと時間短縮はあまりできない可能性が高い。
  • DockerHubのレート制限回避策としては有効と思われる。

環境

  • LocalStack 0.13.0

サンプルコードを用意

docker-compose.ymlとGitHub Actions workflowファイル

まず次のようなdocker-composeファイルを用意します。

# docker-compose.yml

version: '3'
services:
  localstack:
    image: localstack/localstack:0.13.0
    environment:
      SERVICES: s3,dynamodb
    ports:
      - 4566:4566

また、GitHub Actionsのworkflowファイルを用意します。

# .github/workflows/build.yml

name: Build

on: push

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - run: docker compose up -d

docker compose up -dでLocalStackコンテナを立ち上げるだけのシンプルな内容です。

実行結果

全体で46秒でした。その大半を占める42秒が docker-compose up -d で、さらにこのコマンドの実行時間の大半がLocalStackイメージのpull、つまりダウンロードです。

そのため、Dockerイメージをキャッシュすることができればworkflow全体の実行時間を大幅にカットできるのではないかと考えたわけです(当初は)。

キャッシュの仕組みを導入

actions/cacheを導入

actions/cache: Cache dependencies and build outputs in GitHub Actions

actions/cacheを使うことでGitHub Actions上にファイルをキャッシュすることができるので、

  • docker saveコマンドでDockerイメージをファイルに書き出す
    • 使用する際はdocker loadコマンドで読み込むことができる
  • 初回で書き出したファイルをキャッシュし、次回以降は再利用する

こちらの方針でworkflowファイルに追記を行います。

# .github/workflows/build.yml

name: Build

on: push

+env:
+ LOCALSTACK_CACHE_PATH: localstack-image
+ LOCALSTACK_VERSION: 0.13.0

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

+     - name: Cache a LocalStack Docker image
+       id: cache-localstack
+       uses: actions/cache@v2
+       with:
+         path: ${{ env.LOCALSTACK_CACHE_PATH }}
+         key: ${{ runner.os }}-localstack-${{ env.LOCALSTACK_VERSION }}

+     - name: Pull and save a LocalStack Docker image
+       if: steps.cache-localstack.outputs.cache-hit != 'true'
+       run: |
+         docker pull localstack/localstack:${LOCALSTACK_VERSION}
+         docker save localstack/localstack:${LOCALSTACK_VERSION} -o ${LOCALSTACK_CACHE_PATH}

+     - run: docker load -i ${LOCALSTACK_CACHE_PATH}

      - run: docker compose up -d

GitHub Actionsではifが使えるので、これを活用するのがポイントとなります。actions/cacheの仕様で、 steps.${id}.outputs.cache-hit を見ることで「すでにキャッシュが存在するかどうか」を確認することができます。そのため、 steps.cache-localstack.outputs.cache-hit != 'true' つまり「まだキャッシュされていない」場合のみDockerイメージのpullとsaveを行うようにしています。

実行結果

予めworkflowを実行し、すでにキャッシュがある状態にしておきました。この状態でさらに実行すると、

Pull and save a LocalStack Docker image がスキップされているので、狙い通りキャッシュが効いています。

ただ、全体実行時間が54秒と短縮どころかやや遅くなってしまいました。

docker compose up -d は1秒になり、pullがなくなっている恩恵が出ているものの、

  • actions/cacheのrestore: 11秒
  • docker load: 37秒

の時間が代わりに必要となり、結果的に時短効果はあまり得られなかったことになります。特にdocker loadに時間がかかるのは予想外でした。

では、Dockerイメージをキャッシュすることに意味がないのかというと否で、例えば対象のイメージサイズが非常に大きい場合はpullの時間カットの恩恵が勝ってくるかもしれません。GitHub Actions上で自作のDockerfileをビルドするようなケースも時短効果が大きくなると思われます。

また、DockerHubのレート制限に対しては効果があるでしょう。

ただし、逆に絶対キャッシュするべきかというとそれも否です。

  • (今回のように)実行時間短縮の結果が得られない
  • DockerHubのレート制限を気にする必要がない
    • CIの実行回数が少ない、Dockerに課金して制限緩和しているなど

上記に当てはまる場合は、CIの構成が複雑になるデメリットの方が大きいため無理に導入しない方が良いと思います。本記事でキャッシュ方法の話をしたものの、個人的にはキャッシュをせずに済むなら避けたいと思っていて、できるだけ仕組みをシンプルに寄せることで将来に渡り保守しやすくなるメリットを享受できると考えています。

参考