Tech Racho エンジニアの「?」を「!」に。
  • インフラ

GitLab CI: Docker のレイヤーキャッシュを利用して gem のキャッシュを取る

GitLab CIでDockerを利用して環境を用意する場合に、レイヤーキャッシュとキャッシュマウントを活用してライブラリセットアップの時間を短縮する方法を解説します。

この記事の前提環境

以下の環境を前提としています。

  • GitLab Runner: Shell Executor。自社のホストマシン上で稼働
  • 環境構築: Docker, Docker Compose。BuildKitが有効
  • プログラミング言語・フレームワーク: Ruby, Rails

また、GitLab Runnerが動いている自社ホストマシン上ではDocker Daemonが稼働しています。

※ GitLab Runnerが招くセキュリティリスク評価については割愛しますが、懸念のある方は公式ドキュメントなど参照の上で構成のご検討をお願いいたします。
参考: Security for self-managed runners | GitLab Docs

設定例

# .gitlab-ci.yml
stages:
  - setup
  - lint
  - test

variables:
  RAILS_ENV: "test"

setup:
  stage: setup
  script:
    - docker compose -f compose.ci.yml build rails

rubocop:
  stage: lint
  script:
    - docker compose -f compose.ci.yml run --rm rails bundle exec rubocop

rspec:
  stage: test
  script:
    - docker compose -f compose.ci.yml up -d --wait db
    - docker compose -f compose.ci.yml run --rm rails bundle exec rails db:prepare
    - docker compose -f compose.ci.yml run --rm rails bundle exec rspec
# compose.ci.yml
services:
  rails:
    build:
      context: .
      dockerfile: Dockerfile.ci

  db:
    image: postgres:16
    platform: linux/amd64
    environment:
      POSTGRES_PASSWORD: postgres
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 2s
      timeout: 3s
      retries: 20
      start_period: 5s
  # ...
# Dockerfile.ci

# syntax=docker/dockerfile:1

FROM ruby:3.4-slim

# ...

COPY Gemfile Gemfile.lock ./
RUN --mount=type=cache,target=/usr/local/bundle/cache \
    bundle install --jobs 4 --retry 3

COPY . .

EXPOSE 3000
CMD ["/bin/bash"]

レイヤーキャッシュ

Dockerイメージはレイヤーの積み重ねで構成されています。Dockerfileの各命令(FROMRUNCOPYなど)が1つのレイヤーを生成し、それらが順番に積み重なって最終的なイメージになります。

# Dockerfile
# レイヤー構成例
FROM ruby:3.4-slim              # レイヤー1: ベースイメージ
RUN apt-get update              # レイヤー2: パッケージリスト更新
COPY Gemfile Gemfile.lock ./    # レイヤー3: Gemfileをコピー
RUN bundle install              # レイヤー4: gemインストール
COPY . .                        # レイヤー5: アプリケーションコードをコピー

Dockerは一度ビルドしたレイヤーをキャッシュとして保存し、次回のビルド時に活用しています。
今回のケースではジョブ終了後もDocker Daemonが起動しているため、パイプラインを跨いでレイヤーキャッシュが利用可能です。
Gemfile/Gemfile.lock が未変更であれば同じ「gemインストール」レイヤーを利用するため、二回目以降のbundle installを実行せずにスキップできます。

キャッシュマウント

キャッシュマウントはイメージビルド中に特定のディレクトリを永続的にキャッシュする仕組みです。
Dockerのプラクティスとしてよく紹介される機能ですね。

参考: Use cache mounts -- Optimize cache usage in builds | Docker Docs

設定例では /usr/local/bundle/cache の指定によってgemパッケージファイルをキャッシュしています。
Gemfile/Gemfile.lock に更新があった場合でも、全てのgemを再ダウンロードせず、変更されたgemのみを効率的にダウンロードできます。

他のキャッシュ方法との比較

レイヤーキャッシュとキャッシュマウントの利用以外にもライブラリキャッシュを取る方法はいくつか存在します。
この記事の前提環境に適しているか考えてみましょう。

GitLab CIのキャッシュ機能

GitLab CIには標準のキャッシュ機能があります。

# .gitlab-ci.yml
cache:
  key:
    files:
      - Gemfile.lock
  paths:
    - vendor/bundle

しかしDockerでの環境構築を前提とするとこの方法には制限が生じます。

まず、CI実行時にはホストのワーキングディレクトリ内ファイルがクリーンアップされます(デフォルト設定による挙動、変更は可能)。

参考: Git clean flags -- Configuring runners | GitLab Docs

また、Dockerコンテナ内でgemをインストールする際、ホストのワーキングディレクトリをマウントして使うセットアップでは、gemのインストール先(例:vendor/bundle)がワーキングディレクトリ内になります。
そのため、インストールしたgemはCIが回るたびにクリーンアップされ、パイプラインを跨いでキャッシュが生存できません。
今回のケースではこの方法は選択しませんでした。

Dockerボリューム

Dockerボリュームを使ってgemを保存することも可能です。

# compose.ci.yml
services:
  rails:
    volumes:
      - bundle:/usr/local/bundle

この方法であればパイプラインを跨いでダウンロード済みのgemが生存し、実装がシンプルで済みます。キャッシュ方法として採用圏内になるでしょう。

なお、やや横道の話になりますが、私がキャッシュについて検討したときはこの方法を選択しませんでした。

技術説明とは関係ない試行錯誤の背景(クリックで展開)

方法選択の背景として、複数の開発者が並行してMR作成したときに、異なるCIジョブが同じDB(Dockerコンテナ)にアクセスすることを防ぎたかった、という事情があります。
そのためにはDockerコンテナ名やイメージ名の衝突を回避する必要があり、具体的手段として環境変数 COMPOSE_PROJECT_NAME をコミットごとに一意にしようとしていました。
しかし、この運用ではDockerボリューム名もジョブごとに変わってしまい、ライブラリキャッシュが効かなくなります。
一方、レイヤーキャッシュ戦略を取れば別名のイメージに対してもキャッシュを再利用できます。COMPOSE_PROJECT_NAME が変更されても影響を受けない点で好都合でした。

記事執筆に際して当時の選択を振り返りましたが、この方法だとコミットのたびに別名のDockerイメージが作成され続けて自社サーバーのストレージを圧迫する、という問題が発生してしまいます。
本題ではないため深く検討していませんが、今取り組むのであれば COMPOSE_PROJECT_NAME を変更せず、代わりにイメージのタグ名を一意にするような対応が取れないか考えるでしょう。


この記事が、GitLab CIでのDocker環境の最適化に役立てば幸いです。


BPSアドベントカレンダー2025


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。