モノレポの無停止移行とデプロイ分離

サービスを止めずにカラムを改名し、モノレポなのにサービスごとに独立して速くデプロイする。なぜそれが要るのかを分散サービスの性質と開発生産性の歴史から説明し、Parallel Change(expand/contract)+ リリーストグル、依存グラフ由来の CI 分離、ジョブグラフによる順序強制を、既存手法との対比と実測値つきで解説する。

TL;DR

  1. デプロイは一瞬で終わらない。 新旧のバイナリが同時に動く時間が必ずあり、その間 DB は 1 つ。 だからスキーマ変更をアプリと同時に出すことはできない
  2. 答えは 1 回の変更を 3 回に割ること(expand / contract)。そして切り替えだけをフラグに逃がす。 これで「デプロイする」と「ユーザーに出す」が分かれ、戻すのにデプロイが要らなくなる
  3. モノレポで速く出すには、何を検査・デプロイするかを手書きの表ではなく依存グラフから計算し、 順序の保証をスクリプトではなくジョブグラフに置く
  4. 境界はマルチモジュールで守るが、go.work は境界を守らない(実測)。 検査とリリースビルドは GOWORK=off に倒す。これが将来のリポジトリ分離の下準備にもなる

caption → title の改名を負荷をかけたまま完走させた記録(1399 リクエスト・エラー 0 件・ 新旧一致率 100%)と、その過程で掘り当てたバグを書く。


1. なぜこんなものが要るのか

デプロイは「瞬間」ではない

まずここが出発点になる。

アプリを更新するとき、全部のプロセスが同時に切り替わることはない。ローリング更新なら 1 台ずつ、ブルーグリーンでも切り替えの前後がある。つまり 新旧のバイナリが同時に動いている 時間が必ずできる。

③ 更新後

② 更新中 — ここが問題

① 更新前

旧バイナリ

旧バイナリ

新バイナリ

新バイナリ

DB は 1 つ

スキーマも 1 つ

アプリは複数のプロセスに分かれて入れ替わるのに、DB は分かれない。 「新バイナリ用のスキーマ」と「旧バイナリ用のスキーマ」を同時に持つことはできない。

素直にやるとこう壊れる

caption というカラムを title に改名したい、とする。素直にやると 「ALTER TABLE photos RENAME COLUMN caption TO title を打って、同時に新コードを出す」。

これが②の瞬間にどうなるか:

旧バイナリ(まだ残っている)新バイナリ
ALTER の前caption を読む → OKtitle を読む → カラムが無い
ALTER の後caption を読む → カラムが無いtitle を読む → OK

どちらに倒しても、必ず片方が 500 を返す。 しかも厄介なのは戻し方で、アプリだけ ロールバックしてもスキーマは戻っていないので、旧バイナリが caption を探して壊れ続ける。

だから「メンテナンス時間を取って止める」になる。そこを止めずにやりたい、というのが この話の動機になる。

サービスが分かれると、問題が掛け算になる

サービスを分けるとデプロイ単位が増える。つまり上の「②の瞬間」が、サービスの数だけ、 しかも互いに独立したタイミングで発生する。

さらにモノレポ(1 つのリポジトリに複数サービス)で運用していると、別の問題が乗る。 リポジトリは 1 つなのにデプロイ単位は複数という状態で素直に CI を書くと、こうなる。

サービスを分けた目的は「独立して出せること」なのに、CI/CD の都合でその目的が消える。 これが二本目の柱の動機になる。

開発生産性の歴史として見ると、同じ話をしている

この 2 つは、業界が 20 年やってきたことの延長線上にある。

出していいか分からない

コードが混ざる

リポジトリが大きくなり

全部回すと遅い

継続的インテグレーション

統合の痛みを毎日に割る

継続的デリバリ

いつでも出せる状態を保つ

トランクベース開発

長寿命ブランチをやめる

デプロイとリリースの分離

フィーチャートグル

段階的な展開

カナリア / % 展開 / 巻き戻し

影響範囲だけを回す CI

依存グラフ

