CI/CDパイプラインとは、変更がgit pushから本番に届くまでの自動化された道筋です。継続的インテグレーション(CI)はすべてのpushをクリーンなマシンでビルドしてテストし、ミスを数分のうちに表に出します。継続的デリバリーまたはデプロイメント(CD)は、通ったものを繰り返せる一つの手順でリリースします。人がボタンを押すならデリバリー、誰も押さないならデプロイメントです。GitHub Actionsでは、パイプラインは.github/workflows/の下に置くYAMLファイルで、いつ何を実行するかを書きます。私のパイプラインはわざと薄くしています。チェックは手元のノートPCで打つのと同じコマンドを、Docker Composeファイルが立ち上げるのと同じPostgresとValkeyのイメージに対して実行するだけで、デプロイはどれもGitHubがなくても同じように動くスクリプトかCLIです。
2024年にCI/CDのロジックはプラットフォーム固有のYAMLではなくDocker Composeにという記事を書きました。ビルドのロジックはComposeに置き、CIツールにはそれを呼ぶことだけをさせる、という内容です。2年たって、実際にリリースに使っているリポジトリを全部見直しました。ワークフローがあるのは4つで、コミッターはどれも私一人です。そして、そのワークフローのうちdocker composeを呼んでいるものは、もう一つもありません。原則は残り、やり方は残りませんでした。以下はその代わりに入ったものです。ファイルを削って例にしたもの、GitHubが記録したステップごとの所要時間、そしてCI/CDの図が期待するほどには自動化されていない部分も書いています。
動かしているパイプラインを一つの表に#
| パイプライン | 起点 | CIが確認すること | 行き先 | リリースする人 |
|---|---|---|---|---|
| このブログとクライアントのHugoサイト | すべてのpush、ブランチを問わず | hugo --gcがビルドできること | Cloudflare Pages:mainは本番、ほかのブランチはプレビューURL | なし。pushがそのままリリース |
| 私が作っているマーケットプレイスアプリのAPI | デプロイスクリプト、手動 | mainへのpushごとにリント、型チェック、Djangoのテスト | Fly.io、ローリングデプロイ | 私 |
| 同じアプリのフロントエンド:Web、OTAアップデート、ストア向けビルド | リリース用スクリプトとローカルのストア向けビルド、手動 | mainへのpushごとにTypeScriptとJest | オブジェクトストレージ、OTAアップデートのサービス、二つのアプリストア | 私 |
| pyhtml2text(私のオープンソースのPythonパッケージ) | すべてのpush、プルリクエスト、タグ | 15のビルドターゲット向けのwheel、その大半でテスト | PyPI、タグを打ったとき | タグ |
教科書の言葉でいえば、二つのHugoサイトは継続的デプロイメントで、アプリ(私が作っているマーケットプレイス兼コミュニティアプリ)は継続的インテグレーションにコマンド一つのリリースを足した継続的デリバリーです。チェックは何かがおかしいと教えてくれるだけで、デプロイを止めはしません。これは意図してそうしています。OTAアップデートは対象のバイナリバージョンを使っているすべての端末に届くので、マージの副作用ではなく判断であるべきです。そしてAPIはデータベースのマイグレーションが走る場所です。残り二つのDjangoアプリには、ワークフローがそもそもありません。
CIの側:ワークフロー一つ、ジョブ二つ#
アプリのCIは.github/workflows/checks.ymlというファイル一つです。10月11日までは、これすらありませんでした。以下はその形を削って例にしたものです。
# Lint, types and tests on every push and pull request to main.
# Nothing here deploys.
name: Checks
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: myapp
POSTGRES_DB: myapp
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U myapp"
--health-interval 5s --health-timeout 5s --health-retries 20
valkey:
image: valkey/valkey:8-alpine
ports: ["6379:6379"]
env:
DEBUG: "True"
DATABASE_URL: postgresql://myapp:myapp@localhost:5432/myapp
REDIS_URL: redis://localhost:6379
PYTHON_JIT: "0" # match production
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run mypy myapp/
- run: uv run python manage.py test --parallel 4 --noinput
app:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
# Expo writes this on `expo start` and it is gitignored,
# so a fresh checkout doesn't have it.
- run: printf '/// <reference types="expo/types" />\n' > expo-env.d.ts
- run: npm run typecheck
- run: npx jest --ci二つのジョブは並行して走ります。これまでに成功した35回の実行で、アプリのジョブの中央値は約7分、Pythonのジョブは約12分半です。
services:ブロックが、Composeファイルの移った先です。これは次の節で書きます。環境変数はいくつかとフラグ一つだけです。ほかはすべて開発者がdocker compose upで得る開発用のデフォルトに落ちるので、テストにシークレットは一つも要りません。本当にそうか確かめるために、まさにその環境だけで、ほかに何もない状態でテストの一部を走らせました。
CIは手元のノートPCや本番イメージと同じインタープリタ、つまりuv自身がビルドしたPython 3.15.0で動きます。uv syncが.python-versionを読み、まさにそのビルドをダウンロードするからです。(実際のファイルではsetup-uvにpython-version-fileという入力も渡していますが、これは何もしていません。setup-uv v6にはそんな入力がなく、毎回の実行にそういう警告が付いています。この記事を書いていて初めて気づきました。)インタープリタを揃えた甲斐は、ワークフローを入れて8日後に出ました。10月19日に3.15のJITコンパイラを有効にしたところ、有効にして最初の実行でテストが一つ落ちたのです。アプリが保存のたびに通るコードパスのごく普通のyield fromが、AttributeError: 'list' object has no attribute 'send'を投げていました。同じコミットをJITなしで走らせると通ります。私のMacはarm64で一度もこれに当たらず、ランナーは本番マシンと同じx86_64です。JITはCIでもDockerfileでも再び無効にしてあり、このバグが本番に届くことはありませんでした。
実は最初の実行も落ちていて、こちらの理由はもっと地味です。Expoはexpo startのときにexpo-env.d.tsを書き出し、このファイルはgitignoreされています。なので私が使ってきた作業コピーにはどれもあり、まっさらなチェックアウトにはありませんでした。TypeScriptは、ローカルでは一度も出たことのないエラーを五つ出しました。そのファイルを書き出すステップが対処です。
concurrencyは、同じブランチに新しいpushが来ると前の実行を取り消します。これまでに取り消された12回は、すべてこれによるものです。コミット前のフック(pre-commit)がリンターを走らせ、PythonかTypeScriptがステージされていれば型チェッカーも走らせるので、ミスの大半はpushまで行きません。このファイルを使い回すなら、アクションは新しいメジャーバージョンにしてください。actions/checkout@v4、actions/setup-node@v4、astral-sh/setup-uv@v6がサポート終了したNode.js 20向けだという警告が、毎回出ています。
Docker Composeファイルはどこへ行ったか#
アプリのdocker-compose.ymlのうち、CIに関係する部分を例にしたものです。実物にはもっと多くのサービスと、ずっと多くの環境変数があります。
services:
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
# Host port 55432, not 5432, so it can't collide with a local Postgres.
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD=myapp
- POSTGRES_DB=myapp
ports:
- "55432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 5s
retries: 10
api:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp
- REDIS_URL=redis://valkey:6379
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_started
volumes:
postgres_data:checks.ymlのservices:ブロックと並べると、同じイメージが二つです。Postgres 17(こちらはAlpine版、CIはDebian版)とvalkey/valkey:8-alpine。いまの関係はこうです。Composeファイルは開発者が動かすもの。CIは同じイメージをサービスコンテナとして宣言し、開発者が打つのと同じコマンド、uv run …とnpm run …をランナー上で直接実行します。
2024年の記事と比べると、いちばん大事にしていた部分は残りました。CIツールは薄く、チェックもデプロイも自分で実行できるコマンドです。残らなかったのは、CIからComposeを呼ぶやり方です。アプリのCIはDockerイメージをまったくビルドしません。イメージはデプロイのときにFlyのビルダーが一度だけビルドし、CIはコードが大丈夫かどうかだけ答えればよく、ツールチェーンがキャッシュされていればそれは6秒のインストールで済みます。それにComposeファイルは作業コピーをバインドマウントし、データを投入しますが、それはテストの実行がすることではありません。CI用に別のComposeファイルが必要になっていたはずで、それは同じサービスの二つ目の定義を手で揃え続けるということです。
そして2024年の例には、当時気づかなかったバグがありました。docker compose buildはイメージをビルドするだけで、サービスのcommandは実行しません。なので、あのファイルのnpm testはCIで一度も走っておらず、ワークフローはテストが通ろうが落ちようが緑でした。
あの記事がいちばん心配していたリスク、つまりCIベンダーの乗り換えは、結局起きませんでした。実際に起きた移行は反対側でした。このブログとクライアントのサイトがホスティング先をCloudflare Pagesに移し、ブログのほうは45行のワークフローを一つ新しく書くだけで済みました。ビルドがもともとコマンド一つだったからです。それから、私が警告していた300行のワークフロー。一つあります。pyhtml2textのものです。wheelをLinux、macOS、Windowsのランナーで、それぞれ複数のアーキテクチャ向けにビルドしなければならないので長いのですが、だからこそプラットフォーム固有のYAMLが正しい道具になる唯一の仕事でもあります。
キャッシュ:まずステップの所要時間を読む#
GitHubはすべてのステップの所要時間を記録していて、gh api repos/<owner>/<repo>/actions/runs/<run-id>/jobsで取り出せます。以下は10月20日の、アプリで最新の成功した実行です。
| ジョブ | ステップ | 秒 |
|---|---|---|
| Python | PostgresとValkeyの起動 | 18 |
| Python | uv sync --locked、キャッシュあり | 6 |
| Python | mypy | 27 |
| Python | Djangoのテスト | 761 |
| App | npm ci、キャッシュあり | 60 |
| App | Jest | 333 |
依存関係のキャッシュは二つとも有効で、どちらも安い部分です。GitHubがこのリポジトリ用に保持しているuvのキャッシュは200KBほどで、setup-uvがダウンロードし直せば済むものを整理してしまうからです。npmのほうは400MB近くあります。時間がかかっているのはテストで、テストはすでに4並列で走っています。実行を速くしたいなら、キャッシュではなくテストスイートに手を入れます。
ブログは正反対です。Hugoが処理した画像はgitignoreされたresources/_genにあるので、新しいランナーのたびにカバー、本文の画像、プレースホルダーを全部エンコードし直していました。後で載せるワークフローはactions/cacheでこれを復元します。キーは画像そのもののハッシュで、合わなければいちばん新しい古いキャッシュで代用します。Hugoは足りない分しか処理しないからです。キャッシュ導入前の最後の8回では、ビルドのステップに33〜76秒かかっていました。導入後の26回は6〜20秒で、例外は94秒の一回だけ。しかも同じコミットでプレースホルダーと二つ目のカードサイズを追加したうえでの数字です。
アプリのイメージはFlyのビルダーでビルドされ、依存関係のファイルをコードより先にコピーし、uvのダウンロードキャッシュはBuildKitのキャッシュマウント(RUN --mount=type=cache,target=/root/.cache/uv)に置いています。ただ、もっと大きな改善はキャッシュではありませんでした。以前の.dockerignoreはブロックリストで、追加のgit worktreeを置いたフォルダーを漏らしていました。それぞれが自分の依存関係とビルド成果物を抱えています。そのせいで27万7千ファイル、7.7GBが、デプロイのたびにビルダーへ送られていました。許可リストに書き直したいま、ビルドコンテキストは40MBほどです。パターンの例はこうです。
# Exclude everything, then let back in only what the Dockerfile COPYs.
*
!pyproject.toml
!uv.lock
!manage.py
!myapp
!static
# Rules match the whole relative path and the last match wins,
# so these re-excludes apply inside the allowed directories too.
**/__pycache__/
**/*.pyc
**/.DS_Store
**/*.logシークレットはできるだけ少ない場所に#
シークレットは三か所にあります。GitHub Actionsのシークレットはワークフローがすることのためのもので、二つのHugoリポジトリにはそれぞれCLOUDFLARE_API_TOKENが一つあるだけです。Flyのシークレットはアプリの実行時設定で、flyctl secrets setで入れ、Actionsを経由することはありません。そしてノートPCのgitignoreされた.envに、デプロイスクリプトがCloudflareのキャッシュを消すときに使うトークンがあります。
PyPIにはシークレットがそもそも要りません。pyhtml2textはトラステッドパブリッシングを使っています。GitHubがワークフローに発行する短命のOIDCトークンをPyPIが受け付けるので、リリースのジョブはid-token: writeを求めるだけで、リポジトリのシークレットのページは空です。漏れるものも、ローテーションするものもありません。
トークンには必要な分だけを与えます。GitHubにあるアプリのFlyトークンは、マシンの一覧を取るだけの定期チェック用なので、fly tokens create readonlyで作っています。Pagesのデプロイが求めるのはcontents: readとdeployments: writeだけで、それ以上はありません。
入力は直接スクリプトに入れず、必ずenv:を通します。OTAのワークフローは自由記述のリリースノートを受け取ります。これをrun:の中に${{ inputs.notes }}と書くと、bashが読む前にテキストがスクリプトにそのまま貼り込まれるので、引用符とセミコロンを含むノートはコードになってしまいます。環境変数として渡せば、データのままです。パターンの例です。
- name: Publish
env:
OTA_API_TOKEN: ${{ secrets.OTA_API_TOKEN }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ROLLOUT: ${{ inputs.rollout }}
INPUT_NOTES: ${{ inputs.notes }}
run: |
args=("$INPUT_PLATFORM")
[ -n "$INPUT_ROLLOUT" ] && args+=(--rollout "$INPUT_ROLLOUT")
[ -n "$INPUT_NOTES" ] && args+=(--notes "$INPUT_NOTES")
./scripts/publish-ota.sh "${args[@]}"GitHubの外に置いたままのキーもあります。ある定期レポートには、アプリのサインイン基盤に対する管理者権限を持つキーが必要です。なのでそのレポートは定期ワークフローから、Mac上の定期ジョブに移しました。ワークフローは手動の予備として残してあり、そのキーが要るセクションを除いて投稿します。
デプロイ:pushごとに、スクリプトで、タグで#
Hugoサイトはpushのたびにデプロイ#
ブログのワークフローを例にしたものです。初回だけPagesのプロジェクトを作るステップは省いています。
name: Deploy to Cloudflare Pages
on:
push:
workflow_dispatch:
env:
HUGO_VERSION: "0.165.0"
PAGES_PROJECT: my-site
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
# Processed images are gitignored; restore them so Hugo only
# re-encodes what is new. An older cache still helps.
- uses: actions/cache@v4
with:
path: resources/_gen
key: hugo-resources-${{ hashFiles('content/**/*.jpg', 'content/**/*.png', 'assets/img/**') }}
restore-keys: |
hugo-resources-
# A multilingual site has no root 404.html; without one, Pages
# answers unknown paths with index.html and a 200.
- name: Build
run: |
hugo --gc
cp public/en/404.html public/404.html
# ...
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}--branch=${{ github.ref_name }}によってmainが本番になり、ほかのブランチはどれもプレビューURLを得ます。クライアントのサイトのワークフローはこれと同じ形で、ビルドの前にrm -rf publicが一行加わっています。あちらはpublic/がコミットされているので、そうしないと削除したページがアップロードに残り続けるからです。
Hugoのバージョンはここでだけ固定しています。10月にパッケージマネージャーのアップグレードでノートPCのHugoが0.168に上がりましたが、これはテーマが対応する最新版より一つ新しいものです。デプロイは0.165のままで、何も起きませんでした。
そして、ビルドそのものがテストです。9月14日、あるブランチのプレビューデプロイが3回続けて失敗しました。そのブランチの記事が、Hugoのrefでまだ下書きの記事を指していたのです。CIは本番と同じように下書きを除いてビルドするので、リンク先は存在せず、HugoはREF_NOT_FOUNDで止まりました。その間もmainは普通にデプロイされ続けていました。
アプリのAPIはスクリプトでデプロイ#
デプロイスクリプトの中心部分を例にしたものです。
IMAGE_LABEL="deploy-$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)"
# Build first, then wake a spare machine, then roll out exactly that image.
flyctl deploy --config fly.toml --build-only --push --image-label "$IMAGE_LABEL"
curl -fsS --retry 5 https://myapp.fly.dev/health > /dev/null
flyctl deploy --config fly.toml --image "registry.fly.io/myapp:$IMAGE_LABEL"そしてfly.tomlの対応する部分です。
[deploy]
# max_unavailable only counts machines that are running,
# which is why the script wakes one first.
strategy = "rolling"
max_unavailable = 1イメージは一度だけビルドし、コミットと時刻からなるラベルを付けてFlyのレジストリに送り、そのラベルでデプロイします。なので、ロールアウトされるのはビルドしたものそのものです。
ゼロダウンタイムを実現しているのは、先に予備のマシンを起こしておくことです。max_unavailable = 1が数えるのは動いているマシンだけですが、アプリは暇なときに動いているマシンを一台も置きません(サスペンドしたマシンは1秒ほどで復帰します)。そのため、起きていた一台が寝ているマシンと同じバッチで置き換えられ、後ろに温まったマシンが一台もない、ということが起きていました。9月28日にオリジンへプローブを送り続けて測ったところ、素のflyctl deployではタイムアウトが7回と7.3秒の空白があり、先にビルドしてロールアウト開始時にはマシンを一台起こしておくスクリプトのデプロイでは、2,514リクエストで失敗はゼロでした。
スクリプトの残りは、エラーの通知が届くチームのチャットチャンネルにデプロイの印を残します。エラーが固まって出たとき、直前に何が変わったかと照らし合わせられるようにです。それからCloudflareのキャッシュを消し、マシンを温めておきます。Webフロントエンドはイメージをまったく通りません。それはAPIを再デプロイせずにWebフロントエンドをリリースするに書きました。
マイグレーションはパイプラインのステップではありません。Flyのrelease_commandが私の環境では固まってしまったので、新しいイメージで最初に起動したマシンがロックを取ってマイグレーションし、ほかのマシンは待ちます。そのため、どのマイグレーションもまだ動いているコードと両立しなければなりません。カラムはあるデプロイで追加し、次のデプロイで使うのをやめ、三回目のデプロイで削除します。
このスクリプトに、私のノートPCでなければならない部分は特にありません。ただ、ノートPCにしかないファイルがいくつかあって、それが縛りになっています。Cloudflareの認証情報と、最後に何をデプロイしたかの記録で、まっさらなランナーにはこれがありません。コミットしていない変更もデプロイしてしまい、チャットでそう知らせますが、Webのリリース用スクリプトは汚れた作業ツリーを拒否します。ブランチごとに環境をひとつで私は、これを手でやるのは自分一人なら構わないが、二人目の開発者からはgitで何かが起きたから起きるようにしなければならない、と書きました。アプリの開発者はまだ一人です。あの記事のブランチ環境のワークフローは、作ったときのブランチにそのまま置いてあります。
OTAのワークフローは手動専用です。公開は判断であるべきですし、アップデートのサービスは部分ロールアウトが開いている間は新しいリリースを拒むので、pushトリガーではカナリアのたびに引っかかってしまいます。このワークフローは一度も実行されたことがありません。これまでのOTAアップデートはすべてノートPCから、ワークフローが呼ぶのと同じスクリプトで出しています。薄いワークフローにする理由は一言でこれです。リリースをどう始めても、同じスクリプトです。
pyhtml2textはタグでリリース#
リリースのジョブです。パッケージ名は一般的なものにしてあります。
release:
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [linux, macos, windows, sdist]
environment:
name: pypi
url: https://pypi.org/p/mypackage
permissions:
id-token: write # trusted publishing and signing
contents: write
attestations: write
steps:
- uses: actions/download-artifact@v7
- uses: actions/attest@v4
with:
subject-path: 'wheels-*/*'
- uses: astral-sh/setup-uv@v7
if: ${{ startsWith(github.ref, 'refs/tags/') }}
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: uv publish --trusted-publishing always 'wheels-*/*'リリースのジョブはすべてのビルドジョブを待ち、実際に公開するのはタグのときだけです。手動実行はビルドとアテステーションまではしますが、何もアップロードしません。actions/attestはどのワークフローが各wheelをビルドしたかを記録し、uv publish --trusted-publishing alwaysはパスワードの代わりにOIDCトークンでアップロードします。パッケージそのものについては閉じられたプルリクエスト、フォーク、そしてPyPIパッケージに書きました。
定期ワークフローはベストエフォート#
アプリには定期ワークフローが一つあります。Flyがベースラインより多くのマシンを起動していないかを見るチェックで、放っておけば請求書で初めて気づくことです。通知はわざと失敗することで行います。定期実行が失敗すると、GitHubが所有者にメールを送るからです。設定はcron: '*/15 * * * *'、1日96回です。ところがこの2週間は1日3〜8回しか動いておらず、実行間隔の中央値は3時間40分ほどでした。GitHubのドキュメントが挙げる理由は負荷だけです。Actionsが混んでいると定期実行は遅れることがあり、混み具合によっては落とされることもある、と。持続的なスケールアウトを見るなら数時間で十分です。数分以内に鳴らなければならないものにとって、GitHubのcronは監視ではありません。
パイプラインに投げかけたい質問#
これから作る人も、すでにあるものを見直している人も:
- GitHub Actionsが丸一日止まったら、それでもリリースできますか? できないなら、リリースのロジックがYAMLの中にあるということで、2024年の記事がまだ当てはまります。
- CIは本番と同じインタープリタ、同じCPUアーキテクチャ、同じデータベースのメジャーバージョンで動いていますか? 私のCIがコンパイラのバグを捕まえられたのは、ランナーと本番がどちらもx86_64で、ノートPCがそうではなかったからです。
- まっさらなチェックアウトで通りますか? 通らないとしたら、たいていは生成されてgitignoreされたファイルのせいです。
- 実行時間は実際どこに使われていますか? キャッシュを足す前に、ステップの所要時間を読んでください。
- シークレットはそれぞれ何か所にありますか? そして本当にGitHubに置く必要がありますか?
