AWS実践 #2 RDS 連携とマイグレーションの運用

読了 9分

#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 つ:

  1. DB はインターネットから見えない — Private subnet または隔離された DB Subnet Group
  2. パスワードはコードに置かない — Secrets Manager → Task Definition の secrets フィールド
  3. マイグレーションはデプロイ段階に分離 — コンテナ起動時に migrate するのは避ける

1) DB Subnet Group を作る #

RDS は DB Subnet Group の中だけに住めます。最低 2 つの AZ にまたがるサブネットが必要 (Multi-AZ をオンにするしないにかかわらず)。

DB Subnet Group
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 の構成
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 を更新する必要がありません。

DB 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_FARGATE

3) Secrets Manager でパスワードを作る #

上級 #6 のパターンそのまま。2 通り:

方式説明
手動作成強いランダムを自分で作って RDS に同じ値を入れる
RDS が Secrets Manager と自動連携RDS コンソールで “Manage in Secrets Manager” にチェック → パスワード自動生成 + 自動ローテーション

本番は 自動連携 が推奨。手動は学習用。

手動 — Secrets を作成
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 インスタンスを作る #

RDS Postgres Multi-AZ
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.smallARM ベース、x86 (t3) より ~20% 安く、小さなワークロードのスタート地点
gp3最新 SSD、IOPS / スループットを分離設定可能
--multi-azStandby が自動 — フェイルオーバー 60~120 秒
--publicly-accessible falseインターネットからアクセス不可。必ず false
--backup-retention-period 7自動バックアップ 7 日保持 (PITR 可)
--deletion-protection誤削除防止。CLI でも別途オプションが必要
--enable-performance-insightsクエリ単位の性能分析 (7 日間無料)
--storage-encryptedKMS でディスク暗号化 — 必ずオン

フェイルオーバーの形 #

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 を指定します。

task-definition.json (抜粋)
{
  "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 内の形 (推奨) #

blog-api/db secret JSON
{
  "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 にアクセスできる必要があります。

executionRole にポリシー追加
{
  "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) 初回接続の検証 #

Bastion または CloudShell で
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 に行く綺麗な方法:

Fargate Task で enableExecuteCommand を有効化
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 の流れ。

マイグレーション task を 1 回実行
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 が違います。

migrate task definition (抜粋)
{
  "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 でよく出会うケース:

この 1 行が本番を止める
ALTER TABLE posts ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft';

PG 11+ では DEFAULT が instant (メタデータのみ変更)。ただし一部のパターンは依然として全行書き換え → 大テーブルロック → 本番停止。

安全な方法:

3 段階に分ける
-- 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 が登場します。

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 ブロックで保護。

Terraform で保護
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 でデプロイが終わる仕組みを作ります。

X