並べると、大きい塊を小さく割り続けているだけだと分かる。

何を割ったか結果
統合を割った継続的インテグレーション
リリースを割った継続的デリバリ、トランクベース
「デプロイする」と「出す」を割ったフィーチャートグル
「出す」をさらに割ったカナリア、% 展開
「検査する」を割った影響範囲だけの CI

この目で見ると、今回の二本柱はこうなる。

同じ思想の適用先が違うだけなので、1 つの検証で両方扱う価値がある。


2. 柱 1: 止めずにカラムを改名する

定番の型がある — Parallel Change

自分で考える前に、これは名前の付いた型がある。Parallel Change(別名 expand / contract、あるいは expand-migrate-contract)と呼ばれるリファクタリングの型で、 「変更を、新旧が並存する期間を挟んだ 3 段階に割る」というもの。

DB に限らず API の変更などにも使う。今回はそれを DB スキーマに当てた形になる。

gh-ost とは何が違うのか

ここで「それって gh-ost の仕事では?」と思う人がいると思う。自分も最初そう考えた。 結論から言うと、解いている問題の層が違うので、置き換え関係ではなく直交する。

問題 B: アプリとスキーマを同時に変えられない

問題 A: ALTER 自体が重い / ロックする

DB の中だけで完結する

アプリとリリース手順にまたがる

大きなテーブルの

型変更・INDEX 追加

gh-ost / pt-online-schema-change

影テーブルを作って差分を追いつかせ、最後に入れ替える

新旧バイナリが

同時に動く時間がある

Parallel Change

expand / contract に割り、フラグで切り替える

DB

アプリ + デプロイ

gh-ost / pt-online-schema-changeParallel Change(今回)
解く問題ALTER が重い・ロックするアプリとスキーマを同時に変えられない
影響範囲DB の中だけ。アプリは触らないアプリのコードとリリース手順にまたがる
やること影テーブルを作り、コピーしつつ差分を追従(binlog / トリガ)、最後に入れ替え1 回の変更を 3 リリースに割り、途中は新旧を並存させる
出番型変更、巨大テーブルへの INDEX 追加、文字コード変更など参照している名前や意味が変わる変更すべて

重要なのは、gh-ost を使っても Parallel Change は要るということ。

たとえば gh-ost で CHANGE COLUMN caption title ... を実行したとする。ロックはしないし 時間もかからない。でも入れ替わった瞬間、まだ動いている旧バイナリは caption を探して 壊れる。gh-ost が解決したのは「ALTER 中に DB が詰まること」であって、 「アプリが古いこと」ではないからだ。

逆も言える。今回の ADD COLUMN ... NULL / DROP COLUMN は MySQL 8.0 の ALGORITHM = INSTANT でメタデータ更新だけで終わる(テーブルを作り直さない)。 ここに gh-ost を持ち出す必要はない。

つまり住み分けはこうなる。

実際、MODIFY COLUMN で型を縮める変更は INSTANT が使えず ERROR 1846 ... Need to rebuild the table になることを実測した(後述)。 この線を越えたら別の道具が要る、という境界がはっきりしたのは収穫だった。

3 回に割る

リリース 3: contract(減らす)

アプリ: 旧カラムの参照を消す

DB: 旧カラムを DROP

リリース 2: migrate(移す)

過去分を埋める

バックフィル

読み先をフラグで切り替え

1% → 50% → 100%

リリース 1: expand(増やす)

DB: 新カラムを足すだけ

ADD COLUMN title NULL

アプリ: 新旧の両方に書く

読むのは今までどおり旧

大事なのは、どの時点でも新旧どちらのバイナリが動いていても壊れないこと。 リリース 1 の直後を見てみる。

書き込み読み取り
旧バイナリcaption だけに書くcaption を読む → ある
新バイナリcaption と title の両方に書くcaption を読む → ある

どちらも成立している。これが「止めずに出せる」ということになる。

書き込みは常に両方、フラグで切るのは読みだけ

実装で一番のポイントがここ。

