はじめに
商用 Cloud Spanner への移行で 2 テーブルが失敗し、片方は 10,923件のリカバリが必要になりました。「バッチ分割すれば終わりでしょ」と高をくくっていたら、Spanner 側の制約と Linux シェル側の制約が同時に効いてくる、というやや珍しいケースで二重に詰みました。
最終的に「50KB / 約300件」というバッチサイズで安定運用に落ち着いたのですが、ここまでの経緯を整理しておきます。読み終えるころには、
- DBの制約
- OSの制約
- 認証の境界
という3レイヤが同時に効くトラブルの解き方の感覚が掴めるはずです。
Part 1: 第一の壁 — Spanner のミューテーション上限
問題発見: Spanner コンソールから全件流したらアボート
10,923件のレコードを Spanner コンソールから複数行 VALUES INSERT で一気に流したところ、トランザクションがアボートしました。
原因: 「行数」と「ミューテーション数」は別物
Spanner には 1 トランザクションあたり 20,000 ミューテーションという上限があります。注意したいのは、ミューテーション数 ≠ 行数ということです。
1行 INSERT のミューテーション数 = カラム数 + (各セカンダリインデックスのカラム数)
つまり、
- 10 カラム × インデックス 5本(各2カラム)あるテーブルなら、1行で 10 + 10 = 20 ミューテーション
- 20,000 ミューテーション ÷ 20 = 1,000 行で上限到達
「列が多い・インデックスが多い」テーブルほど、行数ベースの直感よりずっと早く詰まります。今回のテーブルもインデックスが多く、感覚的に「1万件くらい一発でいけるでしょ」と思っていたら、はるかに手前で爆発しました。
第一の解決: 複数行 VALUES INSERT を 1,500 件 × 8 バッチに分割
1万件超を 1,500 件 × 8 バッチに分けて、INSERT INTO ... VALUES (...), (...), ... 形式で SQL ファイルを生成しました。これで Spanner 側の制約はクリア。
…のはずでした。
Part 2: 第二の壁 — Argument list too long
問題発見: 突然の謎エラー
バッチファイルを VM 上で gcloud に流します。
gcloud spanner databases execute-sql DB_NAME --sql="$(cat batch_001.sql)"
返ってきたのが、
-bash: /usr/bin/gcloud: Argument list too long
Spanner のエラーですらありません。シェルがそもそも gcloud を起動できなかったんです。
原因: Linux カーネルの ARG_MAX
Argument list too long は POSIX 環境のカーネル側の制限で、execve システムコールに渡せる引数文字列+環境変数の合計サイズに上限があります。
$ getconf ARG_MAX
2097152 # 例: 約2MB の環境もあれば、コンテナだと128KB台のことも
私の作業環境では実効的に 約128KB が上限でした。1,500 件・複数行 VALUES の SQL ファイルは約170KB。$(cat ...) でファイル中身を引数に展開した瞬間に ARG_MAX を超え、gcloud 起動前に弾かれていたわけです。
「Spanner の制限を頑張って回避したら、今度は OS の制限に引っ掛かる」── 違うレイヤの壁が連続して立ちはだかる典型例でした。
補足: なぜ --sql-file ではなく --sql を使っていたか
--sql-file オプションが使えるならそちらが正解です。ただ、今回の運用では既存のラッパーやログ収集の都合で --sql= 直渡しが標準化されており、その流儀のままバッチサイズで解決する方向に倒しました。**「正しいオプション」より「既存運用の中で動く解」**を選ぶ判断です。
Part 3: 実測で導いた「50KB / 300件」
境界条件は2点で見えた
ここからが本題です。バッチサイズをどこまで下げれば安全に流せるのか、実測で詰めました。
| バッチ | 件数 | ファイルサイズ | 結果 |
|---|---|---|---|
| batch_001 〜 007 | 1,500件 | 約 170KB |
失敗 (Argument list too long) |
| batch_008 | 423件 | 約 50KB | 成功 |
2点しかありませんが、これで上下を挟めました。170KB はアウト、50KB はOK。間のどこかに閾値がある、ということで、安全マージンを取って 「50KB 以下」を運用基準に固定しました。
なぜ 50KB が安全圏か
ARG_MAX 自体は 128KB あるとして、なぜ 50KB まで下げるのか。3つの余裕を見ています。
-
環境変数も同じ枠を食う: ARG_MAX は「引数 + 環境変数」の合計上限。
PATHやGOOGLE_APPLICATION_CREDENTIALSなどが乗ると引数枠は実質減る -
シェルの展開で膨らむ:
$(cat ...)展開時、改行・エスケープ・クォートで体感サイズより大きくなることがある - コンテナ/sudo 経由で更に縮む: 実行コンテキストによっては ARG_MAX 相当の実効値がさらに小さい
128KB の半分以下に倒しておけば、実行コンテキストが多少変わっても事故らないマージンになります。
「件数」より「バイトサイズ」で管理するべき理由
300件と決め打ちでも今回のテーブルなら通りますが、他のテーブルにそのまま使うと危険です。
- URLや本文系の長い VARCHAR を持つテーブルでは、300件で 50KB を軽く超える
- 逆に短いカラムだけのテーブルなら、1000件でも 50KB 未満で済む
なので、バッチ分割のロジックは「累積バイト数が閾値を超えたら次のバッチへ」で書くのが正解です。件数しきい値は目安にとどめて、実際の境界はサイズで切る。
Part 4: スクリプト化の設計判断
累積バイトでのバッチ分割
スクリプトの骨格はおおよそ次のような形になります(細部はテーブルやカラムによります)。
THRESHOLD_BYTES = 50 * 1024 # 50KB
buffer = []
current_size = 0
batch_no = 1
for values_tuple in iter_values_from_csv():
line = f"({values_tuple}),"
line_size = len(line.encode("utf-8"))
if current_size + line_size > THRESHOLD_BYTES and buffer:
write_batch(batch_no, buffer)
batch_no += 1
buffer, current_size = [], 0
buffer.append(line)
current_size += line_size
if buffer:
write_batch(batch_no, buffer)
ポイントは2つ。
-
len(...encode("utf-8"))で実バイト数を測る: マルチバイト文字を含む場合、文字数では足りない -
INSERT INTO ... VALUESのプレフィックス・サフィックスもバイトに含める: 上のコードでは省略していますが、実装時は忘れずに
AI に分担させた部分
リカバリ作業の中で、AI(Claude Code)に明確に分担させたのは次の3つです。
| 任せた作業 | 理由 |
|---|---|
| CSV + DDL → 複数行 VALUES INSERT 文の生成 | DDL のカラム順・型ごとのクォート要否を機械的に処理してくれる |
| NOT NULL カラムの空欄補完 | 「特定カラムが空の行はデフォルト値で埋める」というルールベース処理が定型化できる |
| 特定行の不整合検出 | 「update_id lock_version が空の行を列挙して」と頼むと一発で出してくれる |
逆に任せなかったのは、「最終的にどのバッチサイズで流すか」の判断です。ここは実測ベースで人間が決める領域で、AI に頼むと「一般論で安全な値を返してきがち」になります。
投入後の件数検証は必須
バッチを分けると、1バッチだけ流し忘れるのが現実的にあり得る事故です。投入後は必ず、
SELECT COUNT(*) FROM target_table WHERE ...;
で期待件数(10,923件)と一致することを確認して終わります。
Part 5: 副次トラブル — GCS から VM へのファイル転送
ARG_MAX 対策でバッチを小ファイルに分けた結果、今度はファイル転送で詰まりました。
gsutil cp -r gs://bucket/path/ ./
# → 空フォルダだけがコピーされる
gsutil cp -r でフォルダ構造ごとコピーしようとすると、たまにフォルダだけ作って中身が空になる事象に遭遇します。
gsutil cp gs://bucket/path/*.sql ./
# → ファイルが正しく取得できる
ワイルドカード指定に切り替えたら一発で解決しました。フォルダ階層をそのまま再現したい用途以外は、ワイルドカードの方が安定します。
「制約を回避すると次のレイヤで新たな摩擦が生まれる」典型例で、リカバリ作業中はこういう連鎖が普通に起きます。
Part 6: 認証境界 — ローカルから商用への壁
最後にもう一つの壁が、商用 Spanner への認証でした。
ERROR: invalid_grant
ローカル PC から商用 Spanner へ gcloud spanner databases execute-sql を直接叩こうとしたら弾かれました。商用環境は IAM のロール境界・条件付きアクセス・接続元IP制限などで守られており、ローカルからの直接実行は前提として通らないようになっています。
ここは技術で突破するのではなく、運用判断で割り切りました。
商用への大量データ投入は、移行用 VM からの実行を標準フローに固定する。
ローカルからやろうとして時間を溶かすより、最初から VM 経由のフローに乗せたほうが速く、かつ事故らない。リリース時は「速さより事故らない経路」を選ぶ、というのは鉄則です。
おわりに: 3 レイヤの壁を順に潰す
今回の経験を整理すると、リカバリで踏んだ壁は 3 つのレイヤにきれいに分かれていました。
| レイヤ | 制約 | 今回の対処 |
|---|---|---|
| DB | Spanner ミューテーション上限 20,000 / トランザクション | バッチ分割(最初は1,500件) |
| OS | Linux ARG_MAX ≒ 128KB | バッチサイズを 50KB に絞る |
| 認証 | ローカルから商用への直接アクセス不可 | 移行用VMからの実行に固定 |
そして覚えて帰ってほしいのは次の3点です。
-
ミューテーション数は
行数 × (カラム数 + インデックスカラム数)で見積もる。行数ベースで考えない -
バッチ分割は「件数」ではなく「バイトサイズ」。閾値は 50KB が安全圏だが、自分の環境では
getconf ARG_MAXで確認する - 制約を回避すると次のレイヤで新たな摩擦が生まれる。連鎖を前提にスケジュールを組む
商用 Spanner のリカバリで似たような壁に当たっている方の参考になれば嬉しいです。