시리즈 "노트북 홈서버 k3s 구축기" 10부.
지난 편까지 = GitOps 원본이 데스크탑 GitLab이고, push하면 3초 만에 클러스터에 반영된다. 다만 배포되는 것은 인프라뿐이고 앱 세 종은 이미지가 없어 뜨지 않는다.
10부 = CI와 CD. 앞 토막(CI)은 push 한 번으로 검증된 이미지가 레지스트리에 서게 하고, 뒤 토막(CD)은 그 이미지가 노드에서 돌게 하고 무엇이 도는지 git이 적고 있게 한다.
0. 이어받기
9부에서 GitOps 원본을 데스크탑 GitLab으로 옮겼다. cgv-infra에 push하면 webhook을 타고 3초 만에 클러스터에 반영된다.
그 길로 흐르는 것은 인프라뿐이다. 앱 세 종(queue·booking·frontend)은 Deployment·Service·Ingress까지 다 서 있는데 ImagePullBackOff다 — ArgoCD 화면에서 Synced이면서 Degraded. GitOps가 보장하는 것은 "git에 적힌 대로 반영"까지고, 그것이 실행 가능한지는 다른 문제다.
멈춘 자리는 매니페스트에 적힌 이미지 주소다.
# cgv-infra — charts/apps/queue/values.yaml
image:
repository: registry.cgv.local/queue-go # 실재하지 않는 주소
tag: dev
registry.cgv.local은 7부에서 일부러 비워 둔 자리 표시다. 주소가 정해지지 않았는데 먼저 적으면 두 번 고치게 되기 때문이다. 이 편에서 그 자리를 채운다.
이 편은 두 토막이다.
CI cgv-onprem 에 push → 검증 → 이미지 → 레지스트리
CD 레지스트리의 이미지 → 노드가 받아서 → 파드로 돈다
그리고 무엇이 도는지를 git 이 적고 있게 한다
── CI ──
1 CI — 검증을 통과한 것만 이미지가 된다 개념 · 표준 8단계 · 게이트
2 어떻게 도는가 GitLab · 러너 · job · stage
3 세운다 러너 · 레지스트리 · 손으로 한 번
4 파이프라인 태그 · 규칙 · 검사 · 실측
── CD ──
5 CD — 배포 엔진은 이미 있다 끊긴 곳 둘
6 노드가 받아오게 한다 자격 · 배선 · 파드 첫 기동
7 태그를 git 에 되쓴다 argocd-image-updater
8 대조 · 9 다음
9부는 "클러스터 밖 누구도 클러스터를 건드리지 않는다"를 지키는 편이었다. 10부도 그 성질을 유지한 채로 간다 — 다만 끝에 가면 GitOps 저장소에 쓰는 쪽이 하나 생긴다. 그걸 누구에게 어디까지 줄지가 이 편의 마지막 결정이다.
1. CI — 검증을 통과한 것만 이미지가 된다
손으로 하면 안 되나
이미지는 세 줄이면 올라간다.
docker login → docker build → docker push
안 되는 게 아니라, 계속 하면 무너진다.
서비스마다 세 줄씩 쳐야 한다 셋이면 아홉 줄. 하나를 잊으면 그 서비스만 옛것
태그를 내가 정해야 한다 어제 올린 게 뭐였는지 내 기억이 장부다
검증이 없다 깨진 코드도 컴파일만 되면 이미지가 된다
CI는 이 셋을 없앤다 — push가 트리거고, 태그는 규칙이 정하고, 검증을 통과한 것만 이미지가 된다.
CI 의 뜻
CI(Continuous Integration)는 "지속적 통합" — 자주 합치고, 합칠 때마다 자동으로 검증한다. 여럿이 몇 주씩 따로 작업하고 한 번에 합치면 충돌이 수백 개 쌓이고 누구 코드가 범인인지 갈리지 않는다. 자주 합치면 한 번에 섞이는 양이 적고, 합칠 때마다 검증하면 깨진 커밋이 그 자리에서 드러난다.
git과 CI가 보는 것이 다르다. git은 텍스트를 합칠 뿐이라 "충돌 없음"까지만 보장한다. A가 함수 이름을 바꾸고 B가 옛 이름으로 호출하는 코드를 쓰면, 파일이 달라 충돌 없이 합쳐지는데 결과는 컴파일이 안 된다. 합쳐진 결과가 실제로 도는지는 CI가 본다.
혼자 하는 프로젝트에도 값이 있다. 사람 대신 시간이 벌린다 — 어제의 나와 오늘의 내가 서로를 모른다. 그리고 CI는 매번 빈 컨테이너에서 clone부터 다시 하므로, 로컬에 우연히 깔린 것에 기대던 빌드가 거기서 드러난다. "내 PC에서는 됐는데"가 성립하지 않게 된다.
표준 8단계
CI가 검증하고 배달하는 것을 펼치면 여덟이다.
1 lint 내가 쓴 코드의 정적 결함. 컴파일은 되는데 이상하게 도는 자리
2 build 컴파일이 되는가
3 test 단위 테스트
4 secret scan 커밋에 자격(토큰·키·비밀번호)이 섞였는가
5 dependency scan 가져다 쓰는 라이브러리에 알려진 취약점이 있는가
6 package 이미지로 만다
7 image scan 만든 이미지 안에 취약점이 있는가
8 publish 레지스트리에 올린다
손으로 하던 세 줄은 이 중 package(이미지 생성)과 publish(레지스트리 푸시)이다. 나머지가 검증이고, 검증이 빠지면 CI가 아니라 빌드 자동화다. 이미지는 검증을 통과한 결과물이다.
3(test)은 이 편에서 못 넣는다. 저장소에 돌릴 테스트가 0개다 — queue-go에 *_test.go가 없고 booking은 src/test가 없어 -DskipTests로 빌드한다. 코드만 읽고 테스트를 쓰면 껍데기가 되기 쉬워서, 앱을 실제로 띄우고 동작을 보면서 쓰는 11부로 미룬다. 이 편에서는 들어올 자리만 만들어 둔다.
실패하면 막는가 — 게이트
각 단계(Stage)가 실패했을 때 publish를 막을 것인가가 파이프라인의 설계다. 안 막으면 검사는 로그일 뿐이고, 로그는 다음 주면 아무도 안 연다.
막으려면 고칠 수 있어야 한다.
lint · test · secret scan 내가 쓴 코드다. 실패하면 내가 고친다 → 조건 없이 막는다
dependency scan 내가 고른 라이브러리. 버전을 올리면 고쳐진다 → 조건 없이 막는다
image scan 베이스 이미지의 OS 패키지가 대부분이다 → 조건이 필요하다
eclipse-temurin:21-jre나 nginx를 훑으면 CVE가 수십 개 나오고 상당수는 아직 패치가 없다. 손쓸 수 없는 것으로 막으면 파이프라인이 영구히 빨간불이 되고, 결국 게이트를 끄게 된다 — 게이트가 죽는 방식이 이것이다. 그래서 이미지 스캔만 조건을 붙인다: 수정 가능한 HIGH 이상이면 막는다. 나머지는 기록만 남긴다.
게이트가 가장 강해야 하는 자리는 secret scan이다. 자격이 한 번 커밋에 들어가면 파일에서 지워도 히스토리에 그대로 남아 되돌릴 수 없다.
2. 어떻게 도는가 — GitLab 은 목록을 만들고 러너가 실행한다
.gitlab-ci.yml — job 목록을 적는 파일
CI 전체가 저장소 루트의 파일 한 장으로 정의된다. GitLab은 그 파일 하나만 읽고, 서비스가 몇이든 job을 그 안에 적는다.
job은 코드가 아니라 "할 일" 한 덩어리다.
build-queue: # job 이름. 아무거나 된다
script: # 돌릴 명령. 이게 본체다
- docker build ...
파일에 적혀 있는 동안은 글자다. push가 그 글자에 대상 커밋을 붙여 실체로 만든다. 한 push로 생긴 job 묶음을 파이프라인이라 부른다.
stage — 순서 칸
job이 여럿이면 순서가 필요하다. stage는 종류가 아니라 순서 칸이다.
같은 stage 의 job 순서가 없다. 동시에 돌 수 있는 후보
stage 와 stage 사이 앞 stage 가 전부 성공해야 다음 stage 가 열린다
나누는 기준은 한 줄이다 — 앞것이 실패했을 때 뒷것을 돌릴 이유가 없으면 stage를 나눈다. 20초짜리 검사가 깨졌는데 3분짜리 빌드를 돌릴 이유가 없으니 그 사이에 벽을 세우고, 서로 무관한 검사끼리는 같은 stage에 나란히 둔다.
러너 — 가져가서 실행하는 쪽
GitLab은 파일을 읽고 job 목록을 만드는 데서 멈춘다. 실행까지 하면 저장소 서버가 빌드마다 눌리기 때문에, 실행은 밖으로 뺐다. 그 실행을 맡는 상주 프로그램이 러너다.
방향이 한쪽이다. 러너가 GitLab에 "내 몫 일 있냐"고 주기적으로 묻고, GitLab은 러너를 부르지 못한다. 그래서 러너는 GitLab에서 보이는 자리에 있을 필요가 없고 — 실제로 러너 컨테이너에는 열린 포트가 하나도 없다 — 밖으로 나갈 수만 있으면 어디서든 돈다.
러너는 파일을 읽지 않는다. GitLab이 파일을 해석해서 job 하나치 명세(이미지 · script · 변수 · 저장소 주소와 커밋 · 그 job 전용 토큰)만 넘긴다. stages도 extends도 러너에게는 안 간다 — 이미 펼쳐진 결과만 온다. 그래서 러너는 프로젝트가 뭔지, 언어가 뭔지 모르고, 몰라도 된다. 언어는 job의 image:가 정하고 러너는 그 이미지를 띄울 뿐이다.
한 바퀴
① git push 커밋이 GitLab 에 도착한다
② GitLab 이 파일을 읽는다 job 을 만든다. 첫 stage 는 pending · 뒤 stage 는 created
③ 러너가 물어서 가져간다 pending 인 것만. created 는 후보에 안 오른다
④ 명세를 받는다 이미지 · script · 변수 · 저장소 · 커밋 · 토큰
⑤ 실행한다 컨테이너를 띄우고 → clone → script 한 줄씩 → 로그를 실시간 전송
⑥ 보고하고 치운다 종료 코드 0 이면 success → 컨테이너 삭제 → 다시 ③
⑦ stage 가 넘어간다 앞 stage 가 전부 success 면 다음 stage 가 created → pending
하나라도 failed 면 뒤 stage 는 skipped
성패는 로그가 아니라 종료 코드로 정해진다. script는 위에서부터 돌다가 0이 아닌 코드가 나오면 그 자리에서 멈춘다 — push 앞줄에 검사를 두면 통과 못 한 이미지는 올라가지 않는다. 게이트는 별도 장치가 아니라 이 순서다.
컨테이너는 job마다 새로 뜨고 끝나면 지워진다. 그 안의 소스·임시 파일·로그인 자격이 전부 사라지고, GitLab에는 로그와 종료 코드만 남는다.
3. 세운다 — 러너 · 레지스트리 · 손으로 한 번
러너를 어디에 — 데스크탑
클러스터 안 파드로 뜬다. 빌드가 노드 자원과 디스크를 쓴다
데스크탑 Docker GitLab 옆에 컨테이너로 ← 고른 것
노드 셋이 USB SSD 한 장 위에 있고, 13부에서 그 클러스터에 부하를 걸어 stg·prd 스펙을 산정한다. 빌드가 같은 디스크를 쓰면 그때 나올 숫자가 흔들린다. 데스크탑도 자원이 남아서 고른 게 아니라(이 시점 RAM 16GB에 WSL VM 상한 6GB, 그중 GitLab이 3GB — 파이프라인이 자리 잡은 뒤 RAM 20GB·상한 8GB로 늘린다), 눌리는 곳이 나중에 재야 할 곳이 아니라서다.
누구 일을 받나 — cgv 그룹
프로젝트 러너 한 저장소의 job 만
그룹 러너 그룹 안 모든 저장소의 job ← 고른 것
인스턴스 러너 이 GitLab 전체. 관리자만 만든다
이미지를 만드는 저장소는 cgv-onprem 하나지만, cgv-infra에도 CI가 붙는다 — 이미지 빌드가 아니라 매니페스트 검사(렌더가 깨지는지, 스키마가 맞는지, 봉인본이 빠졌는지)다. 러너 하나로 둘 다 받게 그룹에 둔다. 대가는 cgv-infra의 job도 이 러너의 권한을 그대로 물려받는 것 — 두 저장소를 나 혼자 고치는 동안은 두고, 사람이나 저장소가 늘면 프로젝트 러너로 가른다.
job 을 어떻게 돌리나 — docker executor
shell 러너 안에서 직접 명령을 돈다. 도구를 미리 깔아 둬야 하고 그 안이 쌓인다
docker job 마다 컨테이너를 새로 띄운다 ← 고른 것
앱 셋의 빌드 환경이 Go·Maven·nginx로 다르다. job마다 필요한 이미지로 새로 뜨는 쪽이 맞다.
이미지 빌드는 어떻게 — 소켓 마운트
docker executor를 고르면 바로 걸리는 게 있다. job 컨테이너 안에는 Docker가 없다.
Docker는 실제로 일하는 데몬(dockerd, root로 돈다)과 부탁하는 명령어(docker)로 나뉘어 있고, 둘이 대화하는 통로가 /var/run/docker.sock이라는 파일이다. 크기 0짜리 소켓 파일이고 오가는 것은 HTTP다. docker build를 쳐도 그 명령이 빌드하는 게 아니라 — 소켓 너머 데몬에게 부탁하는 것이다.
docker build ──▶ /var/run/docker.sock ──▶ dockerd 가 실제로 빌드한다
이 소켓 파일을 컨테이너 안에 마운트하면, 컨테이너 안의 docker 명령이 데스크탑 데몬에게 부탁할 수 있게 된다. 그게 소켓 마운트다. 대안인 dind(컨테이너 안에 데몬을 또 띄우는 것)는 격리되는 대신 privileged와 메모리가 들고, job마다 데몬이 새것이라 빌드 캐시가 매번 사라진다. 띄울 RAM이 없어 소켓으로 간다.
대가는 권한이다. 소켓으로는 빌드만 시킬 수 있는 게 아니라 Docker API 전부를 부를 수 있다 — 데스크탑의 /를 통째로 마운트한 컨테이너를 띄우라는 요청도 그중 하나고, 데몬이 root로 도니 소켓을 쥔 쪽은 사실상 데스크탑 root다. 거기에 GitLab 컨테이너와 레지스트리 볼륨이 있다. RAM에 여유가 생기면 dind로 바꾼다.
세우고 등록한다
러너 설정은 두 군데로 갈려 있다. 범위·태그·Protected는 GitLab이 들고 있고, executor·이미지·볼륨은 러너 쪽 파일이 들고 있다. 그래서 GitLab에서 먼저 만든다.
그룹 cgv → Build → Runners → Create group runner. 태그 칸에 docker를 적는다 — 태그는 러너가 여럿일 때 job을 어디로 보낼지 고르는 이름표고, GitLab은 그 글자의 뜻을 모르고 같은지만 본다. Protected(보호 브랜치 job만 받기)는 아직 안 켠다 — 돌릴 job도 브랜치 규칙도 없는데 켜면 첫 파이프라인이 안 도는 것부터 겪는다. 저장하면 glrt-로 시작하는 토큰이 그 화면에 한 번만 표시된다.
러너 프로세스는 GitLab을 띄우고 있는 데스크탑 docker-compose.yml에 서비스로 하나 더 넣는다.
# 데스크탑의 docker-compose.yml — 서비스 하나 추가
gitlab-runner:
image: gitlab/gitlab-runner:v19.2.1 # GitLab CE 와 같은 버전
volumes:
- gitlab-runner-config:/etc/gitlab-runner
- /var/run/docker.sock:/var/run/docker.sock # 러너가 job 컨테이너를 띄우는 통로
뜬 것만으로는 아무 GitLab도 모르는 빈 일꾼이다. 아까 받은 토큰으로 도커컴포즈로 생성한 깃랩에 접속(exex)해서 등록한다.
docker exec -it gitlab-runner gitlab-runner register \
--non-interactive \
--url http://192.168.0.167:8929 \
--token <glrt- 토큰> \
--executor docker \
--docker-image alpine:3.20 \
--docker-volumes /var/run/docker.sock:/var/run/docker.sock
--url · --token 어느 GitLab 의 어느 범위 job 을 받을지. 판단 재료는 GitLab 쪽에 있다
--executor docker job 마다 컨테이너를 새로
--docker-image job 이 image: 를 안 적었을 때의 기본값. 실제 job 은 전부 적는다
--docker-volumes job 컨테이너 안에도 소켓을 넣는다 — script 의 docker build 가 데몬에 닿게
소켓은 두 번 나온더. docker-compose.yml에 그리고, exec로 접속해서 깃랩에 설정 등록할때. 이는 이유가 갈린다.
- compose의 것은 러너가 job 컨테이너를 만들려고(러너 자신도 컨테이너라 안에 데몬이 없다),
- register의 것은 job 안의 docker build가 데몬에 닿으려고다.
- 그리고 러너가 부탁해 만들어진 job 컨테이너는 러너 안이 아니라 러너 옆에 생긴다 — 만든 쪽이 데스크탑 데몬이기 때문이다. 파이프라인이 도는 동안 docker ps를 치면 gitlab·gitlab-runner 옆에 runner-xxxx-concurrent-0이 잠깐 보인다.
register가 한 일은 /etc/gitlab-runner/config.toml에 항목 하나를 적은 것이다. 러너는 이후로 그 파일만 읽고 돈다 — 파일 안의 토큰으로 GitLab에 "내 몫 job 있냐"고 몇 초마다 묻고, 받으면 파일 안의 설정(executor·기본 이미지·소켓)대로 job 컨테이너를 띄워 실행하고, 끝나면 보고하고 다시 묻는다. 그 경로를 볼륨으로 빼 둔 이유가 이것이고, 컨테이너를 다시 만들어도 등록이 남는다. 등록이 끝나면 그룹 러너 목록에 Online·Idle로 뜬다. Idle은 붙어서 묻고는 있는데 받아 갈 job이 없다는 뜻이다.

