Gitlab Runner で分散キャッシュや --docker-volumes を使ってジョブ間のキャッシュを共有する

Gitlab Runner で concurrent が 1 より大きい場合、パイプラインを 1 つの Runner で実行するよう構成したとしてもジョブ間でキャッシュが共有されないことがあります。

例えば concurrent = 4 な Runner で次のパイプラインを実行します。

image: alpine

stages:
  - stage1
  - stage2

1A:
  stage: stage1
  tags: [ore]
  cache:
    key: {}
  script:
    - echo ok

1B:
  stage: stage1
  tags: [ore]
  cache:
    key: hoge
    paths:
      - cache.txt
  script:
    - touch cache.txt
    - cat cache.txt
    - echo "$CI_JOB_NAME" > cache.txt

2A:
  stage: stage2
  tags: [ore]
  cache:
    key: hoge
    paths:
      - cache.txt
  script:
    - touch cache.txt
    - cat cache.txt
    - echo "$CI_JOB_NAME" > cache.txt

1B と 2A は stage が異なる& key: hoge でキャッシュキーを固定値にしているのでキャッシュが共有されそうですが、実際には共有されません。

concurrent = 4 の場合、Runner が 1 つしか登録されていなくても実際には下記の 4 つの Runner が存在するのと同じような状態になっているためです。

  • runner-XXXXXXXX-project-N-concurrent-0
  • runner-XXXXXXXX-project-N-concurrent-1
  • runner-XXXXXXXX-project-N-concurrent-2
  • runner-XXXXXXXX-project-N-concurrent-3

ジョブ開始時の runner-XXXXXXXX-project-N-concurrent-0 via ... のようなメッセージでどの Runner で実行されているかわかります。

キャッシュはこのそれぞれの Runner ごとに保持されます。そのため、↑のパイプラインだと次のように Runner とジョブが対応して実行されるので、

  • runner-XXXXXXXX-project-N-concurrent-0: 1A -> 2A
  • runner-XXXXXXXX-project-N-concurrent-1: 1B

1A と 2A ならキャッシュが共有されますが、1B とは共有されません。

.gitlab-ci.yml 上で 1A と 1B の位置を入れ替えたり、あるいは同じ Runner で実行されている別のジョブがあってたまたま 1B と 2A が同じ runner-XXXXXXXX-project-N-concurrent-? で実行されれば共有されることもあります。

Docker executor のキャッシュの保存先

ジョブが実行されるコンテナは --cache-dir で指定されたコンテナ内のパスにキャッシュのアーカイブを保存します。これはデフォルトでは /cache です。

ジョブの実行時、--docker-volumes で指定されたディレクトリがジョブを実行するコンテナにマウントされます。これは <host-path>:<path> の形式と <path> の形式で指定することができて、<host-path>:<path> の形式ならホストのディレクトリがコンテナに bind マウントされます。

<path> の形式の場合、--docker-cache-dir が指定されていれば、そのディレクトリの中の runner-<short-token>-project-<id>-concurrent-<job-id>/<unique-id> のようなサブディレクトリをコンテナにマウントします。--docker-cache-dir が指定されていなければ runner-<short-token>-project-<id>-concurrent-<job-id>-cache-<unique-id> のような名前のデータボリュームコンテナを作成して、そのボリュームをコンテナにマウントします。ただし --docker-disable-cache が指定されていると --docker-cache-dir が指定されていてもデータボリュームコンテナが作成され、かつ、そのデータボリュームコンテナはジョブの終了時に自動で削除されます。

<unique-id> の部分はコンテナのパス(<path>)に基づくハッシュ値です。

要約すると・・・

  • ジョブのコンテナは --cache-dir のパスにアーカイブを保存する
    • デフォルトは /cache
  • --docker-volumes が・・
    • <host-path>:<path> の形式なら・・
      • ホストのディレクトリをジョブのコンテナにマウントする
    • <path> の形式なら・・
      • --docker-disable-cache が指定されていれは・・
        • データボリュームコンテナを作成してボリュームをジョブのコンテナにマウントする
        • ジョブの終了時に自動で削除される
      • --docker-cache-dir が指定されていれば・・
        • そのディレクトリのサブディレクトリをジョブのコンテナにマウントする
      • --docker-cache-dir が未指定なら
        • データボリュームコンテナを作成してボリュームをジョブのコンテナにマウントする
    • デフォルトは /cache

