AWS実践 #2 RDS 連携とマイグレーションの運用
#1 で ECS Fargate にブログ API を立ち上げましたが、DB はメモリの中にありました。今回はその DB を RDS Postgres Multi-AZ に移し、マイグレーションを 本番トラフィックを止めずに 回す方法を整理します。
中級 #4 RDS がコンソールで作った最初の RDS だったとすれば、今回は 本番仕様 — VPC 分離、Secrets 注入、マイグレーションパターンまで一気に。
全体像 — DB の構成 #
Fargate Task (Private Subnet)
│
│ DATABASE_URL (Secrets Manager から)
▼
sg-rds ← 5432 ← sg-fargate
│
▼
RDS Postgres Multi-AZ (DB Subnet Group)
Primary (AZ-a)
Standby (AZ-c) ← Failover 時に昇格中核となる原則は 3 つ:
- DB はインターネットから見えない — Private subnet または隔離された DB Subnet Group
- パスワードはコードに置かない — Secrets Manager → Task Definition の
secretsフィールド - マイグレーションはデプロイ段階に分離 — コンテナ起動時に
migrateするのは避ける
1) DB Subnet Group を作る #
RDS は DB Subnet Group の中だけに住めます。最低 2 つの AZ にまたがるサブネットが必要 (Multi-AZ をオンにするしないにかかわらず)。
aws rds create-db-subnet-group \
--db-subnet-group-name blog-db-subnets \
--db-subnet-group-description "Blog API DB subnets" \
--subnet-ids subnet-db-a subnet-db-cこのサブネットは インターネットゲートウェイへのルートを持たない べきです。外部からのアクセスは ALB / Fargate を経由するのみ。
2) DB 専用 Security Group #
sg-rds inbound:
port 5432 ← sg-fargate ← Fargate task のみ
port 5432 ← sg-bastion ← (任意) 運用者の踏み台
sg-rds outbound:
不要 (デフォルトを絞る)重要: source は IP レンジではなく sg-fargate (別の SG) で指定。Fargate task が増えても自動で適用されます。CIDR 変更で SG を更新する必要がありません。
aws ec2 create-security-group \
--group-name sg-rds \
--description "RDS allow from Fargate" \
--vpc-id $VPC_ID
aws ec2 authorize-security-group-ingress \
--group-id $SG_RDS \
--protocol tcp --port 5432 \
--source-group $SG_FARGATE3) Secrets Manager でパスワードを作る #
上級 #6 のパターンそのまま。2 通り:
| 方式 | 説明 |
|---|---|
| 手動作成 | 強いランダムを自分で作って RDS に同じ値を入れる |
| RDS が Secrets Manager と自動連携 | RDS コンソールで “Manage in Secrets Manager” にチェック → パスワード自動生成 + 自動ローテーション |
本番は 自動連携 が推奨。手動は学習用。
aws secretsmanager create-secret \
--name blog-api/db \
--secret-string '{
"username": "blog_admin",
"password": "S3cr3t-r4nd0m-32-bytes-...",
"engine": "postgres",
"host": "PLACEHOLDER",
"port": 5432,
"dbname": "blogdb"
}'DB ができたあとに host だけ更新。
4) RDS インスタンスを作る #
aws rds create-db-instance \
--db-instance-identifier blog-db \
--db-instance-class db.t4g.small \
--engine postgres --engine-version 16.4 \
--allocated-storage 20 --storage-type gp3 \
--master-username blog_admin \
--master-user-password "S3cr3t-r4nd0m-32-bytes-..." \
--db-name blogdb \
--vpc-security-group-ids $SG_RDS \
--db-subnet-group-name blog-db-subnets \
--multi-az \
--publicly-accessible false \
--backup-retention-period 7 \
--deletion-protection \
--enable-performance-insights \
--storage-encrypted \
--auto-minor-version-upgrade本番オプションの整理:
| オプション | 意味 |
|---|---|
db.t4g.small | ARM ベース、x86 (t3) より ~20% 安く、小さなワークロードのスタート地点 |
gp3 | 最新 SSD、IOPS / スループットを分離設定可能 |
--multi-az | Standby が自動 — フェイルオーバー 60~120 秒 |
--publicly-accessible false | インターネットからアクセス不可。必ず false |
--backup-retention-period 7 | 自動バックアップ 7 日保持 (PITR 可) |
--deletion-protection | 誤削除防止。CLI でも別途オプションが必要 |
--enable-performance-insights | クエリ単位の性能分析 (7 日間無料) |
--storage-encrypted | KMS でディスク暗号化 — 必ずオン |
フェイルオーバーの形 #
Multi-AZ がどう動くか:
Primary (AZ-a) ─ 同期レプリケーション ─▶ Standby (AZ-c)
│ │
× 障害 │
▼ ▼
フェイルオーバートリガー Standby → Primary 昇格
│
▼
DNS endpoint が新 Primary へ自動更新 (60~120s)アプリ側からは endpoint hostname はそのまま (blog-db.xxxx.ap-northeast-2.rds.amazonaws.com)。DNS TTL が短い (~5s) のでフェイルオーバー後に自然に新 Primary へ繋がります。ただし、コネクションプールが切れた接続を検証 する必要があります — プールにゾンビコネクションが残ると一定時間 504 が出ます。
5) Secrets を Task に注入する #
パスワードを環境変数に平文で置かず、Task Definition の secrets で ARN を指定します。
{
"containerDefinitions": [
{
"name": "api",
"image": "...",
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:blog-api/db-AbCdEf:url::"
}
],
"environment": [
{ "name": "ENVIRONMENT", "value": "production" }
]
}
]
}valueFrom の形式:
arn:aws:secretsmanager:<region>:<account>:secret:<name>-<random>:<json-key>::JSON の特定キーだけ抜きたければ :url:: のようにキーを明示。JSON 全体を受け取りたいならキー部分を空に。
Secret 内の形 (推奨) #
{
"url": "postgresql://blog_admin:PASSWORD@blog-db.xxxx.rds.amazonaws.com:5432/blogdb",
"username": "blog_admin",
"password": "PASSWORD",
"host": "blog-db.xxxx.rds.amazonaws.com",
"port": 5432,
"dbname": "blogdb"
}すでに組み立て済みの url キー を持たせておくと、アプリは 1 行で受け取れて楽です。
IAM 権限 #
executionRole が Secrets にアクセスできる必要があります。
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:blog-api/db-*"
}]
}抜けると task が
ResourceInitializationErrorで STOPPED — #1 の落とし穴 で扱った落とし穴。
6) 初回接続の検証 #
psql "postgresql://blog_admin@blog-db.xxxx.ap-northeast-2.rds.amazonaws.com:5432/blogdb"
# パスワード入力 → SELECT 1;VPC 内からしかアクセスできないので、踏み台 / CloudShell VPC 環境 / Session Manager port forwarding のいずれかが必要。
Session Manager port forward (推奨) #
bastion なしで ECS task 経由で RDS に行く綺麗な方法:
aws ecs update-service \
--cluster blog-cluster --service blog-api \
--enable-execute-command --force-new-deployment
# task の中に入る
TASK=$(aws ecs list-tasks --cluster blog-cluster --service-name blog-api \
--query 'taskArns[0]' --output text)
aws ecs execute-command --cluster blog-cluster --task $TASK \
--container api --interactive --command "/bin/sh"task の中から直接 psql — 一時的なデバッグに強力。SSM / iam:PassRole / ecs:ExecuteCommand 権限が必要。
7) マイグレーションの運用 #
事故の大半はマイグレーションで起きます。要点は 2 つ。
どこで走らせるか #
A) コンテナ起動時に自動 (CMD before uvicorn/gunicorn)
─ 簡単。小チーム向き。
─ 危険: replica N 個が同時実行 → ロック / race
─ 危険: 失敗するとコンテナ自体が立たない → ロールバックが厄介
B) 別途 ECS RunTask (deploy 段階で)
─ 1 回だけ実行。ロック/race なし。
─ 結果を分けて追跡可能。
─ 推奨。
C) CodeDeploy lifecycle hook
─ blue/green デプロイの一部として
─ pre-traffic-shift または before-allow-traffic本番推奨は B (RunTask)。CI が新イメージを push → migration RunTask → 成功したら Service update の流れ。
aws ecs run-task \
--cluster blog-cluster \
--task-definition blog-api-migrate:5 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[subnet-aaa],
securityGroups=[sg-fargate],
assignPublicIp=ENABLED
}" \
--count 1 \
--started-by "deploy-$(git rev-parse --short HEAD)"blog-api-migrate は 別の Task Definition — 同じイメージだけど command が違います。
{
"family": "blog-api-migrate",
"containerDefinitions": [{
"name": "migrate",
"image": "...:v123",
"command": ["alembic", "upgrade", "head"],
"essential": true
}]
}Django なら ["python", "manage.py", "migrate", "--noinput"]。
Backward Compatible — Blue/Green 互換 #
デプロイ中の一瞬は 旧バージョンと新バージョンが同時に生きています (rolling デプロイの定義通り)。この 2 バージョンが同じ DB を見ても壊れないようにするには、マイグレーションが 両方向互換 であることが必要です。
| 危険な変更 | 安全な流れ (3 段階) |
|---|---|
| カラム削除 | 1) コードの使用停止デプロイ → 2) カラム削除マイグレーション → 3) 後始末 |
| カラム名変更 | 1) 新カラム追加 + 両方に書く → 2) 新カラムだけ読む → 3) 旧カラム削除 |
| NOT NULL 追加 | 1) NULL 許容 + デフォルトを埋める → 2) コードが常に埋めるように → 3) NOT NULL 追加 |
| 大きなインデックス | CREATE INDEX CONCURRENTLY (PG) — ロックなし。Alembic op.create_index() オプション |
原則: 1 つのマイグレーションは N 分以内に終わり、それを適用した DB は旧バージョンのコードでも新バージョンのコードでも正常動作する こと。
ALTER TABLE のロックの罠
#
PostgreSQL でよく出会うケース:
ALTER TABLE posts ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft';PG 11+ では DEFAULT が instant (メタデータのみ変更)。ただし一部のパターンは依然として全行書き換え → 大テーブルロック → 本番停止。
安全な方法:
-- 1) カラムだけ追加 (NULL 許容)
ALTER TABLE posts ADD COLUMN status VARCHAR(20);
-- 2) バックフィル (バッチで)
UPDATE posts SET status = 'draft' WHERE status IS NULL AND id BETWEEN 1 AND 10000;
-- ... 繰り返し
-- 3) NOT NULL + default
ALTER TABLE posts ALTER COLUMN status SET DEFAULT 'draft';
ALTER TABLE posts ALTER COLUMN status SET NOT NULL;Django 上級 #3 クエリ最適化 の N+1 / インデックスの問題と同じ手触りの本番感覚。
8) バックアップとリカバリ #
RDS の自動バックアップは PITR (Point-In-Time Recovery) まで可能です。
| 種類 | 説明 |
|---|---|
| 自動バックアップ | retention 期間 (7~35 日) で日次 + WAL を 5 分単位で |
| 手動スナップショット | 明示的 — retention 無関係、明示削除まで保持 |
| PITR | 自動バックアップウィンドウ内の任意時点に復元 (新インスタンスとして) |
aws rds create-db-snapshot \
--db-snapshot-identifier blog-db-pre-deploy-$(date +%Y%m%d-%H%M) \
--db-instance-identifier blog-db危険なマイグレーションの直前に 1 枚撮っておけば、事故時に 新インスタンスとして復元 → DNS を切り替えるだけで完了。
9) コネクションプールと IAM 認証 #
本番トラフィックが大きくなると、さらに 2 つの仕組みが入ります。
RDS Proxy #
各 task が個別にプールを持っていると task × pool size = 同時接続数。PG は接続あたりのメモリが大きい → DB が死にます。
上級 #1 ECS の延長線で RDS Proxy が登場します。
Fargate (10 task × pool 20) ──▶ RDS Proxy ──▶ RDS
合計 200 接続 ▼
実際の RDS 接続: 20~50利点: フェイルオーバー時間短縮 (~30 秒に)、接続爆発の防止、IAM 認証オプション。欠点: 時間あたりのコスト (~$0.015 vCPU/h)。
IAM 認証 #
パスワードの代わりに IAM トークンで RDS アクセス。Task role の IAM ポリシーで制御。安全だがトークン TTL (15 分) 管理 / 一部 ORM の互換性 / TLS 強制 — 運用の形が一段複雑に。小さなシステムは Secrets Manager で十分。
落とし穴 — 本番でよく壊れる箇所 #
1) password authentication failed — ローテーション問題
#
Secrets Manager の自動ローテーションが有効なのに task が 古いパスワード をキャッシュしているケース。Task Definition の secrets は ARN を毎回呼びますが、アプリが起動時に 1 回読んで保持 すると更新されません。
解決:
- アプリ起動時に 1 回 + 接続失敗時にもう 1 回読む
- ローテーションのタイミングに合わせて task を再起動 (deployment)
2) RDS Free Tier が終わって本番運用中 #
Free Tier は 12 ヶ月 — 終わると毎月 ~$30 が自動請求。基礎 #3 コストアラート で早期に検知。
3) 単一 AZ 運用 #
コスト削減で --multi-az を外す → AZ 障害 = DB ダウン = 全トラフィックダウン。小さな本番でも Multi-AZ 推奨。1 回の障害損失 vs 月 ~$30~50。
4) deletion-protection を入れずに誤削除
#
コンソールでクリック 1 つで RDS が消えます。必ず deletion-protection を有効化。解除には別途 update 呼び出しが必要 → 意識的な行為に。
5) terraform destroy が RDS まで消す
#
#4 IaC で扱う内容。terraform の lifecycle ブロックで保護。
resource "aws_db_instance" "blog" {
# ...
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "blog-db-final-${formatdate("YYYYMMDD-hhmm", timestamp())}"
lifecycle {
prevent_destroy = true
}
}6) マイグレーションのロックが解放されない #
PG の ALTER TABLE は lock_timeout なしで 無限待機。本番トラフィック中の大ロックは全クエリを待たせます。
SET lock_timeout = '5s';
ALTER TABLE posts ADD COLUMN status TEXT;
-- 5s 以内にロックを取れなければエラーで抜けるAlembic なら op.execute("SET lock_timeout = '5s'") をマイグレーションの先頭に。
まとめ #
今回押さえたこと:
- DB の構成 — Private DB Subnet Group、sg-rds は sg-fargate のみ inbound
- Secrets Manager — パスワード平文 X、Task Definition の
secretsで ARN 注入、executionRole 権限 - RDS 運用オプション — Multi-AZ、gp3、deletion-protection、storage-encrypted、performance-insights、auto-minor-upgrade
- フェイルオーバー — 60~120 秒、endpoint そのまま、コネクションプールの検証必要
- Session Manager — bastion なしで task から psql、
enableExecuteCommand - マイグレーション — コンテナ起動ではなく別途 RunTask、blue/green 互換、ALTER TABLE ロック分割
- バックアップ — 自動 PITR + 危険なデプロイ前の手動スナップショット
- コネクションプール — 大トラフィックは RDS Proxy、小システムは Secrets で十分
- 落とし穴 — パスワードローテーションキャッシュ、Free Tier 期限、単一 AZ、deletion-protection 抜け、マイグレーション無限ロック
次回 — CI/CD #
これで イメージビルド → ECR push → Service update → マイグレーション の流れを手で 1 周しました。毎回手作業ではやっていられません。
#3 CI/CD — GitHub Actions + ECR + ECS ではこの流れを GitHub Actions OIDC で自動化し、デプロイ失敗時の自動ロールバック (deployment circuit breaker)、CodeDeploy の blue/green オプションまで — 1 回の git push でデプロイが終わる仕組みを作ります。