레지스트리 — 이미지를 둘 곳
러너가 빌드한 이미지는 데스크탑 데몬 안에만 남는다. 노드 셋은 그 안을 들여다볼 방법이 없다. 양쪽이 같이 보는 자리가 필요하고, 그게 레지스트리다.
따로 세우지 않고 GitLab 내장 Container Registry를 쓴다 — 이미 있는 것을 쓰면 자격과 백업이 한 자리에 모인다. 켜는 것은 데스크탑 docker-compose.yml의 두 줄이다.
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.0.167:8929'
registry_external_url 'http://192.168.0.167:5050' # 더한 줄 — 레지스트리 프로세스가 뜬다
ports:
- '8929:8929'
- '5050:5050' # 더한 줄
같은 컨테이너인데 포트가 갈리는 것은 규약이 달라서다. git은 git 프로토콜로, 레지스트리는 Docker Registry API로 대화하고, docker push는 항상 호스트[:포트]/경로:태그 형태로만 부르기 때문에 한 포트에서 경로로 가를 수 없다.
그 형태가 곧 이미지 이름이다.
192.168.0.167:5050 / cgv/cgv-onprem / queue-go : dev
└─ 어느 서버 ────┘ └─ 어느 프로젝트 ┘ └ 이름 ┘ └ 태그 ┘
가운데가 GitLab의 그룹/프로젝트 경로 그대로다. 레지스트리 프로세스와 저장 공간은 하나고, 프로젝트마다 갈리는 것은 경로와 권한이다 — 그 프로젝트에 권한이 있어야 그 경로를 읽고 쓴다.
http://로 연 것은 평문으로 열겠다는 뜻이다. TLS를 붙이려면 자체 서명 인증서를 만들어 노드 셋과 데스크탑 Docker에 CA를 심어야 하는데, 14부에서 전 구간 TLS를 할 때 어차피 다시 한다 — 두 번 할 일이라 지금은 안 한다. 대가는 같은 망에서 들여다보면 이미지도 로그인 토큰도 읽힌다는 것이고, 닫는 시점은 14부다.
컨테이너를 다시 올리고 확인한다.
$ curl -i http://192.168.0.167:5050/v2/
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer realm="http://192.168.0.167:8929/jwt/auth",service="container_registry"
401이 맞는 응답이다. "인증하고 오라"는 뜻이고, 인증 서버로 GitLab의 8929를 가리킨다 — 둘이 제대로 물려 있다는 확인이다.
데스크탑 Docker는 레지스트리를 HTTPS로 가정하므로, 평문 주소를 예외로 등록해야 한다.
// Docker Desktop → Settings → Docker Engine
{ "insecure-registries": ["192.168.0.167:5050"] }
전역 해제가 아니라 이 주소 하나만 인증서 검증을 건너뛰고 평문으로 내려간다. 주소는 이미지 이름 앞부분과 글자까지 같아야 걸린다. 데몬 설정이라 적용하면 데몬이 재시작하고, 그 데몬이 물고 있는 GitLab·러너 컨테이너도 같이 내려갔다 올라온다.
손으로 한 번 올린다
CI를 붙이기 전에 경로부터 확인한다. 여기서 안 되면 나중에 CI 문제인지 레지스트리 문제인지 갈리지 않는다.
자격은 personal access token으로 만든다. scope는 read_registry·write_registry 둘 — git을 읽는 read_repository와는 다른 칸이고, 올리는 데는 읽기·쓰기가 둘 다 있어야 한다.
docker login 192.168.0.167:5050
docker build -t 192.168.0.167:5050/cgv/cgv-onprem/queue-go:test ./queue-go
docker push 192.168.0.167:5050/cgv/cgv-onprem/queue-go:test
-t에 적은 이름이 곧 보낼 주소다. Go 빌드가 128초, push가 끝나면 GitLab의 Deploy → Container Registry에 cgv-onprem/queue-go가 나타난다.
여기서 셋이 한 번에 확인된다 — Dockerfile이 로컬 compose 밖에서도 돌고, 이미지 경로가 맞고, 자격이 통한다. 그리고 그 화면에 하나 더 적혀 있다: Cleanup is not scheduled. 올라온 태그를 지우는 규칙이 없다는 뜻인데, 태그를 늘리기 전에 걸어야 하는 것이라 파이프라인을 붙이면서 함께 정리한다.
4. 파이프라인 — 손으로 한 것을 규칙으로
손으로 하던 login·build·push를 .gitlab-ci.yml로 옮긴다. 파일을 쓰기 전에 정할 것이 둘 있다 — 이미지에 붙일 이름과, 쌓일 이름을 지우는 규칙이다.
태그부터 정한다
docker build -t ...:<여기>에 무엇을 넣을지가 배포 방식을 통째로 가른다.
지금 차트는 tag: dev 고정에 pullPolicy: IfNotPresent다. IfNotPresent는 "노드에 그 이름이 이미 있으면 안 받아온다"는 뜻이라, :dev를 새로 빌드해 올려도 노드는 예전에 받아 둔 :dev를 그대로 쓴다. 배포했는데 안 바뀐다.
A 태그를 고정한다 :dev 를 계속 덮어쓴다. 매니페스트는 안 고쳐도 된다
B 태그를 매번 바꾼다 커밋마다 다른 이름. 매니페스트에 그 이름을 적어야 한다
A는 배포를 시킬 방법이 없다. 매니페스트가 안 변하니 ArgoCD는 할 일이 없고, 파드를 다시 띄우려면 CI가 클러스터에 kubectl rollout restart를 걸어야 한다 — 클러스터 접근 자격이 밖으로 나가고, 9부에서 지킨 "밖이 안을 안 건드린다"가 깨진다. 이전 :dev가 무엇이었는지 기록도 없어 롤백이 안 된다.
B로 간다. 매니페스트의 tag 한 줄이 바뀌면 ArgoCD가 sync하고 파드가 교체된다 — 트리거가 git 커밋이 되어 9부에 세운 길을 그대로 탄다. 롤백은 git에서 이전 태그로 되돌리는 일이 된다.
이름에는 셋을 담는다.
dev-42-a1b2c3d
│ │ └ 커밋 해시. 이미지와 커밋이 1:1 — 장애 때 "지금 도는 게 어느 코드냐" 가 태그로 나온다
│ └ 파이프라인 번호. 1 씩 늘어 순서가 눈에 보인다
└ 브랜치. stg 브랜치가 생기면 목록에서 갈린다
번호만 쓰면 어느 커밋인지 몰라 찾아 들어가야 하고 같은 커밋을 재빌드하면 내용이 같은 이미지가 둘 생긴다. 해시만 쓰면 순서를 못 읽는다.
GitLab이 job에 넣어 주는 변수를 그대로 쓴다 $CI_COMMIT_REF_SLUG·$CI_PIPELINE_IID·$CI_COMMIT_SHORT_SHA.
늘리기 전에 지우는 쪽을 먼저 건다
파이프라인이 붙으면 커밋마다 서비스 셋씩 태그가 쌓인다. cgv-onprem의 Settings → Packages and registries → Container registry cleanup policies.
Enable cleanup policy 켠다
Run cleanup Every day
Keep the most recent 10 tags per image name
Remove tags older than 7 days 기본값 90 일에서 줄였다
Remove tags matching .* 필수 칸. 전부를 후보로 올린다
GitLab은 Remove tags matching으로 후보를 모은 뒤 최근 10개와 7일 안쪽을 후보에서 뺀 나머지를 지운다. 하루에 몇 번을 올리든 최근 10개는 항상 남고, 롤백할 여지가 그만큼이다. 90일을 7일로 줄인 것은 — 최근 10개 유지가 이미 롤백을 보장하는데 90일을 얹으면 처음 석 달 동안 아무것도 안 지워지기 때문이다.
태그를 지워도 디스크는 바로 안 준다. 규칙은 목록에서 없애는 데까지고, 레이어를 치우는 것은 별도 작업(gitlab-ctl registry-garbage-collect)이다. 규칙만 걸어 두고 볼륨이 실제로 부풀면 돌린다.
파일의 뼈대 — 설정 · job · 틀
.gitlab-ci.yml의 최상위 이름은 세 부류다.
설정 workflow · stages · default · variables GitLab 이 정해 둔 예약어
job 그 밖의 이름 전부. 실제로 도는 것
틀 점(.)으로 시작하는 이름. 실행되지 않고 extends 로 가져다 쓴다
default는 적는 순간 모든 job에 자동으로 붙는 것이다. 셋을 둔다.
- 러너 태그
docker— 지목해야 그 러너가 가져간다 interruptible: true— 같은 브랜치에 새 커밋이 오면 돌던 파이프라인을 취소한다. 옛 커밋의 결과는 볼 이유가 없는데 실행 슬롯을 계속 잡는다timeout: 30m— 프로젝트 기본값 1시간이면 멈춘 job이 그동안 슬롯을 잡는다
variables에는 정해 둔 TAG가 앉는다. 틀은 extends한 job만 받는다. 이제 파일을 위에서부터 짚는다.
workflow — 같은 커밋이 두 번 돌지 않게
파일 맨 위는 "이 push로 파이프라인을 만들까 말까"다. 이 층이 필요한 것은 push 하나가 GitLab 안에서 사건 둘이 되기 때문이다 — "브랜치에 새 커밋이 왔다"와 "열린 MR의 소스 브랜치가 바뀌었다". 사건마다 따로 판단하므로 MR이 열린 브랜치에 push하면 같은 커밋이 두 번 돈다. job에 조건(rules)을 적기 시작하면 "브랜치 파이프라인만 만든다"는 GitLab 기본 동작이 꺼져서, 이 위층에서 명시해야 한다.
workflow:
rules:
# MR 사건이면 만든다. "머지해도 되나" 를 판정하는 쪽이라 이걸 남긴다
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# 브랜치 push 인데 그 브랜치에 열린 MR 이 있으면 만들지 않는다. 위에서 이미 만들었다
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
# 브랜치 파이프라인은 dev(머지 결과 검증)만. 일반 브랜치까지 열어 두면 push 를 먼저 하고
# MR 을 나중에 만들었을 때 push 사건과 MR 생성 사건이 각각 파이프라인을 만든다 —
# 위의 차단은 "push 시점에 MR 이 이미 열린" 경우만 걸러서 그 반대 순서를 못 막는다
- if: $CI_COMMIT_BRANCH == "dev"
규칙은 위에서부터 보다가 처음 맞는 줄에서 멈추고, 아무 줄에도 안 걸리면 안 만든다. 일반 브랜치는 MR을 열어야 CI가 돌고, 그 대신 파이프라인은 언제나 하나다.
stage 다섯, 그리고 job 지도
stage는 다섯을 선언한다. 벽마다 근거가 있다 — 검사가 깨졌는데 분 단위 빌드를 돌릴 이유가 없고, 스캔의 대상은 build가 만든 이미지고, 스캔에 걸린 이미지는 레지스트리에 안 올린다.
stages:
- check # 코드를 훑는다. 여기서 걸리면 뒤로 안 간다
- test # 단위 테스트. 11부에서 채운다
- build # 이미지를 만든다. 올리지는 않는다
- scan # 방금 만든 이미지의 취약점을 훑는다
- publish # 스캔까지 통과한 것을 레지스트리로. dev 에서만 생긴다
build·scan·publish가 다른 job인데도 이미지가 이어지는 것은 소켓 마운트 덕이다. build가 만든 이미지는 job 컨테이너가 아니라 데스크탑 데몬에 남고, 다음 stage의 job이 같은 이름($SVC:$TAG)으로 그것을 본다. dind였으면 job마다 데몬이 새것이라 이미지를 파일로 저장해 넘겨야 했고, 이 분리 자체가 성립하지 않았다. 갈라서 얻는 것은 둘이다 — 실패가 job 단위로 화면에 갈리고(scan:booking 빨강이면 클릭 없이 원인이 보인다), 스캔 결과를 MR 화면에 붙일 자리가 생긴다.
이 다섯을 채우는 job이 열셋이다.
stage job 이미지 하는 일
check secret-scan gitleaks 저장소 전체. 과거 커밋까지
dep-scan:queue-go trivy go.sum 의 의존성 취약점
lint:queue-go golang:1.26 go vet
lint:booking maven 컴파일 + SpotBugs
test (비어 있다) 11부에서 채운다
build build:{queue-go·booking·frontend} docker:27-cli 이미지를 만든다
scan scan:{...} trivy 만든 이미지를 훑는다
publish publish:{...} docker:27-cli dev 에서만 생긴다
lint와 dep을 나눠 두는 것은 실패의 원인과 대응이 다르기 때문이다. lint는 내가 코드를 건드려야 새로 실패하고 고치는 법도 코드 수정이다. dep은 코드가 그대로여도 새 CVE가 발표되면 어제 통과한 커밋이 오늘 실패하고, 고치는 법은 버전 올리기다. 합쳐 두면 "내가 뭘 잘못했나"부터 뒤지게 된다.
frontend는 정적 파일뿐이라 check·test에 없다 — build·scan·publish만 타고, scan이 그 서비스의 유일한 검사가 된다. booking의 의존성 검사가 check에 따로 없는 것도 같은 자리에서 해결된다 — scan:booking이 이미지 안의 app.jar를 직접 읽으므로, 선언(pom.xml)이 아니라 실제로 담긴 것을 판정한다.
check — 항상 도는 것과 바뀔 때만 도는 것
secret scan은 조건 없이 모든 커밋에서 돈다. 어느 파일이 바뀌든 자격이 섞였을 수 있어서다.
secret-scan:
stage: check
image:
name: zricethezav/gitleaks:latest
entrypoint: [""] # 이미지가 gitleaks 를 바로 실행하게 돼 있어, script 를 넣으려면 비운다
variables:
GIT_DEPTH: 0 # 기본은 얕은 clone — 과거 커밋이 안 온다. 히스토리 전체를 받는다
script:
- gitleaks detect --source . --no-banner --redact # redact = 찾은 값을 로그에 안 찍는다
dep 검사도 조건이 없다 — 코드가 안 바뀌어도 새 CVE 하나로 결과가 바뀌는 검사라, "바뀐 파일"을 조건으로 걸 이유가 없다.
dep-scan:queue-go:
stage: check
image:
name: aquasec/trivy:latest
entrypoint: [""]
variables:
TRIVY_NO_PROGRESS: "true" # DB 내려받기 진행바를 로그에 안 찍는다
TRIVY_CACHE_DIR: /root/.cache/trivy/$CI_JOB_NAME
script:
- trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ./queue-go
대상이 ./queue-go 하나다. go.sum은 간접 의존까지 버전이 전부 파일에 적혀 있어 원격 조회 없이 판정된다. pom.xml은 반대다 — 버전을 부모 POM이 정하므로 읽으려면 Maven Central에 물어야 하고, 같은 IP에서 빌드가 판마다 의존성을 수백 개 받고 있으면 그 조회가 429(Too Many Requests)로 차단된다. Java 의존성은 scan:booking이 app.jar를 직접 읽는 것으로 판정한다 — 잃는 것은 build 전에 잡는 이점 하나다.
서비스별 lint는 다르다. 저장소 하나에 서비스 셋이 들어 있어서(모노레포), 조건이 없으면 booking 한 줄을 고쳐도 세 서비스의 job이 다 돈다. rules: changes가 커밋에서 바뀐 파일을 보고 그 job을 만들지 말지를 정한다.
lint:queue-go:
stage: check
image: golang:1.26
script:
- cd queue-go
- go vet ./... # 컴파일은 되는데 의도와 다르게 도는 자리를 잡는다
rules:
- changes: [ "queue-go/**/*", ".gitlab-ci.yml" ]
안 바뀐 서비스의 job은 건너뛰는 게 아니라 화면에 아예 없다. .gitlab-ci.yml을 목록에 넣는 것은 — CI 파일만 고친 커밋에서 job이 하나도 안 생기면 고친 게 도는지 확인할 방법이 없어서다.
booking의 lint는 SpotBugs다. 소스가 아니라 컴파일된 바이트코드를 훑는 도구라 compile이 먼저 온다.
lint:booking:
stage: check
image: maven:3.9-eclipse-temurin-21
variables:
MAVEN_OPTS: "-Xmx768m"
script:
- cd booking
- mvn -B compile spotbugs:check
artifacts:
when: always # 실패했을 때야말로 받아 봐야 한다
expire_in: 1 week
paths:
- booking/target/spotbugsXml.xml
rules:
- changes: [ "booking/**/*", ".gitlab-ci.yml" ]
두 설정에 각각 이유가 있다.
MAVEN_OPTS— JVM은 힙 상한을 안 주면 컨테이너가 보는 메모리의 25%를 잡는다. job 컨테이너에 메모리 제한이 없어 VM 전체(7.8GB)를 보고 혼자 2GB 가까이 쓴다. 동시에 도는 job 수만큼 곱해지므로 빌드 스테이지와 같은 값으로 고정한다.artifacts— 콘솔에는 결함 개수만 남고 어느 파일 몇 줄인지는spotbugsXml.xml에 있다. job이 끝나면 컨테이너째 사라지므로 리포트를 GitLab에 올려 둬야 실패를 추적할 수 있다.
검사 규칙은 booking/pom.xml(플러그인 선언)과 booking/spotbugs-exclude.xml(제외 필터)에 있다. 생성자 주입으로 받은 스프링 빈을 필드에 저장하는 자리가 전부 EI_EXPOSE_REP 계열로 걸리는데, 컨테이너가 관리하는 싱글턴이라 방어적 복사가 성립하지 않는다. 그 두 패턴만 제외하고 나머지 Medium 이상은 그대로 실패로 본다.
build · scan · publish — 서비스마다 셋, 올리는 건 dev 만
서비스마다 build·scan·publish job이 하나씩 서고, 같은 서비스의 셋이 같은 경로 규칙을 공유한다 — 안 바뀐 서비스는 셋이 함께 안 생긴다. publish에만 조건이 하나 더 붙는다.
publish:queue-go:
extends: .publish
variables:
SVC: queue-go
rules:
- if: $CI_COMMIT_BRANCH == "dev" # dev 가 아니면 이 job 은 만들어지지 않는다
changes: [ "queue-go/**/*", ".gitlab-ci.yml" ]
build와 scan은 브랜치에서도 돌린다 — 컴파일이 되는지, 취약점이 있는지는 머지 전에 걸러야 한다. 올리는 것은 다르다. 아무 브랜치에서나 push되면 리뷰를 통과하지 않은 이미지가 배포 후보와 같은 자리에 쌓인다.
그래서 브랜치·MR 파이프라인은 check → build → scan에서 끝나고, publish job은 화면에 아예 없다.
부수 효과로 태그가 정리된다 — 실험 브랜치 이름이 섞인 태그가 레지스트리에 안 생기고 dev-*만 남는다.
브랜치에서도 build는 도니 레이어 캐시는 데스크탑에 그대로 쌓이고, 머지 뒤의 build가 그 캐시로 빨리 끝나고 publish가 올린다. 서비스마다 태그가 다른 상태가 정상이 된다 — 안 바뀐 서비스는 예전 이미지가 그대로 맞는 것이다.
틀 — 절차는 한 번만 적는다
build 이후의 절차는 서비스 셋이 똑같다. 언어별 차이가 CI가 아니라 각 서비스의 Dockerfile 안에 있어서다 — CI는 "그 디렉터리에서 docker build 해라"만 알고, 그 안에서 Go를 컴파일하는지(queue-go) Maven이 도는지(booking) 복사만 하는지(frontend)는 멀티스테이지 Dockerfile이 품고 있다. 나온 이미지를 훑고(scan) 올리는(publish) 데는 애초에 언어 구분이 없다.
그래서 절차를 틀에 한 번만 적고, 각 job은 SVC 값 하나만 바꾼다. SVC는 서비스 디렉터리 이름이고, 그 한 값이 빌드 컨텍스트(./$SVC)와 이미지 이름(.../$SVC:$TAG)을 함께 정한다 — 디렉터리 이름과 이미지 이름을 같게 지어 둔 것이 여기서 값을 한다.
.build:
stage: build
image: docker:27-cli # 등록 기본값 alpine 엔 docker 명령이 없다. CLI 만 든 이미지
dependencies: []
script:
# --pull: 같은 태그라도 업스트림은 패치를 넣어 다시 굽는다. 로컬 캐시가 있으면 낡은 것으로 빌드된다
- docker build --pull -t "$CI_REGISTRY_IMAGE/$SVC:$TAG" "./$SVC"
.scan:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
dependencies: []
variables:
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: /root/.cache/trivy/$CI_JOB_NAME
TRIVY_TIMEOUT: 20m
script:
# 수정 가능한 HIGH 이상이면 종료 코드 1 → job 실패 → publish 가 안 열린다
- trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$CI_REGISTRY_IMAGE/$SVC:$TAG"
.publish:
stage: publish
image: docker:27-cli
dependencies: []
interruptible: false # push 중간에 끊기면 레이어가 일부만 올라간 상태로 남는다
before_script:
# job 마다 발급되고 끝나면 폐기되는 토큰. 만들 것도 보관할 것도 없다
# -p 로 주면 비밀번호가 명령줄 인자에 남으므로 파이프로 넣는다
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker push "$CI_REGISTRY_IMAGE/$SVC:$TAG"
build:queue-go:
extends: .build
variables:
SVC: queue-go # booking · frontend 도 이 값 하나만 다르다
로그인이 .publish에만 있다 — build는 공개 베이스 이미지만 받아오고, scan은 데몬에 있는 로컬 이미지를 보므로 레지스트리 자격이 필요 없다. 주소와 자격도 파일에 안 적는다: $CI_REGISTRY_IMAGE가 "레지스트리 주소 + 프로젝트 경로"를 담고 있어 레지스트리가 옮겨 가도 이 파일은 안 고친다 — 하드코딩된 IP가 하나 안 늘었다.
틀 셋에 공통인 dependencies: []는 앞 stage의 아티팩트를 안 받는 선언이다. 기본값이 "앞 stage 전부에서 받는다"라, 이게 없으면 lint:booking이 남긴 SpotBugs 리포트를 build·scan·publish가 job마다 내려받고 풀고 지운다 — 쓰지도 않는 파일에 시간을 쓴다.
.scan의 변수 셋은 각각 이렇다.
TRIVY_NO_PROGRESS— 진행바 갱신이 로그 줄로 쌓여 한 번의 내려받기가 수백 줄이 된다. 결과 표가 그 뒤로 밀린다.TRIVY_CACHE_DIR— trivy는 캐시 디렉터리에 배타 잠금을 건다. scan job 셋이 한 디렉터리를 공유하면 먼저 잡은 하나만 돌고 나머지는 잠금 대기로 죽는다. job 이름으로 하위 폴더를 갈라 잠금이 안 겹치게 한다.TRIVY_TIMEOUT— 기본 제한 5분 안에 DB 내려받기까지 들어간다. Java DB가 903MB라 캐시가 비었거나 만료(3일)된 판은 내려받기만으로 제한을 넘기고, 취약점 유무와 무관하게 job이 죽는다.
반복해서 받지 않게 — 캐시는 두 층이다
job 컨테이너는 끝나면 지워진다. 안에서 내려받은 것도 같이 사라져, 손대지 않으면 매 판이 처음이다 — trivy 취약점 DB 103MB에 Java DB 903MB, Maven 의존성, Go 모듈을 판마다 다시 받는다. 해법은 하나다: 죽는 곳에서 받은 것을 안 죽는 곳에 둔다. 그게 캐시다.
그런데 이 파이프라인에서는 "죽는 곳"이 두 군데다. 같은 Maven 다운로드가 job에 따라 다른 방에서 일어난다.
lint:booking job 컨테이너 안에서 mvn 이 직접 돈다
받은 jar 는 그 컨테이너 안에 쌓인다 → job 끝 = 컨테이너 삭제 = 같이 삭제
build:booking job 컨테이너는 "docker build 해라" 명령만 소켓으로 보낸다
실제 빌드는 데스크탑 데몬의 세계에서 돈다 — 거기서 mvn 이 또 받는다
job 컨테이너와는 다른 방이다
방이 다르면 선반도 따로 놔야 한다. 그래서 두 층이다.
층 하나 — job 컨테이너의 방: 러너 볼륨. 컨테이너 안의 경로 자체를 바깥 창고(이름 붙인 도커 볼륨)로 연결한다. 도구는 평소 쓰던 경로에 쓸 뿐인데 그 문이 밖으로 나 있어서, 컨테이너가 죽어도 내용물은 창고에 남고 다음 job이 그대로 이어받는다 — 러너를 세울 때 소켓 파일 하나를 꽂아 줬던 것과 같은 기술이고, 이번엔 디렉터리다.
# 러너 config.toml — job 컨테이너마다 이 볼륨들이 꽂힌다
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache",
"maven-cache:/root/.m2", # lint:booking 의 의존성
"trivy-cache:/root/.cache/trivy", # 취약점 DB. 안에서 job 별 폴더로 갈린다
"gomod-cache:/go/pkg/mod", # Go 모듈
"gobuild-cache:/root/.cache/go-build"] # Go 컴파일 결과. go vet 도 컴파일을 한다
이 볼륨이 둘째 방에는 안 닿는다 — 볼륨은 job 컨테이너에 꽂는 것인데, build의 다운로드는 그 컨테이너 밖(데몬)에서 일어난다. 옆방에 꽂은 콘센트다.
층 둘 — 데몬의 방: 레이어 캐시, 그리고 그 틈. 데몬에는 선반이 기본으로 하나 있다. 레이어 캐시 — COPY pom.xml의 내용이 지난번과 같으면 그 아래 RUN(의존성 다운로드)의 결과를 통째로 재사용한다. 평소 build가 십몇 초로 끝나는 이유다. 약점은 조건부다: 그 파일이 한 글자라도 바뀌면 "재료가 달라졌다"로 보고 아래를 전부 무효로 만들어 수백 개를 처음부터 다시 받는다.
pom.xml이 바뀌는 커밋이 언제인가 — 의존성을 올린 커밋, "이거 올려도 되나"를 CI로 제일 급히 확인하고 싶은 바로 그 순간이다.
그 틈을 메우는 것이 BuildKit의 캐시 마운트다. BuildKit은 도커가 이미지를 굽는 빌드 엔진인데, RUN 한 줄에 레이어 바깥의 전용 선반을 꽂아 주는 기능이 있다. 레이어가 무효가 돼 RUN이 다시 돌아도 그 선반은 무효화와 무관하게 남아 있으니, Maven이 이미 있는 것은 건너뛰고 바뀐 것만 받는다 — 층 하나의 볼륨과 똑같은 발상을 데몬의 세계 안에서 한 번 더 한 것이다.
# syntax=docker/dockerfile:1 ← 이 기능을 쓰는 문법 버전 선언
RUN --mount=type=cache,target=/root/.m2 mvn -q -B dependency:go-offline # booking
RUN --mount=type=cache,target=/go/pkg/mod go mod download # queue-go
정리하면:
방 죽는 것 선반 쓰는 job
job 컨테이너 컨테이너 자체 러너 볼륨 4종 lint · scan · dep-scan
데몬 (docker build) 무효화된 레이어 레이어 캐시(기본) build 셋
+ BuildKit 캐시 마운트(보강)
캐시의 대가는 디스크다. trivy 캐시는 job별 폴더라 DB가 네 벌로 앉아 8GB대가 된다. 그리고 러너 볼륨은 job이 돌 때만 컨테이너에 붙어 있어 docker volume prune에는 "안 쓰는 볼륨"으로 보인다 — 정리 명령을 돌리면 캐시가 통째로 지워진다.
concurrent — 동시에 몇 개. needs 는 보류
concurrent는 러너 설정(config.toml 맨 위)이다. 러너가 GitLab에 묻는 창구 수라서, 1이면 job을 하나 끝내야 다음을 받고 2면 둘을 동시에 든다. 러너 개수와는 별개다. 2로 둔다. 더 올려도 한 stage의 시간은 그 안에서 제일 오래 걸리는 job이 정한다 — check 넷이 0:14 · 0:15 · 0:15 · 0:36일 때 슬롯 2개면 0:44, 4개면 0:36으로 차이가 초 단위다. 슬롯을 늘리는 값은 메모리와 캐시 동시 접근으로 치른다.
needs는 job이 기다릴 대상을 앞 stage 전체가 아니라 특정 job으로 좁히는 것이다 — build:queue-go가 check:booking을 안 기다리게 된다. 다만 걸어 둔 job은 나중에 stage를 추가해도 그것을 조용히 건너뛴다. 11부에 test stage를 채울 예정이라, 기본은 stage로 두고 특정 job이 유독 길어 나머지가 그것만 기다리는 게 보일 때만 건다. rules: changes가 붙으면 대부분의 커밋에서 서비스 하나 것만 돌아 기다릴 대상 자체가 적기도 하다.
돌아간 결과
캐시가 채워진 상태의 MR 파이프라인 — job 10개, 전체 1분 54초. publish 셋은 dev가 아니라 화면에 없다.
check secret-scan 0:15 · dep-scan:queue-go 0:15 · lint:queue-go 0:14 · lint:booking 0:36
build queue-go 0:16 · booking 0:15 · frontend 0:11
scan queue-go 0:12 · booking 0:32 · frontend 0:16
캐시가 빈 첫 판은 다르다 — trivy DB와 Java DB, Maven 의존성, Go 모듈을 처음 한 번 받으므로 job 몇 개가 분 단위로 걸리고, 그다음 판부터 위 숫자가 된다.
MR을 스쿼시로 머지하면 dev에 커밋 하나가 생기고, 브랜치 파이프라인이 돈다. 이번엔 $CI_COMMIT_BRANCH == "dev"가 참이라 publish 셋까지 13개다. 전체 2분 35초. publish는 셋 다 20초 안쪽 — 레지스트리가 같은 데스크탑에 있어 push가 바깥 회선을 안 탄다.
레지스트리에 서비스 셋이 같은 태그로 선다.
booking dev-15-17dcc495 277.5 MiB JRE 가 들어 있어 셋 중 제일 크다
queue-go dev-15-17dcc495 46.5 MiB distroless
frontend dev-15-17dcc495 42.0 MiB nginx alpine
목록에는 지나온 흔적도 그대로 있다 — 손으로 올려 본 test, push를 dev 머지 뒤로 묶기 전에 올라간 ci-pipeline-1·2. cleanup 규칙이 7일이 지난 것부터 걷어 간다.


