目次
23 章

DB 連携 — RDS · External Secrets

第22章で外部公開まで作った myshop-api は、データストアのない空の殻です。本章はその空いた場所を埋めます。RDS PostgreSQL を Terraform で立ち上げ、マスターパスワードを AWS Secrets Manager に置き、External Secrets Operator でその秘密を K8s Secret へ自動同期し、IRSA で静的な認証情報なしに権限を付与し、PgBouncer でコネクションプールを載せ、スキーママイグレーションを Helm hook ベースの Job パターンで自動化する流れまでを一連の流れとして整理します。

第22章 アプリ配備の骨格 で myshop-api は外部 HTTPS の入り口まで整いましたが、そのコンテナの中にはまだデータが入る場所がありません。/health/ready だけが 200 を返す空の殻の状態です。本章はその空いた場所を埋める段階です。RDS PostgreSQL を Terraform で立ち上げ、マスターパスワードを AWS Secrets Manager に置き、External Secrets Operator でその秘密を K8s Secret へ自動同期し、IRSA で静的な認証情報なしに権限を付与し、PgBouncer でコネクションプールを載せ、スキーママイグレーションを Job で自動化する流れまでを一つのまとまりとして扱います。

本章の目標は、myshop-api が RDS PostgreSQL と正常に通信し、パスワード回転が自動化され、コネクションプールの上限への脅威が制御された状態です。次の 第24章 CI / CD では、新バージョンが入ってくる道を自動化します。

なぜマネージド RDS なのか #

K8s の中に PostgreSQL StatefulSet を立ち上げる道もあります。第8章 StatefulSet · DaemonSet · Job の StatefulSet モデルが自前ホスティングの出発点です。しかし運用環境の標準はマネージド RDS です。バックアップ、Multi-AZ failover、パッチ、モニタリング、メジャーバージョンアップグレードの負担がすべて AWS の責任に抜けていき、私たちはクラスタの stateless なワークロード運用だけに集中できます。

項目K8s 自前ホスティング (StatefulSet)マネージド RDS
データの信頼性PV のバックアップ · 復旧を直接運用自動バックアップ · PITR · Multi-AZ
メジャーバージョンアップグレードダウンタイム · マイグレーションを直接RDS コンソールまたは Terraform で自動
HAStatefulSet + 外部の運用ツールMulti-AZ オプション一行
コストノードリソース + 運用人員の時間RDS インスタンス + Storage
運用負担大きい小さい

本書の質感は stateless なワークロードだけを K8s に、stateful なシステムはマネージドサービスに 置く分離です。EKS のベストプラクティスであり、ほぼすべての運用クラスタの出発点です。

RDS — Terraform で PostgreSQL を立ち上げる #

Terraform モジュール #

terraform/modules/myshop-rds/main.tf
module "rds" {
  source  = "terraform-aws-modules/rds/aws"
  version = "~> 6.0"

  identifier = "myshop-${var.env}"

  engine               = "postgres"
  engine_version       = "16.3"
  family               = "postgres16"
  major_engine_version = "16"
  instance_class       = var.env == "prod" ? "db.m6g.large" : "db.t4g.medium"

  allocated_storage     = 50
  max_allocated_storage = 500
  storage_type          = "gp3"
  storage_encrypted     = true

  db_name  = "myshop"
  username = "myshop_admin"
  port     = 5432

  manage_master_user_password   = true
  master_user_secret_kms_key_id = aws_kms_key.rds.arn

  multi_az               = var.env == "prod"
  db_subnet_group_name   = var.db_subnet_group_name
  vpc_security_group_ids = [aws_security_group.rds.id]

  backup_retention_period = var.env == "prod" ? 30 : 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  performance_insights_enabled = true
  monitoring_interval          = 60
  monitoring_role_arn          = aws_iam_role.rds_monitoring.arn

  enabled_cloudwatch_logs_exports = ["postgresql"]

  deletion_protection = var.env == "prod"
  skip_final_snapshot = var.env != "prod"
}

運用標準が一つのマニフェストの中に集まっています。核心オプションを押さえます。

  • manage_master_user_password = true — RDS がマスターパスワードを直接作って Secrets Manager に保存します。人がパスワードを見たことがない状態 になるパターンです。
  • multi_az — prod は Multi-AZ で failover 可能、dev は単一 AZ でコストを節約します。第21章 の VPC マルチ AZ の決定とつながります。
  • storage_encrypted — KMS 暗号化。運用標準です。
  • performance_insights_enabled — PostgreSQL のクエリ性能分析。RDS 自体のコストにほとんど影響がありません。
  • enabled_cloudwatch_logs_exports — PostgreSQL の slow query / error ログを CloudWatch へ送り出します。第25章 モニタリング · アラート のアラートのソースになります。
  • deletion_protection — prod には必ず有効にします。terraform destroy 事故を防ぐ最後の保護線です。

