Development Log
4편. 로컬 Docker 이미지에서 GHCR과 GitOps로.
GHCR, GitHub Actions와 Argo CD를 연결해 main push가 K3s RollingUpdate로 이어지는 GitOps 흐름을 만든 기록입니다.
- #GitHub Actions
- #GitOps
- #Argo CD
- #K3s
문제 로컬 Docker image와 K3s의 containerd는 분리돼 있었고, 새 image를 만들었다고 실제 Pod가 자동으로 갱신되지는 않았다. 선택 GHCR을 image registry로 두고 GitHub Actions가 SHA image를 만들고 manifest까지 갱신하도록 한 뒤, Argo CD가 Git의 Desired State를 클러스터에 반영하게 했다. 결과
main push → Actions → GHCR → manifest SHA 변경 → Argo CD → RollingUpdate의 GitOps 배포 흐름을 완성했다. 배운 점 자동 배포에서는 source commit, image artifact, manifest revision, 실제 runtime 상태를 서로 다른 검증 대상으로 봐야 한다.
K3s에 실제
ddongmy-os를 올리려고 하자 또 하나의 경계가 보였다. Docker로 이미지를 만들었다고 해서 K3s가 그 이미지를 자동으로 아는 것은 아니었다. 여기서 단순 로컬 import 대신 GHCR을 선택했고, 그 선택이 GitHub Actions와 Argo CD까지 이어지는 배포 구조의 출발점이 됐다.
왜 이 환경을 직접 만들고 싶었나
내가 참여하고 있는 U+tv모바일도 실제 운영 환경에서는 Kubernetes 기반으로 Pod가 배포되고 재생성되는 구조를 사용한다. 하지만 SI 개발자로 프로젝트에 참여하다 보니 애플리케이션 코드는 개발할 수 있어도 Kubernetes cluster나 Argo CD 같은 운영 영역의 권한은 발주사·인프라 운영 영역에 있어 직접 들여다보기 어려웠다. 이 제약을 가장 크게 느낀 건 서버사이드 메모리 문제를 추적할 때였다. 실제 운영 중 Pod가 반복적으로 재생성되는 이슈가 있었고, 프론트엔드 코드와 SSR 동작을 분석하면서 Node.js heap과 Pod memory 상태를 직접 확인하고 싶었다. 하지만 내가 볼 수 있는 범위만으로는 왜 Pod가 재생성됐는지, 재생성 직전 서버 프로세스의 heap이 어떻게 변했는지, Kubernetes에서는 그 상황이 어떤 이벤트와 상태 변화로 보이는지를 끝까지 따라가기 어려웠다. 그때 단순히 Kubernetes 명령어를 공부하는 것보다 내가 모든 권한을 가진 환경을 직접 만들어보자고 생각했다. 우선은 실무와 비슷한 배포 구조 자체를 처음부터 끝까지 직접 구축하고, Pod를 만들고 지우며 Deployment와 ReplicaSet의 관계, RollingUpdate, Ingress와 TLS, CI/CD와 GitOps가 어떻게 연결되는지를 내 손으로 확인하는 것이 첫 번째 목표가 됐다. 서버사이드 메모리와 리소스 관측은 이 인프라 구성을 충분히 완성한 뒤 별도의 단계에서 다뤄보기로 했다.
실무에서 겪은 문제
Pod 재생성 / SSR 서버 메모리 문제
↓
애플리케이션 개발자 권한만으로는
클러스터 내부 상태를 끝까지 확인하기 어려움
↓
내가 모든 권한을 가진 Kubernetes 환경을 직접 구축
↓
Deployment / ReplicaSet / Pod / Traefik / cert-manager /
GitHub Actions / Argo CD까지 직접 구축
↓
먼저 실무와 유사한 인프라·배포 구조를 끝까지 완성
↓
그 이후 별도 단계에서
Node.js heap / Pod memory / 장애 상황을 직접 관측
결국 이 홈서버 프로젝트의 목적은 단순히 "Docker 대신 Kubernetes를 써보고 싶다"는 것이 아니었다. 실무에서 권한 때문에 직접 볼 수 없었던 인프라와 배포 영역을 내 환경에서는 처음부터 끝까지 직접 구축하고 이해하는 것이 먼저였다. 그렇게 운영 구조에 대한 기반을 충분히 만든 뒤, 나중에는 실제 장애 상황에서 애플리케이션 코드부터 컨테이너와 Kubernetes의 런타임 상태까지 한 흐름으로 추적할 수 있는 환경으로 확장하고 싶었다.
Docker에는 있는데 K3s에는 없다
Ubuntu에서 만든 Docker image와 K3s가 사용하는 containerd image store는 별개였다. 로컬 import로 해결할 수도 있었지만 서버 한 대에서만 통하는 절차가 된다.
그래서 GHCR을 중간에 두었다
당장은 한 단계가 늘어나지만 앞으로 CI/CD를 붙일 때 자연스럽게 확장할 수 있는 구조였다.
배포 흐름과 요청 흐름은 다르다
GHCR과 Deployment는 브라우저 요청이 지나가는 길이 아니다. 실행할 이미지를 준비하고 Pod 상태를 관리하는 배포 흐름에 속한다.
latest에서 commit SHA로
처음에는 Deployment가 ghcr.io/dongmin3891/ddongmy-os:latest를 사용했다. 하지만 registry의 latest가 새 이미지로 바뀌어도 이미 실행 중인 Pod가 자동으로 재시작되는 것은 아니었다. imagePullPolicy: Always도 Pod가 생성될 때 이미지를 다시 받는 정책이지, registry 변경을 감시하는 기능은 아니었다.
그래서 이미지 버전을 Git commit SHA로 고정했다. 어떤 소스 커밋으로 만든 이미지가 현재 배포되어 있는지 manifest만 보고도 알 수 있고, 같은 태그의 내용이 뒤에서 바뀌는 문제도 피할 수 있었다.
GitHub Actions에서 GHCR까지 자동화
기존 workflow는 서버에 SSH로 접속해 Docker Compose를 직접 갱신하는 방식이었다. 이를 GitHub Actions가 Docker image를 빌드하고 GHCR에 push하는 역할로 바꿨다.
처음에는 GHCR push 단계에서 permission_denied: read_package가 발생했다. repository의 package Actions access가 Read로 설정되어 있었고 Write 권한으로 변경한 뒤 정상적으로 image push가 완료됐다.
workflow는 source commit SHA와 latest 두 태그를 함께 생성하도록 했다.
main push
→ GitHub Actions
→ Docker Build
→ GHCR
├─ latest
└─ <source commit SHA>
Kubernetes manifest를 Git에 올리다
그동안 클러스터에 직접 적용하던 Deployment, Service, Ingress를 repository의 k8s/ 디렉터리에 넣었다.
k8s/
├─ deployment.yaml
├─ service.yaml
└─ ingress.yaml
kubectl apply --dry-run=server -f k8s/로 API Server 기준 검증을 통과한 뒤 Git에 push했다. 여기서부터 Git에는 단순한 애플리케이션 소스뿐 아니라 Kubernetes가 어떤 상태여야 하는지에 대한 선언도 존재하게 됐다.
Argo CD로 Desired State를 Git과 연결
Argo CD를 K3s에 설치하고 ddongmy-os Application을 만들었다. source는 GitHub repository의 main, path는 k8s, destination은 현재 K3s cluster의 default namespace로 지정했다.
자동 sync에는 prune과 selfHeal을 활성화했다.
처음 연결했을 때 Argo CD는 클러스터의 Deployment가 latest를 사용하고 있고 Git의 Deployment는 특정 SHA를 사용하고 있다는 차이를 발견했다. OutOfSync 상태가 된 뒤 자동 sync가 실행되면서 새 ReplicaSet과 Pod가 만들어졌다.
이때 처음으로 Git에 선언한 상태가 실제 클러스터 상태를 바꾸는 GitOps 흐름을 직접 확인했다.
Actions가 manifest의 image SHA도 갱신하게 만들다
이미지를 GHCR에 push하는 것만으로는 Argo CD가 새 버전을 배포할 이유가 없다. Git의 Desired State가 바뀌어야 한다.
그래서 Actions가 image build/push를 완료한 뒤 k8s/deployment.yaml의 image tag를 ${GITHUB_SHA}로 변경하고 github-actions[bot]이 이를 다시 commit/push하도록 workflow를 확장했다.
개발자 main push
→ Actions가 SHA image 생성
→ GHCR push
→ deployment.yaml image SHA 변경
→ github-actions[bot] commit
→ Argo CD sync
이 구조에서는 bot이 같은 source repository의 main을 수정하기 때문에, 다음 로컬 작업 전에 remote의 bot commit을 pull해야 하는 불편함도 생겼다. 실제로 fetch first로 push가 거절되면서 이 구조의 단점을 바로 체감했다. 이후에는 애플리케이션 repository와 GitOps manifest repository를 분리하는 것이 개선 후보가 됐다.
Argo CD가 최신 Git을 바로 보지 못한 문제
자동 배포를 처음 연결했을 때 Actions와 bot commit은 성공했는데 실제 Deployment image는 이전 SHA에 머물러 있었다. Argo CD UI 상태는 Synced / Healthy였지만 .status.sync.revision을 확인하니 최신 main이 아니라 이전 Git revision을 보고 있었다.
처음에는 hard refresh로 최신 revision을 강제로 읽게 해 배포가 진행되는 것을 확인했다. 하지만 매번 수동 refresh를 하는 것은 자동 배포라고 보기 어려웠다.
GitHub Webhook으로 refresh 지연 제거
Argo CD는 Git을 주기적으로 확인할 수 있지만 push 직후 바로 반영시키기 위해 GitHub Webhook을 연결했다.
Argo CD server는 ClusterIP라 외부 GitHub에서 접근할 수 없었다. 그렇다고 Argo CD UI 전체를 인터넷에 공개하고 싶지는 않았다. 그래서 argocd-hook.ddongmy.com을 만들고 Traefik Ingress에서 /api/webhook 경로만 argocd-server로 전달했다.
Argo CD server의 내부 self-signed HTTPS를 Traefik backend로 그대로 사용하면 인증서 검증 문제가 생겼다. server.insecure=true로 내부 서버를 HTTP로 동작시키고, 외부 TLS는 Traefik에서 종료하도록 역할을 나눴다.
argocd namespace에는 cert-manager로 argocd-hook.ddongmy.com 전용 Let's Encrypt 인증서를 발급했고, GitHub Webhook과 Argo CD 양쪽에 shared secret도 설정했다.
GitHub의 Recent Deliveries에서 요청 성공을 확인한 뒤 테스트 commit을 push했다. Argo CD의 .status.sync.revision이 별도의 hard refresh 없이 바로 해당 commit SHA로 변경됐다.
마지막 검증: 실제 RollingUpdate
README에 테스트 변경을 commit/push하자 Actions가 새 이미지를 만들고 manifest를 갱신했다. Argo CD가 webhook으로 변경을 즉시 감지했고 Kubernetes Deployment가 RollingUpdate를 시작했다.
기존 Pod 두 개를 한 번에 종료하지 않고 새 Pod가 Pending → ContainerCreating → Running이 된 것을 확인한 뒤 기존 Pod를 하나씩 종료했다. 최종적으로 새 ReplicaSet의 Pod 두 개가 모두 1/1 Running이 됐다.
이제 서버에 SSH로 접속해 docker pull, docker compose up, kubectl set image를 직접 실행하는 것이 아니라 코드의 main push가 배포의 시작점이 됐다.
Argo CD UI로 상태를 눈으로 확인하다
Argo CD UI는 인터넷에 직접 공개하지 않고 SSH tunnel과 kubectl port-forward로 접근했다. 화면에서 Healthy, Synced, Git revision뿐 아니라 Deployment → ReplicaSet → Pod 관계와 이전 ReplicaSet까지 한 번에 볼 수 있었다.
터미널에서 하나씩 조회하던 Kubernetes resource 관계가 트리로 보이면서 Deployment가 ReplicaSet을 만들고 ReplicaSet이 Pod를 유지하는 구조도 더 명확하게 이해할 수 있었다.
처음 목표는 단순히 K3s가 GHCR 이미지를 받아 실행하게 만드는 것이었다. 하지만 이미지 저장소를 외부 registry로 분리하면서 GitHub Actions를 붙일 수 있었고, manifest를 Git에 선언하면서 Argo CD와 GitOps까지 자연스럽게 이어졌다. 최종적으로
main push → Actions → GHCR → manifest SHA 갱신 → Webhook → Argo CD → K3s RollingUpdate까지 실제로 검증했다.
다음 개선 후보
- Node.js
heapUsed/ RSS와 Pod memory를 함께 관측할 수 있는 모니터링 환경 구축 - Pod 재시작·OOMKilled·Evicted 등 Kubernetes 이벤트와 메모리 변화의 상관관계 확인
- 애플리케이션 source repository와 GitOps manifest repository 분리
- readiness/liveness probe 추가
- resource requests/limits 설정
- securityContext 정리
- ConfigMap/Secret 관리 방식 정리
- Argo CD UI 접근 방식 개선 또는 사설망 기반 접근 검토