2026-07-02
Kubernetes

GitOps와 Kubernetes 배포 파이프라인

인프라 관리 프로세스 개선 중에 GitHub Actions, Jib, Helm, ArgoCD로 이미지 빌드부터 클러스터 동기화까지 구성하기

Kubernetes에 애플리케이션을 처음 배포할 때는 서버 저장소의 CI에서 이미지를 만든 뒤 kubectl set imagehelm upgrade를 직접 실행하는 방식을 생각했습니다.

이 방식은 단순하지만 실제 클러스터 상태를 변경한 주체가 CI이고, Git 저장소에는 어떤 이미지가 배포되었는지 남지 않습니다.
배포 중 CI 권한이 과도하게 커지고, 클러스터 설정을 수동으로 바꾸면 Git과 실제 상태가 달라질 수도 있습니다.

그래서 저장소를 다음과 같이 분리했습니다.

  • retoday-server: 애플리케이션 코드와 이미지 빌드
  • retoday-chart: Helm 차트와 환경별 배포 상태

배포 흐름은 다음과 같습니다.

개발 / 운영 브랜치 푸시

GitHub Actions

Gradle Test

Jib 이미지 빌드 및 푸시

Helm values 이미지 태그 변경

차트 저장소 커밋

ArgoCD 자동 Sync

Kubernetes Rollout

CI 결과 Discord 알림

CI는 클러스터를 직접 수정하지 않습니다.
원하는 배포 상태를 차트 저장소에 커밋하고, ArgoCD가 Git과 클러스터의 차이를 동기화합니다.

Jib로 이미지 만들기

서버는 API와 Batch가 분리된 Gradle 멀티 모듈 구조입니다.
브랜치에 코드가 반영되면 먼저 전체 테스트를 실행하고 두 모듈의 이미지를 레지스트리에 푸시합니다.

deploy.yaml
jobs:
  deploy-images:
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          distribution: corretto
          java-version: 21
          cache: gradle
      - name: Run tests
        run: ./gradlew test
      - name: Deploy images to container registry
        env:
          API_IMAGE: ${{ vars.API_IMAGE_NAME }}:${{ github.sha }}
          BATCH_IMAGE: ${{ vars.BATCH_IMAGE_NAME }}:${{ github.sha }}
        run: |
          ./gradlew :api:jib \
            -Djib.to.image=${{ env.API_IMAGE }} \
            -Djib.to.auth.username=${{ secrets.GABIA_USERNAME }} \
            -Djib.to.auth.password=${{ secrets.GABIA_PASSWORD }}
 
          ./gradlew :batch:jib \
            -Djib.to.image=${{ env.BATCH_IMAGE }} \
            -Djib.to.auth.username=${{ secrets.GABIA_USERNAME }} \
            -Djib.to.auth.password=${{ secrets.GABIA_PASSWORD }}

Jib를 사용하면 Dockerfile과 Docker daemon 없이 Gradle 빌드에서 Java 애플리케이션 이미지를 만들고 레지스트리에 바로 푸시할 수 있습니다.
애플리케이션 의존성, 리소스, 클래스가 서로 다른 레이어로 구성되므로 변경되지 않은 레이어도 재사용할 수 있습니다.

이미지 태그에는 버전 문자열 대신 전체 Git SHA를 사용했습니다.

예를 들면 re-today.cr.gabiacloud.com/retoday-api:663ce325d01633f... 같은 형태입니다.

latest처럼 덮어쓰는 태그는 같은 매니페스트여도 실제 이미지가 달라질 수 있고, 어떤 커밋으로 만든 이미지인지 추적하기 어렵습니다.
커밋 SHA를 사용하면 소스, 이미지, 배포 상태를 하나의 식별자로 연결할 수 있습니다.

CI에서 클러스터 대신 차트 저장소 변경하기

이미지 푸시가 끝나면 GitHub Actions는 차트 저장소를 체크아웃하고 현재 브랜치에 해당하는 values 파일의 이미지 값을 변경합니다.

