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가 담당한다.
이 파이프라인은 한 번에 성공하지 못했다. Kubernetes 플러그인 dynamic agent, Harbor 인증, git 체크아웃 방식 등에서 트러블슈팅이 이어지면서 처음 아홉 번은 이런저런 이유로 실패했고, 열 번째 빌드부터 안정적으로 통과했다.

번외: Jenkins 대신 Argo Workflows + Argo Events로 이벤트 기반 CI를 짜보면
Jenkins를 대체하는 게 아니라 완전히 별개 네임스페이스(argo, argo-events)에 병행으로 구축해봤다 - 실무에서 본 적 있는 다른 조직의 구성(CD는 ArgoCD+OCI 레지스트리, CI는 Argo Workflows+Argo Events)을 참고해서, "이벤트 하나가 CI 파이프라인을 트리거하는 배관"을 직접 만들어보는 학습 목적이었다.
구성 요소는 세 가지다.
- EventBus - 이벤트가 오가는 내부 메시지 버스(NATS JetStream 기반). RabbitMQ가 애플리케이션 메시지를 옮기듯, 여기선 "무슨 이벤트가 발생했다"는 신호를 옮긴다.
- EventSource - 실제 이벤트를 받는 창구. GitHub push 웹훅을 받는 EventSource를 하나 만들었는데, 여기서 인증 방식이 흥미롭다 - GitHub API 토큰을 따로 발급하는 대신
webhookSecret으로 서명(HMAC) 검증만 한다. 웹훅을 보내는 GitHub 쪽 서버 IP가 고정돼 있지 않아서 보안그룹으로 발신자를 제한할 수 없는데, 그 대신 "서명이 맞는 요청만 진짜로 취급한다"는 방식으로 우회한 것이다 - Deploy Key에 최소 권한만 주는 것과 같은 결의 판단이다. - Sensor - EventBus에 이벤트가 들어오면 그걸 보고 "그래서 뭘 할지" 정의하는 부분. push 이벤트에서 커밋 메시지·브랜치·작성자를 뽑아 실제 Workflow(Argo Workflows가 실행하는 CRD 기반 파이프라인)를 만든다.
실제로 git push를 몇 번 날려보면서 이 배관이 끝까지 이어지는지 확인했다 - 처음엔 workflowtaskresults 관련 오류(exit code 64)로 실패했고, 원인을 고치고 나서야 두 번 연속 정상 완료됐다.

Jenkins와 비교했을 때의 실제 체감: Jenkins를 구축하며 오늘 겪은 트러블슈팅들 - SSH 자격증명이 파드 안으로 제대로 안 들어오거나, 워크스페이스 볼륨의 .git이 스테이지 사이에서 사라지거나, 같은 컨테이너를 재사용하다 빌드 산출물이 섞이는 문제 - 은 전부 "VM 시대에 만들어진 도구를 쿠버네티스 위에 얹으면서 생기는 마찰"에 가깝다. Argo Workflows는 파이프라인 자체가 처음부터 쿠버네티스 CRD라서 이런 종류의 마찰이 구조적으로 적다.
다만 이게 "그러니 Jenkins보다 항상 낫다"는 결론은 아니다. 설치 기반·생태계 규모는 Jenkins가 압도적으로 크고, Argo Workflows+Events는 원래 ML/데이터 파이프라인(Kubeflow 등) 쪽에서 더 흔히 쓰인다 - CI 용도로 쓰는 건 ArgoCD 등 Argo 생태계를 이미 깊게 쓰고 있는 조직에 더 잘 맞는 선택에 가깝다. 신규 프로젝트라면 서버 관리 자체가 필요 없는 GitHub Actions이 실무 기본값으로 더 대중적이고, 자체 인프라 CI가 꼭 필요할 만큼 k8s 투자가 이미 큰 조직에서만 Jenkins 대신 Tekton/Argo Workflows 쪽에 무게를 두는 게 현실적인 판단이다.
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 배포 방식과 비교할 수치 기준이 된다.
ArgoCD의 Application 상세 화면은 Git 커밋 하나가 클러스터의 어떤 리소스 트리로 펼쳐지는지, 그리고 그 커밋을 누가(사람 커밋 vs Jenkins가 이미지 태그만 갱신한 커밋) 만들었는지까지 한 화면에서 보여준다 - "지금 클러스터에 뭐가 떠 있는지"의 근거가 Jenkins 실행 로그가 아니라 Git 커밋 이력이라는 걸 실제로 확인할 수 있는 지점이다.

(선택 확장) 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 검증