여기까지가 CI다. push 한 번이면 — 자격이 섞였는지 훑고, 코드를 검사하고, 이미지를 만들어 취약점까지 확인한 뒤, dev에 머지된 것만 레지스트리에 오른다. 검증을 통과한 이미지가 dev-15-17dcc495라는 이름으로 레지스트리에 서 있다. 남은 것은 그 이미지를 클러스터가 받아서 돌게 하는 일 — CD다.
5. CD — 배포 엔진은 이미 있다
CD 의 뜻
CD는 두 뜻으로 쓰인다.
Continuous Delivery 언제든 배포할 수 있는 상태를 유지한다. 버튼은 사람이 누른다
Continuous Deployment 검증을 통과하면 사람 손 없이 배포까지 나간다
차이는 마지막 한 걸음에 사람이 있느냐뿐이다. 환경마다 갈라 쓴다 — dev는 자동으로 나가고(Deployment), stg·prd는 사람이 승인한다(Delivery). cgv-infra가 환경을 디렉터리로 갈라 둔 것(environments/dev/)이 여기서 값을 한다: dev 파일만 자동으로 움직이고 stg·prd 파일은 사람이 고치는 형태가 그대로 성립한다.
Push 냐 Pull 이냐
CD를 거는 방식이 둘로 갈린다.
Push CI 가 끝나면 CI 가 클러스터에 직접 명령한다 (kubectl · helm)
→ 클러스터 접근 자격이 클러스터 밖 CI 에 있어야 한다
Pull 클러스터 안의 도구가 git 을 지켜보다 스스로 당겨 온다
→ 밖에서 클러스터를 건드리지 않는다
Pull 쪽은 이미 서 있다. 9부의 ArgoCD가 그것이다 — cgv-infra를 읽고, 커밋이 오면 webhook을 받아 3초 안에 반영한다. 그래서 CD를 새로 세우는 게 아니라, CI가 만든 이미지가 그 길을 타게 끊긴 곳만 이으면 된다.
끊긴 곳이 둘이다
① 노드가 레지스트리에서 이미지를 못 받아온다 자격도 없고, 평문 레지스트리를 믿지도 않는다
② git 이 무엇이 도는지 모른다 레지스트리엔 dev-42-… 가 서는데
매니페스트의 tag 는 옛 글자 그대로다
①을 이어야 파드가 뜨고, ②를 이어야 push 한 번이 파드 교체까지 간다. 순서대로 간다 — 받을 수 있게 만들어 놓고 자동화한다. 반대로 하면 git은 맞는데 파드만 계속 죽어서, 태그가 틀린 건지 자격이 없는 건지 갈리지 않는다.
6. 노드가 받아오게 한다
받아오는 주체가 노드다
컨테이너는 이미지를 자기 디스크에 가진 쪽에서만 만들어진다.
ArgoCD 가 Deployment 를 만든다
↓
스케줄러가 파드를 노드 하나에 배정한다 셋 중 어디가 될지는 그때 정해진다
↓
그 노드의 kubelet 이 containerd 에 시킨다
↓
containerd 가 디스크에 그 이미지가 없으면 레지스트리에서 받아온다
↓
못 받으면 ImagePullBackOff
프로젝트가 private이라 받아오는 데도 자격이 필요하다. 내가 손으로 쓴 자격은 내 PC의 ~/.docker/config.json에 있고 러너의 토큰은 job이 끝나면 사라진다 — 노드는 둘 다 볼 수 없다. 노드는 클러스터 안의 Secret에서만 자격을 읽고, 어느 노드에 뜰지 모르니 셋 다 받아올 수 있어야 한다.
자격 — 노드 몫을 좁혀 만든다
personal access token을 그대로 넣지 않는다. 그건 내 계정에 딸린 열쇠라 내가 닿는 모든 프로젝트에 통한다. 노드에게 필요한 것은 cgv-onprem의 이미지를 받아오는 권한 하나뿐이다.
personal access token 내 계정. 내가 닿는 전부 손으로 올릴 때 썼다
deploy token 프로젝트 하나. 고른 권한만 노드 몫. read_registry 만 준다
이 Secret(Deploy token)은 지금까지의 12종과 타입이 다르다. Opaque(키·값을 그냥 담는 것)가 아니라 kubernetes.io/dockerconfigjson — kubelet이 읽는 정해진 구조다. 그래서 낱개 봉인 스크립트(generic 전용)로는 못 만들고, kubectl이 만들어 주는 것을 봉인한다.
kubectl create secret docker-registry gitlab-registry -n app \
--docker-server=192.168.0.167:5050 \
--docker-username='<deploy token 사용자명>' \
--docker-password='<토큰>' \
--dry-run=client -o yaml \
| kubeseal --format yaml \
--controller-name sealed-secrets --controller-namespace kube-system \
> gitlab-registry.yaml
--docker-server는 차트의 image.repository 앞부분과 글자까지 같아야 한다 — kubelet이 그 문자열 비교로 "이 이미지에는 이 자격"을 매칭한다. 이걸로 봉인본이 13종이 됐다. 재구축 스크립트의 개수 검사와 계약 문서도 같이 움직인다 — 이 편 뒤쪽에서 image updater 몫이 더해져 최종 14종이 된다.
배선 — 차트와 노드가 그것을 보게 한다
파드가 이미지를 받는 데는 셋이 다 맞아야 한다. 하나만 빠져도 결과는 똑같이 ImagePullBackOff인데, 사유가 달라 어디가 빠졌는지는 갈린다.
어느 이미지냐 매니페스트의 image 빠지면 no such host
어느 자격으로냐 파드의 imagePullSecrets 빠지면 401 Unauthorized
그 주소를 믿느냐 노드 containerd 설정 빠지면 http: server gave HTTP response to HTTPS client
앞의 둘은 차트라 git으로 간다.
# charts/apps/cgv-app/values.yaml — 세 인스턴스가 쓰는 공통 차트라 한 번만
imagePullSecrets:
- name: gitlab-registry
# charts/apps/queue/values.yaml — booking · frontend 도 같은 꼴
image:
repository: 192.168.0.167:5050/cgv/cgv-onprem/queue-go
셋째는 노드 파일이다. containerd도 레지스트리를 HTTPS로 가정하므로 평문 주소를 예외로 적는다.
# /etc/rancher/k3s/registries.yaml — 노드 3대 공통
mirrors:
"192.168.0.167:5050":
endpoint:
- "http://192.168.0.167:5050"
인증은 이 파일에 안 적는다 — 적으면 노드 디스크에 자격이 평문으로 남는다. 그 몫은 봉인본이 한다.
이 파일은 노드에 놓고 k3s를 재시작해야 읽힌다. 한 대씩 한다. 노드 셋이 etcd 쿼럼 3이라 한 대씩이면 클러스터가 살고, 둘을 동시에 내리면 쿼럼이 깨져 전체가 멈춘다. 7부에서 미룬 kube-reserved 2Gi 반영도 같은 재시작에 얹는다 — 이것도 재시작이 필요한 변경이라, 두 번 내릴 일을 한 번으로 줄인다.
한 대씩 재시작한다
파일 둘을 scp로 노드 홈에 올려 두고, 한 대씩 들어가 제자리에 놓고 재시작한다.
# 노드마다 (k3s-3 → k3s-2 → k3s-1 순)
sudo cp /etc/rancher/k3s/config.yaml /etc/rancher/k3s/config.yaml.bak-1gi
sudo mv ~/config.yaml /etc/rancher/k3s/config.yaml
sudo mv ~/registries.yaml /etc/rancher/k3s/registries.yaml
sudo systemctl restart k3s
재시작이라고 워크로드가 끊기지는 않는다. 컨테이너는 k3s가 아니라 별도 프로세스(shim)가 붙들고 있어, k3s가 내려간 몇십 초 동안도 계속 돈다 — 멈추는 것은 제어면 일(스케줄·kubectl 응답)뿐이다. drain도 걸지 않는다. 로컬 PV에 묶인 파드(ingester WAL 등)는 다른 노드로 못 가는 것들이라, 쫓아내 봐야 Pending으로 떠 있다 제자리로 돌아올 뿐이다.
세 대가 끝나면 둘이 바뀌어 있다.
allocatable 6105Mi → 5081Mi (세 노드 동일) kube-reserved 2Gi 반영
앱 파드 오류 http: server gave HTTP response to HTTPS client
→ frontend:dev: not found
오류가 바뀐 것이 배선의 증명이다. 앞엣것은 문전 거절 — 레지스트리에 닿지도 못했다. 뒤엣것은 http로 접속했고 자격 검사까지 통과한 뒤 "그런 태그는 없다"는 답을 받은 것이다. 길은 열렸고, 남은 것이 태그 하나라는 뜻이다.
태그 한 줄이 첫 배포다
environments/dev의 값 파일 셋에서 tag를 CI가 올린 실물로 바꾼다.
image:
tag: dev-15-17dcc495 # ← dev 였던 자리
이 커밋이 MR로 main에 머지되면 — webhook이 ArgoCD를 깨우고(cgv-infra gitops), sync가 Deployment의 이미지를 바꾸고, 노드가 방금 연 길로 받아온다. 머지 몇 분 뒤 세 서비스가 처음으로 Running이 됐다. 배선을 만든 이래 줄곧 ImagePullBackOff로 서 있던 파드들이다.