セキュリティグループ — EKS ノードだけがアクセス #

terraform/modules/myshop-rds/sg.tf
resource "aws_security_group" "rds" {
  name_prefix = "myshop-${var.env}-rds-"
  vpc_id      = var.vpc_id
}

resource "aws_security_group_rule" "rds_from_eks" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  security_group_id        = aws_security_group.rds.id
  source_security_group_id = var.eks_node_security_group_id
  description              = "Allow from EKS worker nodes"
}

5432 ポートを EKS ノードのセキュリティグループからのみ受けます。他の場所からは RDS に直接アクセスできません — 運用標準です。人が一時的に RDS へ直接入って SQL を見なければならないときは、第26章 運用チェックリスト で扱う bastion または SSM Session Manager を通します。

マスターパスワード — Secrets Manager に置く #

manage_master_user_password = true を有効にしておくと、RDS がパスワードを自動で作って次の形式で Secrets Manager に保存します。

Secrets Manager に保存される RDS の秘密の形
{
  "username": "myshop_admin",
  "password": "<RDS-generated random>",
  "engine": "postgres",
  "host": "myshop-prod.abcdef.ap-northeast-2.rds.amazonaws.com",
  "port": 5432,
  "dbname": "myshop"
}

この秘密を K8s の中の Pod がどう読むかが次の段階です。静的なパスワードを K8s Secret に直接書いておく道は、第6章 ConfigMap · Secret §「秘密管理の限界」で触れたあの落とし穴と正確に重なります — マニフェストに平文が入るか、git に base64 でエンコードされただけの秘密が入るか、パスワード回転時にマニフェストを直接更新しなければなりません。この三つの落とし穴を一度に解くツールが External Secrets Operator です。

External Secrets Operator — K8s Secret とクラウドの秘密の同期 #

第18章 CRD と Operator の Operator パターンが本格的な運用ツールへつながる段階です。AWS Secrets Manager (または Parameter Store、Vault、GCP Secret Manager など) の秘密を K8s Secret へ自動同期 してくれるコントローラです。

インストール #

Helm でインストール
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace \
  --set installCRDs=true

インストール後、二つの新しい CRD がクラスタに登録されます — ClusterSecretStoreExternalSecret です。第18章 で扱ったその CRD パターンです。マニフェストで秘密の出所とマッピングを宣言すると、Operator がその宣言を見て実際の K8s Secret オブジェクトを作って更新します。

IRSA で Secrets Manager へのアクセス権限 #

External Secrets Operator の ServiceAccount に Secrets Manager の read 権限を与える IAM Role を IRSA で付着します。第16章 RBAC / ServiceAccount 深掘り のそのモデルがそのまま適用されます。

terraform/modules/external-secrets/iam.tf
data "aws_iam_policy_document" "secrets_read" {
  statement {
    actions = [
      "secretsmanager:GetSecretValue",
      "secretsmanager:DescribeSecret",
    ]
    resources = [
      "arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:rds!cluster-myshop-${var.env}/*",
      "arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:myshop/${var.env}/*",
    ]
  }
}

resource "aws_iam_policy" "secrets_read" {
  name   = "myshop-${var.env}-external-secrets-read"
  policy = data.aws_iam_policy_document.secrets_read.json
}

module "external_secrets_irsa" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.0"

  role_name = "myshop-${var.env}-external-secrets"

  oidc_providers = {
    main = {
      provider_arn               = var.oidc_provider_arn
      namespace_service_accounts = ["external-secrets:external-secrets"]
    }
  }

  role_policy_arns = {
    main = aws_iam_policy.secrets_read.arn
  }
}

resources の ARN パターンが核心です — myshop の秘密にのみアクセス権限を与え、他のチームの秘密は読めないように防ぎます。最小権限の原則であり、第16章 で trust policy と併せて扱ったセキュリティの質感の二つ目の軸です。

ClusterSecretStore — 秘密のソースの定義 #

cluster-secret-store.yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-northeast-2
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