DBアプリ(新バイナリ)クライアントDBアプリ(新バイナリ)クライアント書き込みはフラグに関係なく必ず両方どちらを表示に使うかだけをフラグで決める写真を投稿INSERT caption = X, title = X一覧を取得SELECT caption, title(両方取る)ON なら title、OFF なら caption

書き込みをフラグで分岐させてはいけない。 分岐させると「フラグが ON だった時間に 書かれた行だけ title が埋まっている」という歯抜けができる。この状態でフラグを OFF に戻すと、caption が空の行が生まれて壊れる。

常に両方へ書いておけば、いつ巻き戻しても両方のカラムが正しい。 フラグの役割は「読みの向きを変えること」だけに限定する。

もう 1 つ、読み側は 「フラグが ON でも title が空なら caption へ倒す」 形にした。 こうするとバックフィルが終わる前でもフラグを上げられる。 展開とバックフィルの順序に依存しないため、運用の自由度が上がる。

過去分を埋める(バックフィル)

title を足しただけでは、すでにある行は空のまま。ここを埋める作業がバックフィル。

photo backfill title --batch N --pause D というサブコマンドを作った。設計は 3 つ。

「割る」という発想はここでも有効である。

フラグは「再デプロイ不要のリリース手段」

この検証で一番確かめたかったのは、切り替えと巻き戻しにデプロイが要らないことだった。

分離していると

戻すのはフラグを下げるだけ

(数秒)

コードを直す

デプロイ

(まだ出ない)

フラグを上げる = リリース

分離していないと

戻すにはもう一度デプロイ

(ビルド待ち・数分〜数十分)

コードを直す

デプロイ

ユーザーに出る

フィーチャートグルには用途別の分類があって、今回使っているのは リリーストグル(未完成・切り替え中のコードを隠すための、短命なフラグ)に当たる。 だからフラグ名にも release. という接頭辞を付けている。短命であることを名前で宣言する、 という意図になる。

実測では 1% → 50% → 100% → OFF へ巻き戻し → 100% へ戻すまで、 フラグ定義ファイルの書き換えだけで完走した。再デプロイはゼロ。

設計で決めた 2 つのこと

① 既定値は「フラグ基盤が落ちていても安全な側」にする。

フラグを配る仕組み自体が落ちることはある。そのときどちらに倒れるかを決めておく。 既定は常に「既存動作」にした。

実装では、入口のミドルウェアで宣言済みのフラグを 1 回だけ評価して ctx に積み、 ハンドラと usecase は flags.Bool(ctx, name) しか触らない。 評価されていない ctx では false を返す。

評価できなかったときに新機能が露出することが最も避けるべき事態だからである。 「壊れたら止まる」ではなく「壊れたら今までどおり」に倒す。

② 同じフラグを複数箇所で評価しない。

フラグは「それで振る舞いが変わるデプロイ単位」だけが持つ。画面側(SSR)はフラグを 評価せず、サービスが評価した結果を「できること」として API の応答で返す。

フラグ名は API の契約に載せない。フラグは消えるが、「できること」はフラグより長生きするから。 フラグ名を契約に出すと、フラグを消すときに API の互換性の話になってしまう。

どこで評価するか

ついでに調べ直して面白かった話。フラグの評価にはリモート評価(サーバへ問い合わせる)と プロセス内評価(定義を配ってアプリの中で判定する)がある。

製品評価の場所
AWS AppConfigプロセス内
GrowthBookプロセス内(JSON を取得して SDK が評価)
LaunchDarklyプロセス内
flagd両方の口がある(rpc / OFREP はリモート、flag sync はプロセス内)

最初 flagd のリモート評価で組んでいたが、AWS 側を AppConfig にすると ローカルと本番で評価の場所が変わってしまうことに気づいた。 「REST か gRPC か」で考えていたのが誤りで、軸はプロトコルではなく評価の場所だった。 主流に合わせてプロセス内評価に寄せた。

最後の DROP をどう安全にするか

一番危ないのは最後の DROP COLUMN。まだどこかが参照していたら本番が壊れる。 人のレビューだけに頼らず、機械が止める形にした。