deploy.yaml
update-chart:
  needs: deploy-images
  steps:
    - name: Checkout chart repository
      uses: actions/checkout@v4
      with:
        repository: ${{ vars.CHART_REPOSITORY }}
        token: ${{ secrets.CHART_REPOSITORY_TOKEN }}
    - name: Update images
      env:
        API_IMAGE: ${{ needs.deploy-images.outputs.api-image }}
        BATCH_IMAGE: ${{ needs.deploy-images.outputs.batch-image }}
      run: |
        yq -i '.api.image = strenv(API_IMAGE)' \
          ./application/values-${{ github.ref_name }}.yaml
        yq -i '.batch.image = strenv(BATCH_IMAGE)' \
          ./application/values-${{ github.ref_name }}.yaml

개발 브랜치는 values-dev.yaml, 운영 브랜치는 values-prod.yaml을 수정합니다.
환경별 replica 수, 리소스 제한, 도메인, 배치 스케줄도 같은 방식으로 분리했습니다.

values-prod.yaml
env: prod
namespace: retoday-prod
 
api:
  replicas: 3
  image: re-today.cr.gabiacloud.com/retoday-api:<git-sha>
  resources:
    requests:
      cpu: 250m
      memory: 1Gi
    limits:
      memory: 2Gi
 
batch:
  image: re-today.cr.gabiacloud.com/retoday-batch:<git-sha>

변경한 values 파일은 자동 커밋합니다.
커밋 본문에는 원본 서버 커밋 링크를 남겼습니다.

deploy.yaml
- name: Commit changes
  run: |
    git config user.name "github-actions"
    git config user.email "github-actions@github.com"
    git add .
    git diff --cached --quiet || \
    git commit \
      -m "chore: update image tag to ${GITHUB_SHA::7}" \
      -m "https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
    git push

이제 배포 이력은 차트 저장소의 Git 로그로 남습니다.
이전 이미지 태그로 되돌리는 것도 과거 커밋을 되돌리는 작업으로 표현할 수 있습니다.

ArgoCD 자동 동기화

ArgoCD의 Application은 차트 저장소의 각 경로와 values 파일을 감시합니다.

applications.yaml
spec:
  source:
    repoURL: https://github.com/retoday/retoday-chart
    targetRevision: main
    path: application
    helm:
      valueFiles:
        - values-prod.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: retoday-prod
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

차트 커밋이 반영되면 ArgoCD가 원하는 상태와 실제 클러스터 상태를 비교하고 자동으로 Sync합니다.

  • prune: Git에서 제거된 리소스를 클러스터에서도 제거합니다.
  • selfHeal: 클러스터를 수동 수정해 Git과 달라지면 다시 Git 상태로 복구합니다.
  • CreateNamespace: 대상 namespace가 없으면 생성합니다.

이 구조에서 Git은 단순한 템플릿 저장소가 아니라 배포 상태의 기준(source of truth)이 됩니다.
운영 중 긴급하게 kubectl edit을 사용하더라도 그 변경은 영구 설정이 아니며, 필요한 변경은 차트에 반영해야 합니다.

CI 성공과 배포 성공은 다르다

현재 서버 저장소의 GitHub Actions는 이미지 Push와 차트 커밋 결과를 Discord로 알립니다.
하지만 이 알림은 CI 파이프라인의 성공 여부를 알려줄 뿐, 새 Pod가 클러스터에서 정상 상태가 되었음을 보장하지 않습니다.

  • CI 성공: 새 이미지를 만들고 원하는 상태를 Git에 기록함
  • 배포 성공: ArgoCD Sync가 성공하고 애플리케이션이 Healthy 상태임

그래서 배포 완료를 판단할 때는 GitHub Actions 결과와 ArgoCD의 Sync/Health 상태를 분리해서 봐야 합니다.
ArgoCD Notifications를 붙인다면 기준은 다음처럼 잡을 수 있습니다.

config-map.yaml
trigger.sync-operation-change: |
  - when: >-
      app.status.operationState != nil &&
      app.status.operationState.syncResult != nil &&
      app.status.operationState.syncResult.revision == app.status.sync.revision &&
      ((app.status.operationState.phase == 'Succeeded' &&
        app.status.health.status in ['Healthy', 'Degraded']) ||
       app.status.operationState.phase in ['Error', 'Failed'])
    oncePer: app.status.operationState.finishedAt
    send: [notification]

