환경: k3s v1.31+ (Traefik 내장), ArgoCD v3.3+, Helm v3, GitHub Actions, Docker Hub
들어가며
2편에서 포트포워딩과 TLS를 적용하여 외부에서 HTTPS로 클러스터에 접속할 수 있게 되었습니다. 하지만 아직 실제 애플리케이션을 배포하는 체계가 없는 상태입니다. 매번 서버에 SSH로 접속해서 kubectl apply를 실행하는 것은, 서비스가 하나일 때는 괜찮아도 배포 빈도가 올라가면 곧 한계에 부딪힙니다.
이 편에서는 GitOps 방식의 배포 파이프라인을 구축합니다. 코드를 GitHub에 푸시하면 Docker 이미지가 자동으로 빌드되고, ArgoCD가 변경을 감지하여 클러스터에 배포하는 흐름입니다.
| 도구 | 역할 |
|---|---|
| GitHub Actions | CI — Docker 이미지 빌드 및 Docker Hub 푸시 |
| Docker Hub | 컨테이너 이미지 레지스트리 |
| Helm | Kubernetes 매니페스트 템플릿화 및 패키징 |
| ArgoCD | CD — Git 저장소 감시 및 클러스터 자동 배포 |
저장소 구조 설계
GitOps에서 중요한 설계 결정 중 하나가 앱 코드와 클러스터 설정을 같은 저장소에 둘 것인가, 분리할 것인가입니다.
앱 코드와 Helm Chart를 한 저장소에 두면 CI가 단순해지는 대신, 앱을 추가할 때마다 각 저장소에 Helm Chart가 흩어지게 됩니다. 클러스터 전체의 배포 상태를 한눈에 파악하기 어려워지며, ArgoCD가 감시해야 할 저장소도 앱 수만큼 늘어납니다.
이 클러스터에서는 ArgoCD 공식 문서의 권장 사항에 따라 설정 저장소를 별도로 분리했습니다. 앱 코드는 앱 저장소에, 클러스터에 배포되는 모든 Helm Chart와 ArgoCD 설정은 설정 저장소에 모아두는 구조입니다.
mirunamu/ 디렉토리 아래에 네임스페이스 단위로 Helm Chart를 배치하고, argocd/ 디렉토리에는 ArgoCD 자체 설정과 Application 매니페스트를 모아두었습니다. 앱을 추가할 때는 mirunamu/새앱/ 디렉토리를 만들고, argocd/에 Application YAML을 추가하면 됩니다.
Ingress를 앱 차트에 포함시키지 않고 별도 차트로 분리한 것도 의도적인 설계입니다. 앱이 여러 개로 늘어났을 때 각 앱마다 Ingress를 만들면 도메인 라우팅 규칙이 흩어지는데, 하나의 Ingress 차트에서 모든 앱의 라우팅을 관리하면 도메인 추가나 변경이 values.yaml 한 곳에서 끝납니다.
ArgoCD 설치
ArgoCD는 Git 저장소에 정의된 매니페스트와 클러스터의 실제 상태를 지속적으로 비교하며, 차이가 발생하면 자동으로 동기화하는 GitOps CD 도구입니다.
# argocd 네임스페이스 생성 및 설치
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
-f https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.0/manifests/install.yaml--server-side 플래그
ArgoCD의 CRD 중 일부는 kubectl apply의 클라이언트 사이드 어노테이션 크기 제한(262KB)을 초과합니다. --server-side --force-conflicts 플래그를 사용하면 서버 사이드에서 적용되어 이 제한을 우회할 수 있습니다.
# Pod 상태 확인 (7개 모두 Running이면 정상)
kubectl get pods -n argocd
# 초기 관리자 비밀번호 확인
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d; echo출력된 문자열이 admin 계정의 초기 비밀번호입니다.
ArgoCD 외부 접속 설정
ArgoCD 대시보드를 argocd.mirunamu.info로 접속할 수 있도록 설정합니다. 2편에서 와일드카드 A 레코드(*)를 등록해두었으므로 서브도메인 DNS 설정은 추가로 필요 없습니다.
ArgoCD 서버는 기본적으로 자체 TLS를 사용하는데, Traefik이 앞단에서 TLS를 처리하도록 내부 TLS를 비활성화해야 합니다. 그렇지 않으면 Traefik과 ArgoCD 간에 TLS 이중 처리가 발생하여 502 에러가 나타날 수 있습니다.
이 설정을 kubectl patch로 직접 실행할 수도 있지만, 설정 저장소에 YAML 파일로 관리하면 ArgoCD가 자기 자신의 설정까지 Git으로 관리하게 됩니다.
# argocd/argocd-cmd-params-cm.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmd-params-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cmd-params-cm
app.kubernetes.io/part-of: argocd
data:
server.insecure: "true"처음에는 ArgoCD가 아직 설정 저장소를 감시하고 있지 않으므로 수동으로 적용합니다.
kubectl apply -f argocd-cmd-params-cm.yaml
kubectl -n argocd rollout restart deployment argocd-server
kubectl apply -f argocd-ingress.yaml1~2분 후 https://argocd.mirunamu.info에 접속하면 로그인 화면이 나타납니다. admin 계정으로 로그인한 뒤 반드시 비밀번호를 변경합니다.
앱 차트: mirunamu/docs
Next.js 앱을 배포하는 Helm Chart입니다. Deployment, Service, ConfigMap을 생성하며, 이미지 태그와 리소스 설정은 values.yaml로 관리합니다.
# mirunamu/docs/values.yaml (핵심 설정)
replicaCount: 1
image:
repository: mirunamu00/mirunamu-docs
tag: 610df67af85e18a2cdbca38f0e41d2afe1b64dfa
pullPolicy: Always
service:
port: 80
targetPort: 3000
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: GITHUB_OWNER
value: "mirunamu00"
- name: GITHUB_REPO
value: "docs"
existingSecret: "docs-secret"
healthCheck:
enabled: true
path: /
strategy:
type: RollingUpdate
maxSurge: 1
maxUnavailable: 0image.tag에 커밋 해시가 들어가 있는 것이 핵심입니다. GitHub Actions가 이미지를 빌드한 후 이 값을 업데이트하면, ArgoCD가 변경을 감지하여 새 버전을 배포합니다.
existingSecret은 API 키나 OAuth 시크릿 같은 민감 정보를 Helm Chart에 포함시키지 않고, 클러스터에 직접 생성한 Secret을 참조하는 패턴입니다. 이렇게 하면 시크릿 값이 Git에 노출되지 않습니다.
Deployment 템플릿에서 주목할 부분은 프로덕션 환경을 위한 설정들입니다.
# mirunamu/docs/templates/deployment.yaml (핵심 부분)
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: {{ .Values.strategy.type }}
rollingUpdate:
maxSurge: {{ .Values.strategy.maxSurge }}
maxUnavailable: {{ .Values.strategy.maxUnavailable }}
template:
metadata:
annotations:
# ConfigMap 내용이 바뀌면 해시값이 달라져서 Pod가 자동 재시작
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 10 }}
# 기존 Secret에서 환경변수 일괄 주입
envFrom:
- secretRef:
name: {{ .Values.existingSecret }}
# readinessProbe: 트래픽 받을 준비 확인 (실패 시 Service에서 제외)
readinessProbe:
httpGet:
path: {{ .Values.healthCheck.path }}
port: {{ .Values.service.targetPort }}
initialDelaySeconds: 5
periodSeconds: 10
# livenessProbe: 컨테이너 생존 확인 (실패 시 재시작)
livenessProbe:
httpGet:
path: {{ .Values.healthCheck.path }}
port: {{ .Values.service.targetPort }}
initialDelaySeconds: 10
periodSeconds: 30RollingUpdate 전략에서 maxSurge: 1, maxUnavailable: 0은 새 Pod를 먼저 띄우고 정상 확인 후 기존 Pod를 내리는 무중단 배포를 의미합니다. checksum/config 어노테이션은 ConfigMap 내용이 바뀌면 해시가 달라지면서 Pod가 자동으로 재시작되는 트릭입니다. Kubernetes는 ConfigMap 변경만으로는 Pod를 재시작하지 않기 때문에, 이런 방식으로 변경을 감지합니다.
Next.js 캐시 저장소
Next.js는 런타임에 ISR 재생성, fetch 데이터 캐시, 이미지 최적화 결과 등 다양한 캐시를 생성합니다. 기본적으로 이 캐시들은 Pod의 로컬 파일시스템에 저장되기 때문에, Pod가 재시작되면 사라지고 여러 Pod 간에 공유도 되지 않습니다.
이 문제를 해결하기 위해 처음에는 PVC(Persistent Volume Claim)로 캐시 디렉토리를 영속화하는 방식을 사용했지만, 이후 Redis를 클러스터에 배포하고 Next.js의 cacheHandler를 통해 런타임 캐시를 Redis에 저장하는 방식으로 전환했습니다. Redis를 사용하면 Pod 간 캐시 공유가 자연스럽고, 레플리카 확장 시에도 별도의 StorageClass 고민이 필요 없습니다.
Redis 배포부터 Next.js 캐시 핸들러 연동까지의 전체 과정은 4편: Redis 배포와 5편: Next.js Redis 캐시 연동에서 다룹니다.
Ingress 차트: mirunamu/ingress
Ingress를 앱 차트에서 분리하여 독립 차트로 관리합니다. values.yaml의 apps 리스트에 도메인과 서비스 이름을 추가하는 것만으로 새 앱의 라우팅을 설정할 수 있습니다.
# mirunamu/ingress/values.yaml
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
enabled: true
secretName: mirunamu-info-tls
apps:
- host: docs.mirunamu.info
serviceName: docs
servicePort: 80apps 리스트를 range로 순회하면서 각 도메인별 라우팅 규칙과 TLS 호스트를 동적으로 생성합니다. 새 앱을 추가할 때는 apps에 항목 하나를 추가하면 됩니다.
# 앱 추가 시 values.yaml에 항목 추가
apps:
- host: docs.mirunamu.info
serviceName: docs
servicePort: 80
- host: api.mirunamu.info # 새 앱
serviceName: api
servicePort: 80TLS Secret 하나(mirunamu-info-tls)에 모든 서브도메인의 인증서가 포함됩니다. cert-manager가 apps에 등록된 호스트들의 인증서를 자동으로 발급하고 갱신합니다.
App-of-Apps 패턴
여기까지 Helm Chart를 작성했지만, ArgoCD에게 "이 차트를 배포하라"고 지시하는 Application이 아직 없습니다. 앱마다 Application을 kubectl apply로 수동 생성할 수도 있지만, 앱이 늘어날수록 관리가 번거로워집니다.
App-of-Apps 패턴은 이 문제를 해결합니다. 루트 Application 하나가 argocd/ 디렉토리를 감시하고, 그 안에 있는 Application YAML들을 자동으로 생성하고 관리합니다. 새 앱을 추가할 때는 argocd/ 디렉토리에 YAML 파일 하나를 커밋하면 끝입니다.
# argocd/app-of-apps.yaml — 루트 Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: app-of-apps
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/mirunamu00/helm-chart.git
targetRevision: master
path: argocd
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: trueapp-of-apps.yaml이 argocd/ 경로를 감시하므로, 같은 디렉토리에 있는 docs-app.yaml, ingress-app.yaml, argocd-ingress.yaml, argocd-cmd-params-cm.yaml까지 전부 ArgoCD가 관리합니다. ArgoCD가 자기 자신의 Ingress와 설정까지 Git으로 관리하는 셈입니다.
설정 저장소가 프라이빗이라면 ArgoCD가 읽을 수 있도록 인증 정보를 등록해야 합니다.
# argocd/repo-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: helm-chart-repo
namespace: argocd
labels:
argocd.argoproj.io/secret-type: repository
stringData:
type: git
url: https://github.com/mirunamu00/helm-chart.git
username: mirunamu00
password: "ghp_xxxxxxxxxxxxxxxxxxxx" # GitHub PAT (repo 스코프)Secret 관리
repo-secret.yaml에는 GitHub PAT가 포함되어 있으므로 .gitignore에 추가하거나, 최초 1회 kubectl apply로 직접 생성한 뒤 Git에는 커밋하지 않는 것이 안전합니다.
최초 부트스트랩
모든 파일이 준비되었으면 app-of-apps.yaml 한 번만 적용하면 됩니다.
# 저장소 인증 (프라이빗 저장소인 경우)
kubectl apply -f repo-secret.yaml
# 루트 Application 생성 — 이것 한 번이면 나머지는 전부 자동
kubectl apply -f app-of-apps.yamlArgoCD가 argocd/ 디렉토리를 읽어서 docs-app.yaml, ingress-app.yaml 등을 자동으로 생성하고, 각 Application이 mirunamu/docs, mirunamu/ingress 경로의 Helm Chart를 클러스터에 배포합니다. ArgoCD 대시보드에서 모든 앱의 동기화 상태를 확인할 수 있습니다.
GitHub Actions CI
앱 저장소(docs)에 코드를 푸시하면 Docker 이미지를 빌드하고, 설정 저장소의 values.yaml에 새 이미지 태그를 커밋하는 워크플로우입니다. 앱 저장소와 설정 저장소가 분리되어 있으므로, 설정 저장소에 CI 워크플로우가 없어 무한 루프 걱정이 없습니다.
# .github/workflows/deploy.yaml (앱 저장소에 위치)
name: Build and Deploy
on:
push:
branches: [master]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
mirunamu00/mirunamu-docs:${{ github.sha }}
mirunamu00/mirunamu-docs:latest
- name: Update Helm Chart
run: |
git clone https://x-access-token:${{ secrets.HELM_REPO_PAT }}@github.com/mirunamu00/helm-chart.git
cd helm-chart
sed -i "s|tag:.*|tag: ${{ github.sha }}|" mirunamu/docs/values.yaml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add mirunamu/docs/values.yaml
git commit -m "chore: update docs image tag to ${{ github.sha }}"
git push이 워크플로우가 동작하려면 앱 저장소의 Settings → Secrets에 세 개의 시크릿을 등록해야 합니다.
| 시크릿 | 설명 |
|---|---|
DOCKERHUB_USERNAME | Docker Hub 사용자명 |
DOCKERHUB_TOKEN | Docker Hub Access Token |
HELM_REPO_PAT | GitHub PAT — 설정 저장소에 push 권한(repo 스코프) 필요 |
GitHub Actions가 설정 저장소의 values.yaml을 업데이트하면, ArgoCD가 이 변경을 감지하여 새 이미지 태그로 배포를 시작합니다.
배포 흐름 확인
모든 설정이 끝났으면, 실제로 코드를 변경하여 자동 배포가 동작하는지 확인합니다.
코드 변경 및 푸시
앱 소스코드를 수정하고 master 브랜치에 푸시합니다.
git add .
git commit -m "feat: update homepage"
git push origin masterGitHub Actions 확인
앱 저장소의 Actions 탭에서 워크플로우 실행을 확인합니다. Docker 이미지가 빌드되어 Docker Hub에 푸시되고, 설정 저장소의 values.yaml이 새 커밋 해시로 업데이트됩니다.
ArgoCD 동기화 확인
ArgoCD가 설정 저장소의 변경을 감지하면 자동으로 동기화를 시작합니다. 기본 폴링 간격은 3분이므로, 즉시 확인하고 싶다면 대시보드에서 Sync 버튼을 클릭합니다.
배포 결과 확인
https://docs.mirunamu.info에 접속하여 변경 사항이 반영되었는지 확인합니다.
# Pod 상태 확인
kubectl get pods -n mirunamu
# 현재 배포된 이미지 태그 확인
kubectl get deployment docs -n mirunamu \
-o jsonpath='{.spec.template.spec.containers[0].image}'전체 흐름을 정리하면 다음과 같습니다.
앱 코드 푸시 → GitHub Actions (이미지 빌드 + Docker Hub 푸시)
→ 설정 저장소 values.yaml 업데이트
→ ArgoCD (변경 감지 + 클러스터 동기화) → 새 버전 배포 완료
트러블슈팅
정리
3편 요약
- ArgoCD 설치 및 외부 접속: GitOps CD 도구를 설치하고, Traefik Ingress로 대시보드를 외부에 노출합니다
- 설정 저장소 분리: 앱 코드와 Helm Chart를 별도 저장소로 분리하여 클러스터 전체 설정을 한 곳에서 관리합니다
- Ingress 차트 독립: 다중 앱 라우팅을 하나의 Ingress 차트에서 관리하여 도메인 추가를
values.yaml수정으로 처리합니다 - App-of-Apps: 루트 Application 하나로 모든 하위 Application을 자동 관리합니다
- GitHub Actions CI: 앱 저장소에서 이미지를 빌드하고, 설정 저장소의 이미지 태그를 자동 업데이트합니다
이것으로 물리 장비 위에 올린 k3s 클러스터에 완전한 GitOps 배포 파이프라인이 갖춰졌습니다. USB에 Ubuntu를 구워 넣는 것에서 시작하여, 외부 접속과 TLS를 거쳐, 코드 푸시 한 번으로 자동 배포되는 환경까지 도달했습니다. 새로운 서비스를 추가할 때는 mirunamu/새앱/ 디렉토리에 Helm Chart를 만들고, argocd/에 Application YAML을 추가하면 됩니다.