残っている

通過

残っている

通過

旧カラムを DROP したい

ゲート 1

クエリからの参照がゼロか

sqlc generate が失敗

column does not exist

ゲート 2

対応するフラグは削除済みか

まだ切り替え中かもしれない

DROP COLUMN

生成コードから型が消え

参照が残っていればコンパイルエラー

ゲート 1 は実測済み。 sqlc(SQL からコードを生成するツール)は、スキーマとクエリを 入力に取る。旧カラムを落としたスキーマで生成を回すと column "caption" does not exist で 生成自体が通らない。つまりクエリから参照を外すまで先へ進めない。

さらに DROP した後、生成された構造体からフィールドが消えるので、まだ使っている箇所が あればコンパイルエラーになる。これが contract の安全確認そのものになっている。

ただし正直に書いておくと、ゲート 2 には自動チェックがまだ無い。 「対応する release. フラグが削除済みであること」は今のところ人が見ている。 検証ログにもその訂正を入れてある。

ALTER は fail fast させる

スキーマ変更で本番を止める典型がこれ。ALTER 自体は一瞬で終わるのに、 その手前で全部が詰まるという事故になる。

何が起きるか順に見る。

① 誰かの長いトランザクションが

photos テーブルを掴んでいる

② そこへ ALTER が来る

→ 掴めないので待ち行列に入る

③ 後から来た普通の SELECT も

ALTER の後ろに並ばされる

④ ALTER 1 本のせいで

アプリ全体が止まる

③は直感に反する。「ALTER が待っているだけなら、普通のクエリは 通ればいいのに」と感じるが、そうはならない。MySQL は ALTER のためにテーブル全体を 占有する権利を確保しようとし、その順番待ちの列に後続のクエリも並ぶからだ。 先頭の ALTER がいつまでも待っていると、列がどんどん伸びる。

結果、ALTER の実行時間が 1 秒でも、その前の待ち時間の間ずっとアプリが停止する。

対策: ALTER を fail fast + backoff にする

列を作らせなければよい。そのために ALTER 側のセッションだけ、ロックを待つ上限を短くする。 設定は 2 つあり、メタデータロックが lock_wait_timeout、行ロックが innodb_lock_wait_timeout。今回は前者を 5 秒にした(既定は 31536000 秒 = 1 年)。

これで ALTER は 5 秒で fail fast する。失敗した時点で列は解消し、後ろに並んでいたクエリは すぐ流れる。あとは backoff して投げ直せばよい。

掴めた

掴めない

ALTER を投げる

5 秒でロックを

掴めたか

適用して終わり

fail fast

(ALTER だけ失敗。列を作らない)

backoff

つまり ALTER を fail fast させ、backoff して再試行する。 停止を ALTER 側に引き受けさせ、アプリは止めないという交換になる。 デプロイが 1 回失敗してやり直しになるのは構わないが、アプリが数十秒止まるのは困る、 という優先順位をそのまま設定にした形。

実験した結果:

何を見たか結果
行をロックしたまま contract(DROP COLUMN)を投入ALTER が Error 1205 Lock wait timeout exceeded で失敗(想定どおり)
そのとき裏で流していたアプリのクエリ59 リクエスト、エラー 0。停滞もなし
ロックを解放してからリトライ成功(8 秒で完了)

注目したいのは真ん中の行で、ALTER がロック待ちで失敗している間も、 アプリのクエリは 1 件も失敗していないし遅くもなっていない。列ができていない証拠になる。

実測

caption → title の改名を、負荷を流したまま完走させた結果。

段階新旧カラムの一致率
フラグ 50%100.00%(83 行)
フラグ 100%100.00%(96 行)
OFF へ巻き戻し → 100% へ戻す—
負荷終了後の最終検算100.00%(324 行)

負荷の結果は 1399 リクエスト・エラー 0 件・全て 200。 フラグ操作は定義ファイルの書き換えのみで再デプロイなし。

ここで効いたのは、観測手段を先に作ったことだった。