このオブジェクトが「AWS Secrets Manager から秘密を取得する」とクラスタの次元で宣言します。auth.jwt.serviceAccountRef が IRSA が付着されたその ServiceAccount で、External Secrets Operator はその ServiceAccount の projected token で STS の AssumeRoleWithWebIdentity を呼んで Secrets Manager 権限を受け取ります。第16章 §「IRSA の動作原理」の流れが本格的な秘密同期へつながる時点です。

ExternalSecret — マニフェストで秘密を取得する #

myshop-api/templates/externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: myshop-api-db
  namespace: myshop
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: myshop-api-db
    creationPolicy: Owner
    template:
      data:
        DATABASE_URL: "postgresql://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .dbname }}?sslmode=require"
  data:
    - secretKey: username
      remoteRef:
        key: rds!cluster-myshop-prod
        property: username
    - secretKey: password
      remoteRef:
        key: rds!cluster-myshop-prod
        property: password
    - secretKey: host
      remoteRef:
        key: rds!cluster-myshop-prod
        property: host
    - secretKey: port
      remoteRef:
        key: rds!cluster-myshop-prod
        property: port
    - secretKey: dbname
      remoteRef:
        key: rds!cluster-myshop-prod
        property: dbname

この一つのマニフェストで起こることを整理すると次のとおりです。

  1. External Secrets Operator が1時間ごとに Secrets Manager の rds!cluster-myshop-prod の秘密を fetch します。
  2. その秘密の5つのフィールド (username, password, host, port, dbname) を取得します。
  3. template.data.DATABASE_URL でその値を Connection String 形式に組み立てます。
  4. 名前が myshop-api-db の K8s Secret を作り、その中に DATABASE_URL キー一つで保存します。

myshop-api Pod は envFrom でこの Secret を環境変数として注入されれば終わりです。

deployment.yaml — Secret 注入
envFrom:
  - configMapRef:
      name: myshop-api
  - secretRef:
      name: myshop-api-db   # External Secrets が自動同期

第22章 で placeholder にしておいた DATABASE_URL が、この時点で本物の値で埋まります。RDS のパスワードが回転されると1時間以内に K8s Secret も新しい値で自動更新されます。

Pod 再起動の必要性 #

K8s Secret が更新されても Pod 内の環境変数は自動では更新されません。envFrom で注入された値は Pod の起動時点でだけ固定されます。パスワード回転のあとに Pod を再起動して初めて新しいパスワードが適用されます。

Secret 更新後の Pod 強制再起動
kubectl rollout restart deployment/myshop-api -n myshop

External Secrets には Reloader という別のコンポーネントとの統合があり、Secret が変わると自動で rollout restart をトリガーできます。運用クラスタの標準セットアップの一部としてよく一緒に入ってきます。第4章 Deployment / ReplicaSet のローリングアップデートが、秘密の更新にもそのまま活用される形です。

コネクションプール — なぜ、そして PgBouncer #

myshop-api Pod が5個立ち上がっていて、各 Pod 内のアプリケーションが自分の PostgreSQL コネクションプールを50個ずつ持っているとすれば、クラスタ全体で250個の RDS コネクションを占有します。RDS インスタンスクラスごとに max_connections の上限があり、db.t4g.medium はデフォルトで約100、db.m6g.large もデフォルトで約800です。Pod が 第13章 オートスケーリング の HPA で増える環境では、この上限が素早く脅かされます。

この空いた場所に コネクションプーラー が入ってきます。最も標準的なものが PgBouncer です。

PgBouncer の役割 #

構成
[myshop-api Pod 5個] --> [PgBouncer 2個] --> [RDS PostgreSQL]
   各 50 conn                                  20 backend conn

myshop-api は PgBouncer に接続し、PgBouncer がその接続を少ない数の backend 接続へ多重化します。transaction pooling モードでは、一つの PostgreSQL 接続が複数クライアントの短いトランザクションを順に処理するので使用効率が非常に高いです。

マニフェスト #

pgbouncer-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pgbouncer
  namespace: myshop
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: pgbouncer
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pgbouncer
    spec:
      containers:
        - name: pgbouncer
          image: edoburu/pgbouncer:1.22.1
          ports:
            - containerPort: 6432
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: myshop-api-db
                  key: DATABASE_URL
            - name: POOL_MODE
              value: transaction
            - name: MAX_CLIENT_CONN
              value: "1000"
            - name: DEFAULT_POOL_SIZE
              value: "20"
            - name: SERVER_RESET_QUERY
              value: "DISCARD ALL"
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: pgbouncer
  namespace: myshop
