目次
23 章

RDS 連携とマイグレーション運用

VPC 内の RDS Postgres Multi-AZ、Security Group 設計、Secrets Manager でのパスワード注入、Alembic / Django migrations の運用フロー、blue/green 互換マイグレーションパターンまで整理します。

第22章 インフラの骨格 で ECS Fargate の上にブログ API を立ち上げましたが、DB はメモリの中にありました。本章はその部分を RDS Postgres Multi-AZ へ移し、マイグレーションを 運用トラフィックを殺さずに 回す方法を整理します。

第11章 RDS がコンソールで作った最初の RDS だったとすれば、本章はその上で 運用パターン を扱います。VPC 分離、Secrets 注入、そしてマイグレーションパターンまで一度につかみます。4部の二つ目の章として、コンソールで手作業で作っていた DB を運用可能な形まで引き上げる段階です。

大きな絵 — 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 の中でしか住めません。Multi-AZ をオンにしてもしなくても、最低 2つの 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:
   不要 (default から絞る)

ここでも 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 でパスワードを作る #

第20章 Secrets / Parameter Store のパターンそのままです。二つの方式があります。

方式役割
手動生成強いランダムを自分で作り、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 だけ update します。

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 へ接続されます。ただし、接続プールが切れた接続を検証 しなければなりません。pool にゾンビコネクションが残ると一定時間 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 キー を置けば、アプリがそのまま受け取れて便利です。

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 します。第22章 インフラの骨格 の落とし穴で扱ったあの役割です。

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) マイグレーションの運用 #

ほとんどの事故はマイグレーションで起きます。二つが核心です。

どこで回すか #

オプション比較
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 を一度実行
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 デプロイの定義)。この二つのバージョンが同じ DB を見ても壊れないためには、マイグレーションが 双方向互換 でなければなりません。

危険な変更安全なフロー(3 段階)
カラム削除1) コードの使用停止デプロイ → 2) カラム削除マイグレーション → 3) 整理
カラム名変更1) 新カラム追加 + 両カラムへ同期書き込み → 2) 新カラムのみ読むように → 3) 旧カラム削除
NOT NULL 追加1) NULL 許可 + default を埋める → 2) コードが常に埋めるように → 3) NOT NULL 追加
大きなインデックスCREATE INDEX CONCURRENTLY (PG) — ロックなし。Alembic op.create_index() オプション

原則は次のとおりです。一つのマイグレーションは N 分以内に終わり、そのマイグレーションを適用した DB は以前のバージョンのコードも新しいバージョンのコードもすべて正常に動作しなければなりません。

ALTER TABLE のロックの落とし穴 #

PostgreSQL でよく出会う落とし穴です。

この一行が運用を止めうる
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;

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

危険なマイグレーションの直前に一度撮っておけば、事故時には 新しいインスタンスへ復元 した後に DNS だけ変えれば終わりです。災害復旧のより深いパターンは 第30章 災害復旧・バックアップ で扱います。

9) 接続プールと IAM 認証 #

運用トラフィックが大きくなると、二つがさらに入ってきます。

RDS Proxy #

各 task が別々のプールを持っていると task × pool size = 同時接続数 になります。PG は接続一つあたりのメモリが大きいです。そのまま放置すると DB が死にます。

第15章 ECS Fargate の延長線で 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 を毎回呼び出しますが、アプリが起動時に一度読んで保持 するとキャッシュが更新されません。解決策は次のとおりです。

  • アプリ起動時に一度 + 接続失敗時にもう一度読む
  • ローテーションのタイミングに合わせて task を再起動(deployment)

2) RDS Free Tier が終わったのに運用中 #

Free Tier は12か月です。終わると毎月 ~$30 が自動請求されます。第3章 コスト管理 の課金アラートで早期に検知します。

3) 単一 AZ 運用 #

費用を節約しようと --multi-az を外すと AZ 障害 = DB ダウン = すべてのトラフィックダウン です。小さな運用でも Multi-AZ が推奨されます。障害1回の損失と毎月 ~$30 ~ 50 を秤にかけます。

4) deletion-protection をオンにせず誤削除 #

コンソールでクリック一つで RDS が消えます。必ず deletion-protection をオンにします。 オフにするには別途の update 呼び出しが必要なので、意識的な行為になります。