줄어든 살림의 청구서 — request 를 실측으로 조인다
kube-reserved 2Gi는 노드당 할당 가능량 1Gi 감소로 돌아온다. 재시작 직후 mimir-ingester-1이 Pending에 걸렸다 — WAL이 로컬 PV라 k3s-2에만 갈 수 있는 파드인데, 그 노드의 request 합이 92%라 1Gi짜리가 앉을 자리가 없다.
이때 기준은 실측이다. request가 실사용보다 크게 잡힌 컴포넌트를 kubectl top으로 골라 조인다.
request 실사용(실측) 조정
mimir-ingester 1Gi 410-510Mi 768Mi 만재(시리즈 30만, 약 1.2Gi) 접근 시 재상향
mimir-compactor 512Mi 54Mi 미만 192Mi 병합 스파이크는 limit 1Gi 가 받는다
tempo 256Mi 54Mi 미만 128Mi
limit은 안 건드린다 — limit은 스케줄링에 쓰이지 않아 자리를 잡아먹지 않는다. 커밋이 머지되자 세 컴포넌트가 새 request로 돌고 ingester-1이 앉았다. 최종 request 비율은 k3s-1 81% · k3s-2 94% · k3s-3 74% — k3s-2는 남은 자리가 263Mi라, 다음 증설은 다른 노드 몫이다.
7. 태그를 git 에 되쓴다
마지막 한 줄이 남는다
여기까지로 이미지는 dev-*-abcdefg로 서고 노드는 그것을 받아올 수 있다. 그런데 매니페스트의 tag는 아직 옛 글자다. 그 한 줄을 누가 고치나.
사람이 매번 손으로 자동화한 의미가 없다. 잊으면 그만인 상태로 되돌아간다
CI 가 고친다 GitOps 저장소 쓰기 자격이 클러스터 밖으로 나간다. Push 방식으로 회귀
클러스터 안의 도구 9부의 성질이 유지된다 ← 고른 것
argocd-image-updater
클러스터 안에서 레지스트리를 지켜보다 새 이미지가 오르면 git에 되쓰는 도구다.
CI 가 push 레지스트리에 dev-43-e5f6a7 이 선다
↓
image-updater 레지스트리를 폴링하다 새 태그를 본다
↓
git 커밋 cgv-infra 의 environments/dev/values-<서비스>.yaml 의 tag: 한 줄
↓
webhook 9부에 걸어 둔 그 길. 3초 안에 ArgoCD 가 refresh
↓
ArgoCD sync 파드가 새 이미지로 교체된다
배포는 여전히 ArgoCD가 한다. image-updater는 파드를 만지지 않고 커밋 한 줄을 쓸 뿐이다 — CI와 CD 사이의 다리다.
비용도 있다. 파드가 하나 늘고, 레지스트리를 뒤지는 폴링이 하나 생기고, git에 쓰는 자격이 새로 필요하다.

