K8s 실전 #3 DB 연동 — RDS / Secrets Manager / External Secrets / 커넥션 풀

10 분 소요

K8s 실전 시리즈의 세 번째 글입니다. #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에 안전하게 두는 세 모델 중 하나로 짚었던 도구입니다. 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

설치 후 두 가지 새 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에서 비밀을 가져온다"고 클러스터 차원에서 선언합니다. 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. 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가 자동 동기화

#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 모드에서는 한 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의 한 가지 함정 #

PgBouncer의 transaction pooling 모드에서는 PostgreSQL의 prepared statement, advisory lock, session 변수를 안전하게 쓸 수 없습니다. 한 트랜잭션이 끝나면 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의 관점으로는 마이그레이션은 한 번, 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 인증은 한층 더 보안이 엄격한 환경에서 추가 도입을 검토하는 결입니다.

첫 연결 후 점검 #

마이그레이션 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가 진짜 데이터를 받을 수 있는 상태에 도착한 시점입니다.

마무리 #

myshop-api의 데이터 저장소를 채우는 한 사이클을 따라갔습니다. RDS PostgreSQL을 Terraform으로 띄우고, 마스터 비밀번호를 Secrets Manager에 두고, External Secrets Operator로 그 비밀을 K8s Secret으로 자동 동기화하고, IRSA로 정적 자격 증명 없이 권한을 부여하고, PgBouncer로 커넥션 풀의 비용을 잡고, 스키마 마이그레이션을 Helm hook 기반 Job 패턴으로 자동화했습니다. IAM 인증의 더 진보된 결도 짚었습니다. 이 시점에서 myshop-api는 외부 진입점,내부 워크로드,DB 연결까지 갖춘 완전한 서비스이지만, 새 버전이 들어오는 길이 사람의 손(helm upgrade)에 묶여 있습니다. 다음 글에서는 그 빈 곳을 자동화하겠습니다 — GitHub Actions에서 컨테이너를 빌드해 ECR로 푸시하고, ArgoCD가 git의 매니페스트 변경을 감지해 클러스터로 자동 동기화하는 GitOps 파이프라인의 한 사이클을 다루겠습니다.

X