「たぶん大丈夫」を「エラー 0 件・一致率 100.00%」に変えるには、先に数える道具が要る。

掘り当てたもの

実際に動かさなければ表に出ないものが出た。検証ビルドの価値はここにある。

① リトライは、書いただけでは有効になっていない。 ロック待ちを判定するために errors.As(err, &*mysql.MySQLError) を使っていたが、 golang-migrate はドライバのエラーを独自の型で包み、Unwrap を通さない。 つまり判定が常に false で、リトライは一度も動いていなかった。 メッセージ中の Error 1205 も見るようにして解決した。 実際にロックを掛けて試すまで、リトライが有効かどうかは確認できない。

② 失敗すると golang-migrate は「dirty」を立て、そのままでは再試行できない。 2 回目以降が Dirty database version 1. Fix and force version. で止まる。 1 つ前のバージョンへ Force() して dirty を解消してから再試行する処理を足した。 ここから 「1 ファイルに 1 文だけ書く」 という規約も導かれる —— 複数文を 1 ファイルに書くと部分的に適用された状態が起こりうるので、自動で戻せない。

③ INSTANT には境界がある。 ADD COLUMN ... NULL は ALGORITHM = INSTANT で一瞬で終わるが、 型を縮める MODIFY COLUMN は ERROR 1846 ... Need to rebuild the table になる。 「expand に置ける変更」と「置けない(=別の道具が要る)変更」の線がここにある。

④ sqlc は同じ名前のパラメータを型の違う 2 カラムに使えない。 caption(NOT NULL)と title(NULL 許容)に同じ sqlc.arg を共用しようとして incompatible types になった。二重書きでは、呼び出し側が同じ値を 2 つの引数へ渡す形にする。


3. 柱 2: モノレポでサービスごとに速く出す

前提として、境界が守られていること

デプロイを分ける前に、コードの境界が守られていることが要る。 守られていないと、分けて出した瞬間に「別サービスの内部を直接呼んでいたので壊れた」が起きる。

この検証ビルドでは go.work + マルチモジュール(core / 各サービス / 各サービスの client)に していて、モジュールグラフそのものを依存規律にしている。

第 3 層: クエリ

sqlc の入力は

自ドメインのスキーマだけ

越境クエリはコード生成で失敗

第 2 層: DB

サービスごとに専用の DB ロール

他ドメインのテーブルには

SELECT すら GRANT しない

第 1 層: コード

photo の go.mod に

gear の実装モジュールを書かない

gear の内部は import できない

コンパイラと go mod が弾く

3 層にしてあるのは、どれか 1 つをすり抜けても次で止まるようにするため。 「規約に書いてある」ではなく「構造上できない」に寄せる、という考え方になる。

「読むだけなら直接 SELECT してもいいのでは」も禁止している。読み取りでも相手のスキーマに 結合するので、相手がカラムを変えたら壊れるし、将来 DB を分けることが不可能になる。 他ドメインのデータが要るときは HTTP で取るか、イベント購読で複製を持つ。

なぜ「初日から」マルチモジュールなのか

後からでもよさそうに見える。でも変更コストが時間に対して非対称だから初日にやる、 という整理になっている。

つまり「先送りしてよいのは、待つほど安くなる変更か、可逆な変更だけ」であって、 モジュール分割はどちらでもない。

ところが go.work は境界を守らない

ここが実測して初めて分かったところ。

go.work は複数モジュールをまとめて扱うための仕組みで、開発時の利便性は高い。 ところが use に載っているモジュールは、go.mod に依存を書いていなくても import が解決してしまう。

ビルドのモードservices/photo から services/gear/domain を import
ワークスペース(普通に go build)成功してしまう(越境が通る)
GOWORK=off(モジュール単体)失敗 no required module provides package
GOWORK=off + gear-client を依存に追加成功(client 経由のみ許可)

つまりワークスペースモードのビルドは、一番効かせたい検査を無効にしている。

なので運用をこう分けた。

代償として「ローカルでビルドが通っても越境しているかもしれない」状態になる。 mise run check を通すまで安心できない、というのは正直に受け入れている副作用。

