| name | aws-data-migrate |
| description | 資料遷移工具,從 Production 安全地遷移資料到 Local/Staging 環境。自動清理敏感資料,硬編碼禁止反向遷移。 |
| version | 3.1.0 |
AWS Data Migrate
安全地從 Production 環境遷移資料到 Local/Staging,用於本地除錯和測試。
Core Principles
Production 資料不可變 - 只允許 Production → Local/Staging 的單向遷移
允許的遷移方向
✅ Production → Local
✅ Production → Staging
❌ Local → Production # 硬性禁止
❌ Staging → Production # 硬性禁止
❌ Local → Staging # 需要討論
Prerequisites
- AWS CLI 已配置且 MFA 認證有效(
aws sts get-caller-identity)
- 目標資料庫已建立(PostgreSQL)
- 有 Production 資料庫的讀取權限
- SSM 可連線到相關 EC2 instance
- AWS Session Manager Plugin 已安裝(
session-manager-plugin --version;macOS: brew install --cask session-manager-plugin)
- Docker Desktop 已啟動(用於 pg16 pg_dump/psql)
pg_dump 版本相容性(重要)
RDS 已升級到 PG16,但 EC2 都是 Ubuntu 16.04(glibc 2.23),裝不了 pg16 client。
兩條可用路徑
路徑 ①(推薦,restore 專用):S3 + EC2 本地 psql 直連 RDS
Local Mac: pg_dump (via SSM tunnel + Docker pg16) → .sql → gzip
→ aws s3 cp → S3 bucket
EC2: aws s3 cp ← S3 bucket
→ gunzip → psql 9.6(EC2 自帶)直連目標 RDS (VPC 內) → restore
- 何時用:restore 大 DB(> 100 MB),或任何預期跑超過 5 分鐘的操作
- 為何穩:EC2 ↔ RDS 在同一 VPC,不經過任何 tunnel;SSM
send-command 只負責 kick off + 輪詢進度
- psql 9.6 對 PG16 server:純 text restore 相容(psql 只是 SQL 發送器),唯一要處理的是 pg_dump 16 會產生
\restrict / \unrestrict meta-command → 9.x 不認識,dump 後先 sed -i -e '/^\\restrict /d' -e '/^\\unrestrict /d' 刪掉
- 實測:402 MB DB restore 54 秒 0 錯誤
路徑 ②(dump 階段不得已才用):SSM Port Forwarding + 本地 Docker pg16
Mac (localhost)
├── SSM tunnel :15440 → Production EC2 → Production RDS (PG16)
└── Docker postgres:16 container
└── pg_dump → host.docker.internal:15440 (dump)
- 何時用:dump 階段還是需要 pg16 client(EC2 的 pg_dump 9.6 無法 dump PG16 server)
- ⚠️ 風險:SSM tunnel 會 idle timeout 靜默斷線,pg client 不會偵測到、整個 process 悄悄 hang、檔案大小停止成長、stderr 乾淨。實際踩雷過 dump 卡 3 hr、restore 卡 30+ min
- 若非用不可:加
--verbose 觀察進度、設 PGOPTIONS="--statement-timeout=0"、每次重要操作前先 psql -c "SELECT 1;" 確認 tunnel 還活著、斷了就重開重試
- 絕對不要:用 SSM tunnel 做
psql -f xxx.sql restore 大檔案 —— 改走路徑 ①
macOS 的 Docker 不支援 --network host,必須用 host.docker.internal 連回 Mac 的 localhost。
開 SSM Tunnel(通用模板)
aws ssm start-session \
--target "{INSTANCE_ID}" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["{RDS_HOST}"],"portNumber":["5432"],"localPortNumber":["{LOCAL_PORT}"]}'
預設保留 admin_staffs
所有 Production → Staging 遷移預設走 Path C(保留 admin_staffs 的 staging 測試帳號)。
只有明確要求「完全覆蓋」時才走 Path A。
Why:Staging admin 帳號(密碼、OTP、角色)是手動設定的測試環境狀態,被 production 覆蓋後需要重新設定密碼 + OTP,非常麻煩。
Instructions
1. 確認遷移需求
在開始遷移前,必須確認:
📦 資料遷移需求確認
1. 遷移模式:{full_sync / partial_reset / selective}
2. 目標環境:{local / staging}
3. 需要遷移的 Table(s):{all / 指定 tables / 全部但排除某些 table 的資料}
4. 敏感資料處理:{mask / keep}(Staging 同 VPC 可選 keep)
請確認以上資訊是否正確?
根據遷移模式選擇對應路徑:
- 全庫同步(full_sync) → 走 Path A(DROP DATABASE 重建,需要 RDS master 權限)
- 部分重置(partial_reset) → 走 Path C(保留指定 table 的資料,其他全部從 production 取代;不需要 master 權限)
- 選擇性遷移(selective) → 走 Path B
Path A:全庫同步(Production → Staging)
適用場景:Staging 環境需要完整的 Production 資料副本。
A1. 前置檢查
aws sts get-caller-identity
aws ec2 describe-instances --instance-ids "$PROD_INSTANCE_ID" "$STAGING_INSTANCE_ID" \
--query "Reservations[].Instances[].{Id:InstanceId,State:State.Name}" --output table
A2. 取得資料庫連線資訊
從兩邊的 .env 或 shared/.env 取得 DB 連線:
aws ssm send-command --instance-ids "$PROD_INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["cat /home/apps/{app_name}/shared/.env | grep -E \"^(DATABASE_|RAILS_ENV)\""]'
aws ssm send-command --instance-ids "$STAGING_INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["cat /home/apps/{app_name}/shared/.env | grep -E \"^(DATABASE_|RAILS_ENV)\""]'
A3. 測試 RDS 直連
Staging 可能能直連 Production RDS(同 VPC / peering),這樣可以省去檔案傳輸:
aws ssm send-command --instance-ids "$STAGING_INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["sudo su - apps -c \"PGPASSWORD={prod_password} psql -h {prod_rds_host} -U {prod_user} -d {db_name} -c \\\"SELECT 1 as test;\\\"\""]'
- 連得上 → 直接在 Staging 上 pg_dump(推薦,省去傳輸步驟)
- 連不上 → 在 Production dump 後透過 S3 傳輸
A4. 檢查磁碟空間
重要:dump 前必須確認磁碟空間足夠(建議可用空間 > dump 預估大小的 1.5 倍)
aws ssm send-command --instance-ids "$TARGET_INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["df -h /home/apps && echo --- && ls -lhS /home/apps/*.sql /home/apps/*.sql.gz /home/apps/*.dump 2>/dev/null || echo no dump files"]'
空間不足時,確認後刪除舊的 dump 檔案:
rm -f /home/apps/{app_name}_YYYYMMDD.sql
A5. 執行 pg_dump
標準方式:SSM Tunnel + 本地 Docker pg16(推薦)
EC2 上的 pg_dump 版本太舊(PG12),無法 dump PG16 RDS。必須用本地 Docker。
aws ssm start-session --target "{PROD_INSTANCE_ID}" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["{prod_rds_host}"],"portNumber":["5432"],"localPortNumber":["15440"]}'
docker run --rm -e PGPASSWORD={prod_password} -v /tmp:/dump postgres:16 \
pg_dump -h host.docker.internal -p 15440 -U {prod_user} -d {db_name} \
--no-owner --no-acl -f /dump/{app_name}_YYYYMMDD.sql
ls -lh /tmp/{app_name}_YYYYMMDD.sql
備援方式:EC2 上直接 dump(僅當 EC2 的 pg_dump 版本 >= RDS 版本時可用)
PGPASSWORD={prod_password} pg_dump \
-h {prod_rds_host} -U {prod_user} -d {db_name} \
--no-owner --no-acl \
-f /home/apps/{app_name}_YYYYMMDD.sql
A6. Drop + Recreate + Restore
重要:這會清掉 Staging 現有資料,執行前必須確認用戶同意。
⚠️ 預設應走 Path C 保留 admin_staffs。只有用戶明確要求「完全覆蓋」時才走 Path A。
推薦流程:DROP/CREATE 用 tunnel(短操作)+ Restore 走 S3 + EC2(長操作不用 tunnel)
aws ssm start-session --target "{STAGING_INSTANCE_ID}" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["{staging_rds_host}"],"portNumber":["5432"],"localPortNumber":["15441"]}'
PGPASSWORD={staging_password} psql -h 127.0.0.1 -p 15441 -U {staging_user} -d postgres \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='{db_name}' AND pid<>pg_backend_pid();"
PGPASSWORD={staging_password} psql -h 127.0.0.1 -p 15441 -U {staging_user} -d postgres \
-c "DROP DATABASE IF EXISTS {db_name}; CREATE DATABASE {db_name};"
PGPASSWORD={staging_password} psql -h 127.0.0.1 -p 15441 -U {staging_user} -d {db_name} -c "
GRANT SELECT ON ALL TABLES IN SCHEMA public TO blazer;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO blazer;
"
PGPASSWORD={staging_password} psql -h 127.0.0.1 -p 15441 -U {staging_user} -d {db_name} \
-c "SELECT count(*) as table_count FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';"
A7. 驗證(使用 real count(*),不要依賴 pg_stat)
aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "$STAGING_INSTANCE_ID" \
--query "[Status, StandardErrorContent]" --output text
for t in {large_table_1} {large_table_2} {medium_table}; do
P=$(PGPASSWORD={prod_pw} psql -h {prod_rds} -U {prod_user} -d {db} -tAc "SELECT count(*) FROM $t;")
S=$(PGPASSWORD={stg_pw} psql -h {stg_rds} -U {stg_user} -d {db} -tAc "SELECT count(*) FROM $t;")
printf "%-50s prod=%s staging=%s\n" "$t" "$P" "$S"
done
為什麼不用 pg_stat_user_tables
n_live_tup 是統計值,需要 ANALYZE 才更新
- Production 經常看到 stale 統計(tens of thousands 的 table 顯示只有幾十筆)
- 剛 restore 的 staging stats 是「新的」,拿它跟 production 的舊 stats 比會看到誤差
- 資料正確與否,只有
SELECT count(*) 說了算
A8. 清理暫存
rm -f /home/apps/{app_name}_YYYYMMDD.sql.gz
aws s3 rm s3://{bucket}/db-backup/{app_name}_YYYYMMDD.sql.gz
Path B:選擇性遷移(Production → Local/Staging)
適用場景:只需要特定 table 或特定時間範圍的資料,用於除錯或分析。
B1. 識別敏感欄位(若需要清理)
自動偵測以下類型的敏感欄位:
| 類型 | 欄位名稱模式 | 處理方式 |
|---|
| Email | *email*, *mail* | 替換為 xxx@example.com |
| 電話 | *phone*, *tel*, *mobile* | 替換為 0900-000-000 |
| 地址 | *address* | 替換為 測試地址 |
| 密碼 | *password*, *passwd* | 清空或替換為固定 hash |
| Token | *token*, *secret*, *key* | 清空 |
| 身分證 | *id_number*, *identity* | 替換為 A000000000 |
| 銀行帳號 | *bank*, *account* | 替換為 000-0000000 |
Production → Staging(同 VPC) 且用戶確認不需要清理時,可跳過此步驟。
B2. 匯出資料
方法 A:使用 pg_dump(單表或多表)
PGPASSWORD={password} pg_dump -h {rds_host} -U {user} -d {db_name} \
-t {table_name} --data-only \
-f /tmp/{table_name}.sql
PGPASSWORD={password} pg_dump -h {rds_host} -U {user} -d {db_name} \
-t table1 -t table2 -t table3 --data-only \
-f /tmp/selected_tables.sql
方法 B:使用 Rails Runner(需要條件過濾時)
require 'csv'
records = Invoice.where(created_at: 30.days.ago..Time.current).limit(1000)
CSV.open('/tmp/export.csv', 'w') do |csv|
csv << records.first.attributes.keys
records.each { |r| csv << r.attributes.values }
end
puts "Exported #{records.count} records"
B3. 下載到本地(如果目標是 Local)
COMMAND_ID=$(aws ssm send-command \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["cat /tmp/export.csv"]' \
--output text --query "Command.CommandId")
aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "$INSTANCE_ID" \
--query "StandardOutputContent" \
--output text > /tmp/downloaded_data.csv
B4. 清理敏感資料(本地處理,若需要)
require 'csv'
data = CSV.read('/tmp/downloaded_data.csv', headers: true)
data.each do |row|
row['email'] = "test_#{row['id']}@example.com" if row['email']
row['phone'] = '0900-000-000' if row['phone']
row['address'] = '測試地址' if row['address']
row['password_digest'] = nil if row['password_digest']
row['token'] = nil if row['token']
end
CSV.open('/tmp/sanitized_data.csv', 'w') do |csv|
csv << data.headers
data.each { |row| csv << row.values_at(*data.headers) }
end
B5. 匯入
psql -d {db_name} < /tmp/selected_tables.sql
psql -d {db_name} -c "COPY {table} FROM '/tmp/sanitized_data.csv' CSV HEADER"
B6. 驗證 + 清理
psql -d {db_name} -c "SELECT count(*) FROM {table};"
rm -f /tmp/export_data.rb /tmp/export.csv /tmp/*.sql
Path C:部分重置(保留指定 table 的資料)
適用場景:Staging 要刷新 Production 最新資料,但某些 table 的 staging 測試資料要保留(例如 admin_staffs、users 只存測試帳號的情境)。
優點:不需要 RDS master 權限(不做 DROP DATABASE),app user 即可完成整個流程。
C1. 備份要保留的 table(data-only)
PGPASSWORD={stg_password} pg_dump \
-h {stg_rds_host} -U {stg_user} -d {db_name} \
-t {keep_table} --data-only --no-owner --no-acl \
-f /home/apps/{keep_table}_backup_YYYYMMDD.sql
PGPASSWORD={stg_password} psql -h {stg_rds_host} -U {stg_user} -d {db_name} \
-c "SELECT count(*) FROM {keep_table};"
💡 多個 table 可重複 -t table1 -t table2 一起備份。
C2. pg_dump production(本地 Docker pg16 via SSM tunnel,--clean --if-exists + 排除要保留 table 的資料)
⚠️ dump 必須用 pg_dump 16(EC2 上的 9.6 無法 dump PG16 server)。走本地 Docker + SSM tunnel。開始前先 psql -c "SELECT 1;" 確認 tunnel 活著。
aws ssm start-session --target "{PROD_INSTANCE_ID}" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["{prod_rds_host}"],"portNumber":["5432"],"localPortNumber":["15440"]}'
docker run --rm -e PGPASSWORD={prod_password} -v /tmp:/dump postgres:16 \
pg_dump -h host.docker.internal -p 15440 -U {prod_user} -d {db_name} \
--no-owner --no-acl \
--clean --if-exists \
--exclude-table-data={keep_table} \
--verbose \
-f /dump/{app_name}_prod_YYYYMMDD.sql
sed -i '' -e '/^\\restrict /d' -e '/^\\unrestrict /d' /tmp/{app_name}_prod_YYYYMMDD.sql
三個關鍵 flag:
--clean --if-exists → dump 內含 DROP TABLE IF EXISTS,restore 時自動清掉舊 table(不需 DROP DATABASE 權限)
--exclude-table-data={keep_table} → 保留 schema 但清空資料,restore 後是空表等待 C4 灌入
--no-owner --no-acl → 跨 user restore 時避免 owner / grant 衝突
C3. Restore dump 到 staging(推薦走 S3 + EC2 本地 psql,避開 SSM tunnel)
⚠️ 不要用本地 Docker + SSM tunnel 做 restore —— SSM tunnel 會在中途 idle 斷線 hang 住;踩過 30+ min 靜默 stuck 的雷。
改走 S3 → EC2 → psql 直連 RDS(VPC 內,穩定)。psql 9.6 對 PG16 做 text restore 相容,前提是 C2 已把 \restrict/\unrestrict 刪掉。
gzip -cf /tmp/{app_name}_prod_YYYYMMDD.sql > /tmp/{app_name}_prod_YYYYMMDD.sql.gz
aws s3 cp /tmp/{app_name}_prod_YYYYMMDD.sql.gz \
s3://{bucket}/db-backup/{app_name}_prod_YYYYMMDD.sql.gz
cat > /tmp/remote_restore.sh << 'EOF'
set -e
cd /home/apps
aws s3 cp s3://{bucket}/db-backup/{app_name}_prod_YYYYMMDD.sql.gz /home/apps/restore.sql.gz
gunzip -f /home/apps/restore.sql.gz
export PGPASSWORD={stg_password}
export PGHOST={stg_rds_host}
export PGUSER={stg_user}
psql -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='{db_name}' AND pid<>pg_backend_pid();"
psql -d {db_name} -v ON_ERROR_STOP=0 -f /home/apps/restore.sql \
> /home/apps/restore.stdout 2> /home/apps/restore.stderr
echo "DONE stdout=$(wc -l < /home/apps/restore.stdout) stderr=$(wc -l < /home/apps/restore.stderr)"
rm -f /home/apps/restore.sql
EOF
B64=$(base64 < /tmp/remote_restore.sh | tr -d '\n')
cat > /tmp/ssm_kick.json <<EOF
{
"commands": [
"echo '$B64' | base64 -d > /home/apps/restore.sh && chmod +x /home/apps/restore.sh",
"sudo -u apps bash -c 'nohup bash /home/apps/restore.sh > /home/apps/restore.log 2>&1 &' && sleep 2 && ps -ef | grep restore.sh | grep -v grep"
]
}
EOF
aws ssm send-command --instance-ids "{STAGING_INSTANCE_ID}" \
--document-name "AWS-RunShellScript" \
--parameters file:///tmp/ssm_kick.json --timeout-seconds 600
C4. 灌回保留的 table 資料(一樣走 S3 路徑,或資料量小可直接 SSM 灌)
sed -i '' -e '/^\\restrict /d' -e '/^\\unrestrict /d' /tmp/{keep_table}_backup_YYYYMMDD.sql
gzip -cf /tmp/{keep_table}_backup_YYYYMMDD.sql > /tmp/{keep_table}_backup.sql.gz
aws s3 cp /tmp/{keep_table}_backup.sql.gz s3://{bucket}/db-backup/{keep_table}_backup_YYYYMMDD.sql.gz
C5. 驗證(real count(*),參照 A7)
比對幾個代表性 table 的 count(*),確認 {keep_table} 的筆數等於備份時的筆數,其他 table 等於 production。
C6. 清理
rm -f /home/apps/{app_name}_prod_YYYYMMDD.sql
rm -f /home/apps/{keep_table}_backup_YYYYMMDD.sql
SSM 實用技巧
避免引號地獄
SSM send-command 的多層引號 escape 非常容易出錯。推薦做法:
方法一:JSON 檔案傳參數(推薦)
cat > /tmp/ssm-commands.json << 'EOF'
{
"commands": [
"printf '#!/bin/bash\\n...' > /tmp/script.sh",
"chmod +x /tmp/script.sh",
"sudo su - apps -c 'bash /tmp/script.sh 2>&1'"
]
}
EOF
aws ssm send-command --instance-ids "$ID" \
--document-name "AWS-RunShellScript" \
--parameters file:///tmp/ssm-commands.json
方法二:簡單命令可直接用 parameters
aws ssm send-command --instance-ids "$ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["ls -la /home/apps/"]'
方法三(推薦,複雜腳本):base64 encode 把整個 bash 腳本灌進 SSM
避開所有 quote / escape 地獄:本地把 shell script 寫好、base64 編碼、SSM 在遠端 decode 回檔案後執行。
cat > /tmp/remote.sh << 'EOF'
set -e
echo "hello from $(hostname)"
PGPASSWORD=xxx psql -h rds-host -U user -d db -c "SELECT count(*) FROM tablename;"
EOF
B64=$(base64 < /tmp/remote.sh | tr -d '\n')
cat > /tmp/ssm.json << EOF
{
"commands": [
"echo '$B64' | base64 -d > /tmp/s.sh && chmod +x /tmp/s.sh",
"sudo -u apps bash /tmp/s.sh 2>&1"
]
}
EOF
aws ssm send-command --instance-ids "$ID" \
--document-name "AWS-RunShellScript" \
--parameters file:///tmp/ssm.json \
--timeout-seconds 600
優點:
- 腳本內想怎麼
'"' / EOF 都不會壞
- 本地先
bash /tmp/remote.sh dry-run 更直覺
- 長腳本(幾 KB)也塞得進 SSM parameters
等待 SSM 命令完成
COMMAND_ID=$(aws ssm send-command ... --output text --query "Command.CommandId")
sleep {seconds} && aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "$INSTANCE_ID" \
--query "[Status, StandardOutputContent, StandardErrorContent]" \
--output text
Safety Guidelines
絕對禁止
❌ 將 Local/Staging 資料推到 Production
❌ 直接在 Production 執行 UPDATE/DELETE
❌ 遷移超過必要範圍的資料(selective 模式)
必須執行
✅ 遷移前確認需求和範圍
✅ 遷移前檢查磁碟空間
✅ 全庫同步前確認用戶同意覆蓋目標 DB
✅ 遷移後驗證資料完整性
✅ 清理遠端暫存檔案
敏感資料處理原則
Production → Local # 建議清理敏感資料
Production → Staging # 同 VPC 內可選擇保留,由用戶決定
Common Patterns
全庫同步到 Staging(最常見)
1. 確認 staging 能直連 production RDS
2. 檢查磁碟空間,清理舊 dump
3. 在 staging 上 pg_dump production RDS
4. terminate connections → drop → create → restore
5. 驗證 table count
遷移特定專案的資料
project = Project.find_by(serial: 'RT130044')
export_data = {
project: project.attributes,
invoices: project.invoices.map(&:attributes),
payments: project.payments.map(&:attributes)
}
File.write('/tmp/project_data.json', export_data.to_json)
只遷移結構(不含資料)
pg_dump -h {rds_host} -U {user} -d {db_name} --schema-only -f /tmp/schema.sql
Output Format
遷移完成後輸出:
┌──────────────────────────────────────────────────────┐
│ 📦 資料遷移完成 │
├──────────────────────────────────────────────────────┤
│ 來源:Production RDS ({prod_rds_host}) │
│ 目標:Staging RDS ({staging_rds_host}) │
│ 資料庫:{db_name} │
│ │
│ 📊 遷移統計: │
│ • 資料表數:{table_count} 張 │
│ • Dump 大小:{size} │
│ │
│ ✅ 無錯誤 / ⚠️ 有警告(列出) │
│ ✅ 目標 DB 已完整覆蓋 │
└──────────────────────────────────────────────────────┘