
GitHub Actions에서 워크플로우 파일 하나 올리면 CI/CD가 알아서 돌아간다. 편하고 빠르다. 그런데 "알아서 돌아간다"가 정확히 뭘 하는 건지 물어보면 대답하기 어려운 사람이 꽤 많다.
나도 그랬다. 홈랩 쿠버네티스 클러스터에서 kubectl apply를 반복하다가, "이거 Git push 한 번으로 배포까지 끝나면 좋겠다"는 생각이 들었고, Gitea를 올렸다. GitHub 대신 셀프호스팅 Git 서버를 택한 이유, act_runner DinD로 CI/CD를 구축하면서 깨진 것들, 그리고 ArgoCD까지 연결한 GitOps 파이프라인 이야기를 풀어보겠다.
GitHub은 훌륭한 서비스다. 솔직히 대부분의 프로젝트에서는 GitHub을 쓰는 게 맞다. 그런데 내 상황은 좀 달랐다.
Gitea는 Go로 작성된 경량 Git 서버로 SQLite 하나면 돌아간다. K8s에 PVC 하나 붙여서 배포하면 끝이다. GitHub Actions와 호환되는 Gitea Actions도 지원하니까 워크플로우 문법을 새로 배울 필요도 없다.
두 주 동안 구축한 파이프라인 전체 흐름이다.
git push 한 번이면 Gitea가 webhook으로 act_runner를 깨우고, DinD 사이드카 안에서 Docker 빌드가 돌아간다. 빌드된 이미지는 Gitea의 OCI 레지스트리에 올라가고, 워크플로우가 homelab 레포의 Kustomization 태그를 업데이트하면 ArgoCD가 자동으로 K8s 클러스터에 배포한다. 여기까지 전부 셀프호스팅이다.
Gitea Actions의 러너인 act_runner는 GitHub의 self-hosted runner와 비슷하다. 문제는 우리 클러스터가 containerd 기반이라 Docker 소켓이 없다는 거다.
CI에서 Docker 이미지를 빌드하려면 Docker 데몬이 필요한데 containerd에는 그런 거 없다. 해결책은 Docker-in-Docker(DinD) 사이드카 패턴이다. Pod 안에 docker:dind 컨테이너를 하나 더 띄우고 TCP로 통신한다.
containers:
- name: runner
image: gitea/act_runner:latest
env:
- name: DOCKER_HOST
value: tcp://localhost:2375
- name: dind
image: docker:dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: "" # 같은 Pod 내부 통신이라 TLS 불필요핵심은 DOCKER_HOST=tcp://localhost:2375다. Unix 소켓 공유 방식은 containerd 환경에서 안 된다. 같은 Pod 안이라 localhost TCP 통신이 가능하고 TLS도 필요 없다.
DinD 환경의 두 번째 함정. GitHub Actions에서 당연하게 쓰던 actions/checkout이 동작하지 않는다. DinD 안에서 실행되는 워크플로우는 일반적인 러너 환경과 달라서 표준 액션이 깨지는 경우가 많다. 결국 git clone을 직접 쓰는 수밖에 없었다.
steps:
- name: Install dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends \
docker-ce-cli docker-buildx-plugin
- name: Checkout
run: git clone --depth 1 --branch master $REPO_URL .
- name: Build and Push
run: |
docker build -t gitea.xssh.org/admin/museck:$TAG .
docker push gitea.xssh.org/admin/museck:$TAGDocker CLI도 수동으로 설치해야 한다. DinD 사이드카의 Docker API 버전과 맞는 최신 docker-ce-cli를 Docker 공식 리포에서 받아 설치했다. API 버전 불일치로 빌드가 깨진 적이 두 번이나 있었다.
파이프라인은 돌아가기 시작했는데 빌드가 느렸다. 매번 의존성을 처음부터 설치하고 Docker 이미지도 캐시 없이 빌드하니까 당연한 결과였다. 4개 커밋에 걸쳐 최적화를 진행했다.
가장 효과가 컸던 건 다층 캐시 전략이다.
# 캐시 이미지 당겨오기
- name: Pull cache image
run: docker pull gitea.xssh.org/admin/museck:buildcache || true
# 캐시 활용 빌드
- name: Build
run: |
docker build \
--cache-from gitea.xssh.org/admin/museck:buildcache \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t gitea.xssh.org/admin/museck:$TAG .
# 3개 태그 병렬 푸시
- name: Push images
run: |
docker push gitea.xssh.org/admin/museck:$TAG &
docker push gitea.xssh.org/admin/museck:latest &
docker push gitea.xssh.org/admin/museck:buildcache &
waitDockerfile 쪽도 손봤다. BuildKit의 --mount=type=cache로 pnpm store를 빌드 간에 유지하고 .dockerignore를 확장해서 .husky, .claude, scripts 같은 빌드에 불필요한 파일을 제외했다.
CI는 이미지를 만들고 끝이다. 실제 배포는 ArgoCD가 담당한다. ArgoCD는 Git 레포를 감시하다가 매니페스트가 바뀌면 K8s 클러스터에 자동으로 적용해주는 GitOps 컨트롤러다.
서비스가 하나일 때는 ArgoCD Application 하나면 된다. 그런데 홈랩에 서비스가 하나둘 늘어나면? 매번 ArgoCD UI에서 새 앱을 등록하는 건 귀찮다. 그래서 App of Apps 패턴을 쓴다.
# root Application: apps/ 디렉토리를 감시
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
source:
repoURL: https://gitea.xssh.org/homelab/homelab.git
path: k8s-platform/services/argocd/apps
syncPolicy:
automated:
prune: true
selfHeal: trueroot Application이 apps/ 디렉토리를 감시한다. 새 서비스를 배포하고 싶으면 apps/ 안에 Application YAML 파일 하나만 커밋하면 ArgoCD가 알아서 등록하고 배포한다. 20개가 넘는 서비스를 이 방식으로 관리하고 있는데 한 번도 ArgoCD UI를 열 필요가 없었다.
파이프라인이 잘 도는지 확인하려고 매번 ArgoCD 대시보드를 열어보는 건 바보 같은 짓이다. ntfy라는 셀프호스팅 push notification 서버를 올려서 ArgoCD 배포 알림을 모바일로 받게 만들었다.
처음에 글로벌 구독을 설정했더니 알림이 폭주했다. homelab 레포는 mono-repo라서 push 한 번에 20개 넘는 앱 전부의 sync 상태가 바뀌고 각각 알림이 왔다. 하루에 알림 수백 개. 해결책은 per-app annotation이다.
# 필요한 앱에만 알림 annotation 추가
metadata:
annotations:
notifications.argoproj.io/subscribe.on-deployed.ntfy: ""
# 트리거: 성공 + Healthy + root 앱 제외
trigger.on-deployed: |
- when: >-
app.status.operationState.phase in ['Succeeded']
and app.status.health.status == 'Healthy'
and app.metadata.name != 'root'
oncePer: app.status.sync.revisionroot 앱을 제외하는 것도 중요하다. App of Apps 패턴에서 root Application은 자식 앱이 sync될 때마다 같이 상태가 바뀌니까 빼지 않으면 중복 알림이 쏟아진다. oncePer로 같은 revision에 대한 중복 알림도 막았다.
2주간 Gitea Actions를 쓰면서 느낀 점을 솔직하게 정리해봤다.
Gitea가 나은 점. Git 서버와 컨테이너 레지스트리가 하나다. 네트워크 비용이 0이다. CI 러너가 같은 클러스터에 있어서 이미지 push가 빠르다. 모든 데이터가 내 서버에 있다.
GitHub이 나은 점. actions/checkout이 그냥 된다. marketplace에서 액션 가져다 쓸 수 있다. 러너 환경을 신경 쓸 필요가 없다. 당연히 가용성도 비교가 안 된다.
결론은? 프로덕션 서비스라면 GitHub을 쓰겠다. 하지만 "CI/CD가 실제로 어떻게 돌아가는지 이해하고 싶다"면 Gitea + act_runner를 직접 구축해보는 걸 추천한다. actions/checkout이 안 될 때 비로소 checkout이 뭘 하는 건지 알게 된다.
관리형 서비스는 추상화를 제공한다. 그 추상화 덕분에 빠르게 움직일 수 있다. 하지만 추상화 아래에서 무슨 일이 벌어지는지 모르면 문제가 생겼을 때 손을 못 쓴다.
Gitea + act_runner + ArgoCD를 직접 구축하면서 배운 것은 CI/CD 파이프라인의 각 단계가 실제로 무엇을 하는지에 대한 이해다. checkout이 뭘 하는지, Docker 빌드 캐시가 어떻게 작동하는지, GitOps에서 빌드와 배포가 왜 분리되어야 하는지. 이런 것들은 GitHub Actions만 쓰면 알 수 없는 것들이다.
셀프호스팅이 정답은 아니다. 그런데 한 번쯤 직접 해보면 관리형 서비스를 쓸 때도 더 나은 판단을 내릴 수 있게 된다. git clone 한 번이면 전체 인프라를 복원할 수 있는 GitOps의 가치는 직접 삽질해봐야 체감된다.