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 編です。
- #1 EKS クラスタセットアップ — Terraform / eksctl / IRSA / アドオン
- #2 アプリデプロイ骨格 — Deployment / Service / Ingress / Helm
- #3 DB 連動 — RDS / Secrets Manager / External Secrets / コネクションプール ← この記事
- #4 CI/CD パイプライン — GitHub Actions / ECR / ArgoCD
- #5 モニタリング・アラーム — Prometheus / CloudWatch / Alertmanager
- #6 運用チェックリスト — アップグレード / バックアップ・リカバリ / コスト / セキュリティ
RDS — Terraform で PostgreSQL を立てる #
K8s 内で PostgreSQL StatefulSet を立てる道もありますが、運用環境の標準は管理型 RDS です。バックアップ、Multi-AZ failover、パッチ、モニタリングがすべて AWS の責任に抜けて、私たちはクラスタ運用にだけ集中できます。
Terraform モジュール #
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 ノードだけアクセス #
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 に次の形式で保存します。
{
"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 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 がクラスタに登録されます — ClusterSecretStore と ExternalSecret。
IRSA で Secrets Manager アクセス権限 #
External Secrets Operator の ServiceAccount に Secrets Manager の read 権限を与える IAM Role を IRSA で付加します。
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 — 秘密ソースの定義 #
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 — マニフェストで秘密を取ってくる #
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 - その秘密の 5 つのフィールド(username、password、host、port、dbname)を取ってくる
template.data.DATABASE_URLでその値たちを Connection String 形式に組み立てる- 名前が
myshop-api-dbの K8s Secret を作ってその中にDATABASE_URLキー 1 つで保存
myshop-api Pod は envFrom でこの 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 を再起動してこそ新しいパスワードが適用されます。
kubectl rollout restart deployment/myshop-api -n myshopExternal 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 connmyshop-api は PgBouncer に接続し、PgBouncer がその接続を少ない数の backend 接続に多重化します。transaction pooling モードでは 1 つの PostgreSQL 接続が複数のクライアントの短いトランザクションを順次処理するので使用効率が非常に高いです。
マニフェスト #
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: 6432myshop-api の DATABASE_URL は今は RDS を直接指すのではなく pgbouncer.myshop.svc.cluster.local:5432 を指すように環境別に override します。
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 マニフェスト #
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 です。
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 に直接アクセスします。
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 デプロイがすべて終わった時点で点検するコマンドたちです。
kubectl get secret myshop-api-db -n myshop -o jsonpath='{.data.DATABASE_URL}' | base64 -dkubectl exec -it deployment/myshop-api -n myshop -- \
psql "$DATABASE_URL" -c "SELECT version();"kubectl exec -it deployment/pgbouncer -n myshop -- \
psql -p 6432 pgbouncer -c "SHOW POOLS;"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 サイクルを扱います。