spec:
  selector:
    app.kubernetes.io/name: pgbouncer
  ports:
    - port: 5432
      targetPort: 6432

myshop-api の DATABASE_URL は今や RDS を直接指すのではなく、pgbouncer.myshop.svc.cluster.local:5432 を指すように環境別に override します。第5章 Service の ClusterIP + DNS モデルのおかげで、RDS の実際のホストが変わっても myshop-api の環境変数はそのまま保たれます。

values-prod.yaml — DATABASE_URL を PgBouncer へ
config:
  DATABASE_URL: "postgresql://myshop_admin:$(DB_PASSWORD)@pgbouncer.myshop.svc.cluster.local:5432/myshop?sslmode=disable"

transaction pooling の落とし穴 #

PgBouncer の transaction pooling モードでは、PostgreSQL の prepared statement、advisory lock、session 変数を安全に使えません。 一つのトランザクションが終わると backend コネクションが別のクライアントに移るので、セッション単位の状態が保たれません。ORM が prepared statement を自動で使う場合 (SQLAlchemy の一部設定など) は、オプションを切るか session pooling モードに変える必要があります。

代替として RDS Proxy が同じ役割をするマネージドオプションです。AWS が運用してくれて IAM 認証との統合が深いですが、コストが追加され、transaction pooling の落とし穴は同じです。第28章 コスト最適化 で PgBouncer vs RDS Proxy のコストの質感を改めて押さえます。

スキーママイグレーション — Job パターン #

データベーススキーマを新しいバージョンへ移す作業は、K8s では Job で扱います。

Job マニフェスト #

migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: myshop-api-migrate-1.4.2
  namespace: myshop
spec:
  backoffLimit: 3
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      serviceAccountName: myshop-api
      restartPolicy: OnFailure
      containers:
        - name: migrate
          image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api:1.4.2
          command: ["alembic", "upgrade", "head"]
          envFrom:
            - secretRef:
                name: myshop-api-db

第8章 StatefulSet · DaemonSet · Job の Job パターンが運用クラスタのマイグレーションへつながります。イメージは myshop-api と同じものを使い、コマンドだけをマイグレーションツール (alembic, flyway, golang-migrate など) に変えます。ttlSecondsAfterFinished: 86400 が24時間後に Job オブジェクトを自動削除します — 終わった Job が etcd に溜まらないようにする標準オプションです。

配備との連携 — Helm hook #

マイグレーションが終わってからでないと新バージョンの Pod が立ち上がってはいけません。順序を強制するパターンが Helm hook です。

migration-job.yaml — Helm hook annotation
metadata:
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded

この annotation が付いた Job は helm upgrade 時に新しい Deployment より先に実行され、成功してから次の段階へ進みます。マイグレーションの失敗が Pod 配備の失敗へ自然につながり、間違ったスキーマの上に新しいコードが立ち上がる事故 を防ぎます。第24章 CI / CD の ArgoCD Sync の流れで、この Helm hook がどう PreSync hook に対応するかが自然につながります。

initContainer との比較 #

マイグレーションを Pod の initContainer に入れるパターンもあります。しかし myshop-api Pod が5個なら、マイグレーションが5回試行されます。一部のマイグレーションツール (alembic, flyway) は advisory lock で重複を防ぎますが、K8s の観点では マイグレーションは一度、Job で の方がすっきりします。責任の質感を一箇所に集めるパターンであり、第27章 kubectl デバッグパターン でも、マイグレーション事故の診断経路が単一の Job のログへ狭まるという運用上の利点が併せて解かれます。

IAM 認証 — パスワード自体をなくす道 #

最も進んだパターンは RDS の IAM 認証です。パスワードを完全になくし、IRSA トークンで RDS に直接アクセスします。

Python コード — IAM トークンで RDS 接続
import boto3
import psycopg2

rds_client = boto3.client('rds')
token = rds_client.generate_db_auth_token(
    DBHostname='myshop-prod.abcdef.ap-northeast-2.rds.amazonaws.com',
    Port=5432,
    DBUsername='myshop_app',
    Region='ap-northeast-2',
)

conn = psycopg2.connect(
    host='myshop-prod.abcdef.ap-northeast-2.rds.amazonaws.com',
    port=5432,
    user='myshop_app',
    password=token,  # パスワードではなく IAM トークン
    dbname='myshop',
    sslmode='require',
)