副産物もあって、モジュール間の結線が require v0.0.0 + replace ../.. という形になるため、 境界違反(client 以外への replace)が go.mod の差分として PR レビューに現れる。

「何を回すか」を手で書かない

ここからが本題。モノレポで影響範囲だけ CI を回すというのは、よくある要求になる。

既存のやり方

この領域には成熟した道具がある。

道具やり方
Bazel / Pants / Buckビルドグラフを持ち、変更から影響先を逆引きする(rdeps 等)。Go なら Gazelle が BUILD を生成
Nx / Turborepo(JS 圏)プロジェクト間の依存グラフを作り、affected だけを回す。結果のキャッシュも持つ
dorny/paths-filter 等パスのパターンを手で書く。導入は一番軽い

Bazel や Nx は強力だが、ビルドシステムごと乗り換える話になる。今回は Go の標準的な ビルドのままでいたかったので、一番軽い paths-filter を検討した。が、これには弱点がある。

手書きのパスフィルタ

サービスを足したとき

追記を忘れる

新サービスの CI が

一切走らない

go.mod に依存を足したとき

フィルタ側を忘れる

依存先を変えても

利用側が検査されない

どちらも

「走らなかったので落ちなかった」

「0 件で成功」は成功に見える。 追記を忘れても誰も気づかない、というのがこの方式の弱点である。

go.mod の replace から計算する

なので依存グラフを、各モジュールの go.mod の replace(相対パス)から計算することにした。 dev/affected が変更ファイルを受け取り、対象モジュールを JSON で返す。

変更ファイル

(merge-base からの差分)

dev/affected

各 go.mod の replace

対象モジュール(JSON)

