# DSQL → TiDB 移行
- 起票日: 2026-06-26
- 移行元: AWS DSQL(PostgreSQL 15.15 互換)
- 移行先: TiDB(Self-hosted, Docker / Kubernetes)
- ステータス: 計画策定済み
## 概要
ブログ基盤の主データベースを AWS DSQL から TiDB(Self-hosted)に切り替える。
移行方針はユーザー指定の以下 2 ステップを基本とする。
1. `dsql-cli` で TSV 形式のバックアップを取得
2. 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 |
| ---------------------------------------------------- | ------------------------------------------------------------------- |
| `UUID PRIMARY KEY DEFAULT gen_random_uuid()` | `CHAR(36) NOT NULL DEFAULT (UUID()) PRIMARY KEY` |
| `TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP` | `DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)`(UTC 固定運用) |
| `CHECK (status IN ('draft', ...))` | TiDB 7.x の `CHECK` 互換構文でそのまま |
| スキーマ修飾 `app.
` | TiDB は `database.table` として等価 |
| バインドパラメータ `$1, $2, ...` | `?` |
## タスク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::` (複合キーは行値比較 `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 が置換)
```mysql
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`, `\\`) と MySQL `FIELDS ESCAPED BY '\\'` は相互運用可能。SET 句で `@var` 受けにしたカラムだけは `\N` が文字列のまま入るため `NULLIF(@var, '\N')` を明示する。
### 開発環境での実行手順(dev)
前提:
- TiDB は Tailnet 上のホスト `tidb.` で 4000/tcp listen
- ローカル PostgreSQL は `docker compose up -d postgres` で起動 (5433 を listen、`db.yml` 参照)
- マイグレーションは `bun run migrate --endpoint postgresql://postgres:postgres@localhost:5433/postgres`
```bash
# 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 `)
- 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 との差分が発生する)
```bash
# 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` を `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) に格納 (既存規約 `///...` に揃える)
- 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](https://tailscale.com/docs/install/cloud/aws/aws-lambda)) に沿って **container image にサイドカーとして `tailscaled` (または tsnet forwarder バイナリ) を同梱** する。
### 既存 ACL の確認
現状の tailnet policy file (HuJSON):
```json
{
"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 での作成手順
1. Tailscale admin console → [Settings → OAuth clients](https://login.tailscale.com/admin/settings/oauth) を開く
2. **Generate OAuth client** をクリック
3. 以下を入力:
| 項目 | 値 | 補足 |
| --------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Description** | `blog-api-lambda` | 用途を識別できる名前 |
| **Scopes** | `auth_keys` (write) のみチェック | `devices` 系は不要 (ephemeral ノードは disconnect で自動削除されるため) |
| **Tags** | `tag:aws-app` を選択 | **必須**。未指定だと API 経由の auth key 発行が 400 で落ちる。この client は `tag:aws-app` 付きのキーしか発行できない |
4. **Generate client** を押すと `client_id` と `client_secret` が表示される (secret はこの画面でしか出ない、必ずコピー)
5. **`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 パス規約 (`///...`) と既存 GH App / Cloudinary パターン (path だけ env に渡し値は SSM 側) に揃え、3 つの parameter に分けて格納する。詳細手順は `docs/source/01_開発ドキュメント/01_development.md` 参照:
```text
//shuntaka/tailscale/oauth-client-id (SecureString)
//shuntaka/tailscale/oauth-client-secret (SecureString)
//shuntaka/tailscale/tailnet-suffix (String, tailscale CLI から動的取得)
```
Lambda は起動時 (INIT phase) に下記の順で動く:
1. SSM Parameter Store から上記 3 つを取得 (cold start で 1 回、warm 中は再利用)
2. `POST https://api.tailscale.com/api/v2/oauth/token` (`client_credentials` grant) で OAuth access token を取得 (1h 有効)
3. `POST https://api.tailscale.com/api/v2/tailnet/-/keys` で auth key を発行
```json
{
"capabilities": {
"devices": {
"create": {
"reusable": false,
"ephemeral": true,
"preauthorized": true,
"tags": ["tag:aws-app"]
}
}
},
"expirySeconds": 600
}
```
4. 得られた `tskey-auth-...` を `tailscale up --auth-key=... --hostname=blog-api-lambda` に渡す
### 認証パラメータの推奨値
| パラメータ | 値 | 理由 |
| --------------- | ----------------- | ----------------------------------------------------------------------------- |
| `reusable` | `false` | 一度限り。cold start ごとに発行 |
| `ephemeral` | `true` | **Lambda には必須**。disconnect で admin console から自動削除される |
| `preauthorized` | `true` | device approval 待ちにしない |
| `expirySeconds` | `600` (10 分) | 発行してすぐ `tailscale up` に渡すので短くて良い。長いと未使用 key が散らかる |
| `tags` | `["tag:aws-app"]` | 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.: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 で同梱** (推奨) | `tsnet.Server.Dial(ctx, "tcp", "tidb.${TAILNET}:4000")` で `127.0.0.1:13306 → tidb.${TAILNET}:4000` を中継。Rust は `mysql://...@127.0.0.1:13306/blog_dev` で繋ぐ。`sqlx` 触らない | Go バイナリ 30 行、SOCKS5 不要、Rust ノータッチ |
| A2. `mysql_async` に乗り換え | `mysql_async::Opts::socks_proxy` で SOCKS5 直サポート | sqlx 全置換コスト |
| A3. sqlx + `tokio-socks` | 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_keys` write、Tags: `tag:aws-app`)
- [x] `client_id` / `client_secret` / `tailnet_suffix` を SSM Parameter Store `//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` に接続でき、dev `api.shuntaka.tech` / prd `api.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 で起動失敗、必ず `/tmp`
- **Lambda が VPC かつ NAT GW なし** → `login.tailscale.com:443` に出られず join 失敗
- **direct connection 前提で性能設計** → Lambda は短命で NAT traversal が安定する前に終わることが多い。DERP relay 前提でレイテンシ見積もる
### 実装記録
(実装後に追記)
## 残課題 / フォローアップ
(移行完了後に追記)