generate_db_auth_token が IAM 認証情報で15分間有効なトークンを作ってくれます。そのトークンが PostgreSQL のパスワードの位置に入ります。パスワード回転を気にする必要がなく、すべてのアクセスが CloudTrail に記録されます。

ただし短所もあります。

  • 15分ごとに新しいトークンを受け取らなければならないので、コネクションプールとの連携が難しいです。
  • PostgreSQL 側に IAM 認証のためのユーザー (rds_iam グループ) と grants の管理が必要です。
  • PgBouncer transaction pooling と一緒に使うのが非常に難しいです。

従来のパスワード + Secrets Manager + External Secrets モデルが運用負担とセキュリティのバランスが最も良い目標で、IAM 認証はさらにセキュリティが厳格な環境で追加導入を検討する質感です。本書では前者を標準経路に置き、IAM 認証はオプションとして触れる程度で締めくくります。

最初の接続後の点検 #

マイグレーション Job と myshop-api の配備がすべて終わった時点で点検するコマンドです。

Secret が作られたか
kubectl get secret myshop-api-db -n myshop -o jsonpath='{.data.DATABASE_URL}' | base64 -d
Pod 内で DB 接続テスト
kubectl exec -it deployment/myshop-api -n myshop -- \
  psql "$DATABASE_URL" -c "SELECT version();"
PgBouncer 統計
kubectl exec -it deployment/pgbouncer -n myshop -- \
  psql -p 6432 pgbouncer -c "SHOW POOLS;"
External Secrets 同期状態
kubectl get externalsecret myshop-api-db -n myshop
kubectl describe externalsecret myshop-api-db -n myshop

この四段階で、秘密の同期 · DB 接続 · コネクションプールがすべて正常に動作するかが確認されます。PostgreSQL の version() が応答すれば、myshop-api が本物のデータを受け取れる状態に到達した時点です。もし DATABASE_URL が空なら ExternalSecret の同期が失敗したということで、describe の Events が原因を見せてくれます — IRSA Role の ARN のタイプミス、Secrets Manager の秘密キー名の不一致、OIDC trust policy の SA ネームスペースの不一致が最も多い三つです。

練習問題 #

  1. 本章の RDS Terraform モジュールを適用して dev 環境に PostgreSQL インスタンス一台を立ち上げます。manage_master_user_password = true が作った Secrets Manager の秘密の名前と形式を確認し、その秘密の ARN を External Secrets IRSA Role の resources パターンに一致させたあと ExternalSecret マニフェストを適用します。K8s Secret が自動で作られる時点までの時間を計測し、kubectl describe externalsecret の Events を一段落で整理します。
  2. PgBouncer の POOL_MODEtransactionsession の二つに変えながら、myshop-api がどう動作するかを比較します。使っている ORM (SQLAlchemy / Prisma / GORM など) が prepared statement を自動で使うかを確認し、transaction pooling で壊れるケースがあれば、その落とし穴をご自身のコードで再現してみます。発見した限界を 第25章 モニタリング · アラート でどのメトリックで検知するかをメモします。
  3. マイグレーション Job を Helm hook でセットアップしたあと、わざと失敗するマイグレーションを一度作ってみます (例: 存在しないカラムを ALTER)。helm upgrade がどう止まるか、新バージョンの Pod がどんな状態で残るか、そして 第24章 CI / CD の ArgoCD Sync の流れでこの失敗がどう可視化されるかを一段落で整理します。

一行まとめ: 運用ワークロードの DB 連携の標準は、マネージド RDS + Secrets Manager + External Secrets + IRSA + PgBouncer + Helm hook マイグレーション Job の六つのツールが一つのまとまりとして動作する流れ。RDS のマスターパスワードは manage_master_user_password で人の目を経ずに Secrets Manager に保存され、External Secrets Operator がその秘密を K8s Secret へ自動同期し、IRSA が静的な認証情報なしに権限を付与し、PgBouncer がコネクションプール上限への脅威を防ぐ。IAM 認証はさらに厳格な環境のオプション。

次の章 #

この時点で myshop-api は外部の入り口 · 内部ワークロード · DB 接続まで備えた完全なサービスですが、新バージョンが入ってくる道が人の手 (helm upgrade) に縛られています。次の章ではその空いた場所を自動化します。

第24章 CI / CD パイプライン では、GitHub Actions でコンテナをビルドして ECR へプッシュし、ArgoCD が git のマニフェストの変更を検知してクラスタへ自動同期する GitOps パイプラインの一連の流れを扱います。第20章 GitOps のモデルが本格的な EKS パイプラインへつながる段階です。

X