core/** を変更

→ 全モジュール発火

services/photo/** を変更

→ photo だけ

client を再生成

→ 利用側も

手書きの対応表を持たないので、依存を足せば CI の発火範囲も自動的に変わる。 Bazel や Nx がやっていることの、Go のモジュール機構だけで済ませる小さい版という位置づけになる。

前節の replace がここで二役を担っている。 境界を守る仕掛けが、そのまま CI の発火範囲の情報源でもある。 真実が 1 箇所にしかないので、ずれようがない。

順序の保証は、スクリプトではなくジョブグラフに置く

デプロイは migrate → release → healthcheck の 3 段になる。 マイグレーションが失敗したら、アプリを出してはいけない。

これを 1 つのシェルスクリプトにまとめることもできる。 でもまとめると、その保証は「スクリプトの制御フロー」になる。 set -e の書き忘れや、|| による握り潰しで簡単に破れる。

ジョブグラフ(CI の依存関係)に置けば、保証は実行機構の側に移る。 スクリプトを読まなくても成り立つ。

needs

needs

失敗したら

migrate

(expand 適用)

release

(イメージ差し替え)

healthcheck

(/healthz 200)

release: skipped

healthcheck: skipped

これも定番のある領域で、Argo CD の sync wave や Helm の hook、 Kubernetes で本体の前に Job を回す構成など、「マイグレーションを先に、 失敗したら進めない」を宣言で表す仕組みは各所にある。要点は同じで、 順序をスクリプトの中ではなく、外側の仕組みに持たせること。

わざと壊して確かめた。存在しないカラムへ INDEX を張る expand を仕込むと:

確認結果
migratefailure(Error 1072 Key column ... doesn't exist)
release / healthcheckskipped(そもそも開始しない)
環境は無傷かコンテナは同一 ID のまま動き続け、/healthz は 200
スキーマ壊れた INDEX は残っていない

失敗しても本番が無傷、が確認できた形になる。

この実験が掘り当てた致命的なバグ

一番大きかったのがこれ。dirty を解消する処理が、 「dirty なバージョンが見つからない」を「失敗したのは最初のマイグレーションだ」と誤判定し、 Force(-1) でマイグレーション履歴を全消ししていた。

なぜ踏むかというと、失敗した expand を消す・番号を変えるのは前方修正の普通の手順だから。 そのとき「1 つ前のバージョン」は見つからない。旧コードはそれを最初のマイグレーションと読む。

結果、次の実行が CREATE TABLE から始まって Error 1050 already exists で落ち、 以後どのデプロイも通らなくなる。

直し方は「履歴の先頭と一致するか」で判定し、一致しなければ dirty を残したまま、 理由と直し方を出して止める。推測でマイグレーション履歴を書き換えない。

同一サービスは直列、別サービスは並列

GitHub Actions の concurrency グループを deploy-<サービス名> にする、というだけに見える。 実測すると挙動がはっきり出た。photo を 2 本、gear を 1 本、3 秒間隔で投げた結果:

18:28:4518:28:5018:28:5518:29:0018:29:0518:29:1018:29:1518:29:2018:29:2518:29:3018:29:3518:29:4018:29:4518:29:5018:29:5518:30:0018:30:05migrate migrate 待ち(42秒) release release healthcheck healthcheck migrate release healthcheck photo #1gearphoto #2デプロイの実測タイムライン(18:28:40 起点)

待たせたのがランナー不足ではないことも確認した。gear が終わった後もランナーは空いていて、 それでも photo #1 の完了を待っている。concurrency が有効に機能している証跡である。

concurrency は「順番」しか分けてくれない

この実験が 2 つ目のバグを掘り当てた。修正前は gear の release が落ちていた。

Container greenfield-mysql Recreate
Error response from daemon: Conflict. The container name
"/..._greenfield-mysql" is already in use by container ...

docker compose up が depends_on を辿って共有インフラ(mysql)の再作成に手を出すため、 photo と gear の release が同時に走るとコンテナ名を奪い合っていた。 --no-deps を付けて、release が触るのは自分のサービスだけにした。

教訓は明確で、「別サービスは互いに待たない」と主張するなら、待たない状態で同時に走っても 壊れないことまでが主張の範囲になる。ジョブグラフの宣言だけ見て成功にすると、この穴は残る。

もう 1 つ。ランナーが 1 台ではこの検証自体ができない。 ジョブがランナー待ちで直列化するので、 「待っているのは concurrency なのかランナー不足なのか」が区別できない。2 台にして初めて 同じ条件で比較できた。確かめたい性質に対して、環境が足りているかを先に見る必要がある。

将来: リポジトリを分けるとき

この構成が最も効果を発揮するのは切り出すときである。

モジュールごと移動

core に初タグ

参照をバージョンへ

切り出した後(別リポジトリ)

gear-client を参照

require v1.2.0

(replace を消す)

いま(モノレポ)

gear-client を参照

require v0.0.0

replace ../gear-client

やることは、モジュールごと移動して、core に初めてのタグを打ち、消費側の参照を バージョン指定に切り替えるだけ。差分としては replace を外す形になる。

狙いは依存関係の調査を後回しにしないこと。「切り出すときに初めて依存を解きほぐす」のを 避けるために、最初からこの形にしてある。

タグを打たないのも意図的で、モジュールが外のリポジトリから使われるまでタグは打たない。 外から使われて初めてバージョンが要る。

切り出しを容易にする仕組みは他にもある。

一方で **リポジトリを分けること自体は「条件付きの先送り」**にしている。 切り出す価値が確定したモジュールに限る、という条件で、それまでは 横断的な変更をまとめて行えるモノレポを維持する。分けること自体が目的ではない。


4. 二本柱が噛み合うところ

別々に書いてきたが、組み合わせると 1 つの主張になる。

互いに待たない

互いに壊さない

gear サービス

普通の機能追加を

普通にデプロイ

photo サービス

expand

backfill

feature flag 1% → 100%

contract

photo がオンライン改名の最中でも、gear は普通にデプロイできる。

これが「モノレポなのにサービスごとに独立して、安全に、速く出せる」ということで、 二本柱が交わる点になる。

まとめ

そして全体を通して一番効いたのは、先に数える道具を作ったことと、実際に壊して試したこと。 リトライも、順序の保証も、並列性も、「書いた」だけでは有効かどうか確認できない。

← Back to all posts