デフォルトの動きは次のようになります。

  • ジョブのコンテナは /cache にアーカイブを保存する
  • ジョブの開始時にデータボリュームコンテナを作成して /cache にマウントする

Docker executor でジョブ間でキャッシュを共有

--docker-volumes でホストのディレクトリを /cache にマウントすれば runner-<short-token>-project-<id>-concurrent-<job-id>/<unique-id> のようなサブディレクトリは作成されないため(サブディレクトリが作成されるのは <path> の形式のときだけだから)、前述のような異なる Runner の concurrent になってもジョブ間でキャッシュが共有されるようになります。

gitlab-runner register \
  --non-interactive \
  --url "$url" \
  --registration-token "$token" \
  --name 'ore-no-runner' \
  --executor 'docker' \
  --run-untagged \
  --tag-list 'ore' \
  --docker-image 'alpine:latest' \
  --docker-volumes '/srv/gitlab-runner/cache:/cache'

ただ、並列に実行される複数のジョブが同じアーカイブを読み書きしようとするだろうので、壊れたアーカイブが作成されたりしてしまいそうです、たぶんこれはやめておいたほうが良いでしょう。

Docker executor のビルドディレクトリ

--docker-disable-cache--docker-cache-dir の指定はソースがチェックアウトされるディレクトリにも関係します。これらの指定に基づいてソースファイルをチェックアウトするためのデータボリュームやホストのディレクトリのマウントが行われます(cache という名のついたパラメータなのに build ディレクトリに関係するのわかりにくい・・・)。

--build-dir でコンテナのどこにマウントするかを指定できます(デフォルトは /builds)。このディレクトリに /builds/<namespace>/<project-name> のようにサブディレクトリを掘ってチェックアウトされます。

つまり、<--docker-cache-dir>/runner-<short-token>-project-<id>-concurrent-<job-id>/<unique-id>/ のようなホストのディレクトリ、または、runner-<short-token>-project-<id>-concurrent-<job-id>-cache-<unique-id> のようなデータボリュームが、ジョブのコンテナの /builds/<namespace>/<project-name> にマウントされます。

なお、/cache とは異なり、--docker-volumes でホストのディレクトリを /builds にマウントしている場合は /builds/<short-token>/<concurrent-id>/<namespace>/<project-name> のようなサブディレクトリが掘られてそこにチェックアウトされるようになります。

分散キャッシュ

--docker-volumes で変なことをやらなくても、分散キャッシュを使えばジョブ間でキャッシュを共有させられます。

S3 や Google Cloud Storage を使えば楽そうですけど minio でホストさせても OK です。

# minio のコンテナを開始
docker run --detach \
  --name minio \
  --hostname minio \
  --restart always \
  --publish 9005:9000 \
  --volume /srv/minio/root/.minio:/root/.minio \
  --volume /srv/minio/export:/export \
  minio/minio:latest server /export

# バケット用のディレクトリを作成
sudo mkdir /srv/minio/export/runner

# ホストの IP  アドレスをメモる
hostname -i

# アクセスキーとシークレットキーをメモる
docker exec minio cat /export/.minio.sys/config/config.json | grep Key

# Runner を登録
gitlab-runner register \
  --non-interactive \
  --url "$url" \
  --registration-token "$token" \
  --request-concurrency 4 \
  --name 'ore-no-runner' \
  --executor 'docker' \
  --run-untagged \
  --tag-list 'ore' \
  --docker-image 'alpine:latest' \
  --docker-cache-dir '/srv/gitlab-runner/cache' \
  --cache-type 's3' \
  # minio の IP アドレスとポート番号
  --cache-s3-server-address '192.0.2.123:9005' \
  # アクセスキーとシークレットキー
  --cache-s3-access-key 'abc...' \
  --cache-s3-secret-key 'abc...' \
  # バケット名
  --cache-s3-bucket-name 'runner' \
  --cache-s3-insecure true

Gitlab Runner も Docker で実行して minio と同じネットワークに入れれば --publish 9005:9000 などせずに --cache-s3-server-address 'minio:9000' で大丈夫かと思いきやそんなことはありません(最初そうしようとしてあれー?と思いました)。

minio にはジョブとして実行されるコンテナの中からアクセスできる必要があり、ジョブのコンテナを minio と同じネットワークにはできないので、minio で --publish 9005:9000 のようにポートを晒して --cache-s3-server-address '192.0.2.123:9005' のようにホストのアドレス・ポートを指定する必要があります。

参考