GitOps 파이프라인 완전 정복 — Jenkins + ArgoCD + k3s로 코드를 서버에 배포하기

GitLab push 한 번으로 Jenkins가 빌드하고, ArgoCD가 k3s에 자동 배포하는 전체 흐름. Docker Buildx 멀티 플랫폼 빌드, 레이어 캐싱, GitOps의 핵심 원칙까지 로우 레벨로 설명한다.

Seobway · · 18분

현대 배포의 목표: "사람이 서버에 로그인하지 않는다"

코드를 배포하는 방법은 크게 두 가지다.

방식 설명 문제점
Push 방식 CI 서버가 직접 kubectl apply CI 서버에 클러스터 자격증명 노출
Pull 방식 (GitOps) Git 변경 → ArgoCD가 감지 후 적용 보안 ↑, 감사 이력 ↑

GitOps는 Git 저장소가 인프라의 단일 진실의 원천(Single Source of Truth)이 되는 방식이다.[1]


전체 파이프라인 구조

%% desc: GitLab → Jenkins → DockerHub → ArgoCD → k3s 전체 흐름
flowchart LR
  subgraph DEV["개발자"]
    A["git push\n(GitLab)"]
  end

  subgraph CI["CI: Jenkins"]
    B["Webhook 수신"]
    C["Docker Buildx\n멀티플랫폼 빌드"]
    D["DockerHub\nImage Push\n태그: git-{commit}-{build}"]
    E["K8s 매니페스트\n저장소 태그 업데이트\n(kustomization.yaml)"]
  end

  subgraph CD["CD: ArgoCD"]
    F["매니페스트 저장소\n변경 감지\n(1분 폴링 or Webhook)"]
    G["k3s 클러스터\n자동 동기화\nkubectl apply"]
  end

  subgraph K8S["k3s Cluster"]
    H["Django Web\nDeployment"]
    I["Celery Worker\nDeployment"]
    J["Redis\nStatefulSet"]
    K["MySQL\nStatefulSet"]
  end

  A --> B --> C --> D --> E --> F --> G --> H
  G --> I & J & K

Jenkins: CI 파이프라인 상세

Jenkinsfile 구조

// Jenkinsfile
pipeline {
    agent any

    environment {
        // Jenkins Credentials에 저장된 비밀값 주입
        DOCKERHUB_CRED  = credentials('dockerhub-credentials')
        IMAGE_NAME      = "myorg/pms-web"
        // 이미지 태그: Git 커밋 해시 + Jenkins 빌드 번호
        IMAGE_TAG       = "${GIT_COMMIT[0..7]}-${BUILD_NUMBER}"
        MANIFEST_REPO   = "git@gitlab.com:myorg/k8s-manifests.git"
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Build & Push') {
            steps {
                script {
                    // Docker Buildx: 멀티 플랫폼 빌드
                    sh """
                        docker buildx create --use --name multiarch-builder || true

                        docker buildx build \\
                            --platform linux/amd64,linux/arm64 \\
                            --cache-from type=registry,ref=${IMAGE_NAME}:buildcache \\
                            --cache-to   type=registry,ref=${IMAGE_NAME}:buildcache,mode=max \\
                            --tag ${IMAGE_NAME}:${IMAGE_TAG} \\
                            --tag ${IMAGE_NAME}:latest \\
                            --push \\
                            .
                    """
                }
            }
        }

        stage('Update Manifest') {
            steps {
                script {
                    // 매니페스트 저장소의 이미지 태그를 새 태그로 교체
                    sh """
                        git clone ${MANIFEST_REPO} manifests
                        cd manifests
                        sed -i 's|newTag:.*|newTag: ${IMAGE_TAG}|' kustomization.yaml
                        git config user.email "jenkins@ci"
                        git config user.name  "Jenkins CI"
                        git add kustomization.yaml
                        git commit -m "ci: update image tag to ${IMAGE_TAG}"
                        git push origin main
                    """
                }
            }
        }
    }

    post {
        failure {
            // Slack, Email 알림
            echo "Pipeline failed!"
        }
    }
}

Docker Buildx: 멀티 플랫폼 빌드와 레이어 캐싱

왜 Buildx인가

일반 docker build는 현재 시스템 아키텍처(amd64)만 지원한다. Buildx를 쓰면 한 번의 명령으로 amd64/arm64 양쪽 이미지를 동시에 빌드할 수 있다.[2]

%% desc: Docker Buildx 레이어 캐싱 동작 — 변경된 레이어부터만 다시 빌드
flowchart TD
  A["FROM python:3.11-slim\n(기반 이미지)"]
  B["COPY requirements.txt\npip install\n→ 의존성 설치"]
  C["COPY . .\n→ 애플리케이션 코드"]
  D["CMD gunicorn..."]

  A -->|"캐시 HIT ✅"| B
  B -->|"캐시 HIT ✅\n(requirements.txt 변경 없음)"| C
  C -->|"캐시 MISS ❌\n(코드 변경)"| D

  style C fill:#ff6b6b,color:#fff
  style D fill:#ff6b6b,color:#fff
# Dockerfile — 캐싱을 최대화하는 레이어 순서
FROM python:3.11-slim

WORKDIR /app

# requirements.txt를 먼저 복사 → pip install 결과가 캐시됨
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 코드는 마지막에 복사 → 코드가 바뀌어도 pip install은 재실행 안 됨
COPY . .

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

캐시 플래그 설명

