[챌린지 #2] 팀 프로젝트 K8s 마이그레이션 - Part 3: 백엔드 배포 & Secret 관리

6 minute read

🎯 핵심 과제

이번 Part에서는 FastAPI 백엔드를 K8s에 배포하면서 실전에서 마주치는 문제들을 다뤄보겠습니다.

  1. 로컬 이미지를 k3d로 가져오기
  2. Secret으로 민감 정보 관리하기
  3. 다른 네임스페이스의 DB 접근하기
  4. 메모리 부족으로 Pod가 죽는 문제 해결하기

💡 k3d image import: 로컬 이미지 사용

Docker Hub 없이 개발하기

개발할 때마다 Docker Hub에 푸시하고 풀하는 건 번거롭습니다. k3d는 로컬 이미지를 클러스터에 바로 넣을 수 있습니다.

# 1. 백엔드 이미지 빌드
cd applications/backend/services/kanban
docker build -t wealist-board-api:latest .

# 2. k3d 클러스터로 import
k3d image import wealist-board-api:latest -c k3s-local

# 3. 확인
docker exec k3d-k3s-local-server-0 crictl images | grep wealist

k3d image import

이제 매니페스트에서 이 이미지를 바로 쓸 수 있습니다.

imagePullPolicy 설정

containers:
- name: board-api
  image: wealist-board-api:latest
  imagePullPolicy: Never  # 중요!

imagePullPolicy: Never를 꼭 써야 합니다. 안 그러면 K8s가 Docker Hub에서 이미지를 찾으려고 해서 ImagePullBackOff 에러가 납니다.

사용 빈도: 로컬 개발 95%

실제 운영 환경에서는 컨테이너 레지스트리(Docker Hub, ECR, GCR)를 쓰지만, 로컬 개발할 땐 이 방법이 훨씬 빠릅니다.

⚠️ 실제 환경에서는?

운영 환경에서는 컨테이너 레지스트리를 반드시 사용해야 합니다.

# AWS ECR
image: 123456789.dkr.ecr.ap-northeast-2.amazonaws.com/wealist-board-api:v1.0.0
imagePullPolicy: Always  # 최신 이미지 자동 pull

# Docker Hub
image: mycompany/wealist-board-api:v1.0.0
imagePullPolicy: IfNotPresent  # 없을 때만 pull

로컬 vs 운영 비교:

로컬 개발 (k3d):
- k3d image import 사용
- imagePullPolicy: Never
- 빠른 반복 개발

운영 환경:
- 레지스트리에 이미지 푸시 필수
- imagePullPolicy: Always 또는 IfNotPresent
- 버전 태그 명시 (latest 금지)
- CI/CD로 자동화

📌 Secret: 민감 정보 관리

ConfigMap vs Secret

환경변수를 넣을 때 두 가지 선택지가 있습니다.

# ConfigMap: 일반 설정
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "INFO"
  API_PORT: "8000"
# Secret: 민감 정보
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
type: Opaque
data:
  POSTGRES_PASSWORD: bXlzdXBlcnNlY3JldA==  # Base64
  DATABASE_URL: cG9zdGdyZXNxbDovLy4uLg==

차이점:

  • ConfigMap: 평문 저장, 누구나 볼 수 있음
  • Secret: Base64 인코딩, RBAC으로 접근 제어 가능

Base64 인코딩 실전

Secret은 값을 Base64로 인코딩해봅시다. (실전에서는 Base64로 안됩니다. 이건 암호화가 아닙니다)

# 비밀번호 인코딩
echo -n "mysupersecret" | base64
# 결과: bXlzdXBlcnNlY3JldA==

# DATABASE_URL 인코딩 (크로스 네임스페이스 FQDN 포함)
echo -n "postgresql://postgres:mysupersecret@postgres-service.postgresql-prod.svc.cluster.local:5432/wealist" | base64
# 결과: cG9zdGdyZXNxbDovL3Bvc3RncmVzOm15c3VwZXJzZWNyZXRAcG9zdGdyZXMtc2VydmljZS5wb3N0Z3Jlc3FsLXByb2Quc3ZjLmNsdXN0ZXIubG9jYWw6NTQzMi93ZWFsaXN0

⚠️ 중요: -n 옵션을 꼭 써야 합니다. 안 그러면 줄바꿈 문자가 포함돼서 인코딩이 틀어집니다.

Secret 생성

# 3-configs/db-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
  namespace: board-api-prod
type: Opaque
data:
  POSTGRES_USER: cG9zdGdyZXM=
  POSTGRES_PASSWORD: bXlzdXBlcnNlY3JldA==
  POSTGRES_DB: d2VhbGlzdA==
  DATABASE_URL: cG9zdGdyZXNxbDovL3Bvc3RncmVzOm15c3VwZXJzZWNyZXRAcG9zdGdyZXMtc2VydmljZS5wb3N0Z3Jlc3FsLXByb2Quc3ZjLmNsdXN0ZXIubG9jYWw6NTQzMi93ZWFsaXN0

네임스페이스별로 Secret을 만들어야 합니다. board-api-prod에서 만든 Secret은 다른 네임스페이스에서 쓸 수 없습니다.

Deployment에서 Secret 사용

# 5-backend/board-api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: board-api
  namespace: board-api-prod
spec:
  replicas: 2
  selector:
    matchLabels:
      app: board-api
  template:
    metadata:
      labels:
        app: board-api
    spec:
      containers:
      - name: board-api
        image: wealist-board-api:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8000
          name: http
        envFrom:
        - secretRef:
            name: db-secret  # Secret 전체를 환경변수로
        resources:
          requests:
            memory: "256Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "200m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 10
          periodSeconds: 5

envFrom + secretRef를 쓰면 Secret의 모든 키를 환경변수로 주입합니다.

# Pod 안에서 확인
kubectl exec -n board-api-prod <pod-name> -- env | grep DATABASE_URL
# DATABASE_URL=postgresql://postgres:mysupersecret@...

🌐 크로스 네임스페이스 통신

문제 상황

백엔드는 board-api-prod 네임스페이스, DB는 postgresql-prod 네임스페이스로 서로 다른 네임스페이스로 관리합니다.

# ❌ 같은 네임스페이스 접근 (안 됨)
postgresql://postgres:mysupersecret@postgres-service:5432/wealist

이렇게 하면 board-api-prod 네임스페이스 안에서 postgres-service를 찾으려고 합니다.

당연히 못 찾습니다.

FQDN으로 해결

다른 네임스페이스의 Service에 접근하려면 FQDN(Fully Qualified Domain Name)을 써야 합니다.

# ✅ 크로스 네임스페이스 접근
postgresql://postgres:mysupersecret@postgres-service.postgresql-prod.svc.cluster.local:5432/wealist

형식:

<service-name>.<namespace>.svc.cluster.local

K8s의 CoreDNS가 이 주소를 해석해서 올바른 Service로 연결해줍니다.

실무 팁 ⭐⭐⭐:

  • 같은 네임스페이스: service-name
  • 다른 네임스페이스: service-name.namespace.svc.cluster.local
  • 항상 FQDN 쓰면 안전 (네임스페이스 이동해도 작동)

⚠️ 실무에서는 어떻게 하나요?

실제 운영 환경에서는 케이스별로 다르게 접근합니다.

Case 1: 같은 네임스페이스 (일반적 ⭐⭐⭐ 90%)

my-app (namespace)
├── frontend
├── backend
└── redis

관련 서비스를 같은 네임스페이스에 두고 간단하게 service-name으로 접근합니다.

Case 2: 공유 인프라 분리 (이 글 케이스 ⭐⭐ 30%)

postgresql-prod (namespace) ← 여러 앱이 공유
board-api-prod (namespace)
user-api-prod (namespace)

DB를 독립 네임스페이스로 분리하고, FQDN으로 접근합니다. 주로 인프라 팀이 DB를 중앙 관리할 때 씁니다.

Case 3: 외부 관리형 DB (중요!! 실무 가장 많음 ⭐⭐⭐ 95%)

# AWS RDS, Google Cloud SQL 등
DATABASE_URL: postgresql://user:pwd@mydb.abc123.rds.amazonaws.com:5432/db

K8s 클러스터 외부의 관리형 DB를 사용합니다. 백업/복구, 고가용성, 스케일링이 자동화되어 있어서 실무에서 가장 많이 씁니다.

K8s 안에 DB를 띄우는 건:

  • 개발/테스트 환경
  • 작은 사이드 프로젝트
  • 온프레미스 환경
  • 특수한 요구사항 (데이터 주권, 컴플라이언스)

이번 챌린지에서는 K8s 학습 목적으로 StatefulSet을 사용했지만, 실제 서비스라면 RDS 같은 관리형 DB를 고려해야 합니다.

🔥 트러블슈팅: OOMKilled

문제 발견

배포하고 보니 Pod가 계속 재시작 되는 현상이 있었습니다. 실행하고 모니터링을 하니깐 시작 후 바로 꺼지고 다음과 같이 나옵니다.

kubectl get pods -n board-api-prod

NAME                        READY   STATUS      RESTARTS   AGE
board-api-d6f7f94d7-2nvfd   0/1     OOMKilled   3          2m

OOMKilledOut Of Memory Killed의 약자입니다. Pod가 할당된 메모리를 초과해서 강제 종료된 겁니다.

원인 분석

OOMKilled 가 보이지만 한번 더 describe 를 이용해 확인해 보겠습니다.

kubectl describe pod -n board-api-prod board-api-d6f7f94d7-2nvfd

# Last State:
#   Terminated:
#     Reason: OOMKilled
#     Exit Code: 137

처음에 설정한 값은 다음과 같습니다.

resources:
  requests:
    memory: "128Mi"
  limits:
    memory: "256Mi"  # 너무 작음!

FastAPI 앱이 시작할 때 256Mi를 넘어버린 겁니다. Python은 런타임과 라이브러리들이 메모리를 많이 쓴다고 합니다.

해결: 메모리 증가

resources:
  requests:
    memory: "256Mi"  # 2배 증가
  limits:
    memory: "512Mi"  # 2배 증가

이렇게 바꾸니까 정상적으로 돌아갔습니다.

kubectl get pods -n board-api-prod

NAME                        READY   STATUS    RESTARTS   AGE
board-api-84744fcc8b-abc12  1/1     Running   0          5m
board-api-84744fcc8b-def34  1/1     Running   0          5m

파이썬은 실행할 때 생각보다 메모리를 많이 먹는다는것도 추가로 알 수 있었네요.

실무 팁 ⭐⭐⭐:

  • Python/Node.js 앱: 최소 256Mi
  • Java/Spring Boot: 최소 512Mi
  • 프로덕션: limits를 requests의 2배로 설정
  • 처음엔 넉넉하게, 모니터링하면서 조정

재시작 완료

# Deployment 재시작
kubectl rollout restart deployment board-api -n board-api-prod

# 롤아웃 확인
kubectl rollout status deployment board-api -n board-api-prod

# 로그 확인
kubectl logs -n board-api-prod -l app=board-api --tail=50

로그를 보니 DB 연결도 성공했습니다.

INFO: Connected to database
INFO: Application startup complete
INFO: Uvicorn running on http://0.0.0.0:8000

⚠️ 주의사항

Secret은 네임스페이스별로

# board-api-prod용 Secret
kubectl apply -f 3-configs/db-secret.yaml

# ❌ postgresql-prod에서는 못 씀
kubectl get secret db-secret -n postgresql-prod
# Error: secrets "db-secret" not found

네임스페이스마다 Secret을 따로 만들어야 합니다. 공유되지 않습니다.

Base64는 암호화가 아님

# Base64 디코딩은 누구나 가능
echo "bXlzdXBlcnNlY3JldA==" | base64 -d
# mysupersecret

Secret은 그냥 인코딩일 뿐 암호화가 아닙니다. 실제 운영 환경에서는:

  • 외부 저장소 사용 (AWS Secrets Manager, Vault)
  • 암호화된 Secret (Sealed Secrets, SOPS)
  • RBAC으로 접근 제어

이런 추가 보안 레이어가 필요합니다.

헬스체크 경로 확인

livenessProbe:
  httpGet:
    path: /health  # 이 경로가 실제로 있어야 함!
    port: 8000

FastAPI 앱에 /health 엔드포인트가 없으면 헬스체크가 실패합니다. 백엔드 코드에 추가해야 합니다.

# FastAPI 앱
@app.get("/health")
async def health():
    return {"status": "healthy"}

정리

백엔드를 K8s에 배포하면서 여러 가지를 배웠습니다.

  • Secret으로 민감 정보를 Base64 인코딩해서 관리(실제는 암호화 적용과 분리 필수)
  • FQDN으로 다른 네임스페이스의 Service 접근(실제는 외부 저장소 RDS 같은것을 사용)
  • OOMKilled는 메모리 부족, limits를 늘려서 해결(올리는 프로그램이 무거운지 아닌지도 확인필요(python, java등))

다음 Part에서는 프론트엔드를 배포하고, Ingress로 외부에서 접근할 수 있게 만들어보겠습니다.

💭 한번 더 생각해볼 질문들

Q1: Secret을 환경변수로 주입하는 것과 파일로 마운트하는 것, 뭐가 다를까요?

힌트: 환경변수는 envFrom으로 간단하지만, 프로세스 목록에서 보일 수 있습니다. 파일 마운트는 volumeMounts로 복잡하지만, 더 안전합니다. 특히 큰 인증서 파일은 파일로 마운트하는 게 좋습니다.


Q2: 다른 클러스터의 DB에 접근해야 한다면? (예: 외부 RDS)

힌트: ExternalName Service를 만들어서 외부 도메인을 K8s Service처럼 쓸 수 있습니다. 또는 Endpoints를 직접 만들어서 IP를 지정할 수도 있습니다.


Q3: OOMKilled가 계속 나는데 메모리를 무한정 늘릴 수는 없다. 이럴땐?

힌트: 애플리케이션 레벨에서 메모리 누수를 찾아야 합니다. Python의 경우 memory_profiler로 분석하고, 불필요한 객체를 del하거나 가비지 컬렉션을 강제로 실행할 수 있습니다. K8s는 어디까지나 인프라일 뿐, 근본 원인은 코드에 있습니다.

🎯 추가 학습

  • Sealed Secrets로 Git에 안전하게 Secret 저장
  • Horizontal Pod Autoscaler로 메모리 기반 오토스케일링
  • Resource Quota로 네임스페이스별 리소스 제한

🔗 참고