187万行のレガシーCSVと戦った3週間 — 過去データ分析機能をRDSからS3直読みに移行した話

りょた
目次
こんにちは!@Ryo54388667です!☺️
普段は都内でエンジニアとして業務をしてます!TypeScriptやNext.jsが多めですが、最近はGoとAWSのバックエンドをよく触っています。
今回は 社内システムの過去データ分析機能を、RDSの中間テーブルをやめてS3上のCSVを直接読む構成に移行した話 を書いていきます!
移行自体は成功したのですが、その過程でメモリ不足によるプロセスの強制終了(OOM Kill)を2回、詳細APIが1件も応答を返せなくなるタイムアウト(504エラー)を1回踏みました。。
この記事は、S3直読みのようなアーキテクチャを検討している方、コンテナでGoを動かしていてメモリに悩んでいる方に特に参考になると思います!
背景: 159列・187万行の「癖が強すぎる」CSV
#社内のTMS(輸送管理システム)には、旧配車システムから月次でエクスポートされるCSVを検索・集計できる「過去配車データ分析」という機能があります。
このCSVが、なかなかの代物なんです。。
- 159列固定、Shift-JIS(CP932)、CRLF。NULバイトとフィールド内改行が混入していて、素朴なCSVパースは壊れる
- 1行が「オーダーNo × 明細行 × データ区分」の多次元クロス構造で、(オーダーNo, 行)は一意ではない
- 配車レコードの区分は配車先の数だけ満額が並ぶので、単純合計すると売上を過大計上する(区分の解釈自体、業務側の最終確認が残る暫定のまま運用しています)
- 159列のうち実質意味があるのは40〜50列程度
実際に単月分を検品してみると、物理15,092行のうち正常な行は14,505行。フィールド内改行で分断された継続行が533行、列過剰の行が54行ありました。物理行を159列に繋ぎ直す前処理を経て、ようやく論理15,091行になります。
規模感としては、1ファイル(1ヶ月)あたり生17〜18MB・約1.5万行。アーカイブ全体では2017-05〜2026-06の110ファイル、約187万行、生2.1GBです。
最初の設計(RDS取込方式)と、そこで起きたこと
#「いやいや、素直にRDBに取り込めばええやん?」と思いますよね。僕も最初はそうしました。
当初はimporterがCSVをパースしてAuroraの中間テーブル(159列+JSONB)に取り込み、動的SQLで検索する王道の構成で、まず単月分を本番投入してリリースしました。
すると1ヶ月足らずで、取込パイプライン起因の問題が続けて出ました。
- 旧CSVの傭車先情報は2系統あるのにimporterが第1系統しか取り込んでおらず、212オーダー(2.6%)で傭車先の社名が画面に出ない。うち195オーダーでは、傭車料にして数百万円規模の支払先が画面から分からない状態
- 一覧と件数のSQLでGROUP BYの粒度が非対称になっていて、本番実測で904オーダー(11.2%)が重複表示。ページャは82ページまでしか出ないのに実データは93ページ分あり、末尾約1,100行が閲覧不能
どちらも「取込時にデータを変換する」工程そのものがバグの温床になっているのが根っこでした。
しかもこのデータ、月次で追記されるだけで一切更新されない読み取り専用データなんですよね。Auroraのトランザクション性も更新性能も活きません。
さらに悩ましいのが、データ量と料金です。データはこの先も毎月約1.5万行ずつ膨れ上がり続けます。全110ヶ月分(187万行)を取り込めばAuroraのストレージとインスタンスサイズを押し上げますし、増え続けるデータをRDBに載せ続ける料金も懸念でした。
そこでRFC(アーキテクチャ移行の提案書)を書きました。
設計判断: CSVをそのまま正本にする
#方針は「改ざんされたくない月次スナップショットの集合、という本質に合わせて、S3 + Object Lock(一度置いたファイルを削除・上書きできなくするS3の機能)を正本にする」です。
- 正本はS3上のgzip CSV。キー規約は
v1/legacy-dispatch/year=YYYY/month=MM/{開始日}_{終了日}.csv.gz - RDSには行データを持たず、ファイル台帳のみ
- アップロードは管理者のS3手動PUT。検証LambdaがS3イベントで起動し、キー規約・gzip・159列ヘッダ・解凍後サイズ上限(512MiB、gzip-bomb対策)を検証して、不正ファイルは隔離してSNS通知
- 差し替えは新ファイルを置いて古い方を論理的に無効化する方式(supersede)。物理削除はしない
- 週次の整合性チェックLambdaがS3と台帳の乖離を検知・自己修復
- APIは
/api/v2/legacy-dispatchesとして新設し、既存v1(SQL経路)と並行運用。JSONレベルのパリティ検証を通してからv1を廃止する段階移行
判断の決め手は3つです。
- コスト: RFCの試算では、RDS方式が月300〜500ドルほどに対し、S3直読みは月100〜300ドルほど。桁が変わるわけではなく2〜5割削減が現実的、とRFC自身に明記しました
- 正しさ: CSVをそのまま正本にすることで、取込時変換バグによるデータ欠落クラスを構造的に排除できる。読み取りロジックのバグは直せば直りますが、取込時に落とした情報は戻りません
- 改ざん耐性: Object Lock + KMS CMK + MFA必須のロール分離
一方でADRには「v2 APIはS3から全ファイルを読みメモリ展開する。データ量増への対応は将来課題」と書いていました。
この一文が、後で牙を剥きます。
作る: パリティテストと実インフラ
#SQLとインメモリエンジンの「パリティ」をどう保証するか
#v2の技術的な肝は、動的SQLでやっていた検索・集計(WHERE/HAVING/集計/ソート/ページング)をインメモリで1:1再現することです。
正しさの担保は、v1とv2に同じ入力を与えて応答の一致を検証する「パリティテスト」に全振りしました。同一の159列Shift-JIS CSVを
- (A) importer → RDS → v1 API
- (B) gzip → MinIO(テスト用S3) → 台帳登録 → v2 API
の2経路に流し、レスポンスをJSONレベルで完全一致比較します。
ソート9列×2方向、ページング、フィルタ19種のマトリクスで初版52サブテスト(その後の機能追加に追随して現行67)。過去にバグった論点は、ピンポイントでサブテストを立てました。
意外とハマった差分たち:
- 照合順序: v2はGoのバイト順比較で、Postgres側は
en_US.utf8。非数値文字列のソートや代表選定は理論上ズレうる(後述しますが、これは「解消」ではなく「顕在化を抑えた」だけです) - 日付: lib/pqと
time.ParseでLocationが違ってstruct比較が不一致に。"YYYY-MM-DD"へ整形した後のJSON比較で解決 - NUMERIC(10,2): RDS往復で付くスケールに合わせ
big.Rat.FloatString(2)で正規化
もう一つの設計判断は、importerの純関数(パース・行繋ぎ・行分類)をリーダーがそのまま再利用することです。パース仕様を二重管理しないことで、「v1とv2で行の解釈が違う」というバグクラスを潰しました。
実インフラで踏んだ細かい罠
#検証Lambda・Terraform(Object Lockバケット、KMS、DLQ、EventBridge、MFAロール)を実装してstaging → prodと有効化していく中でも、細かい罠を踏んでいます。
- AWS Chatbotは対応スキーマ外のSNSメッセージをSlackへ転送せず黙って破棄する。隔離通知がPublish成功なのにSlackに届かず、カスタム通知スキーマへの変更が必要でした
- S3の404判定は
errors.Asで*types.NoSuchKeyを見るだけだとCopyObject/HeadObjectにマッチしない。smithyのErrorCode("NoSuchKey"/"NotFound")での判定が必須でした - 環境変数が実際に本番へ届く経路はTerraformではなく、CIが使うECSタスク定義ファイル(taskdef JSON)。Terraform側のタスク定義はブートストラップ用で
ignore_changesになっていて、Terraformだけに足しても本番に届かない
3つ目の知見は、後のGOMEMLIMIT設定でそのまま効いてきます。
歴史データの一括投入(backfill)はGitHub Actionsで行う設計にしました。csvcheck(ファイル名の暦日実在性・gzip・ヘッダ・行数検証)を正式ツール化し、110ファイルをローカルで疑似実行して全緑を確認してから、2026-07-09にprodへ110ファイル(約187万行)を投入しました。
ここまでは、順調だったんです。
一度目のメモリ不足(OOM): 毎リクエスト約10GB
#backfill完了後にv2の一覧APIを叩くと、約28秒でタスクがメモリ不足で強制終了されて、502エラーが返ってきました。
切り分けの結果はこうです。
- v2リーダーはキャッシュ無しで、毎リクエスト全アクティブファイル(110個)をS3からフルロードするPoC実装のままだった
- さらに主犯はRaw JSON生成。CSV 1行ごとに159列ヘッダを再埋め込みしたJSONを生成して全行保持していた。このRawを実際に使うのは詳細画面の代表1行だけなのに、です。。
- 概算でRaw JSON約7.9GB + 型付き構造体約1.7GB、合わせて 1リクエストで約10GB を要求。ECSタスクに割り当てているメモリ上限は2048MiB
勝負になりません😇
不幸中の幸いで、フロントエンドは全画面v1(RDS)参照のままだったので、機能面のユーザー影響は出ませんでした。ただし無傷でもなく、v2がメモリ不足で落ちた瞬間に、同一タスクへ載っていたv1リクエストが巻き添えで一時502になっています(タスク入替の1〜2分間)。
封じ込めとしては、台帳の検証ステータスを一時停止に更新してv2の配信対象を空にしました。S3実体は無傷、コード変更なしで即効です。
なぜstagingで気づけなかったのか?staging検証を18MB(7.5万行)の1ファイルで行っていたからです。それなら全量ロードでも余裕で載ってしまいます。
少量データの機能検証はメモリ不足を検出できない。実データ量での負荷検証をリリースゲートにする、というのがこの日いちばん高くついた教訓でした。
おまけ: データそのものにも爆弾があった
#この調査の過程で、2026年6月分のファイルが2025年6月分のバイト完全一致の複製(MD5一致)であることも発覚しました。
両ファイルのmtimeが同日15分差だったことから、アーカイブ取得時の取り違えと推定されます。つまり、2026年6月の実データがそもそも存在しなかった。。
csvcheckは「1ファイルの内容の正当性」しか見ておらず、ファイル間でハッシュを突き合わせていなかったため素通りでした。後追いで csvcheck -dedup(圧縮後バイト列のsha256で複製を検出)を追加しています。110ファイルの突き合わせは48秒で完了しました(作業ログ実測)。
恒久修正: 「作らない・持たない・読まない」
#対策は3本柱+1で設計しました。
- A. Raw JSONの遅延生成: 一覧では一切生成しない。詳細でも対象オーダーの一致行のみ生成する。これだけで最大の増幅要因(約7.9GB)が消える
- B. ファイル単位キャッシュ + singleflight: パース結果をファイル単位でキャッシュし(あふれたら古いものから捨てるLRU方式)、同じファイルへ同時に来た読み込みは1本にまとめる(singleflight)
- C. 期間プッシュダウン: 台帳に日付範囲クエリ(±1ヶ月マージン)を追加し、期間指定時は該当ファイルしか読まない。既定の「直近13ヶ月」はhandler層でのみ注入する
- D.
csvcheck -dedup(前述)
Cの「±1ヶ月マージンで足りるのか」は経験則に頼らず、本番187万行を全数検証して「同一オーダーNoが隣接月ファイルに跨るケースは0件」を確定させてから採用しました。
ちなみにこの全数検証では「12ヶ月差の跨ぎ」が10,605件検出されて一瞬ヒヤッとしたのですが、全て前述の複製ファイルペアでした。複製された2025年6月分の全オーダーが、12ヶ月差の「跨ぎ」として数えられていただけです。
加えて、ワイドレンジガードも入れました。選択されたファイルの行数合計が50万行を超える検索は400(RANGE_TOO_WIDE)で拒否します。全期間指定で構造体約1.7GBが1リクエストに載る、残されたメモリ不足の経路を明示的に遮断しておく狙いです。
パリティテストとraceテストは全緑。これで解決したはず、でした。
二度目のメモリ不足(OOM): Goの罠2連発 🔍
#前回の教訓に従って、stagingに実データ109ファイル・1,730,545行を投入して実量負荷検証を行いました。
結果、v2初回リクエスト(33秒)でメモリ不足の強制終了が再発しました(exitCode=137、OOM Killのサインです)。
恒久修正したはずなのに、です。マジかぁぁ。。
CloudWatchのタイムラインとローカル計測、そしてメモリプローブ(serviceパッケージに一時的な _test.go を置き、実データ直近24ファイル・429,719行で実測)の3つの証拠で原因を特定しました。
原因は3層の複合でした
#- 全ファイル集計がキャッシュを埋めながら走っていた。一覧に出す代表社名マップは、全ファイルを一度読んで畳み込む集計処理(以下fold)が必要になる。このfoldがファイル単位キャッシュ(上限24ファイル)へ書き込みながら走るため、終盤に直近24ファイル・約49万行がメモリに残り続ける
- 実測は1,820B/行で、設計見積り(900B/行)のほぼ2倍。Goの
encoding/csvは1レコードの全159列を単一のbacking stringに載せ、各フィールドはそのsubstringになる。キャッシュに保持したいのは20列程度でも、参照が生きている限り159列分の行テキスト全体がGCできない(substringピン留め) - GOMEMLIMIT(Goランタイムにメモリ上限の目安を伝える環境変数)が未設定。Goの既定設定(GOGC=100)ではヒープが生存データの約2倍まで成長してからGCが走るので、生存データが900MBなら実メモリ使用量はほぼ2048MiBに到達する
コードにするとこういう話です。
// encoding/csv は1レコードの全159列を1本のbacking stringに載せる。
// 各フィールドはそのsubstringなので、数列だけ保持したつもりでも
// 参照が残っている限り159列ぶんの行テキスト全体がGCされない。
record, _ := reader.Read()
keep := record[3] // この参照が行テキスト全体をピン留めする
// キャッシュに長期保持するなら、格納直前に新規割当へ切り離す
keep = strings.Clone(record[3])
プローブの実測値が、そのまま対策の効果見積りになりました(24ファイル・429,719行)。
| シナリオ | 常駐 | ピーク |
|---|---|---|
| A: 当時の実装(LRU保持) | 746MB(1,820B/行) | HeapSys 1,391MB |
| B: fold非保持案 | 3MB | HeapAlloc 205MB |
C: strings.Clone 格納案 | 402MB(981B/行、-46%) | — |
対策
#- foldはキャッシュを読むだけ(get-only)にする: キャッシュにあれば使うものの、無いときは書き込まず読み捨てで処理し、集計後すぐメモリを手放す
- キャッシュ上限を「ファイル数」から「行数」へ: 「24ファイル」という上限は、ファイルサイズ次第で中身が5万行にも50万行にもなる。行数(合計25万行まで)で管理すれば、常駐メモリの上限を981B/行 × 25万行 ≒ 245MBと見積り可能になる。既定13ヶ月窓(約22.6万行)がちょうど丸ごと収まるサイズ
- キャッシュput直前に全stringフィールドを
strings.Cloneで新規割当に差し替え、CSV行バッファから切断する。フィールド追加時のClone漏れは、reflectを使ったテストで自動検知 - GOMEMLIMIT=1536MiB: タスクのメモリ上限2048MiBの75%を目安に設定し、上限へ近づく前にGCを積極的に走らせる。前述の知見どおり、taskdef JSONとTerraformの両方に対称に設定
staging実測でメモリ不足は解消しました。foldと一覧のピークは約1.1GBでGOMEMLIMITの範囲内、キャッシュが温まった後(warm後)の一覧は既定13ヶ月窓で0.8〜1.8秒、1ヶ月範囲なら62ms〜0.6秒です。
対照実験として、旧設定のままのタスクは同日2回メモリ不足で落ちています。憶測の900Bと実測の1,820Bの差が、そのまま再発の正体だったわけです。
コールドスタート73秒 vs CloudFrontの30秒
#メモリ不足が消えると、次のボトルネックが顔を出します。キャッシュが空のコールド状態からのfoldが実測73〜82秒かかるんです(Fargate 1vCPU、CPU 92%張り付き。ローカルのM系チップでは33秒だったので約2.5倍遅い)。
問題は、CloudFrontがオリジンの応答を待つ上限(OriginReadTimeout)が30秒なこと。デプロイ直後の初回リクエストはCloudFrontに30秒で切断され、リトライ(最大3回)頼みで5xxエラーになり得ます。しかもfoldに相乗りしていた後続リクエストも、先頭のリクエストが切断されると巻き添えで500エラーになります。
対策は起動時WarmUpです。起動時にバックグラウンドgoroutineで、実際の一覧経路(foldと既定13ヶ月窓のキャッシュ構築)を空撃ちします。ECS起動からCodeDeployのトラフィックシフトまでstaging実測で約6.5分あるので、73秒のfoldは流入前に確実に終わります。失敗してもWarnログを残して遅延初期化にフォールバックするだけ、という安全側の実装にしました。
検証時に踏んだ運用の罠が2つあります(いずれも作業ログ実測)。
- CodeDeployのカナリアリリース(まず新版に10%だけ流して5分様子を見る)中は、90%のリクエストが旧タスクに流れる。デプロイ直後に検証して旧コードのメモリ不足を誘発し、「新版でも再現した!」と誤認しかけました。検証はデプロイ完了の後に行うこと
- warmの総所要は実測152秒に対し、カナリアの10%流入は起動94秒後に始まる。warm完了前に流入する58秒の窓が残っていて、warm完了までトラフィックを流さない仕組み(readiness gating)が将来課題です
もう一つ、この調査を難しくした事情として、Container Insightsが2026-07-07から停止していてタスク別メトリクスが取れませんでした。メモリ調査の最中に観測手段が欠けているのは痛かったです。。
詳細APIの全滅と、逆引きインデックス
#一覧が安定したところで、今度は詳細画面のAPIが全リクエストでタイムアウト(504エラー)になっていることが判明しました。
詳細経路は一覧用キャッシュを転用できない(一覧キャッシュにはRawが無い)ため、全109ファイルを昇順にストリーミング走査する実装でした。実測は約1.0〜1.1ファイル/秒で、完走に約110秒。設計時のレビューでは「11〜38秒/リクエスト」と見積もっていたので、実測はその3〜10倍です。
さらに質が悪いことに、singleflightも context.WithoutCancel も無かったので、CloudFrontが30秒で切断してリトライするたびに走査が進捗ゼロからやり直され、1クリックでCPU 90〜98%を約3分占有していました。
調査で得た小ネタ: 約30秒で切れているならALB(60秒)ではなくCloudFront(30秒)の仕業です。エラーログのs3_keyは「オーダーの所在」ではなく「走査の到達位置」を指すので、そこから走査速度を逆算できます。
短期対策: 窓内キャッシュ逆引き + singleflight
#- 探索窓(直近13ヶ月)内は一覧キャッシュを逆引きして所在ファイルを特定し、ヒットしたファイルだけをdetailモードで再取得。窓内詳細は0.6〜1.1秒になりました(作業ログ実測)
- オーダーNo単位のsingleflightと
context.WithoutCancelを導入。CloudFrontのリトライは進行中の走査に相乗りする
面白い副作用がありました。存在しないオーダーNoへの404が、86.9秒かけて「正しく」返ったんです。
フルスキャン(約87秒)がCloudFrontの3回目リトライの窓(60〜90秒)にぎりぎり間に合い、singleflightに相乗りしたリトライへ404が配達された、という理屈です。設計どおりの動作ではあるものの、データ量が増えて90秒を超えた瞬間に504へ戻る、綱渡りの正しさです。
恒久対策: 台帳にorder_no_min/max
#フルスキャンを構造的に消すため、台帳に正規化済みオーダーNoの範囲(order_no_min/order_no_max)を持たせ、検証Lambdaが登録時に算出する方式にしました。詳細取得は範囲に該当する候補ファイルだけを読みます。さらに候補のうち一覧キャッシュに載っているファイルは、対象オーダーの実在をメモリ内で先に判定してS3 GET自体をスキップします。
設計上こだわった点:
- 3値の意味論: NULLは未算出なので常に候補扱い(fail-safe)、空文字列は算出済みで通常行ゼロなので決して候補にしない、値ありなら範囲判定。
sql.NullStringで空文字をNULLに潰すと「通常行ゼロのファイル」が週次バックフィルの無限ループになる、という罠を踏みかけて仕様化しました - 行選定述語のSSOT: 「範囲算出の対象行」と「リーダーが読む行」の述語がズレると取りこぼしが起きるので、行分類関数をimporter・リーダー・範囲算出の3経路で共有し、包含関係を差分テストで固定
- 比較の照合順序: 正規化キーは10文字ゼロ埋めなので、Goのバイト順比較とPostgresのC collationが一致して安全。Auroraの
en_US.utf8に依存しない - デプロイ順序: migration、Lambda、バックフィル、APIの順を守らないと「全ファイルNULL=全ファイル候補」でフルスキャンに退行する
staging実測の変化はこの通りです(作業ログ実測)。
| ケース | Before | After |
|---|---|---|
| 窓内(直近13ヶ月)の詳細 | 0.6〜1.1秒 | 3.5秒 ※ |
| 窓外(例: 2018-06)の詳細 | 約87〜110秒で504 | 6.0〜6.2秒で200 |
| 存在しないオーダーNo | 約87秒(奇跡の404) | 0.42〜0.44秒で404 |
※窓内が遅くなったのは、「詳細は毎回S3からdetail取得する」方式に一本化したトレードオフです(issueに明記の上で採用)。
このトレードオフは後続で埋めました。詳細結果のオーダーNo単位キャッシュと候補ファイル4並列取得で、同一オーダー再訪は4,667msから238msへ(19.6倍)。さらに、応答JSONをDBへ事前生成しておくマテリアライズも実装済みです(エンドツーエンド約230msが設計値。本番デプロイと観察は執筆時点で未了)。
フロントエンド切替とBefore/After
#バックエンドが安定したので、3分割のPRでv1からv2へ切り替えました。
- v2にofficesとCSVエクスポートを追加し、RANGE_TOO_WIDEをエラーコード化。ここでも1件、CSVエクスポート末尾の軽いDBクエリだけがキャンセル可能な元ctxを受けていて、重い走査が完走したのに最後でcontext canceled 500になる非対称バグを踏んでいます
- FEのAPIクライアントをv2に切替。積込日Fromを必須化し、50万行ガードには専用バナーを用意
- CSV出力上限を10,000件から30,000件へ。1ヶ月分が実測10,605件あり、月次利用で毎回上限に当たると分かったためです(30,000件×12列×200Bで約6MBと妥当な範囲)。余談ですが、元の10,000件という上限は根拠がどこにも残っておらず、引き上げ判断の前に「なぜ1万なのか」の考古学から始める羽目になりました
7/9のメモリ不足から7/17の切替まで、機能面のユーザー影響はほぼゼロに保てました(唯一の例外は、v1巻き添え502の1〜2分)。
v1を無改変のまま並行運用し、パリティテストで一致を保証し、v2が安定してから切り替える。この愚直な移行戦略が、メモリ不足2回と504全滅を「ユーザーにほぼ見えない場所での試行錯誤」に封じ込めてくれました。
結果のBefore/Afterはこうです。
| 指標 | Before(RDS方式 / v2初版) | After(2026-07時点) |
|---|---|---|
| データ正本 | Aurora中間テーブル(159列+JSONB) | S3 gzip CSV + Object Lock |
| 取込起因のデータ欠落 | 社名欠落212オーダー等が発生 | 変換工程が無く構造的に排除 |
| 一覧API | v2初版は約10GB要求でメモリ不足の502 | warm後0.8〜1.8秒(13ヶ月窓) |
| 詳細API | 全ファイル走査で全件504 | 窓外約6秒、404は0.42秒、再訪238ms |
| 常駐メモリ | fold時の生存データ約900MBでメモリ不足 | 見積り上限約245MB、実測ピーク約1.1GB |
| コールドスタート | 初回リクエストでfold 73〜82秒の5xx | 起動時WarmUpで流入前に完了 |
| インフラコスト(試算) | RDS方式で月300〜500ドルほど | S3直読みで月100〜300ドルほど |
| 正しさの担保 | — | v1とv2のパリティテスト67サブテスト |
教訓
#- 少量データの機能検証はメモリ不足を検出できない。実データ量での負荷検証をリリースゲートにする。しかも「1回やれば安心」ではなく、メモリに触る変更のたびに要る
- 「B/行」は実測する。Goでは
encoding/csvのsubstringピン留めで、保持したい列数から計算した見積りの2倍になった。キャッシュに長期保持する文字列はstrings.Cloneで切断する - コンテナでGoを動かすならGOMEMLIMITは必須。既定設定のままだと、実メモリ使用量は生存データの約2倍まで伸びる。そして環境変数は「実際に本番へ配信される経路」に入っているかまで確認する
- キャッシュの上限は「件数」ではなく「中身の量(行数・バイト数)」で管理する。件数capはファイルサイズの分散にいくらでも裏切られる
- CDNのタイムアウトが実質のSLA上限。CloudFrontの30秒を超える処理は、リトライで負荷を増幅しながら結局5xxになる。singleflightと
context.WithoutCancelでリトライを進行中の処理に相乗りさせる - 全件走査は「今は速い」でも入れない。設計レビューの見積りですらCloudFrontの上限を超えていたのに、実測はさらに3〜10倍だった。逆引きできるメタデータを台帳に持たせる方が結局安い
- 投入前にファイル間でハッシュを突き合わせる。個々のファイル検証だけでは「別の月として投入された完全複製」は捕まらない
- 移行は並行運用とパリティテストで。パース関数を共有した上で、レスポンスのJSON完全一致をCIで固定するのが費用対効果が高かった
- CodeDeployカナリア中の検証は90%が旧タスクに当たる。デプロイ直後の「再現した!」は疑ってかかる
- マジックナンバーには根拠を残す。「10,000件」の由来がどこにも記録されておらず、考古学が必要になった。今回の30,000件には根拠を残した
- 観測手段は平時に整備する。Container Insightsが止まっていたせいで、メモリ調査の最中にタスク別メトリクスが無いという二重苦になった
最後に
#この記事では、187万行のレガシーCSVをRDS中間テーブルからS3直読みへ移行した3週間を紹介しました。
宿題もまだ残っています。
- 2026年6月分の正データ再取得と再PUT
- v1エンドポイントと中間テーブルの削除、そしてAuroraダウンサイズ(ここまでやって初めてコスト効果を刈り取れる)
- 詳細応答の事前生成(マテリアライズ)の本番デプロイと観察
- WarmUp完了前にカナリア流入が始まる58秒の窓(readiness gating)
- データ区分の解釈の業務側最終確認
同じようにS3直読みのアーキテクチャや大容量CSVと格闘している方の参考になれば幸いです!
より良い方法があれば教えてください〜
最後まで読んでいただきありがとうございます!
気ままにつぶやいているので、気軽にフォローをお願いします!🥺
