10부 CI는 완성본만 설명 했다. 이 글은 순서를 뒤집는다.
- 최종본 해부 — stage마다, job마다 무엇이고 왜 그 자리에 있는지
- 여정 — job 실타래마다, 뭐에 막혔고 무슨 작업으로 풀었는지
- 판정 — 그래서 이게 맞는 CI인지
최종본 — 지금 무엇이 돌고 있나
push 한 번에 이렇게 흐른다.
push ─→ check ─→ test ─→ build ─→ scan ─→ publish
4 job (빈칸) 3 job 3 job 3 job (dev 에서만)
- stage는 벽이다. 한 칸에서 실패가 하나라도 나오면 다음 칸은 실행되지 않는다.
- job은 "명령 목록 + 그걸 돌릴 이미지"다. 러너가 컨테이너를 띄워 돌리고, 끝나면 버린다. 그래서 모든 job은 두 질문으로 읽힌다 — 무엇을 보는가, 실패가 무엇을 뜻하는가.
check — 코드 층의 게이트 넷
이미지를 만들기 전에, 소스만 보고 판정할 수 있는 것들. 넷은 서로 독립이라 한 칸에서 동시에 돈다.
secret-scan — 자격이 섞였나 (gitleaks)
- 무엇을 보나 — 커밋에 토큰·키·비밀번호가 들어갔는지. 대상은 히스토리 전체(GIT_DEPTH: 0) — 자격은 지워도 과거 커밋에 남는다.
- 언제 도나 — 조건 없이 모든 커밋. 어느 파일이 바뀌든 자격은 섞일 수 있다.
- 설정 하나 — --redact: 찾은 값을 로그에 안 찍는다. CI 로그도 유출 경로다.
- 실패의 의미 — 유출. 대응은 커밋 수정이 아니라 그 자격의 폐기·재발급이 먼저다.
dep-scan:queue-go — 선언한 의존성에 알려진 결함이 있나 (trivy fs)
- 무엇을 보나 — go.sum의 모듈 버전들을 공개 취약점 목록과 대조.
- CVE란 — 공개된 보안 취약점 하나하나에 붙는 전 세계 공통 일련번호. 번호가 있어야 스캐너가 "이 버전에 이 결함 있음"을 기계적으로 대조한다. 심각도는 별도로 CVSS 점수(0–10)로 매기며 HIGH·CRITICAL이 그 등급이다.
- 언제 도나 — 조건 없이 모든 커밋. 단 이유가 secret-scan과 다르다 — 코드가 그대로여도 새 CVE 발표 하나로 결과가 바뀐다. 어제 통과한 커밋이 오늘 실패할 수 있다.
- 실패의 의미 — 내 잘못이 아니라 낡음. 대응은 버전 올리기.
lint:queue-go — 컴파일은 되는데 틀린 코드 (go vet)
- 무엇을 보나 — Printf 인자 불일치, 잠긴 뮤텍스 복사처럼 컴파일러는 통과시키지만 의도와 다르게 도는 자리.
- 특성 — 판정에 타입 정보가 필요해서 의존 패키지를 전부 컴파일한다. 이 job의 시간은 코드 크기가 아니라 컴파일 캐시가 정한다.
- 실패의 의미 — 코드 결함. 대응은 코드 수정.
lint:booking — 바이트코드 정적 분석 (SpotBugs)
- 무엇을 보나 — null 역참조·자원 미해제·오버라이드 실수류. 소스가 아니라 컴파일된 바이트코드를 훑는 도구라 mvn compile이 앞에 온다.
- 딸린 설정 셋 —
| 설정 | 왜 |
| MAVEN_OPTS: -Xmx768m | JVM은 힙 상한이 없으면 "컨테이너가 보는 메모리"의 25%를 잡는다. job 컨테이너에 제한이 없어 VM 전체가 보인다 → 상한 고정 |
| artifacts (when: always) | 콘솔엔 개수만 남고 상세는 리포트 파일에 있다. 컨테이너째 사라지기 전에 GitLab에 올린다. 실패했을 때야말로 받아 봐야 하므로 always |
| spotbugs-exclude.xml | 도구가 프레임워크를 모른다. 스프링 생성자 주입을 캡슐화 위반으로 오탐 → 그 패턴만 근거와 함께 제외 |
- 실패의 의미 — 코드 결함이거나 오탐. 오탐이면 "왜 오탐인지"를 필터에 근거로 남기고 좁게 제외한다.
test — 비어 있는 칸
- stage 선언만 있고 job이 없다. 11부에서 채운다.
- 미룬 이유 — 테스트는 앱을 띄워 동작을 보면서 써야 의미 있는 단언이 나온다. 껍데기 테스트를 채우면 초록불이 거짓말을 한다.
- 이 칸이 빈 동안 이 파이프라인을 뭐라고 불러야 하는지는 판정 절에서.
build — 이미지를 만든다, 올리지는 않는다
- 이미지 — docker:27-cli. CLI만 들어 있고 데몬은 소켓 너머 데스크탑 것.
- CI는 한 줄만 안다 — docker build ./$SVC. Go 컴파일인지 Maven인지 정적 파일 복사인지는 각 서비스의 멀티스테이지 Dockerfile이 품는다. 언어 차이를 CI가 아니라 Dockerfile이 가둔다.
- --pull — 베이스 이미지를 매번 레지스트리에서 확인. 같은 태그라도 업스트림은 패치를 넣어 다시 굽는다.
- 산출물 — job 컨테이너가 아니라 데몬에 남는다. 다음 칸이 이름($SVC:$TAG)으로 이어받는다.
scan — 만든 이미지의 실물 검사 (trivy image)
dep-scan이 "선언"을 봤다면 여기는 "실제로 담긴 것"을 본다 — 베이스 OS 패키지, jar 안 라이브러리, Go 바이너리 안 모듈까지 이미지를 열어 읽는다.
--severity HIGH,CRITICAL 높은 등급만
--ignore-unfixed 수정판이 나온 것만 ← "막으려면 고칠 수 있어야 한다"
--exit-code 1 걸리면 job 실패 → publish 가 안 열린다
- 수정판이 없는 결함으로 막으면 파이프라인이 영구히 빨간불이 되고, 결국 게이트를 끄게 된다. 그래서 고칠 수 있는 것만 막는다.
- 운영 특성 둘 — 캐시 디렉터리가 job 이름별로 갈라져 있다(TRIVY_CACHE_DIR). 제한 시간이 20분이다(TRIVY_TIMEOUT). 왜 그래야 했는지는 여정의 scan 실타래에 나온다.
publish — 검증 통과본만, dev 만
- $CI_COMMIT_BRANCH == "dev" — dev가 아니면 이 job은 화면에 생기지도 않는다. 아무 브랜치의 이미지가 배포 후보와 같은 자리에 쌓이지 않게 하는 벽.
- 로그인 — job마다 발급되고 끝나면 폐기되는 토큰. 표준 입력으로 넣는다(--password-stdin) — -p는 비밀번호가 명령줄 인자에 남는다.
- interruptible: false — push가 중간에 끊기면 레지스트리에 레이어가 일부만 올라간 채 남는다.
파일 밖의 짝 — 러너와 Dockerfile
.gitlab-ci.yml만으로는 이 파이프라인이 재현되지 않는다.
| 어디 | 무엇 | 왜 |
| 러너 config.toml | concurrent = 2 | 러너가 동시에 드는 job 수 |
| 러너 config.toml | 캐시 볼륨 넷 (maven·trivy·gomod·gobuild) | job 컨테이너는 버려진다. 내려받은 것을 남기려면 밖(볼륨)에 둬야 한다 |
| 각 Dockerfile | BuildKit 캐시 마운트 (RUN --mount=type=cache) | docker build는 데몬에서 돈다. 러너 볼륨이 안 닿는다 — 캐시가 두 층인 이유 |
이 상태로 캐시가 채워진 판이 job 10개, 1분 54초다. 여기까지가 지도다.
여정
판 결과 시간 한 일
#1 Passed 6:06 3단(check·build·package) 첫 판. 이미지 빌드 + push
#2 Passed 0:46 로그인을 표준 입력으로
#3~4 Passed 0:40대 push 는 dev 머지 뒤에만
#5 Failed 9:03 5단 재편. scan 셋 전부 실패 — 72건
#6 Failed 10:04 1차 수정. booking 21 · frontend 33 남음
#7 Failed 11:16 2차 수정 + 새 게이트 둘. booking 1 · SpotBugs 23 · 429 등장
#8 Warning 10:15 3차 수정. 게이트 전부 통과, 유예 하나만 빨강
#9 Failed 8:42 캐시 도입. 캐시끼리 충돌
#10 Failed 14:26 충돌 해소. 이번엔 시간 초과
#11 Passed 4:27 시간 제한 상향. 전부 초록 (유예 2개 있는 상태)
#12 Passed 3:40 유예 제거. 열 job 이 전부 막는 게이트로
#13 Passed 4:00 메모리 상한 · 자동 취소 · Go 컴파일 캐시
#14 Passed 1:54 빈 커밋 — 캐시가 채워진 상태의 실측
아래부터는 실타래순이다 — 절 하나가 job 하나의 처음부터 끝. 절의 배열은 이야기가 시작된 판 순서라, 기록표와 같은 방향으로 흐른다.
| 실타래 | 판 | 막힌 것 | 한 작업 | 지금 |
| 출발점 | #1–#4 | — | 태그 규칙 · dev 한정 · stdin 로그인 | — |
| 공통 배경 | — | 판마다 같은 것을 다시 받음 | 캐시 두 층 | — |
| scan 셋 | #5–#11 | 72건 → 잠금 충돌 → 시간 초과 | 코드·베이스 수정 → 캐시 폴더 분리 → TIMEOUT 20m | 0:12–0:32 |
| 게이트 반입 | #7 → #12 | 새 게이트가 첫 판부터 막음 | allow_failure 유예 → 정리 → 제거 | 유예 0 |
| dep-scan | #7–#10 | 429 차단 | 대상을 queue-go로 축소 | 0:15 |
| lint:booking | #7–#13 | 오탐 23 → 상세 안 보임 → 힙 2GB | 필터 → artifacts → MAVEN_OPTS | 0:36 |
| build 셋 | #9 | 의존성 바꾼 커밋마다 전부 재다운로드 | BuildKit 캐시 마운트 | 0:11–0:16 |
| lint:queue-go | #12–#14 | 판마다 전부 재컴파일 + 옆 job까지 밀림 | 컴파일 결과 캐시 볼륨 | 0:14 |
| 캐시의 대가 | #14 결산 | 디스크 — C: 여유 1GB | (부채로 기록) | — |
출발점 — 검증 없이 올리던 시절 (#1–#4)
첫 파일은 stage 셋(secret scan·build·package)이었고 네 판이 다 통과했다. 통과한 이유는 잘돼서가 아니라 검증이 없어서다.
이 시기에 정해져 뒤까지 살아남은 것:
- 태그 규칙 <브랜치>-<파이프라인번호>-<커밋해시>
- push는 dev 머지 뒤에만
- 로그인은 표준 입력으로
- 6:06 → 0:46 실측 — 소켓 마운트 덕에 레이어 캐시가 job 컨테이너 밖(데몬)에 남는다는 첫 확인
공통 배경 — 캐시란, 그리고 왜 CI에서 문제인가
여러 실타래가 이 개념 위에 서 있어서 먼저 놓는다.
- 캐시란 — 한 번 받아 둔 것을 버리지 않고 뒀다가, 다음에 같은 게 필요할 때 다시 쓰는 저장소.
- 왜 CI에서 문제인가 — job 컨테이너가 버려질 때 안에서 내려받은 것도 같이 사라진다. 캐시가 없으면 매 판이 처음이다.
- 하면 뭐가 바뀌나 — 첫 판만 받고, 그다음 판부터는 네트워크에 안 나간다.
이 파이프라인에서 "판마다 다시 받던 것"이 이만큼이었다. #7의 11:16 중 실제 작업은 2–3분, 나머지가 전부 다운로드다.
trivy 취약점 DB 103MB × scan 계열 job 4개
trivy Java DB 903MB × 1 (booking)
Maven 의존성 수백 개 × lint 와 build 각각
Go 모듈 수십 개
- trivy DB가 뭔가 — 스캐너가 대조할 "알려진 결함 목록". 전 세계 패키지의 CVE 목록이라 크고, 새 CVE가 매일 등록되므로 낡으면 다시 받는다(3일 만료).
- 의존성 다운로드가 뭔가 — pom.xml·go.mod에 적힌 건 이름과 버전뿐이다. 실물(jar·소스)은 중앙 저장소에서 받아야 컴파일이 된다.
남길 자리는 두 층이다.
- job 안에서 받는 것 → 러너 볼륨
- docker build 안에서 받는 것 → BuildKit 마운트 (빌드는 데몬에서 돌아 러너 볼륨이 안 닿는다)
둘 다 #9에서 넣었다. 그 뒤 각 실타래에서 무슨 일이 났는지가 아래다.
scan 셋 — 72건에서 0까지, 그리고 스캐너 자신의 두 사건 (#5–#11)
72건이 쏟아졌다
5단 재편 첫 판(#5)에서 scan 셋이 전부 실패했다.
scan:queue-go HIGH 2
scan:booking HIGH 33 · CRITICAL 4
scan:frontend HIGH 31 · CRITICAL 2
게이트가 --ignore-unfixed니 72건은 전부 수정판이 이미 나와 있는 결함이다. 문제는 왜 이렇게 쌓였느냐 — 서비스마다 층이 달랐다.
queue-go — 간접 의존 (2건)
- 걸린 golang.org/x/text·google.golang.org/grpc는 직접 import한 적이 없다. OTel 익스포터와 gin의 검증기가 딸고 들어온 간접 의존이다.
- 그래도 바이너리에 들어가는 것은 마찬가지라 스캐너가 잡는다.
- 수정 — go get <모듈>@<버전> && go mod tidy 두 줄.
booking — BOM이 정하는 버전들 (37건)
- jar에서 37건, 베이스 OS는 0건. 걸린 tomcat·spring·jackson·netty는 전부 pom.xml에 버전이 안 적힌 것들이다.
- BOM(Bill of Materials) — Spring Boot 부모 POM이 수백 개 라이브러리의 버전을 한 번에 정한다. 개발자는 이름만 적고 버전은 부모가 준다. 대가 — 부모를 안 올리면 딸린 전부가 함께 낡는다. 3.3.4는 그 사이 tomcat에 RCE(원격 코드 실행)급이 쌓일 만큼 낡아 있었다.
- 수정도 같은 구조로, 부모 한 줄. 다만 한 번에 안 끝났다:
3.5.14 37 → 21 전부 "한 패치 모자람" (#6)
3.5.16 21 → 1 3.5 라인 최신 (#7)
netty 핀 1 → 0 BOM 이 주는 4.1.135 가 수정판 4.1.136 보다 한 칸 낮았다 (#8)
- BOM 기본값 → 안 되는 것만 개별 핀. 순서가 그렇다.
frontend — 죽은 베이스 태그 (33건)
- 코드도 의존성도 없는 서비스에서 33건. 전부 베이스 이미지(alpine 3.21.3)의 OS 패키지.
- 처음엔 로컬 캐시가 낡은 탓으로 보고 --pull을 붙였다. 33건이 그대로였다 — 진단이 틀렸다.
- 실제 원인 — nginx-unprivileged:1.27-alpine은 업스트림이 더 굽지 않는 멈춘 태그. 새로 받아도 같은 낡은 이미지가 온다.
- 살아 있는 라인(1.31-alpine)으로 옮기니 0. --pull은 살아 있는 태그에는 맞는 조치라 남겼다.
셋을 관통하는 결론
queue-go 모듈 버전을 고정해 뒀다 → 낡았다
booking BOM 버전을 고정해 뒀다 → 낡았다
frontend 베이스 태그를 고정해 뒀다 → 낡았다 (태그가 죽기까지 했다)
고정해 둔 것은 시간이 지나면 낡는다. 코드가 안 바뀌었으니 아무 신호가 없었고, 스캔이 처음으로 그 시간을 드러냈다.
사건 1 — 캐시가 서로를 깨뜨렸다 (배타 잠금)
찾은 것을 다 고치자, 이번엔 스캐너 자신이 깨졌다. #9에서 scan 둘이 죽었다 — cache may be in use by another process: timeout.
- 잠금(lock)이 뭔가 — 여러 프로세스가 같은 파일을 동시에 고치면 반쯤 쓰인 데이터를 읽는 쪽이 생긴다. 그래서 "하나가 잡으면 나머지는 기다린다"는 표시를 걸어 둔다. '배타'는 한 번에 하나만.
- 왜 터졌나 — trivy는 DB 갱신을 지키려고 캐시 디렉터리에 이 잠금을 건다. 볼륨 하나를 scan 셋이 공유했는데, 같은 판에 concurrent(러너가 동시에 드는 job 수)를 1→2로 올렸다. 둘이 실제로 동시에 그 디렉터리를 열었고, 늦은 쪽은 기다리다 죽었다.
- 배운 것 — 동시성은 숨어 있던 공유 자원을 드러낸다.
- 해소 — TRIVY_CACHE_DIR를 job별 폴더로(#10). 잠금이 안 겹친다. 대가 — DB가 폴더 수만큼 여러 벌(디스크 실타래에서 돌아온다).
사건 2 — 시간 제한의 범위
폴더를 가르자 다음이 나왔다. #10에서 scan:booking이 context deadline exceeded(시한 초과)로 죽었다.
- 타임아웃이 뭔가 — "이 시간 안에 안 끝나면 강제로 실패"라는 상한. 멈춘 작업이 슬롯을 무한정 잡는 것을 막는 장치다.
- 함정은 범위다 — trivy 기본 5분 안에 DB 내려받기가 포함된다. 갈라진 새 폴더에 Java DB 903MB를 받는데, 옆 scan이 같은 회선으로 제 DB를 받고 있어 9분 10초가 걸렸다. 스캔은 시작도 못 하고 시간이 끝났다 — 취약점 유무와 무관한 실패.
- 해소 — TRIVY_TIMEOUT: 20m(#11). 캐시가 더운 판은 이 값 근처에도 안 간다. 죽은 판이 캐시는 채워 놓아서(로그 끝 "Java DB is cached") 다음 판부터 다운로드 줄 자체가 없다.
이 실타래의 끝 — scan:booking 3:21 → 0:32.
게이트 반입 — allow_failure 유예 → 정리 → 제거 (#7 → #12)
다음 두 실타래(dep-scan · lint:booking)는 #7에서 함께 들어왔다. 들어올 때 공통 장치가 있었다 — allow_failure: true. job은 돌고 결과도 남지만 실패해도 뒤 stage를 막지 않는 스위치다.
- 왜 — stage는 벽이다. 새 게이트가 check에서 빨간불이면 build·scan이 아예 안 돈다. 새 게이트는 "이미 쌓여 있던 것"을 처음 훑는 것이라 첫 판에 반드시 뭔가를 들고 나온다(실제로 SpotBugs 23건, dep-scan은 못 돌았다). 막는 상태로 넣었다면 같은 판의 이미지 쪽 수정이 먹혔는지 확인할 길이 없었다.
- 순서 — ① 유예로 넣는다(무엇을 잡는지 본다) ② 잡힌 것을 정리한다 ③ 유예를 걷는다. ③을 안 하면 아무도 안 보는 노란불로 남는다 — 게이트가 아니라 장식이 된다.
- #12에서 두 줄을 지웠다. 그 판부터 열 job이 전부 조건 없이 막는다.
- 파이프라인 상태가 넷인 이유가 여기 있다 — Passed / Warning(통과인데 유예 job 중 실패가 있음, #8이 그것) / Failed / Canceled.
이 장치를 달고 들어온 두 job이 각자 무엇에 막혔는지가 다음 두 실타래다.
dep-scan — 남의 서버가 화를 냈다 (429, #7–#10)
붙인 첫 판(#7)부터 429 Too Many Requests로 죽었다.
- rate limit이 뭔가 — 서버가 한 이용자(보통 IP 단위)의 요청 횟수에 상한을 거는 것. Maven Central은 전 세계가 무료로 쓰는 공용 서버라, 한 IP가 과도하게 받으면 다른 이용자 몫이 줄어든다. 넘으면 429와 Retry-After(몇 초 뒤에 오라)를 돌려준다 — 처음엔 89초, 나중엔 30분.
- 왜 걸렸나 — 당시 dep-scan은 저장소 전체를 봤다. pom.xml은 버전을 부모 POM이 정하므로 trivy가 실제 버전을 알려면 Maven Central에서 부모를 받아야 한다. 그런데 같은 공인 IP에서 booking 빌드가 판마다 의존성 수백 개를 받고 있었다. 서버 눈에는 전부 한 명이다.
- 배운 것 — 원인이 내 코드가 아니라 외부 서비스의 정책인 실패가 있다. 오류의 주체가 누구인지부터 갈라야 한다.
- 해소 — 대상을 ./queue-go로 좁힌다(#10). go.sum은 모든 버전이 파일 안에 있어 원격 조회가 아예 없다. 429가 "덜 나는" 게 아니라 날 수가 없어진다.
- Java를 버린 게 아니다 — scan:booking이 jar 실물을 읽는 쪽이 선언 추정보다 정확하고, 37건을 0까지 몰고 간 것도 그쪽이다. 잃은 것은 "build 전에 잡는다"는 이점 하나.
이 실타래의 끝 — 0:15, 원격 조회 0회.
lint:booking — 오탐 23건, 리포트, 그리고 JVM (#7–#13)
붙인 첫 판(#7)에 23건이 나왔다. 이 실타래는 사건 셋이 연달아다.
- 오탐 23건 — 전부 두 패턴(EI_EXPOSE_REP/EI_EXPOSE_REP2), 해부에서 본 스프링 주입 오탐이다. 실제 결함 0. 두 패턴만 필터로 빼고 나머지 규칙을 살리니 0(#8). 도구를 붙이는 첫 판은 "무엇이 오탐인지 근거를 정리하는 판"이 된다.
- 상세가 안 보였다 — 콘솔엔 "23 bugs"만 찍힌다. 어느 파일 몇 줄인지는 리포트 파일에 있는데, job이 끝나며 컨테이너째 사라졌다. → artifacts(when: always)로 리포트를 GitLab에 올린다.
- 힙 2GB — 해부의 25% 규칙 그대로, JVM이 VM 전체(7.8GB)를 보고 힙을 2GB 가까이 잡았다. 동시에 도는 job 수만큼 곱해지는 값이라 MAVEN_OPTS: -Xmx768m로 고정(#13).
이 실타래의 끝 — 0:36.
build 셋 — 의존성을 바꾼 날만 느려지는 문제 (#9)
build는 평소 빠르다. 도커 레이어 캐시가 있어서다 — COPY pom.xml 내용이 그대로면 그 아래 RUN(의존성 다운로드) 결과를 재사용한다.
- 함정 — 그 파일이 한 글자만 바뀌어도 아래가 통째로 무효가 되어 처음부터 다시 받는다. 하필 의존성을 고친 커밋, CI 결과가 제일 필요한 순간에 그렇게 된다. 실제로 pom을 고치던 판들에서 build:booking이 1:33–2:27까지 늘었다.
- 해소 — BuildKit 캐시 마운트(RUN --mount=type=cache,target=/root/.m2). 내려받은 것을 레이어 밖 저장소에 두어, 목록이 바뀌어도 바뀐 것만 받는다(#9).
이 실타래의 끝 — build 셋 0:11–0:16.
lint:queue-go — 판마다 전부 재컴파일, 그리고 경합 (#12–#14)
전부 초록이 된 #12에서도 이 job이 2:34로 제일 길었다.
- Go의 캐시는 둘이다 — 모듈 캐시(/go/pkg/mod)는 내려받은 소스를, 빌드 캐시(/root/.cache/go-build)는 그것을 컴파일한 결과를 담는다. 소스가 안 바뀐 패키지는 빌드 캐시가 있으면 다시 컴파일하지 않는다.
- 뭐가 빠졌었나 — 러너 볼륨에 모듈 캐시만 있었다. 소스는 안 받는데 컴파일은 판마다 처음부터 했다. go vet이 의존 전부를 컴파일하므로 그게 2분을 먹었다.
- 해소 — gobuild-cache 볼륨 추가. #13이 캐시를 채우고(399.7MB), 같은 소스로 한 판 더: 2:34 → 0:14.
- 덤 — 경합의 발견. 같은 판에서 lint:booking도 1:37 → 0:36이 됐다. 설정을 하나도 안 바꾼 job이다. 경합(contention) — CPU·디스크·회선은 한정돼 있고 동시에 도는 job들이 나눠 쓴다. 2분 34초짜리가 CPU를 잡는 동안 옆 슬롯이 밀리고 있었던 것. 병목 하나를 지우면 그늘의 job들이 제 속도를 되찾는다.
캐시의 대가 — 디스크, 그리고 VHDX (#14 결산)
캐시 실타래들의 공통 청구서다. #14 시점 캐시 볼륨:
maven 145MB · gomod 433MB · gobuild 400MB · trivy 8.5GB
- trivy가 큰 것은 잠금 문제로 폴더를 갈라 DB가 네 벌이 됐기 때문 — 잠금 충돌과 디스크를 맞바꾼 것이다.
- VHDX가 뭔가 — Docker Desktop은 리눅스 VM(WSL2) 안에서 돈다. 그 VM의 디스크 전체가 Windows에는 docker_data.vhdx라는 파일 하나로 보인다. 이미지 10GB·빌드 캐시 9GB·볼륨 12GB가 전부 그 안이다.
- 무슨 일이 — 이 파일이 32GB까지 자라 C 드라이브 여유가 1.0GB까지 내려갔다. VHDX는 안에서 늘어난 만큼 자동으로 커지지만, 안에서 지워도 저절로 줄지는 않는다. 회수는 별도 작업이다.
- 함정 하나 더 — 캐시 볼륨은 job이 돌 때만 컨테이너에 붙어서, docker volume prune(안 쓰는 볼륨 일괄 삭제)에는 "안 쓰는 볼륨"으로 보인다. 정리 명령 한 번이 캐시 전부를 지운다.
- 캐시를 운영한다는 것은 그 수명과 크기를 관리하는 일까지다.
판정 — 그래서 이게 맞는 CI인가
표준 8단계와 대조
CI 표준 구성(lint · build · test · secret scan · dependency scan · package · image scan · publish)에 겹쳐 보면:
있다 lint(go vet · SpotBugs) · secret(gitleaks) · dep(trivy fs)
build → image scan(trivy image) → publish(dev 한정)
없다 test ★ 칸만 선언. 11부에서 앱 동작을 보며 채운다
SBOM · 이미지 서명 · 커버리지
미설정 러너 Protected · allowed_images / MR "Pipelines must succeed"
- test가 비어 있는 동안은 온전한 CI가 아니다. CI의 정의가 "합칠 때마다 자동 검증"인데 그 핵심이 테스트다. 지금은 "빌드·보안 스캔 자동화"까지다.
- 그 한계를 그대로 두는 쪽을 골랐다 — 껍데기 테스트로 초록불을 만드는 것보다 낫다.
- 머지 게이트가 꺼져 있는 것도 남은 구멍이다. 빨간불이어도 머지 버튼이 눌린다.
SonarQube와 무엇이 다른가
겹치는 것은 lint 계열.
| SonarQube | 이 파이프라인 | |
| 내가 쓴 코드 | 정적 분석·중복·커버리지. 서버에 쌓여 추이가 남는다 | go vet · SpotBugs (판마다 일회성) |
| 가져다 쓴 것 (의존성 CVE) | 범위 밖 (Community 기준) | dep-scan · scan (trivy) |
| 이미지 안 OS 패키지 | 범위 밖 | scan (trivy) |
- 이번 14판에 대입하면 — SpotBugs 23건만 SonarQube 영역이다. queue-go 2건·booking 37건·frontend 33건은 SonarQube를 깔았어도 그대로 통과했다. trivy 두 job은 어느 쪽이든 필요하다.
- 구조에서 배울 것 — allow_failure로 손으로 만든 순서("기존 부채는 기준선, 새로 들어오는 것만 막는다")를 SonarQube는 New Code라는 기능으로 제공한다. 기준일 이후 코드만 Quality Gate로 판정하는 방식이다.
- 도입하지 않은 근거 둘 — 서버+DB로 상시 2GB대 메모리를 더 쓴다. 무료판(Community)은 브랜치·MR 분석이 없어 MR 게이트로는 반쪽이다. 13부 실측 후 재평가.
채용공고의 문구와 대조
공고에 반복되는 문구는 넷이다. 각각 무슨 뜻이고, 이 기록의 어느 부분이 답이 되는지.
| 공고 문구 | 무슨 뜻인가 | 이 기록에서 |
| CI/CD 파이프라인 구축·운영 | push부터 배포 직전까지를 자동 단계로 잇고, 깨지면 고치며 유지하는 일 | 14판 전체. 특히 "넣고 → 깨지고 → 고치는" 순환 |
| 컨테이너 기반 빌드 | 빌드 도구(Go·Maven)를 머신에 안 깔고 컨테이너로 쓰는 것. 어느 머신에서든 같은 결과 | job마다 이미지 지정 · 소켓 마운트 대 dind 선택 · 멀티스테이지 · BuildKit |
| DevSecOps / 보안 스캔 | 보안 검사를 배포 뒤가 아니라 파이프라인 안(개발 단계)으로 당기는 것 | secret·dep·image 3종 게이트 · 72건 → 0 · "고칠 수 있는 것만 막는다" |
| 빌드 최적화 | 파이프라인 소요를 줄이는 일. 피드백이 10분을 넘으면 개발자가 결과를 안 기다리고 떠난다 | 10:15 → 1:54. 두 층 캐시 · 병목 추적(잠금·타임아웃·경합) |
답이 안 되는 것도 그대로 남는다 — 테스트 자동화(11부 전까지), 대규모·다인원 환경(러너 한 대, 개발자 한 명의 기록이다).
부록
A — 커밋 열아홉
eed81cd CI 파이프라인을 붙인다 — 이미지 빌드와 레지스트리 push
02e0792 레지스트리 로그인을 표준 입력으로 받는다
394d5bc push 는 dev 에 머지된 뒤에만 한다
71c7383 파이프라인을 5단으로 재편한다 — check·test·build·scan·publish
a4751ec queue-go: 취약점이 보고된 간접 의존 모듈을 올린다
957a2ee booking: Spring Boot 부모 버전을 3.3.4 에서 3.5.14 로 올린다
5a534f3 이미지 빌드에 --pull 을 붙이고 스캔 로그의 진행바를 끈다
ebbfa2d check 칸에 의존성 스캔과 booking 정적분석을 붙인다
9c4c4fa booking: Spring Boot 부모를 3.5.16 으로 올린다
2f408f7 frontend: nginx 베이스를 1.31-alpine 으로 올린다
fdf3ed2 booking: netty 를 4.1.136.Final 로 고정한다
9cf1c05 SpotBugs 제외 필터를 두고 리포트를 아티팩트로 남긴다
ac6b801 빌드 안에서 받는 의존성을 BuildKit 캐시 마운트로 옮긴다
1e7f85c scan job 마다 trivy 캐시 폴더를 갈라 준다
a6c9c49 의존성 스캔 대상을 queue-go 로 좁힌다
07f087a build·scan·publish 가 앞 stage 아티팩트를 안 받게 한다
c6c508d 이미지 스캔 제한 시간을 20분으로 둔다
ec17637 의존성 스캔과 정적 분석을 막는 게이트로 돌린다
060bee1 job 의 메모리 상한 · 제한 시간 · 자동 취소를 정한다
git 밖이라 커밋에 없는 변경 — 러너 config.toml(concurrent = 2 + 캐시 볼륨 넷), .wslconfig(메모리 상한 6→8GB). 러너 설정은 도커 볼륨 안에 있어 저장소에 남지 않는다. 데스크탑을 갈아엎으면 사라지는 부채로, 10부 §9에서 다룬다.
B — job별 시간 변화 (초록 판 기준)
job #8 #12 #14
secret-scan 0:10 0:14 0:15
dep-scan(:queue-go) 0:27✗ 0:15 0:15
lint:queue-go 1:53 2:17 0:14 ← gobuild-cache
lint:booking 1:04 1:15 0:36 ← 경합 해소
build:queue-go 0:12 0:19 0:16
build:booking 1:33 0:18 0:15 ← BuildKit 캐시
build:frontend 0:14 0:11 0:11
scan:queue-go 0:43 0:14 0:12
scan:booking 3:21 0:36 0:32 ← trivy 캐시
scan:frontend 0:33 0:18 0:16
─────────────────────────────────────────────
전체 10:15 3:40 1:54
※ 파이프라인 목록 화면이 #11·#14를 Warning으로 표시하는 문제가 남았다. GitLab 내부 데이터(status=success, has_warnings=false)와 어긋나는 표시다. 상세 화면과 API가 맞다.
'HomeLab' 카테고리의 다른 글
| 노트북 홈서버 k3s 구축기 - 11. 앱 검증과 시뮬레이터 (0) | 2026.08.11 |
|---|---|
| 노트북 홈서버 k3s 구축기 - 10. CI 와 CD (0) | 2026.08.09 |
| 번외 — WSL2와 커밋 한도 (1) | 2026.08.05 |
| 노트북 홈서버 k3s 구축기 - 9. GitLab 세우기 (0) | 2026.08.04 |
| 노트북 홈서버 k3s 구축기 - 8. 관측 파이프라인 (0) | 2026.08.02 |