DSQL → TiDB 移行
起票日: 2026-06-26
移行元: AWS DSQL(PostgreSQL 15.15 互換)
移行先: TiDB(Self-hosted, Docker / Kubernetes)
ステータス: 計画策定済み
概要
ブログ基盤の主データベースを AWS DSQL から TiDB(Self-hosted)に切り替える。 移行方針はユーザー指定の以下 2 ステップを基本とする。
dsql-cliで TSV 形式のバックアップを取得TiDB に
LOAD DATA LOCAL INFILEで投入
加えてスキーマ DDL の変換、apps/blog-api の sqlx ドライバ切替、iac/aws(CDK)からの DSQL 撤去まで一連で進める。
移行方針
+------------+ SELECT * (paged) +-----------+ LOAD DATA LOCAL INFILE +------+
| AWS DSQL | ----------------------> | TSV files | -------------------------> | TiDB |
+------------+ (dsql-cli export) +-----------+ (dsl-tidb/load.sh) +------+
DSQL は
COPY ... TO STDOUTを公式サポートしていない(ロード方向の\copy FROMのみ言及あり)ため、SELECT *を主キー順にページングして TSV に整形するバックアップは
app.users→app.tags→app.articles→app.articles_tagsの順で取得・投入する出力フォーマットは PostgreSQL 標準の
COPY ... TO STDOUT WITH (FORMAT text)と互換の TSV(タブ区切り、NULL は\N、\t \n \r \\を PG 標準エスケープ)を自前で生成MySQL/TiDB の
LOAD DATAは同じ\Nを NULL として解釈するため、NULL 表現の変換は不要
互換性差分
既存 (DSQL / PostgreSQL) |
TiDB / MySQL |
|---|---|
|
|
|
|
|
TiDB 7.x の |
スキーマ修飾 |
TiDB は |
バインドパラメータ |
|
タスク1: dsql-cli export サブコマンド追加
設計
修正ファイル:
tools/dsql-cli/src/index.ts既存の
connectDsql()/isPostgresUrl()を流用DSQL は
COPY ... TO STDOUT非対応のため、SELECT *を主キー順にページング(WHERE pk > $1 ORDER BY pk LIMIT $2)で取得し、PG TEXT フォーマット互換の TSV に整形して書き出すDSQL の接続タイムアウト(1 時間)と OCC を考慮し、ページサイズはデフォルト 1000 行・必要に応じて調整可能にする
CLI オプション:
-e/--endpoint(環境変数DSQL_CLUSTER_ENDPOINTをデフォルト),-o/--out-dir(デフォルトbackup/),--page-size(デフォルト 1000)
チェックリスト
[x] PG TEXT フォーマット互換のエスケープ処理(
\→\\、TAB →\t、LF →\n、CR →\r、NULL →\N)をユーティリティ化[x]
SELECT * + 主キーページングで TSV を生成するexportサブコマンドを実装[x]
package.jsonのscriptsに"export": "tsx src/index.ts export"を追加[ ] 4 テーブル分の TSV が生成され、行数が
SELECT count(*)と一致することを確認(dev 環境で実施)[x] TiDB に
LOAD DATA LOCAL INFILEで投入し、NULL を含むカラムが正しく復元されることを確認(手作り fixture で検証済み)
実装記録
tools/dsql-cli/src/export.tsを新規追加。information_schema.columns/key_column_usageからカラム順と主キーを取得し、各カラムを(col)::textまたはto_char(col AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US')でキャストして取得。主キーページングは
WHERE pk > $1::<pk_type>(複合キーは行値比較WHERE (a,b) > ($1::ta,$2::tb)) を採用。uuid/bigint/timestamptz等の元型を維持してインデックスを効かせる。tools/dsql-cli/src/index.tsにexportサブコマンドを追加し、package.jsonのscripts.exportも追加。接続初期化時に
SET TIME ZONE 'UTC'を発行し、to_charの出力が UTC 基準になるようにする。
タスク2: TiDB 用 DDL 整備と LOAD DATA 投入手順
設計
新規ディレクトリ
tools/dsql-cli/dsl-tidb/(既存dsl/は改変しない)LOAD DATA は
LOAD DATA LOCAL INFILEを採用(Self-hosted 前提)
チェックリスト
[x]
dsl-tidb/schema/01_schema.sql(CREATE DATABASE IF NOT EXISTS \${SCHEMA}``)[x]
dsl-tidb/schema/02_users.sql[x]
dsl-tidb/schema/03_tags.sql[x]
dsl-tidb/schema/04_articles.sql[x]
dsl-tidb/schema/05_articles_tags.sql[x]
dsl-tidb/load/0{2..5}_*.sql(テーブルごとのLOAD DATA LOCAL INFILE+SHOW WARNINGS)[x]
dsl-tidb/load.shで${SCHEMA}/${TSV}を sed 置換しつつ DDL → LOAD DATA を順次実行[x] TiDB (
blog_dev) で warning 0、SELECT COUNT(*)一致を確認(手作り fixture: users 2 / tags 2 / articles 2 / articles_tags 3)[ ] dev 環境の実データで
SELECT COUNT(*)が DSQL と一致
LOAD DATA クエリ例(テンプレート: ${SCHEMA} / ${TSV} を load.sh が置換)
SET time_zone = '+00:00';
LOAD DATA LOCAL INFILE '${TSV}'
INTO TABLE `${SCHEMA}`.`users`
CHARACTER SET utf8mb4
FIELDS TERMINATED BY '\t' ESCAPED BY '\\'
LINES TERMINATED BY '\n'
(`user_id`, `name`, `email`, @github_installation_id, @created_at, @updated_at)
SET
`github_installation_id` = NULLIF(@github_installation_id, '\\N'),
`created_at` = NULLIF(@created_at, '\\N'),
`updated_at` = NULLIF(@updated_at, '\\N');
SHOW WARNINGS;
実装記録
ディレクトリ構成
tools/dsql-cli/dsl-tidb/ ├── schema/ # CREATE DATABASE / CREATE TABLE (${SCHEMA} 注入) │ ├── 01_schema.sql │ ├── 02_users.sql │ ├── 03_tags.sql │ ├── 04_articles.sql │ └── 05_articles_tags.sql ├── load/ # 各テーブルの LOAD DATA + SHOW WARNINGS (${SCHEMA}/${TSV} 注入) │ ├── 02_users.sql │ ├── 03_tags.sql │ ├── 04_articles.sql │ └── 05_articles_tags.sql └── load.sh # CLI ラッパ${SCHEMA}を sed で--database引数に置換するため、blog_dev/blog_prd等を切り替えて同じ DSL を再利用できる。各 LOAD DATA は
LOAD DATA LOCAL INFILEを使うため、mysql --local-infile=1で接続する。サーバー側にもlocal_infile=1が必要(TiDB 既定で ON、SELECT @@local_infileで確認可)。TiDB は
tidb_enable_check_constraintが既定 OFF で、CHECK 句を含む CREATE TABLE はWarning 1105: the switch of check constraint is offを出す。warning 0 が要件のため04_articles.sqlから CHECK を外し、status のバリデーションはアプリ層 (blog-api) で行う方針とした。クラスタ単位で有効化する場合は管理者がSET GLOBAL tidb_enable_check_constraint = ON;を実施したうえで ALTER TABLE で CHECK を追加する。PG TEXT 形式 (
\N,\t,\n,\r,\\) と MySQLFIELDS ESCAPED BY '\\'は相互運用可能。SET 句で@var受けにしたカラムだけは\Nが文字列のまま入るためNULLIF(@var, '\N')を明示する。
開発環境での実行手順(dev)
前提:
TiDB は Tailnet 上のホスト
tidb.<TAILNET>で 4000/tcp listenローカル PostgreSQL は
docker compose up -d postgresで起動 (5433 を listen、db.yml参照)マイグレーションは
bun run migrate --endpoint postgresql://postgres:postgres@localhost:5433/postgres
# 0) Tailnet を環境変数化(生のホスト名は手順に書かない)
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
echo "TAILNET=$TAILNET"
# 1) ローカル PG -> TSV
cd tools/dsql-cli
bun run export \
--endpoint postgresql://postgres:postgres@localhost:5433/postgres \
--out-dir ./backup
# 生成物の確認
ls -la backup/
# app.users.tsv app.tags.tsv app.articles.tsv app.articles_tags.tsv
# 2) TiDB blog_dev に投入(DDL→LOAD DATA→SHOW WARNINGS→SELECT COUNT(*))
bash dsl-tidb/load.sh \
--database blog_dev \
--tsv-dir ./backup \
--host tidb.$TAILNET
# 3) 必要に応じて再実行(既存 blog_dev を作り直す)
mysql -h tidb.$TAILNET -P 4000 -u root -e "DROP DATABASE IF EXISTS blog_dev"
bash dsl-tidb/load.sh -d blog_dev -t ./backup -H tidb.$TAILNET
本番環境への適用(prd)
前提:
取り元 DSQL endpoint の SSM パラメータが
/prd/shuntaka/dsql/cluster-endpoint、AWS 認証は prd への ssm:GetParameter / dsql:DbConnectAdmin 権限を持つプロファイル (aws-vault exec <profile>)prd 用 SSM Parameter (
/prd/shuntaka/tailscale/*) と gh variable (TS_*_KEY_NAME --env prd) はdocs/source/01_開発ドキュメント/01_development.mdの手順で投入済み (dev/prd 共通の Tailscale OAuth client をfor STAGE_NAME in dev prdの形で両方の path に入れる)取り込み先 TiDB のデータベース名は
blog_prd(stage 名の long と一致、blog_prodではない)blog-api Lambda の
DATABASE_URLは CDK のblog-api-construct.tsでmysql://root@127.0.0.1:13306/blog_devがハードコードされている。prd デプロイ前にblog_${stageName.long}で stage 別に分岐するよう修正する (差分はタスク4 の実装記録に追記)旧 prd DSQL への書き込みを止めるため、GitHub App の Webhook を一時的に Active off にしてから export を行う (export 中に記事が書き込まれると TiDB との差分が発生する)
# 0) Tailnet を環境変数化(生のホスト名は手順に書かない)
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
echo "TAILNET=$TAILNET"
# 1) prd DSQL -> TSV(AWS prd 認証が通ったサブシェルで実行)
cd tools/dsql-cli
DSQL_ENDPOINT=$(
aws ssm get-parameter \
--name /prd/shuntaka/dsql/cluster-endpoint \
--query Parameter.Value --output text
)
echo "DSQL_ENDPOINT=$DSQL_ENDPOINT"
bun run export --endpoint "$DSQL_ENDPOINT" --out-dir ./backup-prd
# 行数を控えておく(この後 TiDB 側と突合する)
ls -la backup-prd/
wc -l backup-prd/*.tsv
# 2) TiDB blog_prd に投入(DDL→LOAD DATA→SHOW WARNINGS→SELECT COUNT(*))
bash dsl-tidb/load.sh \
--database blog_prd \
--tsv-dir ./backup-prd \
--host tidb.$TAILNET
# 3) 取り込み確認: warning 0 + 行数が DSQL と一致
mysql -h tidb.$TAILNET -P 4000 -u root -N -B -e "
SELECT 'users' AS t, COUNT(*) FROM blog_prd.users UNION ALL
SELECT 'tags', COUNT(*) FROM blog_prd.tags UNION ALL
SELECT 'articles', COUNT(*) FROM blog_prd.articles UNION ALL
SELECT 'articles_tags', COUNT(*) FROM blog_prd.articles_tags"
# 4) blog-api prd を TiDB 向きにデプロイ(CDK 経由)
# DATABASE_URL の database 部分が blog_prd になる修正を含むコミットを preview に
# マージしてから deploy.yaml を起動する。
gh workflow run deploy.yaml --ref preview -f stageName=prd
# 5) 本番 API 疎通(cold start を込みで長めに見る)
curl -sS -o /dev/null -w "HTTP %{http_code} time=%{time_total}s\n" \
--max-time 60 https://api.shuntaka.dev/health
curl -sS -o /dev/null -w "HTTP %{http_code} time=%{time_total}s\n" \
--max-time 60 'https://api.shuntaka.dev/users/shuntaka/articles?type=tech'
# 6) GitHub App の Webhook を Active on に戻す
backup-prd/ は記事本文を含むため git にコミットしない (tools/dsql-cli/.gitignore の backup/ を backup*/ のように広げるか、backup-prd/ を個別に追加)。
旧 prd DSQL クラスタは rollback 余地のため当面残し、撤去はタスク4「iac/aws の DSQL 撤去」で実施する。
SHOW WARNINGS の出力が Empty set であれば取り込みは無警告で完了。warning が出た場合の典型例:
Warning 1105: the switch of check constraint is off→ CHECK 制約を DDL から外す(本ツールでは適用済み)Warning 1265: Data truncated for column ...→ DDL の VARCHAR 長さ不足。schema/*.sqlを見直すWarning 1366: Incorrect string value ...→ TSV の文字コードまたはCHARACTER SET utf8mb4指定の確認
タスク3: blog-api の sqlx 切替(Postgres → MySQL)
設計
sqlx::PgPool→sqlx::MySqlPool、Cargo.toml の features をpostgres→mysqlバインドパラメータ
$1, $2, ...→?uuid::UuidをCHAR(36)にマップ、chrono::DateTime<Utc>をDATETIME(6)にマップ
チェックリスト
[ ]
apps/blog-api配下のCargo.tomlの sqlx features をpostgres→mysqlに変更[ ]
apps/blog-api/adapter/src/repository/articles.rsを MySQL 構文に書き換え[ ]
apps/blog-api/adapter/src/repository/users_articles.rsを書き換え[ ]
apps/blog-api/adapter/src/repository/users.rsを書き換え[ ]
cargo test -p adapterのリポジトリ層テストが全て成功[ ]
apps/webのローカル起動で記事一覧 / 記事詳細が表示
実装記録
(実装後に追記)
タスク4: iac/aws の DSQL 撤去と TiDB 接続情報整備
設計
DSQL クラスタ / 関連 IAM /
DSQL_CLUSTER_ENDPOINTを削除TiDB 接続情報を SSM Parameter Store (SecureString) に格納 (既存規約
/<stage>/<project>/...に揃える)Lambda の環境変数を
DATABASE_URL(mysql://形式)に切替
チェックリスト
[ ] CDK スタックから DSQL クラスタ定義を削除
[ ] DSQL 関連 IAM ロール/ポリシーを削除
[ ]
DSQL_CLUSTER_ENDPOINTの Stack Output を削除[ ] TiDB 接続情報を格納する Construct を追加
[ ]
DATABASE_URL(mysql://形式)に切替[ ]
docs/source/01_開発ドキュメント/01_development.mdの「psql接続(DSQL)」「dsql-cli」節を TiDB 版に書き換え[ ]
bunx dotenv -- cdk synth -c stageName=devがエラーなく通る
実装記録
(実装後に追記)
タスク5: blog-api Lambda の Tailscale サイドカー化
TiDB は自宅 LAN (192.168.4.0/22) に置き、Tailscale 経由でアクセスする。blog-api Lambda は既に lambda.DockerImageFunction (container image) なので、公式ガイド (Tailscale on AWS Lambda) に沿って container image にサイドカーとして tailscaled (または tsnet forwarder バイナリ) を同梱 する。
既存 ACL の確認
現状の tailnet policy file (HuJSON):
{
"tagOwners": {
"tag:aws-app": ["autogroup:admin"],
"tag:k8s": ["autogroup:admin"]
},
"acls": [
{ "action": "accept", "src": ["autogroup:member"], "dst": ["192.168.4.0/22:*"] },
{ "action": "accept", "src": ["tag:aws-app"], "dst": ["192.168.4.0/22:4000"] },
{ "action": "accept", "src": ["autogroup:member"], "dst": ["tag:k8s:*"] }
]
}
tag:aws-app → 192.168.4.0/22:4000 が既に許可されているため、Lambda の tailscaled が tag:aws-app で join すれば TiDB (4000/tcp) に届く。ACL の変更は不要。
Tailscale OAuth client の作成方法
Lambda は cold start 毎に新規ノードとして Tailnet に join する。長寿命の auth key を環境変数に焼き込むのは credential leak と tag 変更時の影響範囲が大きいため、OAuth client から都度 ephemeral auth key を発行 する形を取る。
Web UI での作成手順
Tailscale admin console → Settings → OAuth clients を開く
Generate OAuth client をクリック
以下を入力:
項目
値
補足
Description
blog-api-lambda用途を識別できる名前
Scopes
auth_keys(write) のみチェックdevices系は不要 (ephemeral ノードは disconnect で自動削除されるため)Tags
tag:aws-appを選択必須。未指定だと API 経由の auth key 発行が 400 で落ちる。この client は
tag:aws-app付きのキーしか発行できないGenerate client を押すと
client_idとclient_secretが表示される (secret はこの画面でしか出ない、必ずコピー)client_id/client_secret/tailnet_suffixを AWS SSM Parameter Store (SecureString / String) に格納する (下記)
用途別に分ける運用
OAuth client は tag:aws-app 用 / 他バッチ用 といった具合に tag 単位で分ける のがベストプラクティス。client_id を取り違えても発行可能な tag が違うため、blast radius を tag 一つ分に絞れる。tag:k8s 用が必要になったら別途 k8s-cluster 等の名前で新規 client を切る。
SSM Parameter Store への配置
CDK の SSM パス規約 (/<stage>/<project>/...) と既存 GH App / Cloudinary パターン (path だけ env に渡し値は SSM 側) に揃え、3 つの parameter に分けて格納する。詳細手順は docs/source/01_開発ドキュメント/01_development.md 参照:
/<stage>/shuntaka/tailscale/oauth-client-id (SecureString)
/<stage>/shuntaka/tailscale/oauth-client-secret (SecureString)
/<stage>/shuntaka/tailscale/tailnet-suffix (String, tailscale CLI から動的取得)
Lambda は起動時 (INIT phase) に下記の順で動く:
SSM Parameter Store から上記 3 つを取得 (cold start で 1 回、warm 中は再利用)
POST https://api.tailscale.com/api/v2/oauth/token(client_credentialsgrant) で OAuth access token を取得 (1h 有効)POST https://api.tailscale.com/api/v2/tailnet/-/keysで auth key を発行{ "capabilities": { "devices": { "create": { "reusable": false, "ephemeral": true, "preauthorized": true, "tags": ["tag:aws-app"] } } }, "expirySeconds": 600 }
得られた
tskey-auth-...をtailscale up --auth-key=... --hostname=blog-api-lambdaに渡す
認証パラメータの推奨値
パラメータ |
値 |
理由 |
|---|---|---|
|
|
一度限り。cold start ごとに発行 |
|
|
Lambda には必須。disconnect で admin console から自動削除される |
|
|
device approval 待ちにしない |
|
|
発行してすぐ |
|
|
OAuth client に紐付けた tag と一致させる |
Lambda 上の tsnet / tailscaled 運用
state dir = /tmp/tailscaleを必ず使う (Lambda の/tmpのみ rw)INIT phase で
tailscaled --tun=userspace-networking --socks5-server=localhost:1055を起動 (/dev/net/tunが無いため userspace networking 必須)接続先は MagicDNS 名
tidb.<TAILNET>:4000で OK (private IP 直指定は不要)。tailscaled / tsnet が Tailnet の DNS を解決するLambda が VPC 内かつ NAT GW なしだと
login.tailscale.com:443に出られず join 失敗するため、VPC 配置時は NAT GW 必須 (or public Lambda)cold start レイテンシ +1.5〜4s (OAuth token 取得 + auth key 発行 + tsnet up + DERP 確立) を見込む
Rust 連携の方式
sqlx は ALL_PROXY を尊重しないため、公式サンプルの ALL_PROXY=socks5://localhost:1055/ ではそのまま動かない。下記いずれかを取る:
案 |
内容 |
評価 |
|---|---|---|
A1. tsnet forwarder バイナリを Go で同梱 (推奨) |
|
Go バイナリ 30 行、SOCKS5 不要、Rust ノータッチ |
A2. |
|
sqlx 全置換コスト |
A3. sqlx + |
TCP stream を SOCKS5 で wrap して sqlx に渡す |
sqlx 0.9 の公式 API に無くハック必要 |
A1 を採用する場合、adapter/src/database/mod.rs の ensure_tailnet_ready() は永続的に no-op のまま (Rust は 127.0.0.1:13306 を見るだけ)。
チェックリスト
[x] Tailscale admin で OAuth client
blog-api-lambdaを作成 (Scopes:auth_keyswrite、Tags:tag:aws-app)[x]
client_id/client_secret/tailnet_suffixを SSM Parameter Store/<stage>/shuntaka/tailscale/{oauth-client-id, oauth-client-secret, tailnet-suffix}に SecureString / String で格納 (dev/prd 共通 OAuth client を両 stage の path に投入、詳細はdocs/source/01_開発ドキュメント/01_development.md)[x]
apps/blog-api/Dockerfileを multi-stage 化し、tsnet-launcher バイナリ (Go) を同梱[x] tsnet-launcher で SSM Parameter Store → OAuth token → auth key →
tsnet.Server.Up()のチェーンを実装[x] CDK で Lambda の env に
DATABASE_URL(mysql://root@127.0.0.1:13306/blog_${stageName.long}) とTS_OAUTH_CLIENT_ID_KEY_NAME/TS_OAUTH_CLIENT_SECRET_KEY_NAME/TS_TAILNET_SUFFIX_KEY_NAMEを注入[x] Lambda IAM role に
ssm:GetParameterを 3 つの Tailscale Parameter ARN に付与 (GH App / Cloudinary 既存 3 つと合わせて計 6)[x] 初回 invoke で
tsnet up: hostname=blog-api-lambdaと forwarder の pre-warm dial ok が CloudWatch ログに出ることを確認[x] blog-api 経由で
tidb.${TAILNET}:4000に接続でき、devapi.shuntaka.tech/ prdapi.shuntaka.devの記事一覧 API が 200 を返すことを確認
地雷 (Tailscale 経験者からの受け売り)
OAuth client に tag を紐付け忘れる → auth key 発行が全部 400
tagOwners書き忘れ → tag 付きノードが ACL から見えず silent に接続不能 (現状tag:aws-appはautogroup:adminで書かれているので OK)ephemeral: falseで Lambda 回す → admin console が死んだノードで埋まる、device 上限に当たるauth key を環境変数に直書き → ローテ不能、tag 変更時に全 Lambda 再 deploy。OAuth で都度発行に倒す
state を Lambda パッケージ内に置こうとする → read-only で起動失敗、必ず
/tmpLambda が VPC かつ NAT GW なし →
login.tailscale.com:443に出られず join 失敗direct connection 前提で性能設計 → Lambda は短命で NAT traversal が安定する前に終わることが多い。DERP relay 前提でレイテンシ見積もる
実装記録
(実装後に追記)
残課題 / フォローアップ
(移行完了後に追記)