도입
2에서 Redis + Kinesis 기반 대기열 시스템의 구현과 부하 테스트를 분석했다. 10만 동시 접속을 처리하기 위해 Sorted Set으로 실시간 순위를 관리하고, Kinesis로 비동기 이벤트를 처리했다.
그런데 이 코드를 어떻게 배포하는가?
개발자가 코드를 수정할 때마다 수동으로 Docker 이미지를 빌드하고, ECR에 푸시하고, kubectl apply를 실행해야 하는가?
티켓팅 오픈 30분 전에 긴급 버그 수정이 필요하면 어떻게 하는가?
CGV 프로젝트에서 CI/CD 파이프라인 구축은 팀원이 담당했다. 나는 구축된 파이프라인을 받아서 사용하고, Helm Chart의 values.yaml을 수정하여 배포하는 역할이었다. 이번 섹션에서는 팀원이 구축한 CI/CD 구조를 분석하고, 잘 구현된 점과 개선이 필요한 점을 검토한다. 그리고 1, 2 심화에서 제안한 인프라 개선과 함께 내가 직접 CI/CD를 고도화한다면 어떻게 설계할지 구상한다.
1. CGV CI/CD 아키텍처 개요
CGV 프로젝트는 GitLab CI + ArgoCD 조합을 사용했다.
Developer
↓ git push
GitLab (자체 호스팅, Private Subnet)
↓ Webhook
GitLab Runner (CI)
├─ SonarQube (코드 품질)
├─ Maven 빌드
├─ Docker 빌드
└─ ECR 푸시 (VPC Endpoint 경유)
↓
ArgoCD (CD)
├─ Git 저장소 감시
├─ Drift Detection
└─ Kubernetes 배포
↓
EKS Cluster
배포 방식에는 크게 두 가지가 있다. Push 방식은 CI 서버가 직접 kubectl apply를 실행한다. CGV는 Pull 방식으로 ArgoCD가 Git 저장소를 감시하다가 변경을 감지하면 스스로 배포한다.
[Push 방식]
CI 서버 → kubectl apply → EKS
(CI가 클러스터 접근 권한 필요)
[Pull 방식 - CGV]
GitLab CI → Git 저장소에 커밋
ArgoCD ← Git 저장소 감시 (3분마다)
ArgoCD → EKS 배포 (ArgoCD가 클러스터 내부에서 실행)
Pull 방식의 장점은 보안이다. Push 방식에서는 CI 서버에 kubeconfig가 필요하다. CI 서버가 해킹되면 클러스터도 위험하다. Pull 방식에서는 CI 서버가 클러스터 접근 권한이 없다. ArgoCD만 클러스터 내부에서 배포 권한을 가진다.
이 Pull 방식을 GitOps라고 부른다. Git이 Single Source of Truth(단일 진실 공급원)가 되어, Git에 있는 상태 = 실제 클러스터 상태를 유지한다. "현재 배포된 버전이 뭐지?"라는 질문에 Git 커밋 해시로 답할 수 있다. "누가 언제 배포했지?"도 Git 커밋 이력으로 즉시 확인된다.
왜 GitLab + ArgoCD 조합인가?
| 선택 기준 | 이유 |
|---|---|
| GitLab 자체 호스팅 | 소스 코드가 외부로 노출되지 않음, 금융 데이터(결제) 보안 |
| ArgoCD | Pull 방식으로 CI 서버가 클러스터 접근 권한 불필요 |
| GitOps | Git이 진실, 배포 이력 추적, Drift 자동 복원 |
2. Helm Chart 구조 분석
CGV 프로젝트의 Helm Chart는 dev_platform 레포지토리에 있다. Helm은 Kubernetes 패키지 매니저다. 이름은 "배의 키(조타기)"를 뜻한다. Kubernetes가 그리스어로 "조타수"를 뜻하는 것처럼, Helm은 고대 게르만어에서 유래하여 투구(Helmet) 또는 배의 키(조종간)를 의미한다. apt나 yum처럼 복잡한 애플리케이션을 명령어 하나로 설치, 업그레이드, 롤백할 수 있다.
*조타수: 선박의 방향을 바꾸기 위하여 사용하는 장치의 조작을 담당하는 선원
Helm 없이 배포하면 Deployment, Service, ConfigMap, Secret, Ingress, HPA 등 여러 YAML 파일을 순서대로 kubectl apply 해야 한다. 환경별로 또 다른 파일들이 필요하다.
Helm을 사용하면 helm install cgv-api ./helm-chart -f values-dev.yaml 명령어 하나로 모든 리소스가 배포되고, 환경별 설정은 values 파일만 교체하면 된다.
helm-chart/
├── Chart.yaml # Chart 메타데이터 (이름, 버전, 의존성)
├── values-dev.yaml # 개발 환경 설정값 (replicas, resources, image tag)
├── values-prod.yaml # 운영 환경 설정값 (더 많은 replicas, 더 큰 resources)
└── templates/ # YAML 템플릿들 (values.yaml 값이 주입됨)
├── _helpers.tpl # 재사용 가능한 헬퍼 함수 정의
├── deployment.yaml # Pod 배포 설정 (replicas, containers, probes)
├── service.yaml # 클러스터 내부 네트워크 노출 (ClusterIP, Port)
├── configmap.yaml # 환경 변수, 설정 파일 (비밀이 아닌 설정)
├── secret.yaml # 민감 정보 (DB 비밀번호, API 키)
├── serviceaccount.yaml # Pod의 신분증 (IRSA로 AWS 권한 연결)
├── rbac.yaml # Pod의 K8s API 접근 권한 (Pod 조회용)
├── ingress.yaml # 외부 트래픽 라우팅 (ALB, 도메인, TLS)
└── hpa.yaml # 오토스케일링 설정 (min/max replicas, CPU 임계치)
2.1 Chart.yaml 분석
Chart.yaml은 Helm Chart의 메타데이터를 정의하는 파일이다. npm의 package.json, Python의 pyproject.toml과 같은 역할이다. Chart 이름, 버전, 설명, 의존성 등을 선언한다.
apiVersion: v2
name: cgv-api
description: CGV API Platform Helm Chart
type: application
version: 1.0.0
appVersion: "1.0.0"
apiVersion: v2는 Helm 3 전용이다. Helm 2는 v1을 사용했고 클러스터에 Tiller라는 서버 컴포넌트를 설치해야 했다. Helm 3에서 Tiller가 제거되면서 보안이 개선되었다.
version은 Chart 자체의 버전이다. templates 구조가 변경되면 올린다. appVersion은 배포되는 애플리케이션 버전이다. 코드가 변경되면 올린다.
2.2 values-dev.yaml 핵심 설정
values.yaml은 템플릿에 주입될 변수들을 정의하는 파일이다. 환경별로 다른 값을 가진 여러 values 파일을 만들어서 같은 Chart로 개발/운영 환경을 구분한다. 아래는 개발 환경용 설정이다.
image:
repository: "732739477448.dkr.ecr.ap-northeast-2.amazonaws.com/devgitlab-repo"
tag: "1.0.0-250828-122444"
pullPolicy: Always
fullnameOverride: "cgv-api-platform"
namespace: cgv-api
replicaCount: 2
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
pullPolicy: Always를 선택한 이유는 개발 환경에서 같은 태그로 이미지를 자주 덮어쓰기 때문이다.
운영 환경에서는 IfNotPresent로 변경하여 불필요한 이미지 풀을 방지한다.
2.3 Deployment 템플릿 분석
deployment.yaml은 Pod를 어떻게 배포할지 정의하는 핵심 템플릿이다. 컨테이너 이미지, 복제본 수, 리소스 제한, 헬스체크, 환경 변수 등을 설정한다. {{ .Values.xxx }}로 values.yaml의 값을 참조한다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "cgv-api.fullname" . }}
namespace: {{ .Values.namespace }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "cgv-api.selectorLabels" . | nindent 6 }}
template:
spec:
serviceAccountName: {{ include "cgv-api.fullname" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
{{ .Values.replicaCount }}처럼 Go 템플릿 문법을 사용한다. values.yaml의 값이 템플릿에 주입된다. {{ include "cgv-api.fullname" . }}는 _helpers.tpl에 정의된 헬퍼 함수로, 여러 템플릿에서 같은 이름을 일관되게 사용하기 위함이다.
serviceAccountName을 명시한 이유는 IRSA 때문이다. 2에서 분석했듯이 Kinesis, ECR 접근 권한을 Pod에 부여하려면 ServiceAccount와 IAM Role을 연결해야 한다.
2.4 ServiceAccount와 IRSA
ServiceAccount는 Pod의 신분증이다. 사람이 아닌 애플리케이션(Pod)이 Kubernetes API나 AWS 서비스에 접근할 때 "나는 누구다"를 증명한다. 모든 Pod는 기본적으로 default ServiceAccount를 사용하지만, default SA는 권한이 제한적이다.
serviceaccount.yaml은 Pod가 사용할 ServiceAccount를 정의한다. 여기에 AWS IAM Role ARN을 어노테이션으로 붙이면 IRSA가 활성화되어 Pod가 AWS 서비스에 접근할 수 있다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "cgv-api.fullname" . }}
namespace: {{ .Values.namespace }}
annotations:
eks.amazonaws.com/role-arn: "arn:aws:iam::732739477448:role/dev-EKS-App-Role"
eks.amazonaws.com/role-arn 어노테이션이 핵심이다. EKS가 이 어노테이션을 읽어서 Pod에 IAM 자격 증명을 자동 주입한다. 이것이 IRSA(IAM Roles for Service Accounts)다.
IRSA 동작 원리:
1. Pod 생성 시 EKS가 ServiceAccount 확인
2. role-arn 어노테이션 발견
3. STS AssumeRoleWithWebIdentity 호출
4. 임시 자격 증명을 환경 변수로 주입
- AWS_ROLE_ARN
- AWS_WEB_IDENTITY_TOKEN_FILE
5. AWS SDK가 자동으로 이 자격 증명 사용
왜 IRSA를 사용하는가?
Node IAM Role 방식은 설정이 간단하지만 모든 Pod가 같은 권한을 갖는다.
IRSA는 Pod별로 최소 권한을 적용할 수 있다. CGV는 Kinesis, ECR, Aurora 등 여러 AWS 서비스를 사용하므로 IRSA로 최소 권한 원칙을 적용했다.
2.5 Probe 설정 분석
IRSA는 AWS 서비스 접근 권한이다. Kubernetes API 접근에는 RBAC(Role-Based Access Control)이 별도로 필요하다.
2에서 분석한 DynamicSessionCalculator(백엔드 Pod)가 Pod 개수를 조회(Kubernetes API)하려면 RBAC 권한이 있어야 한다.
[권한 구조]
ServiceAccount (신분증: "나는 cgv-api다")
↓
RoleBinding (연결)
↓
Role (권한 목록: "pods 조회 허용")
templates/rbac.yaml은 Pod가 Kubernetes API를 호출할 수 있는 권한을 정의한다.
{{- if .Values.rbac.create }}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: {{ .Values.namespace }}
name: {{ include "cgv-api.fullname" . }}-pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "cgv-api.fullname" . }}-read-pods
namespace: {{ .Values.namespace }}
subjects:
- kind: ServiceAccount
name: {{ include "cgv-api.fullname" . }}
namespace: {{ .Values.namespace }}
roleRef:
kind: Role
name: {{ include "cgv-api.fullname" . }}-pod-reader
apiGroup: rbac.authorization.k8s.io
{{- end }}
Role은 "pods 리소스에 get, watch, list 허용"을 정의하고, RoleBinding은 ServiceAccount에 이 Role을 연결한다. values-dev.yaml에서 활성화한다.
rbac:
create: true
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list"]
IRSA와 RBAC 비교:
Pod
└─ ServiceAccount (신분증)
├─ IRSA 어노테이션 → AWS 서비스 접근 권한
└─ RoleBinding → Kubernetes API 접근 권한
| 구분 | IRSA | RBAC |
|---|---|---|
| 대상 | AWS 서비스 (Kinesis, ECR) | Kubernetes API (Pod, Service) |
| 설정 | IAM Role + ServiceAccount 어노테이션 | Role + RoleBinding |
| CGV 용도 | Kinesis 메시지 발행/소비 | Pod 개수 동적 조회 |
Role vs ClusterRole: CGV는 같은 네임스페이스(cgv-api) 내 Pod만 조회하므로 네임스페이스 범위의 Role로 충분하다. 다른 네임스페이스까지 조회해야 하면 클러스터 범위의 ClusterRole이 필요하다.
2.6 Probe 설정 분석
Probe는 Kubernetes가 컨테이너 상태를 확인하는 방법이다. Probe가 없으면 컨테이너가 Running 상태이면 정상이라고 판단한다. 하지만 프로세스가 실행 중이어도 애플리케이션이 아직 DB 연결을 못 했거나 캐시 워밍업 중일 수 있다.
Probe 설정은 deployment.yaml의 containers 섹션에 정의된다. 아래는 values.yaml에서 관리하는 Probe 설정값이다. startupProbe는 초기화 완료 확인, livenessProbe는 프로세스 생존 확인, readinessProbe는 트래픽 수신 준비 확인 용도다.
startupProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 10
failureThreshold: 30
livenessProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 10
failureThreshold: 3
세 가지 Probe는 각각 다른 질문을 한다:
| Probe | 질문 | 실패 시 동작 |
|---|---|---|
| startupProbe | 초기화 끝났어? | 다른 Probe 비활성화 유지 |
| livenessProbe | 살아있어? | Pod 재시작 |
| readinessProbe | 트래픽 받을 준비 됐어? | Service에서 제외 |
startupProbe의 failureThreshold: 30과 periodSeconds: 10은 최대 300초(5분)까지 초기화를 기다린다는 의미다. Spring Boot 애플리케이션이 DB 연결, 캐시 워밍업 등으로 시작이 오래 걸릴 수 있기 때문이다.
/health/live와 /health/ready를 분리한 이유가 중요하다.
/health/live는 프로세스가 살아있는지만 확인한다. DB 연결이 실패해도 true를 반환한다.
/health/ready는 트래픽을 받을 준비가 되었는지 확인한다. DB 연결이 필수다.
만약 livenessProbe가 DB 연결을 체크하면 어떻게 될까? DB 일시 장애 시 모든 Pod가 재시작된다. DB 장애인데 Pod까지 재시작되면 복구가 더 어려워진다.
2.7 HPA 설정 분석
HPA(Horizontal Pod Autoscaler)는 트래픽에 따라 Pod 개수를 자동으로 조절한다. CPU나 메모리 사용률을 모니터링하다가 임계치 초과 시 Pod를 늘리고, 여유로우면 줄인다.
HPA가 없으면 두 가지 문제가 생긴다. 고정 Pod 수를 적게 잡으면 트래픽 급증 시 장애가 발생한다. 많이 잡으면 평소 90% 시간 동안 리소스가 낭비된다. 티켓팅 시스템에서 이 문제는 심각하다. 평소에는 트래픽이 적지만 오픈 시점에 10배 이상 급증한다.
hpa.yaml은 오토스케일링 규칙을 정의한다. 최소/최대 Pod 수, CPU/메모리 임계치, 스케일링 속도(behavior)를 설정한다. 아래는 values.yaml에서 관리하는 HPA 설정값이다.
hpa:
enabled: true
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 75
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 10
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 5
periodSeconds: 180
scaleUp과 scaleDown의 비대칭 설정이 핵심이다. scaleUp은 30초 안정화 후 30초마다 10개씩 증가한다. 공격적 확장이다. scaleDown은 300초(5분) 안정화 후 180초(3분)마다 5개씩 감소한다. 보수적 축소다.
왜 비대칭인가? 트래픽 급증 시 빠르게 대응해야 사용자 경험이 유지된다. 반면 트래픽 감소 시 성급하게 축소하면 다시 급증할 때 대응이 늦어진다. 티켓팅 오픈 후 트래픽이 잠시 줄었다가 다시 몰리는 패턴이 있다.
3. GitLab CI 파이프라인 분석
3.0 기본 개념: GitLab, Runner, Job
git push하면 뭐가 벌어지나?
개발자가 git push하면 코드가 GitLab 서버에 올라간다. 그게 끝이 아니다. 저장소에 .gitlab-ci.yml 파일이 있으면, GitLab이 이 파일을 읽고 자동으로 빌드/테스트/배포를 시작한다. .gitlab-ci.yml은 프로젝트 초기에 한 번 작성해두면, 이후 모든 git push에서 동일하게 적용된다.
[전체 흐름 - 비유]
개발자 = 주문서 제출
GitLab 서버 = 공장장 ("이거 만들어" 지시)
GitLab Runner = 작업 관리자 (작업자 배치)
Job 컨테이너 = 작업자 (실제로 일함, 일 끝나면 퇴근)
실제 흐름:
[git push 후 벌어지는 일]
1. 개발자: git push origin develop
└─ 코드가 GitLab 서버로 업로드됨
2. GitLab 서버: ".gitlab-ci.yml 파일 발견!"
└─ "5개 Stage 실행해야겠다: test → build → login → docker → deploy"
└─ Runner한테 "일 시작해" 알림
3. Runner: "알겠어, 작업자(Job 컨테이너) 고용할게"
└─ 호스트 Docker Engine한테 "maven:3.9 컨테이너 만들어줘" 요청
4. Docker Engine: Job 컨테이너 생성
└─ [Job 컨테이너 - maven:3.9] 실행
5. Job 컨테이너 안에서:
└─ GitLab에서 소스 코드 다운로드 (git clone)
└─ mvn package 실행 (컴파일 → JAR 생성)
└─ 결과를 GitLab한테 보고
6. Job 완료: Job 컨테이너 삭제 (일회용)
핵심: Runner는 직접 일 안 한다. Job 컨테이너를 만들어서 시킨다.
Job 컨테이너 구조
Runner가 "자기 안에" 컨테이너를 만드는 게 아니다. Runner가 호스트(깃랩용 프라이빗 인스턴스)의 Docker Engine한테 만들어달라고 요청한다.(gitlab 서버랑 gitlab runner도 호스트의 Docker Engine으로 만들어졌음)
[EC2 호스트]
│
├── Docker Engine (호스트에 설치됨)
│ │
│ ├── [GitLab 컨테이너] ← Engine이 만듦 (상주)
│ ├── [Runner 컨테이너] ← Engine이 만듦 (상주)
│ │
│ ├── [Job 컨테이너 1 - maven:3.9] ← Engine이 만듦, Runner 요청 (일회용)
│ ├── [Job 컨테이너 2 - aws-cli] ← Engine이 만듦, Runner 요청 (일회용)
│ ├── [Job 컨테이너 3 - docker:24.0] ← Engine이 만듦, Runner 요청 (일회용)
│ └── ...
왜 Job마다 새 컨테이너를 만들까?
격리 때문이다. .gitlab-ci.yml에서 image: maven:3.9라고 쓰면, Runner가 그 이미지로 컨테이너를 만들어서 작업을 실행한다. 각 Job에 필요한 환경이 다르기 때문에 독립된 컨테이너에서 실행해야 버전 충돌이 없다.
Runner 컨테이너 안에는 Docker Engine이 없다. 호스트의 Docker Engine과 통신해야 Job 컨테이너를 만들 수 있다. 이 통신 방법은 3.4에서 설명한다.
GitLab vs GitLab Runner vs Job 컨테이너
| 구분 | 역할 | 생명주기 |
|---|---|---|
| GitLab 서버 | .gitlab-ci.yml 읽고 지시 | 항상 실행 (상주) |
| GitLab Runner | Job 컨테이너 생성/관리 | 항상 실행 (상주) |
| Job 컨테이너 | 실제 빌드/테스트 수행 | Job마다 생성/삭제 (일회용) |
왜 GitLab을 자체 호스팅했나?
GitLab은 GitHub와 달리 자체 서버에 설치할 수 있다. CGV는 금융 데이터(결제)를 다루기 때문에 소스 코드가 외부로 노출되면 안 된다. 그래서 Private Subnet에 GitLab을 자체 호스팅했다.
3.1 CGV의 GitLab 설치 구조 (팀원 담당)
이 섹션은 팀원이 구축한 GitLab 인프라 구조를 정리한 것이다. 내가 담당한 부분은 3.3부터 시작하는 .gitlab-ci.yml 파이프라인 수정과 ECR 연동이다.
EC2에 Docker Compose로 GitLab + Runner 배포
[EC2 인스턴스 - Private Subnet]
│
├── Docker Engine (호스트에 설치)
│
└── Docker Compose로 실행
├── [GitLab 컨테이너] ─ 소스코드 저장소, CI/CD 관리
└── [Runner 컨테이너] ─ Job 컨테이너 생성 담당
# docker-compose.yml 핵심
services:
gitlab:
image: gitlab/gitlab-ce:latest
ports:
- '80:80'
- '443:443'
gitlab-runner:
image: gitlab/gitlab-runner:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ← 이게 왜 필요한지는 3.4에서
Runner 등록
docker exec -it gitlab-runner gitlab-runner register \
--executor docker \
--docker-image alpine:latest \
--docker-privileged \
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock"
--docker-volumes 옵션이 왜 필요한지는 3.4에서 설명한다.
3.2 파이프라인 전체 흐름
git push 한 번 → 4개 Stage 순차 실행 + Slack 알림
CGV의 .gitlab-ci.yml은 6개 Stage로 선언되어 있지만, 실제 Job이 있는 건 5개다. 각 Stage마다 새 Job 컨테이너가 생성된다.
# 실제 .gitlab-ci.yml
stages:
- test # build-sonar Job
- build # build Job
- login # ecr-login Job
- docker # docker-build-separate Job
- deploy # Job 없음! (선언만 됨)
- notify # notify-success, notify-failure Job
왜 deploy stage에 Job이 없나? CD(배포)는 GitLab CI가 아닌 ArgoCD Image Updater가 담당한다. ECR에 새 이미지가 푸시되면 ArgoCD Image Updater가 자동으로 감지하고 배포를 트리거한다. 상세 흐름은 4장 ArgoCD에서 설명한다.
git push origin develop
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Stage 1: test │
│ Runner: "maven 컨테이너 만들어" │
│ [Job 컨테이너 1] 생성 │
│ └─ 코드 다운로드 → mvn verify sonar (코드 품질 검사) │
│ [Job 컨테이너 1] 삭제 │
├─────────────────────────────────────────────────────────────────────────┤
│ Stage 2: build │
│ Runner: "maven 컨테이너 만들어" │
│ [Job 컨테이너 2] 생성 │
│ └─ 코드 다운로드 → mvn package → JAR 파일 생성 │
│ [Job 컨테이너 2] 삭제 │
│ (JAR 파일은 artifacts로 다음 Stage에 전달) │
├─────────────────────────────────────────────────────────────────────────┤
│ Stage 3: login │
│ Runner: "aws-cli 컨테이너 만들어" │
│ [Job 컨테이너 3] 생성 │
│ └─ aws ecr get-login-password → ECR 토큰 발급 │
│ [Job 컨테이너 3] 삭제 │
│ (토큰은 artifacts로 다음 Stage에 전달) │
├─────────────────────────────────────────────────────────────────────────┤
│ Stage 4: docker │
│ Runner: "docker 컨테이너 만들어" │
│ [Job 컨테이너 4] 생성 │
│ └─ docker build → docker push (ECR에 이미지 업로드) │
│ [Job 컨테이너 4] 삭제 │
│ (GitLab CI의 빌드 역할은 여기서 끝) │
├─────────────────────────────────────────────────────────────────────────┤
│ Stage 5: notify │
│ [Job 컨테이너 5] 생성 │
│ └─ Slack으로 빌드 성공/실패 알림 전송 │
│ [Job 컨테이너 5] 삭제 │
└─────────────────────────────────────────────────────────────────────────┘
│
│ ECR에 새 이미지 등록됨
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ ArgoCD Image Updater (4장에서 설명) │
│ ECR 감시 → 새 이미지 감지 → values.yaml 자동 업데이트 → 배포 트리거 │
└─────────────────────────────────────────────────────────────────────────┘
Stage 간 데이터 전달: artifacts
각 Job 컨테이너는 독립적이다. Job 컨테이너 2에서 만든 JAR 파일을 Job 컨테이너 4에서 어떻게 쓸까?
[Job 컨테이너 2 - build Stage]
mvn package → target/app.jar 생성
│
│ artifacts로 GitLab 서버에 업로드
▼
[GitLab 서버]
app.jar 임시 저장
│
│ 다음 Stage에서 다운로드
▼
[Job 컨테이너 4 - docker Stage]
app.jar 받아서 → docker build에 포함
3.3 각 Stage 상세: test, build, login
3.2에서 설명한 대로 각 Stage는 독립된 Job 컨테이너에서 실행된다. 컨테이너가 다르기 때문에 환경 변수나 파일이 자동으로 공유되지 않는다. 다음 Stage에 전달할 데이터는 artifacts로 GitLab 서버에 업로드해야 한다.
test Stage: SonarQube 코드 품질 검사
코드를 빌드하기 전에 품질을 검사한다. 버그, 보안 취약점, 코드 스멜(나쁜 패턴)을 자동으로 탐지한다.
build-sonar:
stage: test
image: maven:3.9-eclipse-temurin-17
script:
- mvn verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar
allow_failure: true
allow_failure: true인 이유: 6주 프로젝트에서 품질 게이트를 강제하면 개발 속도가 떨어진다. 운영 환경에서는 false로 변경해야 한다.
build Stage: Maven으로 JAR 생성
Java 소스 코드(.java)를 컴파일해서 실행 가능한 파일로 만든다.
JAR(Java Archive)이란?
├── .java 파일들 → 컴파일 → .class 파일들
└── .class 파일들 + 설정 파일들 → 하나로 묶음 → app.jar
JAR = Java 애플리케이션을 하나의 파일로 패키징한 것
java -jar app.jar 명령어로 실행 가능
build:
stage: build
image: maven:3.9-eclipse-temurin-17
script:
- mvn -B -DskipTests clean package
artifacts:
paths:
- target/*.jar
expire_in: 1 hour
artifacts: 생성된 JAR 파일을 GitLab 서버에 업로드한다. 이 Job 컨테이너는 작업 끝나면 삭제되기 때문에, docker Stage(4단계)에서 JAR을 사용하려면 artifacts로 전달해야 한다.
login Stage: ECR 인증 토큰 발급
ECR(Elastic Container Registry)은 AWS의 Docker 이미지 저장소다. 이미지를 푸시하려면 먼저 인증해야 한다.
ecr-login:
stage: login
image:
name: amazon/aws-cli:latest
entrypoint: [""]
script:
- aws ecr get-login-password --region $PROD_REGION > ecr_token.txt
artifacts:
paths:
- ecr_token.txt
토큰을 파일로 저장하고 artifacts로 전달하는 이유: docker Stage가 다른 Job 컨테이너에서 실행되기 때문이다. login 컨테이너에서 발급한 토큰은 login 컨테이너가 삭제되면 사라진다. artifacts로 GitLab 서버에 올려두면 docker 컨테이너에서 다운로드해서 사용할 수 있다.
3.4 docker Stage: 이미지 빌드 및 ECR 푸시
docker-build:
stage: docker
image: docker:24.0.5
services:
- docker:24.0.5-dind
script:
- cat ecr_token.txt | docker login --username AWS --password-stdin $ECR_NAMESPACE
- export TAG=$(date +%y%m%d-%H%M%S)
- echo "$VER-$TAG" > tag.txt # 빌드 태그 기록
- docker build -t "$ECR_NAMESPACE/$REPO_NAME:$VER-$TAG" .
- docker push "$ECR_NAMESPACE/$REPO_NAME:$VER-$TAG"
artifacts:
paths:
- tag.txt # 빌드 기록 보존
tag.txt에 이미지 태그를 저장하고 artifacts로 보존한다. 이 파일은 빌드 이력 추적과 디버깅에 활용된다. 실제 배포 트리거는 ArgoCD Image Updater가 ECR에서 새 태그를 직접 감지하여 처리한다 (4.2에서 설명).
문제: Job 컨테이너 안에서 docker build를 어떻게 실행하나?
docker Stage도 Job 컨테이너(docker:24.0.5) 안에서 실행된다. 그런데 문제가 있다.
먼저 Docker의 구조를 알아야 한다:
[Docker 설치 시 호스트에 생기는 것]
1. 프로그램
├── Docker Engine (dockerd) ─ 백그라운드 데몬, 실제 빌드/실행 처리
└── Docker CLI (docker) ─ 명령어 도구, 사용자가 입력하는 것
2. 통로
└── /var/run/docker.sock ─ CLI → Engine 연결 (Unix 소켓)
3. 데이터 디렉토리
└── /var/lib/docker/ ─ Engine이 관리하는 저장 공간
├── image/ ─ 이미지 레이어
├── containers/ ─ 컨테이너 데이터
└── overlay2/ ─ 파일시스템 레이어
비유:
Docker CLI = TV 리모컨 (버튼 누름)
Docker Engine = TV 본체 (실제 동작)
docker.sock = 리모컨 신호를 TV로 전달하는 통로
리모컨만 있고 TV가 없으면? → 아무 동작 안 함
docker:24.0.5 이미지(깃랩 러너가 만드는 Job 컨테이너)에는 CLI만 있고 Engine이 없다
[Job 컨테이너 - docker:24.0.5]
├── Docker CLI ✓ (있음)
└── Docker Engine ✗ (없음!)
→ docker build 명령어는 입력 가능
→ 하지만 실제 빌드를 처리할 Engine이 없음
→ 에러: "Cannot connect to the Docker daemon"
- 왜 Engine이 없을까? Engine을 넣어봤자 실행할 수 없기 때문이다.그런데 컨테이너는 이미 "격리된 공간" 안에 갇혀 있는 프로세스다. 격리의 목적 자체가 "커널을 함부로 건드리지 못하게" 하는 것이므로, 컨테이너 안에서는 커널 조작 권한이 차단되어 있다. 감옥 안에서 새 감옥을 지을 수 없는 것과 같다. Engine을 넣어봤자 권한이 없으니 실행하면 에러가 난다. 그래서 Docker 공식 이미지가 CLI만 포함하도록 설계된 것이다.
- Docker Engine이 컨테이너를 만들 때 하는 일은 "커널아, 격리된 공간 하나 만들어줘"라는 요청이다. namespace(프로세스 격리), cgroup(자원 제한) 같은 Linux 커널 기능을 직접 조작하는 것이다. 이건 호스트의 관리자 권한이 있어야 가능하다.
해결 1: Socket 마운트 (CGV가 사용)
EC2 호스트에 Docker Engine이 설치되어 있다. Job 컨테이너에서 이 Engine을 사용하면 된다.
/var/run/docker.sock이 CLI와 Engine을 연결하는 통로라고 했다. 이 소켓 파일을 Job 컨테이너에 마운트하면 호스트 Engine과 통신할 수 있다.
[마운트 전]
Job 컨테이너 (CLI만) → ??? → 연결 대상 없음
[마운트 후]
Job 컨테이너 (CLI) → /var/run/docker.sock → 호스트 Docker Engine
3.1에서 Runner 등록할 때 이 옵션을 썼다:
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock"
이 설정이 모든 Job 컨테이너에 호스트의 Docker Socket을 마운트한다.
[최종 구조]
[EC2 호스트]
├── Docker Engine ←──────────────────┐
│ │ Socket 연결
├── [GitLab 컨테이너] (상주) │
├── [Runner 컨테이너] (상주) │
│ │
└── [Job 컨테이너 - docker:24.0.5] │
└── Docker CLI ──────────────────┘
└── docker build 입력 → 호스트 Engine이 처리
해결 2: DinD (Docker-in-Docker)
.gitlab-ci.yml에 services: docker:24.0.5-dind를 설정하면, Job 컨테이너 옆에 별도의 Docker Engine 컨테이너를 띄운다.
[DinD 구조]
[EC2 호스트]
├── Docker Engine (호스트꺼, 안 씀)
│
├── [Job 컨테이너 - CLI만] ─────────┐
│ │ 네트워크로 연결
└── [DinD 컨테이너 - Engine 있음] ←─┘
└── docker build → DinD Engine이 처리
해결 1 vs 해결 2 비교
| 방식 | 장점 | 단점 |
|---|---|---|
| Socket 마운트 | 빠름, 캐시 공유 | 호스트 Docker 접근 (보안 주의) |
| DinD | 완전 격리 | 느림, 레이어 캐시 공유 안 됨 |
[중요] 현재 설정의 모순점
CGV의 .gitlab-ci.yml에는 DinD 설정이 있지만, Runner 등록 시 Socket 마운트도 설정되어 있다.
# .gitlab-ci.yml
docker-build:
services:
- docker:24.0.5-dind # DinD 설정 있음 (해결 2)
# Runner 등록 시
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock" # Socket 마운트도 있음 (해결 1)
둘 다 설정되면? Socket 마운트가 우선 적용된다. DinD 컨테이너는 띄워지긴 하지만 실제로 사용되지 않는다 (리소스 낭비).
실제로는 Socket 마운트만 사용되므로, 운영 환경에서는 services: docker:dind를 제거하는 것이 좋다. 또는 Socket 마운트 없이 DinD만 사용하도록 통일해야 한다.
Dockerfile: 멀티스테이지 빌드
docker build 명령어가 실행되면:
- Job 컨테이너가 GitLab에서 소스 코드를 다운로드한다 (git clone)
- 소스 코드에 포함된 Dockerfile을 읽는다
- 3.3에서 artifacts로 전달받은 JAR 파일을 이미지에 포함시킨다
[docker Stage의 Job 컨테이너]
│
├── GitLab에서 다운로드
│ ├── Dockerfile ← 소스 코드에 포함
│ ├── src/ ← 소스 코드
│ └── pom.xml
│
├── artifacts로 전달받음
│ └── target/app.jar ← 3.3 build Stage에서 생성
│
└── docker build 실행
└── Dockerfile + JAR → Docker 이미지 생성
CGV의 Dockerfile은 멀티스테이지로 구성되어 있다:
# Stage 1: 빌드 (Maven + JDK)
FROM maven:3.9-eclipse-temurin-17 as builder
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# Stage 2: 실행 (JRE만)
FROM eclipse-temurin:17-jre-jammy
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Stage 1의 Maven과 소스 코드는 최종 이미지에 포함되지 않는다. JRE + JAR만 남아서 이미지 크기가 약 300MB로 줄어든다.
브랜치별 ECR 리포지토리 분리
if [ "$CI_COMMIT_BRANCH" = "production" ]; then
REPO_NAME="prod-cgv-api"
else
REPO_NAME="dev-gitlab-repo"
fi
브랜치에 따라 다른 ECR 리포지토리에 푸시한다.
3.5 notify Stage와 CI/CD 역할 분리
notify Stage: Slack 알림
docker stage가 완료되면 Slack으로 빌드 결과를 알린다.
# .gitlab-ci.yml - notify stage (실제 코드)
notify-success:
stage: notify
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- |
if [ "$CI_COMMIT_BRANCH" = "production" ]; then
ENVIRONMENT="🚀 PRODUCTION"
EMOJI="🎉"
else
ENVIRONMENT="🔧 DEVELOPMENT"
EMOJI="✅"
fi
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\": \"$EMOJI GitLab CI completed for $PROJECT_NAME ($ENVIRONMENT - $CI_COMMIT_REF_NAME)\"}" \
$SLACK_WEBHOOK_URL
rules:
- when: on_success # 파이프라인 성공 시에만 실행
notify-failure:
stage: notify
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- |
if [ "$CI_COMMIT_BRANCH" = "production" ]; then
ENVIRONMENT="🚀 PRODUCTION"
EMOJI="💥"
else
ENVIRONMENT="🔧 DEVELOPMENT"
EMOJI="❌"
fi
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\": \"$EMOJI GitLab CI failed for $PROJECT_NAME ($ENVIRONMENT - $CI_COMMIT_REF_NAME)\"}" \
$SLACK_WEBHOOK_URL
rules:
- when: on_failure # 파이프라인 실패 시에만 실행
when: on_success와 when: on_failure로 성공/실패 각각 다른 Job이 실행된다. 두 Job이 동시에 실행되는 게 아니라, 파이프라인 결과에 따라 하나만 실행된다.
CI와 CD의 역할 분리
여기서 중요한 점이 있다. .gitlab-ci.yml에 deploy stage가 선언되어 있지만, 실제 Job은 없다.
stages:
- test
- build
- login
- docker
- deploy # ← Job 없음! 선언만 됨
- notify
왜 deploy stage에 Job이 없을까? CGV 프로젝트에서는 CI(빌드)와 CD(배포)를 분리했기 때문이다.
[역할 분리]
GitLab CI (CI - Continuous Integration)
├─ test: 코드 품질 검사
├─ build: JAR 파일 생성
├─ login: ECR 인증
├─ docker: 이미지 빌드 → ECR 푸시
└─ notify: Slack 알림
(여기까지가 GitLab CI의 역할)
ArgoCD + Image Updater (CD - Continuous Deployment)
├─ ECR 새 이미지 감지
├─ values.yaml 자동 업데이트
└─ EKS 배포
(4장에서 상세 설명)
왜 이렇게 분리했나?
GitOps의 원칙: Git이 진실의 원천(Single Source of Truth)이다. 클러스터에 배포된 상태는 항상 Git에 정의된 상태와 일치해야 한다.
[GitOps 원칙]
Git 저장소 (values.yaml) ←── 항상 일치 ──→ EKS 클러스터
values.yaml에 tag: abc1234가 있으면
EKS에도 abc1234 이미지가 배포되어 있어야 함
만약 수동으로 kubectl set image를 실행하면:
- Git: tag: old-version
- EKS: 실제로는 new-version 실행 중
- 문제: Git과 실제 상태 불일치 (Drift)
ArgoCD가 Git을 감시하면서 이 일치를 보장한다. 그리고 ArgoCD Image Updater가 ECR의 새 이미지를 감지해서 해당 환경의 values.yaml(values-dev.yaml 등)의 image.tag를 자동으로 업데이트한다. 상세 동작 방식은 4장에서 설명한다.
3.6 전체 배포 아키텍처
GitLab 하나로 3개 환경에 배포
CGV 프로젝트에서 GitLab은 개발계 VPC에만 존재한다. 이 GitLab 하나로 개발계, QA계, 운영계 세 환경에 배포한다.
[개발계 VPC - GitLab]
│
│ git push
▼
GitLab Runner
│
├─ develop branch → 빌드 → dev-gitlab-repo (ECR) → 개발계 ArgoCD (자동) → 개발계 EKS
├─ qa branch → 빌드 → qa-cgv-api (ECR) → QA계 ArgoCD (자동) → QA계 EKS
└─ production → 빌드 → prod-cgv-api (ECR) → 운영계 ArgoCD (수동) → 운영계 EKS
참고: 다이어그램은 간략화한 것이다. 실제로는 ECR 푸시 후 values.yaml의 image.tag를 업데이트하고 Git에 커밋해야 ArgoCD가 감지한다. ArgoCD는 ECR이 아닌 Git 저장소를 감시한다. 상세 흐름은 4장에서 설명한다.
ECR 리포지토리 분리 전략
| 브랜치 | ECR 리포지토리 | 배포 환경 | Sync 방식 |
|---|---|---|---|
| develop(개발계) | dev-gitlab-repo | 개발계 EKS | 자동 |
| qa | qa-cgv-api | QA계 EKS | 자동 |
| production(운영계) | prod-cgv-api | 운영계 EKS | 수동 |
운영계만 수동 Sync인 이유: 티켓팅 오픈 중에 배포하면 안 된다. 운영자가 배포 시점을 결정한다.
현재 방식의 한계
브랜치마다 재빌드하는 현재 방식은 문제가 있다:
develop 빌드 (10:00) → dev-gitlab-repo:abc1234
qa 빌드 (14:00) → qa-cgv-api:abc1234
production 빌드 (18:00) → prod-cgv-api:abc1234
세 이미지는 같은 코드(abc1234)지만 빌드 시점이 다름
→ Maven 의존성 버전이 미세하게 다를 수 있음
→ "개발에서 됐는데 운영에서 안 됨" 가능성
이 문제는 8.5에서 이미지 승격(Image Promotion) 전략으로 해결한다.
3.6 GitLab CI vs GitHub Actions
| 항목 | GitLab CI | GitHub Actions |
|---|---|---|
| 설정 파일 | .gitlab-ci.yml | .github/workflows/*.yml |
| Runner | 자체 호스팅 or SaaS | GitHub-hosted or Self-hosted |
| 캐싱 | cache 키워드 | actions/cache |
| Artifact | artifacts 키워드 | actions/upload-artifact |
CGV가 GitLab을 선택한 이유는 자체 호스팅이다. Private Subnet에 위치하여 외부 노출을 차단하고, VPC Endpoint로 ECR에 접근하여 NAT 비용을 절감한다.
여기까지가 CI(Continuous Integration) 파이프라인이다. test(코드 품질 검사) → build(JAR 생성) → login(ECR 인증) → docker(이미지 빌드 + ECR 푸시) → notify(Slack 알림)까지 완료했다.
이제 CD(Continuous Deployment)를 담당하는 ArgoCD를 분석한다.
4. ArgoCD 배포 방식 분석
CI와 CD의 역할 분리
3장에서 GitLab CI가 이미지를 ECR에 푸시하는 것까지 다뤘다. 하지만 ECR에 이미지가 올라갔다고 자동으로 EKS에 배포되는 건 아니다. 누군가 kubectl apply를 해줘야 한다. ArgoCD가 이 역할을 한다.
[CI/CD 전체 흐름]
git push
│
▼
┌─────────────────────────────────────────┐
│ GitLab CI (CI - 3장에서 다룸) │
│ test → build → login → docker → notify │
│ 결과물: ECR에 이미지 푸시 (abc1234) │
└─────────────────────────────────────────┘
│
│ ECR에 새 이미지 등록됨
▼
┌──────────────────────────────────────────────────┐
│ ArgoCD Image Updater │
│ ECR 감시 → 새 태그 감지 │
│ → 해당 환경 values.yaml의 image.tag 업데이트 │
│ → Git 자동 커밋 │
└──────────────────────────────────────────────────┘
│
│ values.yaml 변경이 Git에 커밋됨
▼
┌─────────────────────────────────────────┐
│ ArgoCD (CD - 4장에서 다룸) │
│ Git 저장소 감시 → 변경 감지 → 배포 │
│ kubelet이 ECR에서 이미지 pull │
└─────────────────────────────────────────┘
핵심: ArgoCD는 ECR을 감시하지 않는다. Git(values.yaml)을 감시한다.
그렇다면 누가 values.yaml을 업데이트하나? ArgoCD Image Updater가 ECR을 감시하다가 새 이미지를 감지하면 Helm Chart의 해당 환경 values.yaml(values-dev.yaml, values-prod.yaml 등)의 image.tag를 자동으로 업데이트하고 Git에 커밋한다. 그러면 ArgoCD가 Git 변경을 감지하고 배포를 시작한다.
ArgoCD란?
ArgoCD는 Kubernetes용 GitOps CD 도구다. Git 저장소(Helm Chart)를 감시하다가 변경이 감지되면 자동으로 클러스터에 배포한다.
ArgoCD는 EKS 클러스터 내부에 Pod로 실행된다. 이 Pod가 주기적으로(기본 3분) Git 저장소(GilLab)를 폴링하여 변경을 감지한다.
[ArgoCD 동작 흐름]
1. ArgoCD가 Git 저장소 감시 (3분마다 폴링)
└─ "Helm values.yaml의 이미지 태그가 바뀌었나?"
2. 변경 감지 시 → 현재 클러스터 상태와 비교
├─ 같으면 → Synced (정상)
└─ 다르면 → OutOfSync (동기화 필요)
3. OutOfSync 상태에서
├─ 자동 Sync 설정 → 즉시 kubectl apply 실행
└─ 수동 Sync 설정 → 운영자가 버튼 클릭 후 배포
4. 배포 완료 → Healthy/Synced 상태로 전환
ArgoCD UI에서 애플리케이션 상태를 시각적으로 확인할 수 있다. Deployment, Service, Pod 등 모든 리소스의 상태가 트리 구조로 표시된다. 문제가 있으면 빨간색으로 표시되어 즉시 파악 가능하다.
4.1 Drift Detection
ArgoCD의 핵심 기능이다. Git에 정의된 상태와 실제 클러스터 상태를 주기적으로 비교한다.
[Drift 발생 시나리오]
1. Git에 replicas: 3 정의
2. ArgoCD가 클러스터에 배포 (3개 Pod)
3. 누군가 kubectl scale --replicas=5 실행
4. ArgoCD: "Git과 다르네?" → OutOfSync 표시
5. 자동 Sync 설정 시 → 3개로 복원
왜 이게 중요한가? 개발자가 긴급 상황에서 kubectl로 수동 변경하는 경우가 있다. Push 방식에서는 이 변경이 Git에 반영되지 않아 "현재 상태가 뭔지" 아무도 모른다. ArgoCD의 Pull 방식에서는 Git이 항상 진실이므로 Drift가 자동으로 복원되거나, 최소한 감지된다.
개발계/운영계 환경별 배포 전략(자동 Sync vs 수동 Sync)은 3.6에서 정리했다.
4.2 ArgoCD Image Updater ★
3.5에서 "deploy stage에 Job이 없다"고 설명했다. 그렇다면 values.yaml의 image.tag는 누가 업데이트하나? ArgoCD Image Updater가 이 역할을 담당한다.
[ArgoCD Image Updater 동작 흐름]
1. Image Updater가 ECR 감시 (주기적 폴링)
└─ "새 이미지 태그가 등록됐나?"
2. 새 태그 감지 시 (예: 1.0.0-250601-143000)
└─ values.yaml의 image.tag 업데이트
└─ Git에 자동 커밋 + 푸시
3. ArgoCD가 Git 변경 감지
└─ OutOfSync → 자동/수동 Sync → EKS 배포
왜 GitLab CI에서 직접 안 하고 Image Updater를 쓰나?
| 방식 | 장점 | 단점 |
|---|---|---|
| GitLab CI deploy stage | CI 파이프라인에서 완결 | Git 토큰 관리 필요, CI/CD 결합 |
| ArgoCD Image Updater | CI/CD 완전 분리, 보안 강화 | 추가 컴포넌트 설치 필요 |
CGV 프로젝트에서는 CI(GitLab)와 CD(ArgoCD)를 완전히 분리하는 전략을 선택했다. GitLab CI는 ECR에 이미지 푸시까지만 담당하고, 배포는 ArgoCD 생태계에서 처리한다. 이렇게 하면 GitLab Runner에 Helm Chart 저장소 접근 권한을 줄 필요가 없어 보안이 강화된다.
ArgoCD Image Updater 설치 (팀원 담당)
ArgoCD 자체 설치는 8.3에서 설명한다. Image Updater는 ArgoCD와 별도로 추가 설치하는 컴포넌트다. ArgoCD와 마찬가지로 EKS 클러스터 내부에 Pod로 설치된다.
# ArgoCD Image Updater 설치 (일반적인 방식)
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yaml
설치하면 argocd 네임스페이스에 argocd-image-updater Pod가 생성된다.
[EKS 클러스터 - argocd 네임스페이스]
├── argocd-server Pod ← ArgoCD 본체 (UI, Git 감시)
├── argocd-application-controller Pod ← Sync 실행 (kubectl apply)
├── argocd-repo-server Pod ← Git 저장소 접근
└── argocd-image-updater Pod ← ECR 감시 + values.yaml 업데이트
Image Updater Pod가 ECR을 주기적으로 폴링하면서 새 이미지 태그를 감지하고, 감지하면 해당 환경의 values.yaml을 업데이트해서 Git에 커밋한다. 나머지는 ArgoCD가 평소처럼 Git 변경을 감지하고 배포하는 흐름이다.
5. 대안 비교: Helm vs Kustomize
CGV는 Helm을 선택했지만, Kustomize도 유력한 대안이다.
Helm은 Go 템플릿을 사용한다. values.yaml에서 값을 가져와 템플릿에 주입한다.
# values-dev.yaml
replicaCount: 2
# templates/deployment.yaml
spec:
replicas: {{ .Values.replicaCount }}
Kustomize는 Base + Overlay 패턴을 사용한다. 기본 설정에 환경별 패치를 덮어씌운다.
# base/deployment.yaml
spec:
replicas: 2
# overlays/prod/replica-patch.yaml
spec:
replicas: 10
| 상황 | 추천 도구 | 이유 |
|---|---|---|
| 복잡한 템플릿 로직 | Helm | if/range 등 제어문 지원 |
| 간단한 환경 분리 | Kustomize | 패치 방식이 직관적 |
| 외부 Chart 사용 | Helm | Chart 저장소 생태계 |
| kubectl 네이티브 | Kustomize | kubectl -k 내장 |
CGV가 Helm을 선택한 이유는 range로 여러 리소스 반복 생성, External Secrets와 Fluent Bit 등 외부 Chart 의존성 때문이다.
6. 대안 비교: ArgoCD vs Flux
GitOps 도구의 양대 산맥이다.
| 항목 | ArgoCD | Flux |
|---|---|---|
| UI | 웹 대시보드 제공 | CLI 중심 (UI는 별도) |
| 멀티 클러스터 | Application 단위 관리 | GitRepository 기반 |
| Helm 지원 | Application에서 직접 지원 | HelmRelease CRD |
| 학습 곡선 | 상대적으로 낮음 | 상대적으로 높음 |
| 이미지 자동 업데이트 | Image Updater (별도 설치) | 내장 |
CGV가 ArgoCD를 선택한 이유는 웹 UI로 배포 상태 시각화, 롤백이 UI 클릭 한 번, Helm Application 직접 지원 때문이다.
7. 현재 구현 평가
7.1 잘 구현된 점
첫째, GitOps 원칙을 준수했다. Git이 Single Source of Truth다. 클러스터 상태가 항상 Git과 일치하므로 "현재 배포된 버전이 뭐지?"라는 질문에 Git 커밋 해시로 답할 수 있다.
둘째, 환경 분리가 명확하다. values-dev.yaml과 values-prod.yaml로 환경별 설정을 분리했다.
셋째, 보안을 고려했다. GitLab 자체 호스팅으로 소스 코드 노출을 차단하고, VPC Endpoint로 ECR 접근을 Private 경로로 유지했다. IRSA로 Pod별 최소 권한을 적용했다.
7.2 개선이 필요한 점
첫째, Secret 관리가 미흡하다. values.yaml에 비밀번호가 평문으로 저장된다. Git에 커밋되면 보안 위험이다.
둘째, 롤백 전략이 명시되지 않았다. ArgoCD는 롤백 기능을 제공하지만, "언제 롤백할지"에 대한 기준이 없다.
셋째, 모니터링 연계가 부족하다. 배포 후 애플리케이션 상태를 자동으로 확인하지 않는다. 배포는 성공했지만 실제로 에러가 발생하는 상황을 감지하지 못한다.
8. 심화: 운영 환경 CI/CD 고도화
1 심화에서 제안한 인프라 개선(NAT 고가용성, WAF, Redis maxmemory-policy)과 2 심화에서 제안한 대기열 개선(KCL 체크포인트, Redis Pub/Sub, KEDA, Graceful Shutdown)을 구현했다고 가정한다. 이제 CI/CD 파이프라인도 운영 수준으로 고도화해야 한다.
1과 2 심화에서 제안한 개선 사항들을 먼저 정리한다. CI/CD 고도화는 이 개선들이 구현되었을 때 시너지가 발생한다.
[1 인프라 심화 - 구현 가정]
1. NAT Gateway 고가용성
- 단일 NAT → AZ별 2개 NAT
- 한 AZ 장애 시에도 다른 AZ에서 인터넷 접근 가능
- CI/CD 관점: 배포 중 NAT 장애가 발생해도 다른 AZ에서 ECR 접근 가능
2. WAF Rate Limiting
- IP당 1000 req/5min 제한
- 티켓팅 봇 차단
- CI/CD 관점: 배포 후 트래픽 폭주 시 WAF가 1차 방어선 역할
3. Redis maxmemory-policy: volatile-ttl
- TTL이 짧은 키부터 삭제
- 대기열 데이터 메모리 관리
- CI/CD 관점: 배포 중 Redis OOM 방지, 무중단 배포 안정성 향상
4. VPC Endpoint 확장
- Secrets Manager 엔드포인트 추가
- CI/CD 관점: External Secrets가 Secrets Manager에 프라이빗 접근 가능
[2 대기열 심화 - 구현 가정]
1. KCL 체크포인트
- Kinesis Consumer가 처리 완료 위치를 DynamoDB에 저장
- 장애 복구 시 마지막 체크포인트부터 재처리
- CI/CD 관점: 배포 중 Consumer 재시작해도 메시지 유실 없음
2. Redis Pub/Sub
- 멀티 Pod에 실시간 메시지 브로드캐스트
- 특정 사용자에게 입장 알림 전송
- CI/CD 관점: Pod 롤링 업데이트 중에도 다른 Pod가 알림 처리 가능
3. KEDA 오토스케일링
- 대기열 길이 기반 Pod 스케일링
- HPA의 CPU/메모리 기준보다 정밀한 스케일링
- CI/CD 관점: 배포 전후 트래픽 변화에 빠르게 대응
4. Graceful Shutdown
- SIGTERM 수신 시 WebSocket 연결 정리
- @PreDestroy에서 RECONNECT_SOON 메시지 발송
- 클라이언트가 지수 백오프 + 지터로 분산 재접속
- CI/CD 관점: 배포 중 Thunder Herd 방지의 핵심
이 심화에서는 CI/CD 파이프라인에서 발견한 6가지 문제를 분석하고, 각 문제에 대해 여러 대안을 비교한 뒤 CGV 환경에 최적인 선택을 도출한다. 특히 2.2 심화의 Graceful Shutdown과 직접 연계되는 무중단 배포 전략을 중점적으로 다룬다.
8.1 문제 1: 배포 실패 시 자동 롤백 부재
구체적 시나리오: 티켓팅 오픈 중 버그 배포
[상황]
- 오후 8시 티켓팅 오픈
- 대기열에 1만 명 접속 중
- 긴급 버그 수정 배포 시작
[Rolling Update로 배포 시]
t=0s 배포 시작, Pod 10개 중 2개 새 버전으로 교체
t=10s 새 버전 Pod Ready → 트래픽 유입
t=10s 그런데 새 버전에 DB 커넥션 버그 있음
t=10s 2개 Pod로 가는 요청 전부 500 에러
→ 1만 명 중 2,000명이 "예매 실패" 경험
t=30s 계속 롤링 진행, 4개 Pod가 새 버전
→ 4,000명이 에러 경험
t=60s 운영자가 Slack 알림 보고 인지
t=90s 수동으로 helm rollback 실행
t=120s 롤백 완료
→ 총 2분간 40% 사용자 영향
[Canary로 배포 시]
t=0s 배포 시작, 트래픽 10%만 새 버전으로
t=10s 1만 명 중 1,000명만 새 버전 경험
t=30s AnalysisTemplate: 에러율 20% 감지 (임계치 5% 초과)
t=30s 자동 롤백 시작
t=35s 트래픽 100% 기존 버전으로 복구
→ 총 35초간 10% 사용자만 영향
핵심 차이: Rolling Update는 "Pod 개수"를 제어하고, Canary는 "트래픽 비율"을 제어한다.
현재 방식 (Rolling Update)의 한계
# helm-chart/templates/deployment.yaml (현재)
spec:
strategy:
rollingUpdate:
maxSurge: 25% # 배포 중 추가로 만들 수 있는 Pod 수
maxUnavailable: 25% # 배포 중 죽어도 되는 Pod 수
maxSurge 25%는 "트래픽 25%"가 아니다. 배포 중 추가로 만들 수 있는 Pod 비율이다.
[replicas: 10, maxSurge: 25% 일 때]
배포 중:
├── 구 버전 Pod: 10개 (Ready)
├── 새 버전 Pod: 3개 (Ready, 25% = 2.5 반올림)
└── 총: 13개
Service는 Ready인 모든 Pod에 균등 분배:
├── 구 버전: 10/13 = 77% 트래픽
└── 새 버전: 3/13 = 23% 트래픽
문제: Kubernetes는 Probe만 보고 "정상"을 판단한다
새 버전 Pod:
├── Running? ✓
├── Probe 통과? ✓
└── 실제 API 호출? 500 에러 발생!
Kubernetes: "Probe 통과했으니 정상이야"
→ 배포 계속 진행 → 전체가 버그 버전으로 교체됨
이 문제를 해결하려면 실제 에러율을 보고 판단하는 Argo Rollouts가 필요하다.
Argo Rollouts 이란?
Kubernetes 기본 Deployment는 Rolling Update만 지원한다. Canary나 Blue-Green을 하려면 별도 도구가 필요하다.
Argo Rollouts는 Deployment를 대체하는 CRD(Custom Resource Definition)다. Canary, Blue-Green 배포 + 메트릭 기반 자동 롤백을 지원한다.
*CRD (Custom Resource Definition) = Kubernetes 기본에 없는 리소스를 "추가"하는 것
[Kubernetes 기본]
kind: Deployment → Rolling Update만 가능
[Argo Rollouts]
kind: Rollout → Canary, Blue-Green + 자동 롤백
둘 다 Pod를 생성/관리하므로 동시에 쓰면 충돌한다. Rollout을 쓰면 기존 Deployment는 삭제해야 한다.
Argo Rollouts는 ArgoCD와 같은 Argo 프로젝트 소속이라 네이티브 통합된다. ArgoCD UI에서 Rollout 상태(Canary 몇 %, 분석 결과)를 직접 확인할 수 있다.
대안 비교
| 기준 | Rolling Update | Blue-Green | Canary |
|---|---|---|---|
| 트래픽 제어 | 불가 (Pod 개수만) | 0% 또는 100% | 10% → 50% → 100% |
| 버그 영향 범위 | 점점 확대 | 전환 순간 100% | 10%에서 멈춤 |
| 자동 롤백 | 불가 | 수동 | 메트릭 기반 자동 |
| 리소스 | 효율적 | 2배 필요 | 효율적 |
CGV 선택: Argo Rollouts Canary
선택 이유:
- ArgoCD와 같은 Argo 프로젝트라 네이티브 통합
- Istio 없이 ALB Ingress로 트래픽 제어 가능
- 4. 모니터링 분석에서 Prometheus 구축 시 메트릭 기반 자동 롤백 가능
구현 (helm-chart/templates/rollout.yaml - 새로 생성, deployment.yaml 대체)
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: {{ .Values.name }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
canary:
canaryService: {{ .Values.name }}-canary
stableService: {{ .Values.name }}-stable
trafficRouting:
alb:
ingress: {{ .Values.name }}-ingress
servicePort: 8080
steps:
- setWeight: 10
- pause: { duration: 2m }
- analysis:
templates:
- templateName: error-rate-check
- setWeight: 50
- pause: { duration: 5m }
- setWeight: 100
구현 (helm-chart/templates/analysistemplate.yaml - 새로 생성)
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: error-rate-check
spec:
metrics:
- name: error-rate
interval: 30s
successCondition: result[0] < 0.05 # 에러율 5% 미만
failureLimit: 3
provider:
prometheus:
address: http://prometheus:9090
query: |
sum(rate(http_requests_total{status=~"5..",app="cgv-api"}[1m]))
/ sum(rate(http_requests_total{app="cgv-api"}[1m]))
참고: Argo Rollouts 사용 시 기존 deployment.yaml은 삭제하거나 비활성화해야 한다. Rollout이 Deployment를 대체하는 CRD이기 때문이다.
결과
- 버그 영향 범위: 40% → 10%로 축소 (트래픽 10%에서 분석 후 자동 롤백)
- 복구 시간: 2분 → 35초로 단축 (운영자 인지 → 수동 롤백 과정 제거)
- 자동 롤백으로 운영자 개입 불필요 (AnalysisTemplate이 에러율 5% 초과 시 자동 판단)
8.2 문제 2: Secret 관리 보안 취약
"GitLab 자체를 프라이빗하게 호스팅했는데 왜 또 ASM이 필요해?"
GitLab 자체 호스팅과 Secret Manager는 다른 보안 레이어를 담당한다:
[GitLab 자체 호스팅이 해결하는 것]
- 소스 코드가 인터넷에 노출 안 됨
- 외부 공격자가 코드를 볼 수 없음
- Private Subnet에서 내부 접근만 허용
[GitLab 자체 호스팅이 해결 못 하는 것]
- 내부 접근 권한 분리
- 감사 추적
- 자동 로테이션
구체적 시나리오: 개발자 권한 문제
[현재 상황]
GitLab에 접근 가능한 사람 = 개발자 5명 전원
values.yaml에 DB 비밀번호가 평문으로 저장됨
[문제 1: 과도한 접근 권한]
DB 비밀번호를 알아야 하는 사람 = 운영팀 1~2명
실제로 볼 수 있는 사람 = 개발자 5명 전원
→ 최소 권한 원칙 위반
[문제 2: Git 히스토리]
한 번 커밋된 비밀번호 = 영원히 git log로 조회 가능
비밀번호 변경해도 이전 비밀번호가 히스토리에 남음
→ 퇴사자가 이전 비밀번호로 접근 가능
[문제 3: 감사 추적 불가]
"누가 언제 비밀번호를 봤는지" 추적 불가
금융권 감사에서 필수 요구사항
[문제 4: 수동 로테이션]
비밀번호 90일마다 교체 정책 적용 시
→ 매번 values.yaml 수정 + Git 커밋 필요
→ ASM은 자동 로테이션 지원
두 보안 레이어의 역할
| 보안 레이어 | GitLab 자체 호스팅 | Secret Manager |
|---|---|---|
| 보호 대상 | 소스 코드 | 비밀번호/API 키 |
| 위협 | 외부 공격자 | 내부 과도한 접근 |
| 접근 제어 | 네트워크 레벨 (Private Subnet) | IAM 정책 (역할별) |
| 감사 | Git 커밋 이력 | CloudTrail 로그 |
| 비밀번호 로테이션 | 수동 (커밋 필요) | 자동 (Lambda 연동) |
결론: 둘 다 필요하다. GitLab은 코드를, ASM은 Secret을 각각 보호한다.
CGV 선택: External Secrets + AWS Secrets Manager
선택 이유:
- AWS 네이티브: 추가 서버 없이 완전 관리형
- IRSA 연동: ServiceAccount에 Secrets Manager 접근 권한 부여
- 자동 동기화: refreshInterval로 키 로테이션 시 자동 반영
- 감사 로그: CloudTrail로 "누가 언제 Secret을 조회했는지" 추적
구현 (helm-chart/templates/secretstore.yaml - 새로 생성)
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: ap-northeast-2
auth:
jwt:
serviceAccountRef:
name: cgv-api-sa
구현 (helm-chart/templates/externalsecret.yaml - 새로 생성)
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: cgv-api-secret
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
target:
name: cgv-api-secret
data:
- secretKey: DB_PASSWORD
remoteRef:
key: cgv/prod/database
property: password
현실적 판단
6주 프로젝트에서 External Secrets는 오버스펙이라 생각다. 프로젝트 기간과 팀 규모를 고려해 values.yaml에 직접 관리했다. 하지만 운영 환경에서는 접근 권한 분리, 감사 추적, DB 암호 자동 로테이션을 위해 External Secrets + Secrets Manager 조합이 필요하다.
8.3 문제 3: 배포 파이프라인 가시성 부족
구체적 시나리오: 배포 중 장애 대응
[현재 상황 - 정보 분산]
t=0s 개발자: "배포 시작했어요"
t=30s 팀장: "빌드 끝났어?" → GitLab 확인
t=60s 팀장: "Sync 됐어?" → ArgoCD 확인
t=90s 운영: "Canary 몇 %야?" → ArgoCD 확인
t=120s 운영: "에러율 괜찮아?" → Prometheus 확인
→ 4개 도구를 번갈아 확인, 상황 파악에 5분 소요
[개선 후 - 단일 채널]
t=0s Slack: "[GitLab] Build Started"
t=30s Slack: "[GitLab] Build Succeeded"
t=35s Slack: "[ArgoCD] Sync Started"
t=40s Slack: "[Rollouts] Canary 10%"
t=70s Slack: "[Rollouts] Analysis Passed (Error: 0.2%)"
→ Slack 하나만 보면 전체 상황 파악
클러스터 레벨 도구 vs 애플리케이션 Helm Chart
ArgoCD와 Argo Rollouts는 애플리케이션 Helm Chart에 넣는 게 아니다. 클러스터에 한 번 설치하고 여러 애플리케이션이 공유하는 도구다.
[클러스터 레벨 도구 - EKS에 한 번만 설치]
├── ArgoCD ← argocd 네임스페이스
├── Argo Rollouts ← argo-rollouts 네임스페이스
├── External Secrets ← external-secrets 네임스페이스
└── AWS LB Controller ← kube-system 네임스페이스
[애플리케이션 Helm Chart - 서비스마다 배포]
└── helm-chart/
├── templates/rollout.yaml ← Argo Rollouts "사용"
├── templates/externalsecret.yaml ← External Secrets "사용"
└── ...
8.1에서 제안한 rollout.yaml은 Argo Rollouts가 미리 클러스터에 설치되어 있어야 동작한다. 마찬가지로 8.2의 externalsecret.yaml도 External Secrets Operator가 설치되어 있어야 동작한다.
ArgoCD 설치 (클러스터에 한 번)
# 1. 네임스페이스 생성
kubectl create namespace argocd
# 2. ArgoCD 설치 (Helm)
helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd -n argocd
# 3. 초기 admin 비밀번호 확인
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
# 4. ArgoCD UI 접속 (포트포워딩)
kubectl port-forward svc/argocd-server -n argocd 8080:443
# https://localhost:8080 접속
ArgoCD 설치 후 해야 할 것:
- Git 저장소 연결 (GitLab Private Repo + Deploy Key)
- Application 생성 (어떤 Git 경로를 어떤 클러스터에 배포할지)
- Sync 정책 설정 (자동/수동)
Argo Rollouts 설치 (클러스터에 한 번)
# 1. 네임스페이스 생성
kubectl create namespace argo-rollouts
# 2. Argo Rollouts 설치
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml
# 3. kubectl 플러그인 설치 (선택)
brew install argoproj/tap/kubectl-argo-rollouts
# 4. Rollout 상태 확인
kubectl argo rollouts get rollout cgv-api -n cgv
Argo Rollouts 설치 후:
- kind: Deployment → kind: Rollout으로 교체 가능
- ArgoCD UI에서 Rollout 상태 확인 가능 (같은 Argo 프로젝트라 네이티브 통합)
Kubernetes 네임스페이스와 ConfigMap 개념
위에서 argocd, argo-rollouts 네임스페이스를 만들었다. 다음 구현에서 ConfigMap이 등장하므로 개념을 정리한다.
[Kubernetes 클러스터]
│
├── cgv 네임스페이스/ ← CGV 앱 전용 폴더
│ ├── cgv-api Pod
│ └── cgv-api Service
│
├── argocd 네임스페이스/ ← ArgoCD 전용 폴더
│ ├── argocd-server Pod (프로그램)
│ └── argocd-notifications-cm (설정 파일) ← ConfigMap
│
└── argo-rollouts 네임스페이스/ ← Argo Rollouts 전용 폴더
├── argo-rollouts Pod (프로그램)
└── argo-rollouts-notification-configmap ← ConfigMap
- 네임스페이스: 리소스를 분리하는 폴더
- ConfigMap: Pod가 읽는 설정 파일
아래 구현 2, 3은 이미 설치된 ArgoCD/Argo Rollouts에 설정 파일(ConfigMap)을 추가하는 것이다.
CGV 선택: GitLab + ArgoCD + Argo Rollouts 통합 알림
클러스터 레벨 도구가 설치되면, 각 도구에 Slack 알림을 설정한다.
구현 1 (.gitlab-ci.yml 수정 - 기존 파일)
.notify_template: ¬ify_template
after_script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-d '{"text": "[GitLab] '"${CI_JOB_NAME}"': '"${CI_JOB_STATUS}"'"}'
build:
<<: *notify_template
# ... 기존 빌드 설정
구현 2 (argocd-notifications-cm.yaml - ArgoCD 네임스페이스에 ConfigMap)
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
trigger.on-sync-succeeded: |
- send: [slack]
template.slack: |
message: "[ArgoCD] {{.app.metadata.name}} Sync {{.app.status.operationState.phase}}"
구현 3 (argo-rollouts-notification-cm.yaml - argo-rollouts 네임스페이스에 ConfigMap)
apiVersion: v1
kind: ConfigMap
metadata:
name: argo-rollouts-notification-configmap
namespace: argo-rollouts
data:
trigger.on-rollout-step-completed: |
- send: [slack-step]
template.slack-step: |
message: "[Rollouts] {{.rollout.metadata.name}} - Weight: {{.rollout.status.currentStepIndex}}"
결과: #cgv-deploy 채널에서 빌드 → Sync → Canary 진행 → 완료까지 실시간 추적.
8.4 문제 4: 무중단 배포 시 WebSocket 연결 끊김
이 문제는 2. CGV 대기열 시스템 심화에서 분석한 Thunder Herd 문제와 직접 연결된다. 2장에서는 비정상 종료와 정상 종료 모두 다뤘지만, 개념 설명 없이 "구현해야 함"만 언급했다. 여기서 개념을 상세히 설명하고 CI/CD와 연계한다.
기본 개념 정리
이 섹션에서 사용하는 4가지 개념을 먼저 이해해야 한다.
- Endpoints = 트래픽 받을 수 있는 Pod 목록. ALB/Service는 이 목록을 보고 트래픽을 분배한다. Pod가 종료되면 Endpoints에서 제거되어 새 트래픽을 받지 않는다.
- preStop = 종료 전에 먼저 실행할 스크립트. SIGTERM 보내기 전에 preStop이 먼저 실행된다. 여기서 sleep으로 시간을 벌거나, 클라이언트에게 종료 예정을 알릴 수 있다.
- Graceful Shutdown = 하던 일 마무리하고 종료. SIGTERM 받으면 새 요청은 거부(503)하고, 진행 중인 요청은 완료할 때까지 대기한 후 종료한다.
- PDB (Pod Disruption Budget) = 최소 Pod 유지 규칙. "최소 2개 Pod는 항상 살아있어야 함"처럼 설정하면, 배포 중에도 서비스가 죽지 않게 보장한다.
Thunder Herd 복습 (2장 심화)
Pod 종료 시 WebSocket 연결 5,000개가 즉시 끊기면, 5,000개 클라이언트가 동시에 재접속을 시도한다. 새 Pod에 부하가 폭주하고, 타임아웃이 발생하면 다시 재접속을 시도하는 악순환이 반복된다.
2장 심화에서는 비정상 종료 시 클라이언트 측에서 지수 백오프 + 지터로 재접속을 분산하는 전략을 제안했다. 정상 종료(배포) 시에는 서버 측에서 미리 알려주는 더 나은 방법이 있다.
비정상 종료 vs 정상 종료(배포)
[비정상 종료 - 갑작스러운 크래시]
Pod: (갑자기 OOM Kill) 즉시 사망
클라이언트: "어? 연결 끊겼네?"
→ 서버가 미리 알려줄 기회 없음
→ 클라이언트가 알아서 지수 백오프로 분산 재접속
[정상 종료 - 배포]
Kubernetes: "이 Pod 30초 후에 종료할게" (SIGTERM 신호)
↓
Pod: "아 30초 남았구나"
↓
Pod → 클라이언트들에게: "나 곧 죽어, 너희 미리 재접속해"
↓
클라이언트: 랜덤하게 분산 재접속
↓
Pod 종료
배포는 예정된 작업이다. "언제 종료될지" 알 수 있으므로 서버가 클라이언트에게 미리 알려줄 수 있다.
Pod 종료 시퀀스
Pod가 종료될 때 Kubernetes는 다음 순서로 동작한다:
[Pod 종료 6단계]
1. Terminating 상태 변경
→ "이 Pod 곧 죽을 예정" 표시
2. Endpoints에서 제거
→ ALB: "이 Pod한테 트래픽 보내지 마"
3. preStop 훅 실행
→ 개발자가 설정한 종료 전 스크립트
4. SIGTERM 전송
→ 애플리케이션에 "종료해" 신호
5. terminationGracePeriodSeconds 대기
→ 기본 30초, 정리 작업 시간
6. SIGKILL 전송
→ 강제 종료
문제: 2번과 3번이 동시에 진행된다
시간 | Endpoints 제거 (2번) | preStop 실행 (3번)
------|---------------------------|--------------------
0초 | 시작 (ALB에 전파 중...) | 시작
1초 | 아직 전파 안 됨 | 실행 중
2초 | 아직 전파 안 됨 | 완료
3초 | ALB에 전파 완료 | -
문제: 0~3초 동안 ALB는 아직 이 Pod로 트래픽 보냄!
→ Pod는 종료 준비 중인데 새 요청이 들어옴
→ 연결 에러 발생
해결: 4가지 조합
| 컴포넌트 | 역할 | 설정값 |
|---|---|---|
| preStop | Endpoints 제거될 때까지 대기 | sleep 10초 |
| deregistration_delay | ALB가 기존 연결 유지 | 30초 |
| PDB | 최소 가용 Pod 보장 | minAvailable 66% |
| @PreDestroy | RECONNECT_SOON 발송 | delay 0~2000ms |
구현 1: preStop (helm-chart/values.yaml)
deployment:
terminationGracePeriodSeconds: 45
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
terminationGracePeriodSeconds와 preStop의 관계: terminationGracePeriodSeconds(45초)는 Pod 종료까지 최대 대기 시간이다. preStop(10초)은 그 안에서 먼저 실행된다. preStop 10초 후 SIGTERM이 전송되고, 애플리케이션은 남은 35초 동안 정리 작업을 한다. 45초가 지나면 SIGKILL로 강제 종료된다.
preStop sleep 10초의 효과:
- t=0초에 Endpoints 제거와 preStop이 동시에 시작된다.
- t=3초쯤 ALB가 Endpoints 변경을 인지한다.
- t=10초에 preStop이 끝나고 SIGTERM이 전송되는데, 이때는 이미 ALB가 새 트래픽을 보내지 않는다.
구현 2: deregistration_delay (Terraform)
resource "aws_lb_target_group" "cgv_api" {
deregistration_delay = 30
}
deregistration_delay 30초의 효과: ALB가 Target을 제거할 때 새 요청은 즉시 다른 Pod로 라우팅하지만, 기존 WebSocket 연결은 30초 동안 유지한다. 이 시간 동안 RECONNECT_SOON 메시지로 클라이언트에게 재접속을 유도한다.
구현 3: PDB (helm-chart/templates/pdb.yaml)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .Values.name }}-pdb
spec:
minAvailable: 66%
selector:
matchLabels:
app: {{ .Values.name }}
minAvailable: 66%의 효과: Pod 10개 중 최소 7개는 항상 Running이어야 한다. Rolling Update가 4개를 동시에 종료하려고 하면 PDB가 차단한다. 배포 중에도 항상 66% 이상 Pod가 트래픽을 처리한다.
구현 4: @PreDestroy + RECONNECT_SOON (2장 심화 연계)
2장 심화에서 제안한 Graceful Shutdown 코드:
@PreDestroy
public void onShutdown() {
acceptingNewConnections = false;
// 기존 연결에 RECONNECT_SOON 발송 (delay 랜덤으로 Thunder Herd 방지)
sessions.forEach(session -> {
int randomDelay = ThreadLocalRandom.current().nextInt(2000);
session.sendMessage(new TextMessage(
"{\"type\":\"RECONNECT_SOON\",\"delay\":" + randomDelay + "}"
));
});
Thread.sleep(5000); // 클라이언트 재접속 대기
sessions.forEach(WebSocketSession::close);
}
delay 랜덤의 효과: delay 없이 즉시 재접속하면 5,000개가 동시에 몰린다. delay를 0~2000ms 랜덤으로 주면 2초에 걸쳐 분산되어 Thunder Herd를 방지한다.
클라이언트는 RECONNECT_SOON 메시지를 받으면 서버가 지정한 delay 후에 재접속한다.
전체 타이밍
[배포 시작]
t=0s Pod Terminating 시작
├── Endpoints 제거 시작 (병렬)
└── preStop sleep 10 시작
t=3s Endpoints 제거 완료
→ ALB가 새 트래픽을 다른 Pod로 라우팅
→ 기존 WebSocket은 deregistration_delay 30초 동안 유지
t=10s preStop 완료 → SIGTERM 전송 → @PreDestroy 실행
→ RECONNECT_SOON 메시지 발송 (delay 0~2000ms)
t=10~12s 클라이언트들이 랜덤하게 분산 재접속
→ 이미 Endpoints에서 제거되었으므로 새 Pod로 연결
t=15s @PreDestroy 완료, 남은 연결 종료
t=45s terminationGracePeriodSeconds 만료, Pod 종료
KEDA 연계 (2장 심화)
- 배포 중 Pod 수가 일시적으로 감소하면 KEDA가 추가 Pod를 생성한다.
- 예를 들어 Pod 10개, 대기열 800명 상황에서 Rolling Update가 시작되면 PDB가 최소 7개를 유지한다.
- KEDA는 대기열 800명 ÷ 100 = 8 Pod가 필요한데 현재 7개라서 1개 스케일업을 요청한다.
- 결과적으로 배포 중에도 KEDA가 부족분을 자동 보충한다.
정리
| 상황 | 전략 | 재접속 분산 |
|---|---|---|
| 정상 배포 | RECONNECT_SOON (서버 → 클라이언트) | 0~2초 (짧고 균등) |
| 비정상 종료 | 지수 백오프 + 지터 (클라이언트 자체) | 1~30초 (길고 지수적) |
결과: 정상 배포 시 서버가 미리 알려주므로 짧은 시간 내 분산 재접속. 비정상 종료 시 클라이언트가 알아서 긴 간격으로 재시도. 두 상황 모두 Thunder Herd 방지.
8.5 문제 5: 이미지 버전 관리 문제
5-1. 태그 관리 혼란 → 커밋 해시로 해결
[장애 발생]
운영팀: "어제 배포한 버전으로 롤백해야 해요"
개발자: "어제 배포한 태그가 뭐였죠?"
[ECR 확인]
1.0.0-250828-122444 ← 이게 어떤 커밋?
1.0.0-250828-143322 ← 이건?
1.0.0-250827-091533 ← 이건?
[문제]
- 태그에 날짜-시간만 있고 Git 커밋 정보 없음
- "122444 버전에서 뭐가 바뀌었는지" 알 수 없음
- 잘못된 버전으로 롤백 위험
해결: .gitlab-ci.yml의 docker stage 태그 방식 변경 (3.4 수정)
3.4에서 분석한 현재 docker stage는 date +%y%m%d-%H%M%S로 태그를 생성한다. 이걸 Git 커밋 해시로 변경한다.
# 현재 (3.4)
docker-build:
script:
- export TAG=$(date +%y%m%d-%H%M%S) # ← 날짜-시간 기반
- docker build -t "$ECR_NAMESPACE/$REPO_NAME:$VER-$TAG" .
- docker push "$ECR_NAMESPACE/$REPO_NAME:$VER-$TAG"
# 개선
docker-build:
script:
- export IMAGE_TAG=${CI_COMMIT_SHORT_SHA} # ← Git 커밋 해시 (abc1234)
- docker build -t "$ECR_NAMESPACE/$REPO_NAME:$IMAGE_TAG" .
- docker push "$ECR_NAMESPACE/$REPO_NAME:$IMAGE_TAG"
CI_COMMIT_SHORT_SHA는 GitLab CI가 자동으로 제공하는 변수로, 현재 커밋의 앞 8자리 해시다. 별도 설정 없이 사용 가능하다.
결과: "이 이미지는 어떤 코드?" → git show abc1234로 즉시 확인. 롤백 시 정확한 버전 지정 가능.
5-2. 환경별 재빌드로 이미지 불일치 → 이미지 승격으로 해결
3.6에서 언급한 현재 방식의 한계다. 현재 CGV는 브랜치별로 재빌드한다.
[현재 방식 - 브랜치별 merge + 재빌드]
개발자가 하는 일:
1. develop에서 개발 → git push → 빌드 → dev ECR
2. 개발 완료 → qa 브랜치에 merge → git push → 빌드 → qa ECR
3. QA 통과 → production 브랜치에 merge → git push → 빌드 → prod ECR
결과: 3번 빌드 = 3개의 서로 다른 이미지
같은 커밋(abc1234)을 3번 빌드하면 실제로는 다른 이미지가 나올 수 있다:
| 문제 | 설명 |
|---|---|
| Maven 의존성 | SNAPSHOT 버전이 빌드 시점에 따라 다를 수 있음 |
| 베이스 이미지 | openjdk:17이 오전과 오후에 다를 수 있음 (latest 태그) |
| 빌드 환경 | Runner 캐시 상태에 따라 결과가 다를 수 있음 |
[장애 시나리오]
1. 개발계에서 테스트 완료 (dev-gitlab-repo:abc1234)
2. 운영 배포 (prod-cgv-api:abc1234)
3. 운영에서 NPE 발생
4. "개발에서는 됐는데?"
5. 원인: production 빌드 시 Maven 의존성 버전이 미세하게 달랐음
해결: 이미지 승격(Image Promotion) — 브랜치 전략 자체를 변경
qa/production 브랜치에 merge하는 방식을 버린다. develop에서 한 번만 빌드하고, 이후는 빌드 없이 ECR 간 이미지 복사로 승격한다.
[기존 방식 - 브랜치별 merge + 재빌드]
develop push → 빌드 → dev ECR (이미지 A)
qa merge+push → 빌드 → qa ECR (이미지 B, A와 다를 수 있음)
prod merge+push → 빌드 → prod ECR (이미지 C, A,B와 다를 수 있음)
[개선 방식 - develop만 빌드 + ECR 간 복사]
develop push → 빌드 → dev ECR (이미지 A)
QA 승인 → 복사 → qa ECR (이미지 A 그대로, 재빌드 없음)
운영 승인 → 복사 → prod ECR (이미지 A 그대로, 재빌드 없음)
핵심: qa/production 브랜치에 merge+push 안 한다. 빌드는 한 번만. 이후는 동일한 이미지를 ECR 리포지토리 간 복사(승격)한다.
when: manual로 승격 Job 구현 (.gitlab-ci.yml에 추가)
.gitlab-ci.yml에 when: manual을 설정하면 해당 Job은 자동 실행되지 않고, GitLab Pipeline UI에 ▶ Play 버튼이 생긴다. 사람이 버튼을 눌러야 실행된다.
[develop push]
↓
CI 자동 실행: test → build → login → docker → notify (전부 자동, 기존과 동일)
↓
dev ECR에 이미지 도착
↓
Image Updater 감지 → values-dev.yaml 업데이트 → 개발계 자동 배포 완료
동시에 GitLab Pipeline UI에 Play 버튼 생성:
promote-to-qa ▶ [Play] ← 대기 중
promote-to-prod ▶ [Play] ← 대기 중 (qa 완료 후 활성화)
Play 버튼을 누르면 빌드가 아니라 ECR 이미지 복사가 실행된다.
# .gitlab-ci.yml에 promote stage 추가
promote-to-qa:
stage: promote
image:
name: amazon/aws-cli:latest
entrypoint: [""]
rules:
- if: $CI_COMMIT_BRANCH == "develop"
when: manual # ← 이게 Play 버튼을 만든다
script:
- |
# dev ECR에서 이미지 manifest 가져옴 (이미지의 실체)
MANIFEST=$(aws ecr batch-get-image \
--repository-name dev-gitlab-repo \
--image-ids imageTag=${CI_COMMIT_SHORT_SHA} \
--query 'images[0].imageManifest' --output text)
# qa ECR에 같은 manifest로 등록 (재빌드 없이 복사)
aws ecr put-image \
--repository-name qa-cgv-api \
--image-tag ${CI_COMMIT_SHORT_SHA} \
--image-manifest "$MANIFEST"
promote-to-prod:
stage: promote
image:
name: amazon/aws-cli:latest
entrypoint: [""]
rules:
- if: $CI_COMMIT_BRANCH == "develop"
when: manual
needs:
- promote-to-qa # QA 승격 완료 후에만 Play 버튼 활성화
script:
- |
MANIFEST=$(aws ecr batch-get-image \
--repository-name qa-cgv-api \
--image-ids imageTag=${CI_COMMIT_SHORT_SHA} \
--query 'images[0].imageManifest' --output text)
aws ecr put-image \
--repository-name prod-cgv-api \
--image-tag ${CI_COMMIT_SHORT_SHA} \
--image-manifest "$MANIFEST"
승격 후 배포: 4.2 Image Updater와 동일
Play 버튼 → ECR에 이미지 복사 완료. 이후는 4.2에서 설명한 흐름과 동일하다. ArgoCD Image Updater가 해당 ECR을 폴링하다가 새 이미지 태그를 감지하면, 해당 환경의 values.yaml(values-qa.yaml, values-prod.yaml)의 image.tag를 자동 업데이트하고 Gitlab에 커밋한다. ArgoCD가 Git 변경을 감지하고 배포를 시작한다.
Play 버튼 클릭
↓
ECR에 이미지 복사 (10초)
↓
ArgoCD Image Updater: 새 태그 감지 → values.yaml 업데이트 → Git 커밋
↓
ArgoCD: Git 변경 감지 → Sync
├─ QA계: 자동 Sync → 즉시 배포
└─ 운영계: 수동 Sync → 운영자가 ArgoCD UI에서 Sync 클릭
QA계는 자동 Sync, 운영계는 수동 Sync다 (3.6에서 설명한 대로).
승격 vs 재빌드 비교
| 기준 | 재빌드 방식 | 승격 방식 |
|---|---|---|
| 이미지 동일성 | 보장 안 됨 | 100% 동일 |
| QA/운영 배포 시간 | 빌드 5~10분 | 복사 10초 |
| "개발에서 됐는데" 문제 | 발생 가능 | 원천 차단 |
| Maven 의존성 불일치 | 발생 가능 | 불가능 |
8.6 문제 6: 환경별 설정 관리 복잡성
현재 CGV 구조
helm-chart/
├── values-dev.yaml # 개발계 전체 설정
└── values-prod.yaml # 운영계 전체 설정
두 파일이 완전히 독립되어 있다. Probe 설정, image pullPolicy 같은 공통 설정도 각 파일에 중복 정의되어 있다.
구체적 시나리오: 설정 누락으로 장애
[상황]
Probe 타임아웃을 5초 → 10초로 변경해야 함
[현재 구조의 문제]
values-dev.yaml 수정 ✓
values-qa.yaml 수정 ✓
values-prod.yaml 깜빡함 ✗
[결과]
운영계에서만 Probe 타임아웃 장애 발생
"왜 운영에서만 문제지?" → 설정 누락 발견에 30분 소요
해결: base + 환경별 오버라이드 구조
구현 (helm-chart/values/ 디렉토리 구조 변경)
helm-chart/values/
├── base.yaml # 공통 설정 (Probe, pullPolicy 등)
├── dev.yaml # 개발계 오버라이드 (replicas: 2)
├── qa.yaml # QA계 오버라이드 (replicas: 3)
└── prod.yaml # 운영계 오버라이드 (replicas: 10, HPA)
base.yaml (공통):
probes:
liveness:
path: /health/live
timeoutSeconds: 10 # 여기만 수정하면 전 환경 적용
readiness:
path: /health/ready
prod.yaml (운영계 오버라이드):
replicaCount: 10
hpa:
minReplicas: 10
maxReplicas: 100
배포 명령어:
helm upgrade cgv-api ./helm-chart \
-f values/base.yaml \
-f values/prod.yaml \ # base를 오버라이드
-n cgv-prod
결과: 공통 설정은 base.yaml 한 곳만 수정. 환경별 차이(replicas, resources)만 각 파일에서 관리.
8.7 종합: 2.1 + 2.2 + 2.3 심화 통합 아키텍처
2.1, 2.2, 2.3 심화를 모두 구현하면 다음과 같은 운영 환경이 완성된다.
[인프라 계층 - 2.1 심화]
├─ NAT Gateway 2개 (AZ별 고가용성)
├─ WAF Rate Limiting (IP당 1000 req/5min)
├─ Redis volatile-ttl (TTL 기반 메모리 관리)
└─ VPC Endpoint (ECR, Secrets Manager)
[대기열 계층 - 2.2 심화]
├─ Kinesis + KCL (체크포인트로 메시지 유실 방지)
├─ Redis Pub/Sub (멀티 Pod 브로드캐스트)
├─ KEDA (대기열 길이 기반 오토스케일링)
└─ Graceful Shutdown (지수 백오프 + 지터)
[CI/CD 계층 - 2.3 심화]
├─ Argo Rollouts Canary (10% → 50% → 100%)
├─ AnalysisTemplate (에러율 5% 초과 시 자동 롤백)
├─ External Secrets (AWS Secrets Manager 연동)
├─ PodDisruptionBudget (minAvailable 66%)
├─ Graceful Shutdown 연계 (preStop + deregistration_delay)
└─ 통합 알림 (GitLab + ArgoCD + Rollouts → Slack)
세 심화의 시너지 효과
각 심화는 독립적으로도 가치가 있지만, 조합되었을 때 시너지가 발생한다:
| 조합 | 시너지 효과 |
|---|---|
| 2.1 NAT HA + 2.3 CI/CD | 배포 중 NAT 장애 시에도 다른 AZ의 NAT로 ECR 접근 가능 |
| 2.1 VPC Endpoint + 2.3 External Secrets | Secrets Manager에 프라이빗 접근, Secret 갱신 시 인터넷 노출 없음 |
| 2.2 KCL 체크포인트 + 2.3 Rolling Update | Pod 재시작 시 마지막 체크포인트부터 재처리, 메시지 유실 없음 |
| 2.2 Redis Pub/Sub + 2.3 PDB | Rolling Update 중에도 다른 Pod가 입장 알림 처리 |
| 2.2 KEDA + 2.3 Canary | 배포 중 트래픽 급증 시 KEDA가 자동 스케일업 |
| 2.2 Graceful Shutdown + 2.3 preStop | 정상/비정상 종료 모두 Thunder Herd 방지 |
8.8 구현 체크리스트
8.1~8.6에서 제안한 심화를 실제 구현하려면 아래 작업이 필요하다. CGV 프로젝트를 처음부터 혼자 구축한다는 관점에서 정리했다.
1. 클러스터 레벨 도구 설치 (EKS에 한 번)
| 도구 | 관련 섹션 | 용도 |
|---|---|---|
| ArgoCD | 8.3 가시성 | GitOps CD, Git 저장소 감시 → 자동 배포 |
| Argo Rollouts | 8.1 자동 롤백 | Canary/Blue-Green 배포 + 메트릭 기반 롤백 |
| External Secrets Operator | 8.2 Secret 관리 | AWS Secrets Manager → K8s Secret 동기화 |
설치 순서: ArgoCD → Argo Rollouts → External Secrets (의존 관계 없음, 순서 무관)
2. 새로 생성 (helm-chart/templates/)
| 파일 | 관련 섹션 | 설명 |
|---|---|---|
| rollout.yaml | 8.1 자동 롤백 | Canary 배포 정의, 기존 deployment.yaml 대체 |
| analysistemplate.yaml | 8.1 자동 롤백 | Prometheus 에러율 쿼리, 5% 초과 시 롤백 |
| secretstore.yaml | 8.2 Secret 관리 | AWS Secrets Manager 연결 설정 |
| externalsecret.yaml | 8.2 Secret 관리 | 어떤 Secret을 가져올지 정의 |
| pdb.yaml | 8.4 무중단 배포 | minAvailable 66%, 최소 가용 Pod 보장 |
3. 기존 파일 수정
| 파일 | 관련 섹션 | 수정 내용 |
|---|---|---|
| .gitlab-ci.yml | 8.3, 8.5 | Slack 알림 템플릿, IMAGE_TAG=${CI_COMMIT_SHORT_SHA} |
| values-dev.yaml | 8.4 무중단 배포 | terminationGracePeriodSeconds: 45, preStop: sleep 10 |
| dev_terraform/alb.tf | 8.4 무중단 배포 | deregistration_delay = 30 |
4. 삭제 또는 구조 변경
| 대상 | 관련 섹션 | 이유 |
|---|---|---|
| deployment.yaml | 8.1 자동 롤백 | rollout.yaml로 대체, 동시 사용 시 충돌 |
| values-dev/prod.yaml | 8.6 환경별 설정 | values/base.yaml + dev.yaml + prod.yaml 구조로 분리 |
5. 알림 ConfigMap (클러스터 레벨, 각 네임스페이스에)
| ConfigMap | 관련 섹션 | 위치 |
|---|---|---|
| argocd-notifications-cm | 8.3 가시성 | argocd 네임스페이스 |
| argo-rollouts-notification-cm | 8.3 가시성 | argo-rollouts 네임스페이스 |
9. 마무리
이 문서는 CGV CI/CD 파이프라인을 분석하고, 혼자 처음부터 구축한다면 어떻게 개선할지 정리한 것이다.
현재 구현 분석
| 분석 대상 | Helm Chart 구조, GitLab CI 파이프라인, ArgoCD 설정 |
|---|---|
| 잘된 점 | GitOps 원칙 준수, 환경 분리 (dev/prod), 브랜치별 ECR 분리 |
| 개선 필요 | 자동 롤백 없음, Secret 평문 저장, 알림 분산, 무중단 배포 미흡 |
심화 구현 제안
| 문제 | 비교한 대안 | 선택 |
|---|---|---|
| 자동 롤백 없음 | Rolling vs Blue-Green vs Canary | Argo Rollouts Canary |
| Secret 평문 저장 | Sealed Secrets vs SOPS vs External Secrets vs Vault | External Secrets + Secrets Manager |
| 알림 분산 | Webhook 직접 vs 통합 | GitLab + ArgoCD + Rollouts 통합 알림 |
| 무중단 배포 미흡 | - | preStop + deregistration_delay + PDB |
'Cloudwave > cgv-project' 카테고리의 다른 글
| CI/CD (0) | 2026.02.18 |
|---|---|
| 메시징 시스템 (0) | 2026.02.16 |
| WebSocket (0) | 2026.02.12 |
| 2. CGV 대기열 시스템 (0) | 2026.02.01 |
| 1. 인프라 설계 (0) | 2026.01.31 |