【GitLab CI】LaravelのCI/CD実行時間短縮
概要
業務でLaravelアプリケーションのCI/CD実行時間短縮を行なったため、調査内容などや対応についてまとめる。
構成
Laravelアプリケーションがユーザーからのリクエストを受け付けDBへレコードを追加し、後続のMicroserviceが取得し処理を行うような構成(詳細は割愛)。
DBはlaravelとは別リポジトリでマイグレーション管理されている。git submoduleを使用して変更を取り込み、docker composeでpostgresコンテナへマイグレーションを行い開発を行う。
レジストリにはDockerHubを使用。
services:
php:
(略)
postgres:
image: postgres
(略)
migration:
depends_on:
- postgres
従来のパイプライン
従来のパイプラインは以下の順に実行されていた。
.setup_db:
stage: setup
variables:
POSTGRES_PORT: 5432
POSTGRES_USER: "root"
POSTGRES_PASSWORD: "password"
DBNAME: "dbname"
before_script:
- |
docker run \
-e POSTGRES_ADDRESS="${POSTGRES_ADDRESS}" \
-e POSTGRES_PORT \
-e POSTGRES_USERNAME="${POSTGRES_USER}" \
-e POSTGRES_PASSWORD \
-e POSTGRES_DBNAME="${DBNAME}" \
example/migration:latest
test:
stage: test
services:
- docker:24.0.5-dind
- postgres:15.2-alpine3.17
extends: .setup_db
script:
- docker build --target development -t test -f docker/php/Dockerfile .
- |
docker run -t -v $(pwd):/workspace test sh -ec " \
composer install && \
./vendor/bin/phpstan analyse --memory-limit=1G && \
php artisan test --parallel --processes=8"
build-and-push:
image: docker:latest
stage: build
variables:
DOCKER_TLS_CERTDIR: "/certs"
services:
- docker:dind
before_script:
- docker login -u "${CI_REGISTRY_USER}" -p "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
script:
- docker build -f docker/php/Dockerfile --progress=plain -t exmple/api:latest .
- docker push exmple/api:latest
各ジョブについて
.setup_db
別リポジトリからマイグレーションのイメージを取得し実行するテンプレート。後続のテストジョブ内servicesで定義したpostgresコンテナへマイグレーションを実行。
test
Dockerfileを使用しイメージをビルド後、docker runでテスト実行。
build-and-push
Dockerfileを使用しイメージをビルド後、DockerHubへプッシュ。
問題点
パイプラインの実行時間が長い。通常の開発時はテスト前のビルドのみ実行していたため8分ほど、リリース時にDockerHubへのbuild&pushを行うと14分ほどかかる。理由を以下に整理。
vendor/がキャッシュできない
default:
image: php:latest
cache: # Cache libraries in between jobs
key: $CI_COMMIT_REF_SLUG
paths:
- vendor/
before_script:
# Install and run Composer
- curl --show-error --silent "https://getcomposer.org/installer" | php
- php composer.phar install
test:
script:
- vendor/bin/phpunit --configuration phpunit.xml --coverage-text --colors=never
公式マニュアルではジョブのimageにPHPイメージを指定し、cacheによってvendorディレクトリをキャッシュしている。
しかし、現状構成のdocker runでcomposer installを実行するとcacheは利用できない。そのためパイプラインが実行されるたびにcomposer installが実行され時間がかかる。
ビルドを2回実行
テストとDockerhubへのbuild&pushで2回ビルドを行っている。その上キャッシュも利用していないため時間がかかる。
今回の例では一度のビルドで5分近くかかっていたため、ビルドだけで10分要していた。
以前はテスト時のビルドを行わず、ジョブのimage:にPHPを指定していた。しかし、テスト環境構築のためにPHPコンテナ内で「Dockerfileに記載のパッケージとほぼ同様のパッケージ」をインストールする必要がある。パッケージ追加or削除時の際にDockerfileと.gitlab-ci.ymlを編集しなければならず面倒だったため、現在の形となった。
対応結果
上記の問題点を改善するために以下のように修正決定。パイプラインは以下の流れ。
stages:
- setup
- build
- test
- push
# DBマイグレーションを実行し結果をダンプ、後続のtestジョブへartifactとして渡す
db_setup:
stage: setup
image: docker:latest
services:
- name: postgres:15.2-alpine3.17
alias: postgres
- name: docker:dind
alias: dind
script:
- |
docker run --network=host --rm \
-e POSTGRES_ADDRESS="postgres" \
-e POSTGRES_PORT="${POSTGRES_PORT}" \
-e POSTGRES_USERNAME="${POSTGRES_USER}" \
-e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \
-e POSTGRES_DBNAME="${DBNAME}" \
example/migration:latest
# pg_dumpでダンプするためパッケージを追加
- apk add --no-cache postgresql-client
# マイグレーション内容をダンプ
- PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump -h postgres -U "${POSTGRES_USER}" -d "${DBNAME}" > test_db.sql
artifacts:
# ダンプファイルを1時間保持
paths:
- test_db.sql
expire_in: 1 hour
build:
image: docker:latest
services:
- name: docker:dind
alias: dind
variables:
DOCKER_FILE: ".infrastructure/Dockerfile"
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "${CI_REGISTRY_USER}" -p "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
script:
- echo exmple/api:latest
# キャッシュとして利用するイメージをpull、失敗しても処理を続ける
- docker pull exmple/api:latest || true
- docker buildx build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from exmple/api:latest -f "${DOCKER_FILE}" -t exmple/api:"${CI_COMMIT_SHORT_SHA}" .
- docker push exmple/api:"${CI_COMMIT_SHORT_SHA}"
test:
stage: test
image: "exmple/api:$CI_COMMIT_SHORT_SHA"
services:
- name: postgres:15.2-alpine3.17
alias: postgres
dependencies:
- migration
script:
# DB作成
- PGPASSWORD="${POSTGRES_PASSWORD}" psql -h postgres -U "${POSTGRES_USER}" -c "CREATE DATABASE ${DBNAME};"
# migrationジョブでダンプしたマイグレーションを適用
- PGPASSWORD="${POSTGRES_PASSWORD}" psql -h postgres -U "${POSTGRES_USER}" -d "${DBNAME}" < test_job.sql
# CIのテスト実行時のみDBの接続設定を上書き
- sed -i "/^POSTGRES_ADDRESS=/c\POSTGRES_ADDRESS=${POSTGRES_ADDRESS}" .env.testing
# 依存関係のインストール
- composer install
# 静的解析
- ./vendor/bin/phpstan analyse --memory-limit=1G
# 単体テスト、APIテスト、OpenAPIテスト実行
- php artisan test --parallel --processes=8
cache:
key:
# composer.lockに変更がない場合は同一のキャッシュが利用される
files:
- composer.lock
paths:
- vendor/
tags:
- docker
push:
image: docker:latest
services:
- name: docker:dind
alias: dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "${CI_REGISTRY_USER}" -p "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
script:
# テスト前にbuildしたイメージをpull
- docker pull exmple/api:"${CI_COMMIT_SHORT_SHA}"
# タグ付けしてpush
- docker tag exmple/api:"${CI_COMMIT_SHORT_SHA}" exmple/api:latest
- docker push exmple/api:latest
build&pushジョブを分割
build->test->pushに分割する。
最初のbuildではCI_COMMIT_SHORT_SHAを使用しハッシュをタグにつけてプッシュ。testジョブのimageにプッシュしたイメージを指定、テストが通った場合はイメージをプルし、latest等のタグを付け再度プッシュ。
このように分割することでイメージビルドを一度で済ませる。
testジョブのイメージ変更
dockerイメージを使用しdocker runで実行していたテストについて、上記のbuildジョブでプッシュしたイメージを使用する形へ変更。
composer installを実行後のvendor/をキャッシュすることが可能。
効果
CIの実行時間の大幅な短縮につながった。特にリリース作業時のパイプライン実行時間が10分ほど短縮された。
検討・調査内容
servicesはジョブ間で共有不可
当初は以下のようにservicesをジョブ間で共有できないかと思ったが不可能。
db_setup:
stage: setup
image: docker:latest
services:
- name: postgres:15.2-alpine3.17
alias: postgres
(略)
test:
stage: test
image: "exmple/api:$CI_COMMIT_SHORT_SHA"
services:
- name: postgres:15.2-alpine3.17
alias: postgres
db_setupジョブをテンプレート化しtestジョブで使用することも考えたが、その場合はtestジョブでdockerイメージを使用しなければならない。それでは既存と変わらず、vendor/がキャッシュできない。
そのため、
- db_setupジョブでpostgresをserviceとして使用しマイグレーションを実行
- ダンプデータをartifact経由でtestで渡す
- testジョブで新たなpostgresをservicesとして使用、ダンプを適用
という形をとった。
FF_NETWORK_PER_BUILDとnetwork=host
servicesのpostgresへdocker runで実行したコンテナから接続する際に設定が必要。network=hostも必要。いずれもマニュアル参照。
access-service:
stage: build
image: docker:20.10.16
services:
- docker:dind # necessary for docker run
- tutum/wordpress:latest
variables:
FF_NETWORK_PER_BUILD: "true" # activate container-to-container networking
script: |
docker run --rm --name curl \
--volume "$(pwd)":"$(pwd)" \
--workdir "$(pwd)" \
--network=host \
curlimages/curl:7.74.0 curl "http://tutum-wordpress"
プライベートDockerHubレジストリのイメージpull
DOCKER_AUTH_CONFIGをCI/CD variablesに登録する必要がある。
パイプラインで使用する認証情報でdocker login。($CI_REGISTRY_PASSWORDなど)
~/.docker/config.jsonの中身を取得し登録。
buildジョブで使用するキャッシュイメージのタグ
latestを指定しキャッシュに使用。
$CI_COMMIT_REF_SLUGを使用しブランチ毎の最新イメージをキャッシュして使用するか迷ったが、releaseやfeatureの一発目はキャッシュが効かないので見送り。
main、release、feature関係なくlatest。そもそもDockerfileへの変更はそこまで頻繁に発生しないため。
その他調査
Docker-in-Docker
image: docker:latest
のように、イメージの指定だけではdockerコマンドを使用できない。dindサービスも含める必要がある。
まとめ
LaravelのパイプラインをGitLab CIで構築する記事が少なすぎる。公式も古いし。
あと、本当はbuildジョブでビルドしたイメージをcacheとかartifactとかで渡したい。パイプラインの途中成果物をレジストリにあげたくない。パイプライン内で完結したい。
Discussion
ユースケースが一致するかわかりませんが、Docker イメージの pull や push を省略可したい場合は、GitLab Runner を(クラウドサービスではなく)ローカルマシンで用意して利用すると良いかもしれません。
自分で用意した GitLab Runner の設定ファイルである config.toml で次の指定をすると、プルの指定に if-not-present が可能となり、GitLab Runner がキャッシュしたイメージが使われるようになります。