遅いパイプライン、怖いデプロイ——誰もが抱える悩み
以前、5人チームのWebアプリプロジェクトに関わっていたことがある。GitLab CI/CDのパイプラインが毎回のプッシュで18〜22分かかっていた。誰もマージもデプロイも嫌がっていた——長時間待った挙句、最後のステップで失敗するのが怖かったからだ。フィーチャーブランチは1週間以上生き続け、マージコンフリクトが積み重なり、スプリントのたびにリリースが悪夢と化していた。
問題はGitLabにあったのではない。パイプラインの設定方法にあった。.gitlab-ci.ymlをリファクタリングして適切な戦略を適用した結果、パイプラインは6〜7分に短縮された。チームはスプリント末にまとめてマージするのではなく、毎日マージするようになった。この記事では、実際にやったことを共有する。
GitLab CI/CDの仕組みを正しく理解する
間違った箇所を最適化すると、何もしないよりかえって悪化する。設定に手を入れる前に、GitLab CI/CDの実行モデルをしっかり把握しておこう。
Stage・Job・Pipeline
GitLabはパイプラインをステージ単位で実行する。同じステージのジョブは並列に動き、ステージ同士は順次実行される。これを覚えておくことが、ジョブの分割と実行順序を決める上での基本になる。
stages:
- build
- test
- deploy
build-image:
stage: build
script:
- docker build -t myapp:$CI_COMMIT_SHA .
test-unit:
stage: test
script:
- pytest tests/unit/
test-integration:
stage: test # test-unitと並列で実行される
script:
- pytest tests/integration/
DAG — 有向非巡回グラフ
GitLab 12.2でneeds:が追加され、DAGパイプラインが構築できるようになった。ステージ全体の完了を待つのではなく、依存関係が解決された時点でジョブをすぐに実行できる——ステージのバリアを完全に無視できる。GitHub ActionsでもCI/CD自動化の考え方は共通しており、ゼロから始めるGitHub Actions CI/CDガイドも参考になる。
stages:
- build
- test
- deploy
build-backend:
stage: build
script: make build-backend
build-frontend:
stage: build
script: make build-frontend
test-backend:
stage: test
needs: [build-backend] # build-frontendの完了を待たなくてよい
script: make test-backend
test-frontend:
stage: test
needs: [build-frontend] # build-frontendが終わり次第すぐに実行
script: make test-frontend
deploy-staging:
stage: deploy
needs: [test-backend, test-frontend]
script: make deploy-staging
この構成により、test-backendとtest-frontendはお互いを待たずに済む——すぐに数分の節約になる。
パイプライン最適化の実践:AからZまで
1. スマートなキャッシュ——毎回依存関係を再インストールしない
パイプラインが遅くなる最大の原因:依存関係のキャッシュがないこと。ジョブが実行されるたびに、ランナーがnode_modulesやPythonパッケージを一から取得してくる——前日にすでにインストール済みのものに3〜5分を費やすだけだ。
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
key:
files:
- requirements.txt # このファイルが変わるとキャッシュキーも変わる
paths:
- .cache/pip
- venv/
test:
stage: test
before_script:
- python -m venv venv
- source venv/bin/activate
- pip install -r requirements.txt
script:
- pytest
Node.jsの場合はpackage-lock.jsonをキャッシュキーに使う:
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
2. パラレルマトリックス——複数環境でテストを並列実行
各Pythonバージョンを順番にテストする代わりに、parallel:matrixを使おう:
test:
stage: test
image: python:$PYTHON_VERSION
parallel:
matrix:
- PYTHON_VERSION: ["3.10", "3.11", "3.12"]
script:
- pip install -r requirements.txt
- pytest --tb=short
3つのジョブが順次ではなく並列で実行される——合計時間が1ジョブ分の時間まで短縮される。
3. only/exceptではなくrulesを使う——ジョブの実行条件を正確に制御
only/exceptはすでに非推奨だ。より明確なロジックのためにrules:を使おう:
deploy-production:
stage: deploy
script:
- make deploy-prod
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # mainブランチでのみ実行
when: manual # 手動でボタンを押す必要がある
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never # MRパイプラインでは実行しない
4. アーティファクト——ステージ間でファイルを受け渡す
一度ビルドして、複数の場所で使い回す。各ステージでイメージやバイナリを再ビルドするのはやめよう:
build:
stage: build
script:
- make build
- echo $CI_COMMIT_SHA > build_info.txt
artifacts:
paths:
- dist/
- build_info.txt
expire_in: 1 hour # 1時間後に自動削除してストレージを節約
deploy:
stage: deploy
needs:
- job: build
artifacts: true # buildジョブのアーティファクトをダウンロード
script:
- cat build_info.txt
- rsync -av dist/ user@server:/var/www/app/
継続的デプロイの実践的戦略
Blue-Greenデプロイ
考え方:本番環境を2つ維持する(Blueが稼働中、Greenに新バージョンをデプロイ)。Greenが安定したらトラフィックをGreenに切り替える——ダウンタイムゼロ。Webhookで自動デプロイを実現する方法と組み合わせると、手動SSH作業を完全になくせる。
deploy-green:
stage: deploy
script:
- docker pull myapp:$CI_COMMIT_SHA
- docker stop myapp-green || true
- docker run -d --name myapp-green -p 8081:8080 myapp:$CI_COMMIT_SHA
- sleep 10
- curl -f http://localhost:8081/health || (docker stop myapp-green && exit 1)
environment:
name: production-green
switch-traffic:
stage: switch
needs: [deploy-green]
when: manual
script:
- nginx -s reload # またはロードバランサーの設定を更新
- docker stop myapp-blue || true
- docker rename myapp-green myapp-blue
environment:
name: production
Canaryデプロイ
Canaryデプロイでは、新バージョンに一度に100%のトラフィックを流さない。最初はユーザーの5〜10%だけが新バージョンを受け取る——数時間、エラーレートとレイテンシを監視する。メトリクスが問題なければ全体にロールアウトする。Dockerイメージを軽量に保つことでデプロイ速度がさらに上がるため、Dockerイメージサイズの最適化も合わせて実践したい。
deploy-canary:
stage: deploy
script:
- kubectl set image deployment/myapp-canary app=myapp:$CI_COMMIT_SHA
- kubectl scale deployment/myapp-canary --replicas=1 # 1/10 pods = トラフィックの10%
environment:
name: production/canary
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
promotion-canary:
stage: promote
needs: [deploy-canary]
when: manual
script:
# メトリクスを確認後、全体にロールアウト
- kubectl set image deployment/myapp app=myapp:$CI_COMMIT_SHA
- kubectl scale deployment/myapp-canary --replicas=0
environment:
name: production
環境変数とシークレットの正しい管理
.gitlab-ci.ymlに認証情報をハードコードするのは絶対に避けよう。GitLab CI/CD変数(Settings → CI/CD → Variables)を使うこと。APIキーの管理ミスが本番障害を引き起こした事例についてはAIサービスのAPIキーセキュリティ:真夜中のプロダクション障害から学んだ教訓が参考になる:
deploy:
script:
- echo "$DEPLOY_KEY" > /tmp/deploy_key
- chmod 600 /tmp/deploy_key
- ssh -i /tmp/deploy_key user@$PROD_SERVER "cd /app && ./deploy.sh"
- rm /tmp/deploy_key
機密性の高い認証情報には:Protected(プロテクトブランチでのみアクセス可能)とMasked(ログから非表示)を有効にしよう。この2つのチェックは5秒で設定できるが、多くのトラブルを未然に防いでくれる。
Pipeline as Code——大規模プロジェクトのCIファイル分割
.gitlab-ci.ymlが300行を超えて肥大化してきたら、include:を使って複数ファイルに分割しよう:
# .gitlab-ci.yml
include:
- local: .gitlab/ci/build.yml
- local: .gitlab/ci/test.yml
- local: .gitlab/ci/deploy.yml
- project: 'myorg/ci-templates' # 別リポジトリのテンプレートを使用
ref: main
file: '/templates/docker.yml'
stages:
- build
- test
- deploy
適用後の結果
冒頭で触れたプロジェクトに話を戻そう。効果の80%をもたらしたのは3つのことだった:needs:によるDAG、依存関係のキャッシュ、そして並列テストマトリックス。さらにフィーチャーブランチで不要なジョブを削除した結果、パイプラインは20分から6分に短縮された。チームは毎日マージするようになり、フィーチャーブランチが2日以上生き残ることはほとんどなくなった。
最も大きく変わったのは時間ではなく、デプロイに対するチームの姿勢だった。Blue-Greenによって問題があれば30秒でロールバックできるようになった。「デプロイを間違えた」代償がたった30秒の対応で済むようになると、チームは以前よりずっと積極的にリリースするようになった——2週間に1回から、週に数回へと変化した。
良いパイプラインとは複雑なパイプラインではない——速く動き、失敗が少なく、誰でも読めるパイプラインだ。まずキャッシュと並列ジョブから始めよう。チームが慣れてからBlue-GreenやCanaryを検討すればいい。
