
홈랩 K8s 클러스터에 Gitea Actions CI/CD 파이프라인을 올리는 작업을 하고 있었다. GitHub Actions처럼 push하면 자동으로 Docker 이미지 빌드해서 레지스트리에 올리고, ArgoCD가 배포하는 흐름이다.
그런데 막상 act_runner를 K8s Pod으로 띄우고 워크플로우를 돌려보니 docker build가 실패한다. 에러 메시지를 보니 Docker 소켓을 찾을 수 없다는 거다.
아, 맞다. 이 클러스터는 kubeadm + Cilium CNI 기반인데 컨테이너 런타임이 containerd다. Docker가 아니라 containerd니까 /var/run/docker.sock이 존재하지 않는다. K8s 1.24부터 dockershim이 제거되면서 containerd나 CRI-O를 직접 쓰는 게 표준이 됐는데, 기존에 Docker 소켓에 의존하던 CI/CD 패턴이 전부 깨지는 셈이다.
Docker 소켓이 없으면 직접 만들면 된다. Docker-in-Docker(DinD) 사이드카 패턴을 적용하기로 했다.
이 패턴의 핵심은 간단하다. Pod 안에 두 개의 컨테이너를 띄운다.
같은 Pod 안이니까 localhost로 통신할 수 있고, DinD 사이드카가 TCP 2375 포트로 Docker API를 노출한다. runner에서 DOCKER_HOST=tcp://localhost:2375를 설정하면 Docker 명령어가 사이드카의 데몬을 사용하게 된다.
마트료시카 인형처럼 컨테이너 안에 또 컨테이너가 있는 구조라서 Docker-in-Docker라 부른다.
실제 YAML 설정을 보자. 핵심 부분만 추렸다.
containers:
- name: runner
image: gitea/act_runner:latest
env:
- name: DOCKER_HOST
value: tcp://localhost:2375
command: ["/bin/sh", "-c"]
args:
- |
# Docker CLI 설치 (API v1.44+ 호환)
apt-get update && apt-get install -y docker-ce-cli
act_runner daemon
- name: dind
image: docker:dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: "" # TLS 비활성화 (같은 Pod 내부 통신)몇 가지 포인트를 짚어보겠다.
처음에는 Unix 소켓 공유 방식(/var/run/docker.sock을 emptyDir로 마운트)을 시도했다. 결론부터 말하면 안 된다. containerd 기반 K8s에서는 호스트에 Docker 소켓 자체가 없고, DinD 사이드카가 생성하는 소켓 경로도 컨테이너 내부 파일시스템에 있어서 emptyDir 공유가 타이밍 이슈를 일으킨다.
TCP 방식이 훨씬 깔끔하다. 같은 Pod 안에서 localhost 통신이니까 네트워크 오버헤드도 거의 없다.
DOCKER_TLS_CERTDIR=""로 TLS를 끄는 게 처음엔 좀 찝찝했는데, 생각해보면 같은 Pod 내부 localhost 통신이다. Pod 밖으로 나가는 트래픽이 아니니까 보안 위험은 없다. 오히려 인증서 관리 복잡도만 늘어나니 끄는 게 맞다.
act_runner 이미지에는 Docker CLI가 없다. 워크플로우에서 docker build나 docker push를 쓰려면 CLI를 따로 설치해야 한다. 여기서 주의할 점이 하나 있다. DinD 사이드카가 최신 Docker 데몬을 돌리고 있으니 CLI도 API v1.44 이상을 지원하는 버전이어야 한다. docker-ce-cli 패키지를 설치하면 된다.
DinD 사이드카에 privileged: true를 줘야 한다. Docker 데몬이 cgroup과 네임스페이스를 조작해야 하니까 어쩔 수 없다. 좋은 점은 privileged 권한이 DinD 컨테이너에만 국한된다는 거다. runner 컨테이너는 일반 권한으로 실행되니 공격 범위가 제한적이다.
DinD 사이드카를 붙이고 CI 파이프라인이 잘 돌아가나 싶었는데, 이번엔 CD 쪽에서 문제가 터졌다. ArgoCD의 repo-server Pod이 계속 CrashLoopBackOff에 빠진다.
로그를 까보니 copyutil init 컨테이너에서 실패하고 있었다. 이 init 컨테이너는 argocd 바이너리를 공유 볼륨에 복사하는 역할인데, Helm 차트 업그레이드 과정에서 이미지 버전이 꼬인 거다.
업스트림 Helm 차트가 고쳐질 때까지 기다릴 수도 있지만, 프로덕션에서 ArgoCD가 죽어있으면 배포가 멈추니까 빨리 해결해야 했다.
Helm 차트의 init 컨테이너를 values에서 직접 재정의하는 방법으로 해결했다.
repoServer:
initContainers:
- name: copyutil
image: quay.io/argoproj/argocd:v2.14.4
command: [cp, /usr/local/bin/argocd, /var/run/argocd/argocd-cmp-server]
volumeMounts:
- mountPath: /var/run/argocd
name: var-files이미지 태그를 명시적으로 v2.14.4로 고정했다. 차트가 기본으로 넣는 이미지 대신 내가 검증한 버전을 쓰는 거다. 이렇게 하면 Helm 차트 버전이 올라가도 init 컨테이너는 안정적으로 동작한다.
이 패턴은 ArgoCD뿐 아니라 다른 Helm 차트에서도 쓸 수 있다. init 컨테이너나 사이드카가 호환성 문제를 일으킬 때 values에서 직접 override하면 업스트림 수정을 기다리지 않고 바로 고칠 수 있으니까.
이번 작업에서 가장 오래 걸린 건 Unix 소켓 공유가 안 되는 걸 알아내는 과정이었다. Docker 시절 습관대로 docker.sock을 마운트하면 되겠지 하고 접근했다가 반나절을 날렸다.
K8s 컨테이너 런타임이 Docker에서 containerd로 전환되면서 이런 식으로 기존 워크플로우가 깨지는 경우가 꽤 있다. 검색하면 아직도 docker.sock 마운트를 안내하는 글이 많은데, containerd 환경에서는 통하지 않는다.
정리하면 이렇다.
홈랩이니까 이런 삽질도 배움의 과정이라 생각하고 넘어가지만, 프로덕션에서 이런 일을 겪으면 꽤 고생할 수 있다. 클러스터 런타임이 뭔지 먼저 확인하는 습관을 들이자.