K8s 実戦 #3 DB 連動 — RDS / Secrets Manager / External Secrets / コネクションプール

K8s 実戦シリーズの 3 番目の記事です。#2 で myshop-api が外部公開までされましたが、そのコンテナ内にはまだデータを入れるところがありません。この記事はその空白を埋める流れです。RDS PostgreSQL を Terraform で立て、マスターパスワードを AWS Secrets Manager に置き、External Secrets Operator でその秘密を K8s Secret に自動同期し、IRSA で静的資格情報なしに権限を付与し、PgBouncer でコネクションプールを載せ、スキーマ移行を Job で自動化する流れまで扱います。

このシリーズは K8s 実戦 6 編です。

RDS — Terraform で PostgreSQL を立てる #

K8s 内で PostgreSQL StatefulSet を立てる道もありますが、運用環境の標準は管理型 RDS です。バックアップ、Multi-AZ failover、パッチ、モニタリングがすべて AWS の責任に抜けて、私たちはクラスタ運用にだけ集中できます。

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 でコスト節約。
  • storage_encrypted — KMS 暗号化。運用標準。
  • performance_insights_enabled — PostgreSQL クエリ性能分析。RDS 自体のコストにほぼ影響なし。
  • 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 に直接アクセスできません — 運用標準です。人が一時的に見なければならないときは #6 で扱う 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 がどう読むかが次のステップです。

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

上級 #6 GitOps で秘密を git に安全に置く 3 つのモデルの 1 つとして押さえたツールです。AWS Secrets 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

インストール後 2 つの新しい CRD がクラスタに登録されます — ClusterSecretStoreExternalSecret

IRSA で Secrets Manager アクセス権限 #

External Secrets Operator の ServiceAccount に Secrets Manager の read 権限を与える IAM Role を IRSA で付加します。

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 の秘密にだけアクセス権限を与え、別のチームの秘密は読めないようにします。最小権限原則です。

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 1 か所から秘密を取ってくる」とクラスタ次元で宣言します。auth.jwt.serviceAccountRef が IRSA が付加されたその ServiceAccount で、External Secrets Operator はその ServiceAccount の projected token で STS の AssumeRoleWithWebIdentity を呼んで Secrets Manager 権限を受け取ります。

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 つのマニフェストで起こることをまとめると次のとおりです。

  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 キー 1 つで保存

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

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

#2 で 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 をトリガできます。運用クラスタの標準セットアップの一部としてよく一緒に入ります。

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

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

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

PgBouncer の役割 #

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

myshop-api は PgBouncer に接続し、PgBouncer がその接続を少ない数の backend 接続に多重化します。transaction pooling モードでは 1 つの 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 します。

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 の 1 つの罠 #

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

代替として RDS Proxy が同じ役割をする管理型オプションです。AWS が運用してくれて IAM 認証との統合が深いですが、コストが追加され transaction pooling の罠は同じです。

スキーマ移行 — 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

中級 #1 で扱ったその Job パターンです。イメージは myshop-api と同じものを使い、コマンドだけ移行ツール(例: alembic、flyway、golang-migrate)に変えます。ttlSecondsAfterFinished: 86400 が 24 時間後に Job オブジェクトを自動削除します。

デプロイとの結合 — 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 デプロイ失敗に自然につながり、間違ったスキーマの上に新しいコードが立ち上がる事故を防ぎます。

initContainer との比較 #

移行を Pod の initContainer として入れるパターンもあります。しかし myshop-api Pod が 5 個あれば移行が 5 回試みられます。一部の移行ツール(alembic、flyway)は advisory lock で重複を防ぎますが、K8s の観点では 移行は 1 度、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 認証は 1 段さらにセキュリティが厳格な環境で追加導入を検討する肌理です。

最初の接続後の点検 #

移行 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

この 4 つのコマンドで秘密同期・DB 接続・コネクションプールがすべて正常動作するか確認されます。PostgreSQL の version() が応答すれば myshop-api が本当のデータを受けられる状態に到達した時点です。

締めくくり #

myshop-api のデータストアを埋める 1 サイクルを追いました。RDS PostgreSQL を Terraform で立て、マスターパスワードを Secrets Manager に置き、External Secrets Operator でその秘密を K8s Secret として自動同期し、IRSA で静的資格情報なしに権限を付与し、PgBouncer でコネクションプールのコストを押さえ、スキーマ移行を Helm hook ベースの Job パターンで自動化しました。IAM 認証のさらに進んだ肌理も押さえました。この時点で myshop-api は外部入口・内部ワークロード・DB 接続まで備えた完全なサービスですが、新しいバージョンが入ってくる道が人の手(helm upgrade)に縛られています。次の記事ではその空白を自動化します — GitHub Actions でコンテナをビルドして ECR に push し、ArgoCD が git のマニフェスト変更を検知してクラスタに自動同期する GitOps パイプラインの 1 サイクルを扱います。

X