FastAPI 서비스 골격, RabbitMQ DLQ, Harbor+Trivy, Jenkins→ArgoCD 파이프라인 구축 절차
메시지 큐·CI/CD 개념부터, 헬스체크/메트릭을 표준화한 api-server·inference-worker 골격, RabbitMQ ack/nack+DLQ, Harbor 사설 레지스트리+Trivy 스캔, Jenkins 빌드와 ArgoCD GitOps 배포, 선택 확장인 CloudNativePG까지.
목표
api-server(FastAPI)와 inference-worker(비동기 워커) 두 서비스를 최소 골격으로 만들고, 둘 사이를 RabbitMQ로 연결한다. 그 다음 Jenkins로 빌드하고, Harbor 사설 레지스트리에 Trivy로 스캔한 이미지를 올리고, ArgoCD로 배포하는 파이프라인까지 연결한다. (선택 확장으로 PostgreSQL을 CloudNativePG 오퍼레이터로 운영하는 것도 다룬다.)
실제 구축 절차
1. api-server 골격 - 헬스체크/메트릭 표준화
Kubernetes는 파드가 "살아있는지"와 "트래픽을 받을 준비가 됐는지"를 각각 다른 방식으로 확인한다 - 프로세스가 떠 있어도 DB 연결이 안 되어 있으면 트래픽을 받으면 안 되기 때문이다. 이 확인 방식을 서비스마다 다르게 만들면 게이트웨이 라우팅과 모니터링 양쪽에 태울 수 없으니, 엔드포인트 형식을 먼저 고정한다.
# api-server/app/main.py
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
app = FastAPI()
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.post("/jobs")
def create_job(payload: JobCreate):
job_id = save_job_as_pending(payload)
publish_to_queue("jobs.upload", {"job_id": job_id, **payload.dict()})
return {"job_id": job_id, "status": "PENDING"}
@app.get("/jobs/{job_id}")
def get_job(job_id: str):
return get_job_status(job_id)
/healthz는 프로세스 생존만 확인하고, DB·큐 연결까지 확인하는 /readyz를 별도로 두는 편이 낫다 - /healthz가 무거우면 kubelet의 liveness probe가 불필요하게 서비스를 재시작시킬 수 있다.
2. RabbitMQ 배포 - StatefulSet + ack/nack + DLQ
메시지 큐란 무엇이고 왜 쓰는가: api-server가 이미지 업로드 요청을 받자마자 AI 추론까지 끝내고 응답하면 사용자는 몇 분을 기다려야 한다. 대신 "작업 접수"만 즉시 응답하고, 실제 처리는 별도 워커가 나중에 하도록 미루는 게 비동기 처리다. 이때 api-server와 inference-worker가 서로를 직접 호출하면, 워커가 잠깐이라도 다운된 순간의 요청은 그냥 사라진다. 메시지 큐는 그 사이에서 "작업이 접수됐다"는 사실 자체를 저장해두는 중간 저장소다 - 워커가 나중에 다시 살아나도 큐에 쌓인 작업을 그대로 이어받을 수 있다.
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install rabbitmq bitnami/rabbitmq \
--set auth.username=app \
--set auth.password=<시크릿으로 관리> \
--set replicaCount=1
ack/nack이란: 큐는 메시지를 워커에게 전달한 것만으로 "처리 완료"라고 여기지 않는다. 워커가 실제로 작업을 끝낸 뒤 명시적으로 "ack(확인)"을 보내야 큐에서 그 메시지가 지워진다. 워커가 처리 도중 죽어서 ack를 못 보내면, 큐는 그 메시지를 다른 워커에게 다시 전달한다 - 이게 "작업이 사라지지 않는다"는 보장의 실체다. nack은 "실패했다"는 명시적 신호이고, 반복 실패한 메시지를 **DLQ(Dead Letter Queue)**라는 별도 큐로 격리해 무한 재시도를 막는다.
# inference-worker/app/queue_setup.py
import pika
def declare_queues(channel):
channel.exchange_declare("jobs.dlx", exchange_type="direct")
channel.queue_declare("jobs.dlq", durable=True)
channel.queue_bind("jobs.dlq", "jobs.dlx", routing_key="jobs.upload")
channel.queue_declare(
"jobs.upload",
durable=True,
arguments={
"x-dead-letter-exchange": "jobs.dlx",
"x-dead-letter-routing-key": "jobs.upload",
},
)
# inference-worker/app/consumer.py
def on_message(channel, method, properties, body):
headers = properties.headers or {}
death_count = _retry_count(headers) # x-death 배열 길이로 계산
try:
run_inference(body)
channel.basic_ack(delivery_tag=method.delivery_tag)
except Exception:
if death_count >= 3:
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False) # DLQ로
else:
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
inference-worker는 처음엔 실제 ONNX 모델 대신 time.sleep(180)으로 "오래 걸리는 작업"만 흉내 낸다 - 파이프라인 자체(큐 소비, ack/nack, 상태 갱신)가 먼저 제대로 도는지 검증하는 게 우선이다.
3. 검증 실험 설계 - 워커 강제 종료
# 작업 하나를 등록한 뒤, 처리 중인 워커 파드를 강제 종료
kubectl delete pod <inference-worker-pod> --grace-period=0 --force
# 확인할 것
# 1) 메시지가 ack 안 된 상태로 큐에 남아있다가 재전달되는지 (RabbitMQ Management UI의 Unacked 카운트)
# 2) 재시작된 워커(또는 다른 레플리카)가 같은 메시지를 다시 처리하는지
# 3) 의도적으로 3회 실패하는 메시지를 만들어 DLQ로 격리되는지 (jobs.dlq 큐 확인)
4. Harbor 사설 레지스트리 + Trivy 스캔
컨테이너 레지스트리란: docker build로 만든 이미지는 그 자체로는 클러스터의 다른 노드에서 쓸 수 없다 - 이미지를 어딘가에 올려두고, 각 노드가 그곳에서 내려받도록 해야 한다. Docker Hub 같은 공개 레지스트리를 그대로 써도 되지만, 사내 코드가 담긴 이미지를 외부에 공개하고 싶지 않다면 직접 운영하는 사설 레지스트리가 필요하다. Harbor는 그 사설 레지스트리 구현체이고, 이미지를 푸시할 때 자동으로 취약점을 스캔하는 Trivy가 내장되어 있다.
helm repo add harbor https://helm.goharbor.io
helm install harbor harbor/harbor \
--set expose.type=ingress \
--set expose.ingress.hosts.core=harbor.local \
--set externalURL=https://harbor.local \
--set trivy.enabled=true
Harbor 프로젝트(네임스페이스 개념)를 하나 만들고, 그 프로젝트에 "취약점이 발견되면 배포 차단" 정책을 건다.
# Harbor 프로젝트별 정책 - Critical/High 취약점 발견 시 pull 자체를 차단
curl -X PUT "https://harbor.local/api/v2.0/projects/app/scanner" \
-u admin:<password> \
-d '{"vulnerability_prevent_pull": true, "severity": "high"}'
이 정책이 바로 ISMS-P 2.10(시스템·서비스 보안관리)에서 요구하는 "취약한 이미지의 배포를 막는 절차"에 대한 증적이 된다.
5. Jenkins - 빌드 파이프라인
CI(지속적 통합)란: 코드를 커밋할 때마다 사람이 수동으로 빌드·테스트하는 대신, 그 과정을 자동으로 실행해주는 것이다. Jenkins는 그 자동화를 실행하는 도구 중 하나로, Git 저장소의 변경을 감지해 정의된 파이프라인(빌드→테스트→이미지 생성)을 순서대로 실행한다.
// Jenkinsfile
pipeline {
agent any
stages {
stage('Test') {
steps { sh 'pytest' }
}
stage('Build') {
steps {
sh 'docker build -t harbor.local/app/api-server:${GIT_COMMIT} ./api-server'
}
}
stage('Push to Harbor') {
steps {
sh '''
docker login harbor.local -u $HARBOR_USER -p $HARBOR_PASS
docker push harbor.local/app/api-server:${GIT_COMMIT}
'''
}
}
stage('Wait for Trivy Scan') {
steps {
// Harbor가 푸시된 이미지를 자동 스캔 - 결과가 나올 때까지 폴링
sh '''
harbor-cli scan wait --project app --repo api-server --tag ${GIT_COMMIT}
'''
}
}
stage('Update Manifest') {
steps {
sh '''
cd k8s-manifests
kustomize edit set image api-server=harbor.local/app/api-server:${GIT_COMMIT}
git commit -am "deploy: api-server ${GIT_COMMIT}"
git push
'''
}
}
}
}
Wait for Trivy Scan 스테이지가 Harbor 도입으로 추가된 부분이다 - Critical 취약점이 있으면 여기서 파이프라인이 실패하고, 매니페스트 갱신(배포 트리거)까지 진행되지 않는다. 마지막 스테이지가 핵심 - Jenkins가 클러스터에 직접 배포하지 않고, 매니페스트 저장소에 이미지 태그만 갱신해서 커밋한다. 실제 배포는 ArgoCD가 담당한다.
6. ArgoCD - GitOps 배포
CD(지속적 배포)와 GitOps란: CI가 "이미지를 만드는 것"까지라면, CD는 "그 이미지를 실제 클러스터에 반영하는 것"이다. Jenkins가 클러스터에 직접 kubectl apply를 실행하는 방식(push 배포)도 가능하지만, 그러면 "지금 클러스터에 뭐가 떠 있는지"의 유일한 근거가 Jenkins의 실행 로그뿐이게 된다. GitOps는 발상을 뒤집는다 - Git 저장소에 선언된 상태를 "정답"으로 삼고, ArgoCD가 그 정답과 클러스터의 실제 상태를 계속 비교하며 다르면 자동으로 맞춘다.
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api-server
namespace: argocd
spec:
project: default
source:
repoURL: https://git.local/k8s-manifests.git
targetRevision: main
path: api-server
destination:
server: https://kubernetes.default.svc
namespace: app
syncPolicy:
automated:
prune: true
selfHeal: true # 클러스터 상태가 Git과 달라지면 자동으로 되돌림
selfHeal: true가 push 배포와의 가장 큰 차이다 - 누군가 클러스터에서 리소스를 수동으로 고쳐도 ArgoCD가 Git 상태로 되돌린다.
검증 방법
argocd app get api-server # Sync/Health 상태 확인
argocd app history api-server # 배포 이력이 Git 커밋과 1:1로 매칭되는지 확인
# drift 감지 테스트
kubectl scale deployment api-server --replicas=5 # 수동으로 클러스터 상태 변경
argocd app get api-server # OutOfSync로 표시되는지, selfHeal로 원래 replica 수로 돌아오는지 확인
# Harbor 취약점 차단 테스트
# 알려진 취약점이 있는 오래된 베이스 이미지로 빌드해서 푸시 → Wait for Trivy Scan 단계에서 파이프라인이 실패하는지 확인
배포 소요 시간은 git push 시점부터 argocd app get이 Synced/Healthy가 되는 시점까지를 재서 기록해둔다 - Jenkins push 배포 방식과 비교할 수치 기준이 된다.
(선택 확장) CloudNativePG로 PostgreSQL 운영
개념: PostgreSQL을 그냥 파드 하나로 띄우면, 그 파드가 죽었을 때 자동으로 복구해주는 것도, 백업을 챙겨주는 것도 없다 - 전부 사람이 직접 만들어야 한다. CloudNativePG는 PostgreSQL 전용 Kubernetes Operator다. Operator란 "사람이 반복적으로 하던 운영 작업(장애 감지·failover 판단·백업 실행)을 대신 수행하는 컨트롤러"를 뜻한다 - Cluster라는 리소스 하나를 선언하면 그 뒤의 운영을 오퍼레이터가 대신한다.
kubectl apply -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/main/releases/cnpg-1.24.0.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: app-postgres
spec:
instances: 3 # 프라이머리 1 + 레플리카 2
storage:
size: 20Gi
backup:
retentionPolicy: "7d"
instances: 3으로 선언하는 것만으로 프라이머리가 죽었을 때 레플리카 중 하나를 자동으로 승격시키는 failover가 구성된다.
# failover 검증
kubectl cnpg status app-postgres # 현재 프라이머리 확인
kubectl delete pod app-postgres-1 --grace-period=0 --force # 프라이머리 강제 종료
kubectl cnpg status app-postgres # 다른 인스턴스가 프라이머리로 승격됐는지 확인
체크리스트
- api-server, inference-worker 최소 구현 (
/healthz,/metrics표준화) - RabbitMQ StatefulSet 배포, ack/nack + DLQ(재시도 3회) 구현
- 워커 파드 강제 종료 → 메시지 재투입 검증, DLQ 격리 검증
- Harbor 사설 레지스트리 구축 + Trivy 스캔 + 취약점 발견 시 배포 차단 정책
- Jenkins 파이프라인: 빌드 → 테스트 → Harbor 푸시 → Trivy 스캔 대기
- ArgoCD 도입, GitOps 전환
- 배포 소요 시간 before/after 측정
- (선택 확장) CloudNativePG로 PostgreSQL 운영 + failover 검증