이 편에서 자격을 쥔 쪽이 ArgoCD(저장소 읽기) · 나(손 push) · 노드(pull)에서 늘어나는 마지막 자리가 여기다. 되쓰는 대상을 environments/dev/로 한정하므로 dev만 자동으로 움직이고 stg·prd는 사람이 고치는 구도가 유지된다.
설치 — GitOps 로, 단 자리는 가려서
updater 자체도 GitOps로 배달한다 — argo-helm 차트를 App 하나로 선언하고, 값은 이 저장소에서 읽는다. ArgoCD가 이미 떠 있으니 배포 주체의 순환이 없다(본체와 다른 점 — 본체는 빈 클러스터에 배포해 줄 주체가 없어 최초 한 번을 손으로 설치했다).
자리는 골라야 했다. metallb·traefik이 타는 platform 틀은 대상 네임스페이스에 PodSecurity 라벨을 무조건 거는데, updater의 대상은 이미 존재하는 argocd 네임스페이스다 — 라벨이 걸리면 ArgoCD 본체까지 enforce 대상이 된다. 그래서 root 직속 App으로 세워 네임스페이스를 건드리지 않는다.
버전 하나가 동작 방식을 갈랐다. 핀한 1.2 계열은 컨트롤러 방식이다 — ImageUpdater라는 CR이 있어야 일을 시작하고, 없으면 Application에 annotation이 붙어 있어도 아무것도 하지 않는다(기동 로그: "No ImageUpdater CRs to process"). CR은 처리 대상만 고르고, 무엇을 어떻게 갱신할지는 useAnnotations로 각 App의 annotation에서 읽게 한다.
# 차트 extraObjects 로 같은 App 이 배달
kind: ImageUpdater
spec:
applicationRefs:
- namePattern: queue
useAnnotations: true
# booking · frontend 동일
자격 둘 — 읽는 손과 쓰는 손
updater가 손대는 곳이 저장소 둘이라, 자격도 저장소마다 하나씩이다.
레지스트리 폴링 cgv-onprem 의 deploy token (read_registry)
— 레지스트리가 그 프로젝트에 딸려 있다. 노드 pull 자격과 권한이 같지만
주체(노드/봇)가 달라 가른다 — 새면 그것만 폐기
git 커밋 cgv-infra 의 project access token (write_repository, role Maintainer)
— 태그를 적는 값 파일이 그 저장소에 있다
쓰기 토큰의 role이 Maintainer인 것은 protected main 때문이다. main은 직접 push가 막혀 있고(사람은 MR로만 간다), 무료판(CE)은 push 예외를 유저 단위가 아니라 role 단위로만 열 수 있다 — "Allowed to push = Maintainers"로 열고, 토큰을 그 role로 만들어 봇만 문을 통과하게 한다. 토큰을 만들면 GitLab이 봇 유저(project_N_bot_…)를 같이 만드는데, 이후 자동 커밋의 작성자가 그 봇으로 찍힌다 — git 이력에서 사람과 자동화가 작성자로 갈린다.
어느 태그를 "새것"으로 보나
update-strategy: newest-build 빌드 시각 기준. 태그(dev-<번호>-<해시>)가 사전순 정렬이
안 되는 형태라(dev-9 > dev-15) 시각으로 고른다
allow-tags: regexp ^dev-…$ dev 계열만 후보. MR 파이프라인의 ci-pipeline-* 은 배포 후보가 아니다
write-back-target: helmvalues 이 App 이 읽는 바로 그 값 파일(environments/dev/values-<앱>.yaml)의
image.tag 에 쓴다
평문의 마지막 청구서 — 자격 경로를 바꾼다
첫 실행에서 updater가 새 태그를 발견하고 커밋하려다 죽었다.
could not get creds for repo 'http://…/cgv-infra.git': unknown repository type
전용 시크릿으로 git 자격을 주는 경로(git:secret:)는 코드가 ssh와 https 저장소만 받는다. 이 GitLab은 평문 http라, 설정으로 열 수 없는 벽이다 — 이 편에서 평문이 만든 네 번째 비용이고, 유일하게 예외 등록으로 못 넘는 것이었다.
우회는 같은 코드 안에 있다. ArgoCD의 저장소 자격을 빌리는 경로(repocreds)는 스킴을 검사하지 않는다. 그래서 저장소 자격(argocd-repo-cgv-infra)에 쓰기 토큰을 넣어 읽기와 쓰기를 한 자격으로 통합했다. 원칙의 후퇴다 — 다만 노출 반경은 안 변한다: 쓰기 토큰은 어차피 같은 네임스페이스의 봉인본에 있었고, 그 네임스페이스를 뚫은 쪽은 어느 구성이든 그것을 얻는다. TLS를 도입하는 날 read 전용 토큰 + 전용 쓰기 시크릿으로 되돌린다 — 계약 문서에 되돌리기 항목으로 적어 뒀다.
한 바퀴 — 사람 손 없이
frontend에 한 줄짜리 커밋을 만들어 경로 전체를 태웠다.
사람 코드 커밋 → MR 머지 ← 마지막 개입
54초 CI 5개 job — 바뀐 서비스 것만 생겼다 dev-18-767410b6 이 레지스트리에
2분 내 봇 커밋 author: argocd-image-updater
"updates image … 'dev-15-…' to 'dev-18-…'"
수십 초 webhook → ArgoCD sync → 파드 교체
폴링 주기는 2분이다. ArgoCD처럼 webhook으로 당기지 않는 것은 보내는 쪽이 없어서다 — GitLab 무료판의 컨테이너 레지스트리는 "이미지 올라옴" 이벤트를 쏘는 기능이 없다. 폴링 비용은 내 서버·같은 망·이미지 세 개의 태그 목록 조회라 0에 가깝다. 폴링이 기본이고 webhook은 보내는 쪽이 있을 때의 가속이다.
"git에 적으면 배포된다"가 "머지하면 배포된다"가 됐다. 사람은 코드와 승인만 남고, 이미지 이름이 오가는 길은 전부 기계가 맡는다.
8. 대조 — 이은 것과 빌린 것
끊긴 곳은 이어졌나
끊긴 곳은 둘이었다 — 노드가 이미지를 못 받는 것, 새 태그를 git이 모르는 것. 둘 다 이어졌고, 그 증명이 위의 한 바퀴다. 코드 한 줄이 머지 클릭 하나를 지나 3-4분 안에 파드가 된다. 이 편의 목적이었던 문장이 실물이 됐다.
이 편 안에서 닫은 것
손 push 토큰(docker-push-manual) · 옛 ArgoCD 읽기 토큰 revoke — CI job 토큰과 새 자격이 대체
Pipelines must succeed 켬 — 빨간불이면 머지 버튼이 잠긴다
러너 allowed_images 6종 화이트리스트 — 소켓을 쥔 러너가
아무 이미지나 못 띄우게
같은 커밋이 두 번 돌던 workflow 구멍 브랜치 파이프라인을 dev 로 한정
백업 sealed-secrets 개인키 · GitLab 본체·설정을
클러스터 밖으로 반출
무엇을 빌렸나 — 부채 장부
평문 http 청구서 4건 ① 데스크탑 insecure-registries ② 노드 registries.yaml
③ updater 레지스트리 insecure ④ git 자격 경로 통합(코드 벽)
TLS 는 이름(DNS)이 생기는 네트워크 재구성 편에서 넷을 함께 걷는다.
④의 자격 통합은 그때 원상복구.
트립와이어: 코드 벽이 한 번 더 나오면 그 자리에서 TLS 를 당긴다
러너 소켓 마운트 호스트 도커 전권. Protected 러너는 안 켰다 —
MR 파이프라인이 비보호 브랜치에서 돌아야 해서 켜면 CI 가 굶는다
러너 config.toml 이 git 밖 concurrent·캐시 볼륨 설정이 도커 볼륨 안에만 있다.
데스크탑을 갈아엎으면 사라진다 (GitLab 백업이 유일한 사본)
CI 이미지의 데몬 축적 판마다 이미지 셋이 데스크탑 데몬에 쌓인다. 레지스트리 cleanup 은
걸었는데 데몬 쪽 지우는 규칙이 없다
캐시의 디스크 trivy 캐시 8.5GB(잠금 회피로 DB 네 벌). 도커 VM 디스크(VHDX)는
안에서 지워도 저절로 안 줄고, 캐시 볼륨은 volume prune 에 지워진다
GitLab 무인 운영 불가 Docker Desktop 이 로그인 세션 앱이라 재부팅 후 로그인 전엔 안 뜬다
미러 토큰 평문 GitHub PAT 가 GitLab 미러 설정 안에 산다
ingester request 만재(시리즈 30만) 접근 시 재상향 — 부하 실측 편에서 시리즈 수와 함께
실무·보안 되짚기
이 편에서 실제로 만진 것들에 맞닿는 것만 짚는다.
- 자격 수명이 관리되지 않는다. 이 편에서만 토큰이 넷 생겼는데 만료·로테이션 계획이 없다. deploy token은 무기한으로 만들었고, PAT 만료가 오면 봇 커밋이 조용히 죽는다 — 만료 캘린더든 만료 알림이든, 죽기 전에 아는 장치가 실무의 기본이다.
- 이미지 출처 증명이 없다. 레지스트리의 이미지가 "이 파이프라인이 만든 것"이라는 증거는 push 자격뿐이다. 서명(cosign)·SBOM이 없어, 자격이 새면 위조 이미지가 정품과 구분되지 않는다. 게이트 다음 단계의 공급망 방어다.
- 게이트는 기계뿐이다. 파이프라인 초록이 머지 조건이 됐지만 사람 리뷰(approval)는 0명 설정이다 — 혼자라서 그런 것이고, 사람이 늘면 가장 먼저 켤 항목.
- 백업이 반출까지고 검증 전이다. 개인키·GitLab을 클러스터 밖에 뒀지만 "그걸로 실제로 복원되는가"는 안 해 봤다. 복원 리허설 없는 백업은 희망사항이다 — VM 백업(vzdump)·etcd 스냅샷 반출과 함께 남은 몫.
9. 다음
파드는 돌지만 아직 앱이 잘되는지 체크해보지는 않았다 — 트래픽 0, 테스트 0. 11부에서 앱을 실제로 태운다: 대기열 입장부터 좌석 선택, 예매 확정까지 흐름을 돌려 보고, 그 동작을 근거로 테스트를 써서 이 편에 자리만 만들어 둔 test 칸을 채운다. 지킬 대상이 실물이 됐으니 RBAC과 NetworkPolicy 최소 한 벌도 그때 함께 붙인다.
'HomeLab' 카테고리의 다른 글
| 번외 — 클러스터 안 카프카 (1) | 2026.08.11 |
|---|---|
| 노트북 홈서버 k3s 구축기 - 11. 앱 검증과 시뮬레이터 (0) | 2026.08.11 |
| 번외 — CI 파이프라인 기록 (0) | 2026.08.09 |
| 번외 — WSL2와 커밋 한도 (1) | 2026.08.05 |
| 노트북 홈서버 k3s 구축기 - 9. GitLab 세우기 (0) | 2026.08.04 |