docker buildx build \
  --cache-from type=registry,ref=myimage:buildcache \
  # ↑ 이전 빌드의 캐시를 레지스트리에서 가져옴

  --cache-to type=registry,ref=myimage:buildcache,mode=max \
  # ↑ 현재 빌드 캐시를 레지스트리에 저장 (mode=max: 모든 레이어 저장)

  --platform linux/amd64,linux/arm64 \
  # ↑ 두 아키텍처 동시 빌드

  --push \
  # ↑ 빌드 후 즉시 레지스트리에 푸시

효과: 의존성이 변경되지 않으면 pip install 단계를 건너뛰어 빌드 시간 70~80% 절약.


ArgoCD: GitOps CD

ArgoCD가 동작하는 원리

폴링 vs Webhook:

%% desc: ArgoCD Sync 루프 — Git 상태와 클러스터 상태를 지속적으로 일치시킴
sequenceDiagram
  participant G as Git (매니페스트 저장소)
  participant A as ArgoCD
  participant K as k3s Cluster

  loop 3분마다 폴링 (기본값 180초, Webhook 설정 시 즉시)
    A->>G: 매니페스트 상태 확인
    G-->>A: kustomization.yaml (newTag: abc123)
    A->>K: 현재 클러스터 상태 확인
    K-->>A: 현재 실행 중: tag xyz789
    A->>A: Diff 감지: abc123 ≠ xyz789
    A->>K: kubectl apply -k ./overlays/prod
    K-->>A: Sync 완료
  end

kustomization.yaml 구조

# k8s-manifests/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

images:
  - name: myorg/pms-web
    newTag: abc1234-42   # ← Jenkins가 이 값을 sed로 교체
# k8s-manifests/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pms-web
  namespace: pms-web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pms-web
  template:
    spec:
      containers:
        - name: web
          image: myorg/pms-web  # kustomize가 태그를 붙여줌
          ports:
            - containerPort: 8000
          env:
            - name: DJANGO_SECRET_KEY
              valueFrom:
                secretKeyRef:
                  name: pms-secrets
                  key: secret-key

k3s: 왜 경량 Kubernetes인가

k3s는 Rancher Lab이 만든 경량 Kubernetes 배포판이다.[3]

일반 K8s k3s
바이너리 크기 ~수백 MB ~70MB
메모리 요구량 2GB+ 512MB
설치 복잡 `curl -sfL https://get.k3s.io
용도 대규모 프로덕션 소규모 서버, 엣지, IoT, 개발

이 프로젝트의 k3s 리소스 구조

%% desc: k3s 클러스터 내 리소스 — Deployment vs StatefulSet 구분
flowchart TD
  subgraph NS["Namespace: pms-web"]
    subgraph DEP["Deployment (상태 없음, 확장 가능)"]
      D1["Django Web\n× 2 replicas"]
      D2["Celery Worker\n× 2 replicas"]
    end
    subgraph STS["StatefulSet (상태 있음, 데이터 보존)"]
      S1["MySQL\n× 1\n+ PersistentVolume"]
      S2["Redis\n× 1\n+ PersistentVolume"]
    end
    subgraph SVC["Service"]
      V1["ClusterIP: mysql-svc"]
      V2["ClusterIP: redis-svc"]
      V3["LoadBalancer: web-svc\n(외부 노출)"]
    end
  end

  D1 --> V1 & V2
  D2 --> V1 & V2
  V3 --> D1

Deployment vs StatefulSet:


이미지 태그 전략: 왜 git-{commit}-{build} 형태인가

# 나쁜 예: latest 태그만 사용
docker push myimage:latest
# → 어느 커밋이 배포됐는지 알 수 없음
# → Rollback 불가

# 좋은 예: Commit Hash + Build Number
docker push myimage:a1b2c3d4-42
# → git show a1b2c3d4로 어느 코드인지 즉시 확인
# → 이전 태그로 kustomization.yaml만 되돌리면 Rollback 완료

롤백 방법

# 방법 1: Git revert (권장 — GitOps 원칙 유지)
git revert HEAD
git push origin main
# → ArgoCD가 감지 후 이전 태그로 자동 롤백

# 방법 2: 직접 태그 수정 (긴급 상황)
sed -i 's|newTag:.*|newTag: a1b2c3d4-38|' kustomization.yaml
git commit -am "hotfix: rollback to build 38"
git push

배포 흐름 최종 요약

%% desc: 개발자 git push → 서버 자동 배포까지 전체 단계 타임라인
sequenceDiagram
  participant D as 개발자
  participant G as GitLab
  participant J as Jenkins
  participant R as DockerHub
  participant M as Manifest Repo
  participant A as ArgoCD
  participant K as k3s

  D->>G: git push origin main
  G->>J: Webhook 트리거
  J->>J: Docker Buildx 빌드
  J->>R: Image Push (tag: abc123-42)
  J->>M: kustomization.yaml 태그 업데이트
  J-->>D: Build 성공 알림

  loop 최대 3분 이내 (Webhook 설정 시 즉시)
    A->>M: 변경 감지
    A->>K: kubectl apply
    K-->>A: Sync 완료
  end

  Note over D,K: 총 소요시간: 빌드 3~8분 + ArgoCD sync 최대 3분

참고

  1. [1] ArgoCD Core Concepts — 공식 문서
  2. [2] Docker Buildx build — 공식 문서
  3. [3] k3s 공식 문서
  4. [4] Jenkins Pipeline Syntax — 공식 문서
  5. [5] Kustomize 공식 문서

관련 글