operationState.phase == Succeeded만으로 성공을 판단하지 않고 Health 상태도 확인합니다.
Sync는 매니페스트 적용 성공을 의미하고, Health는 Deployment와 Pod가 실제로 정상 상태에 도달했는지를 나타내기 때문입니다.

ArgoCD 알림을 붙인다면 Sync 상태, Health 상태, 배포된 차트 revision을 함께 넣어 확인할 수 있습니다.

discord.yaml
webhook:
  discord:
    method: POST
    body: |
      {
        "embeds": [{
          "title": "{{ .app.metadata.name }} 차트 배포 결과",
          "fields": [
            { "name": "Sync Status", "value": "{{ .app.status.sync.status }}" },
            { "name": "Health Status", "value": "{{ .app.status.health.status }}" },
            { "name": "Revision", "value": "{{ .app.status.sync.revision }}" }
          ]
        }]
      }

GitHub Actions 알림은 이미지 빌드와 차트 변경 실패를 알려주고, ArgoCD 상태는 클러스터 반영 결과를 알려줍니다.
두 단계를 구분해 어느 구간에서 실패했는지 바로 알 수 있게 했습니다.

환경 분리

개발과 운영은 같은 Helm template을 공유하고 값만 분리했습니다.

  • application/templates/
  • application/values.yaml
  • application/values-dev.yaml
  • application/values-prod.yaml

공통 구조를 복사한 별도 차트 두 개로 관리하면 수정 사항이 한 환경에만 반영될 위험이 있습니다.
하나의 template을 사용하되 replicas, resource, ingress host, 데이터 저장 크기와 배치 suspend 여부를 환경 값으로 관리했습니다.

ArgoCD도 환경별 Application을 따로 두었습니다.

applications:
  - name: retoday-dev
    namespace: retoday-dev
    path: application
    valueFiles: [values-dev.yaml]
  - name: retoday-prod
    namespace: retoday-prod
    path: application
    valueFiles: [values-prod.yaml]

개발 환경의 변경이 운영 namespace에 직접 영향을 주지 않으며, 각 환경의 Sync와 Health 상태도 독립적으로 확인할 수 있습니다.

남은 개선점

현재 워크플로는 서버 커밋마다 API와 Batch 이미지를 모두 빌드합니다.
멀티 모듈 프로젝트에서는 변경 경로를 감지해 영향을 받는 모듈만 빌드할 수 있습니다.

예를 들어 공통 core 모듈이 바뀌면 둘 다 배포하고, api 모듈만 바뀌면 API 이미지만 갱신하는 방식입니다.

api:
  - 'api/**'
  - 'core/**'
  - 'build.gradle.kts'
 
batch:
  - 'batch/**'
  - 'core/**'
  - 'build.gradle.kts'

다만 조건부 빌드를 적용할 때는 변경되지 않은 모듈의 기존 이미지 값을 차트에 그대로 보존해야 합니다.
공통 Gradle 설정과 런타임 의존성 변경 경로도 필터에 빠뜨리지 않아야 합니다.

또 차트 저장소를 CI가 직접 Push하므로 동시 배포 시 push 충돌을 처리할 필요가 있습니다.
배포 빈도가 높아지면 환경별 concurrency group, 재시도 또는 이미지 자동 갱신 도구를 검토할 수 있습니다.

정리

구축한 파이프라인의 책임은 다음처럼 나뉩니다.

  • GitHub Actions: 테스트 → Jib 이미지 Push → Helm values 변경
  • ArgoCD: Git 변경 감지 → Kubernetes Sync → Health 확인 → 배포 결과 알림

CI에 클러스터 관리자 권한을 주지 않고도 자동 배포할 수 있고, 현재 어떤 이미지와 설정이 배포되어야 하는지는 차트 저장소에서 확인할 수 있습니다.

무엇보다 “이미지 빌드가 성공했다”와 “새 버전이 정상적으로 서비스 중이다”를 분리해 관찰하게 되었습니다.
GitOps의 장점은 자동화 자체보다, 배포 상태와 변경 이력을 Git에 명시하고 클러스터가 그 상태로 수렴하도록 만든 데 있었습니다.