5) terraform destroy が RDS まで消す #

第25章 Terraform 入門 で扱う落とし穴です。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'") を置きます。

Terraform 併走 — DB 骨格をコードで #

上の CLI で作った DB Subnet Group · SG · RDS · Secrets を Terraform に移すと次のとおりです。パスワードは manage_master_user_password = true に置いて RDS が Secrets Manager に直接生成・ローテーション させると、§3 の手動シークレット生成の段階がなくなり、平文のパスワードをコードで触ることがなくなります(第20章)。

rds.tf
resource "aws_db_subnet_group" "main" {
  name       = "blog-db"
  subnet_ids = var.db_subnet_ids        # 2 AZ の DB サブネット
}

resource "aws_security_group" "rds" {
  name_prefix = "blog-rds-"
  vpc_id      = var.vpc_id
}

# Fargate SG から来た 5432 のみ許可
resource "aws_security_group_rule" "rds_from_fargate" {
  type                     = "ingress"
  security_group_id        = aws_security_group.rds.id
  source_security_group_id = var.fargate_sg_id
  from_port = 5432, to_port = 5432, protocol = "tcp"
}

resource "aws_db_instance" "main" {
  identifier              = "blog-db"
  engine                  = "postgres"
  engine_version          = "17.2"
  instance_class          = "db.t4g.micro"
  allocated_storage       = 20
  storage_encrypted       = true          # 事故防止: 保存時の暗号化
  multi_az                = true           # 事故防止: AZ 障害のフェイルオーバー
  deletion_protection     = true           # 事故防止: 誤削除の遮断
  backup_retention_period = 14             # PITR 14日 (第30章)
  db_subnet_group_name    = aws_db_subnet_group.main.name
  vpc_security_group_ids  = [aws_security_group.rds.id]

  db_name                     = "blog"
  username                    = "blog"
  manage_master_user_password = true       # → Secrets Manager 自動生成・ローテーション
}

Task 定義では RDS が作ったシークレット ARN を secrets で注入します(第22章 の Task Definition に続きます)。

ecs-api.tf — シークレット注入(抜粋)
secrets = [{
  name      = "DB_SECRET"
  valueFrom = aws_db_instance.main.master_user_secret[0].secret_arn
}]

マイグレーションは §7 のように、サービスではなく 一回限りの Task(aws ecs run-task ... command=["alembic","upgrade","head"])で回します。このコードが6部 キャップストーンrds.tf へそのままつながります。

練習問題 #

  1. 本章の RDS 生成コマンドで --storage-encrypted--deletion-protection--multi-az の3つのオプションがそれぞれどんな事故を防ぐかを一行ずつ書いてみてください。§「落とし穴」 の項目のうちどれと結びつくかも記しておいてください。
  2. カラムに NOT NULL を追加する危険な変更を安全に適用する3段階を、§「Backward Compatible」 を見ずに書いてみてください。この3段階が 第22章 で見た rolling デプロイ(以前・新しいバージョンの共存)とどう噛み合うかを一段落で説明してみてください。
  3. マイグレーションをコンテナ起動時に自動(オプション A)ではなく別の RunTask(オプション B)に分離する理由を §「どこで回すか」 を根拠に整理してください。第24章 CI/CD でこの RunTask がどう自動化されるかを先に思い浮かべておくとよいです。

一行まとめ: RDS はインターネットから見えない DB Subnet Group に置き、sg-rds は sg-fargate のみを inbound で受ける。パスワードは Secrets Manager から Task Definition の secrets で注入し、Multi-AZ / 暗号化 / deletion-protection をオンにする。マイグレーションはコンテナ起動ではなく別の RunTask に分離し、双方向互換と lock_timeout で運用トラフィックを殺さない。

次の章 #

これでイメージのビルド → ECR push → Service update → マイグレーションの流れを手で一度回してみました。毎回手作業ではできません。次の 第24章 CI/CD — GitHub Actions + ECR + ECS では、この流れを GitHub Actions OIDC で自動化し、デプロイ失敗時の自動ロールバック(deployment circuit breaker)、CodeDeploy の blue/green オプションまで — 一度の git push でデプロイが終わる流れを作ります。

X