지난 글에서는 난리가 나버린 나의 CI/CD 학습 내용 중 잘못된 부분 3가지를 수정했다.
다시 한 번 보도록 하자.
| 개선 | 무엇이 문제였나 | 어디를 고쳤나 | 어떻게 바뀌었나 |
| 1. 무중단 배포의 구멍 | docker stop 순간 HAProxy가 몇 초 늦게 알아채서 그 사이 요청이 실패할 수 있었다 | haproxy.cfg (관리 입구 9999, redispatch), Jenkinsfile.prod의 deploy() |
멈추기 전에 HAProxy에서 빼고(drain), 교체하고, 다시 넣는다(ready) |
| 2. 배포 실패 시 자동 롤백 | 새 버전이 헬스 체크에 실패하면 서버 1대가 고장 난 채로 남았다 | Jenkinsfile.prod의 deploy() → replace() 분리 |
실패하면 이전 이미지로 되돌리고, 파이프라인은 실패로 표시한다 |
| 3. 빌드 2번 + 레이어 비효율 | Jenkins와 Dockerfile에서 각각 빌드했고, 코드 1줄만 바꿔도 수십 MB 레이어가 새로 생겼다 | Dockerfile, .dockerignore | Jenkins가 만든 jar를 그대로 담고, 4개 레이어로 나눈다 |
https://tech-monster.tistory.com/342
[인프라] CI/CD 구축하기 6탄 : 실전에서는 이렇게 하면 안 된다 - 1~4탄 개선하기(문제점3개 수정)
1~4탄에서 미니 PC에 Jenkins를 띄우고, push하면 빌드와 테스트를 거쳐 이미지를 만들고,HAProxy 뒤의 was1, was2를 순서대로 교체하는 파이프라인을 만들었다. dev는 자동, prod는 버튼으로 나눠서 Continuous
tech-monster.tistory.com
막상 수정은 어렵지 않았다. (어짜피 AI가 해주자나)
이번 시간에는 4~6번의 문제점을 알아보고 수정하려고 한다.
Claude를 이용해서 내용은 다음 문서를 기반으로 했다.
CI/CD 구축하기 6탄 개선분석 : Claue를 활용해서 분석!
CI/CD 구축하기 6탄 개선분석
Editable docs — documents, reports, plans, memos, briefs that people and Claude read and edit together; export to .docx, PDF or Markdown to download or print. Content is written through the Claude Docs connector, not as files.
claude.ai
목표
1. 지난 시간에 이어 현재 실습 환경의 CI/CD의 문제점을 확인한다.
2. 문제점을 개선하고 개선되었는지 테스트한다.
1. docker.sock 마운트는 root권한과 같다.
네...? 그래서 어쩌라는거죵...?
docker.sock은 도커 클라이언트(CLI)와 도커 데몬(dockerd)이 서로 통신하기 위해 사용하는 유닉스 도메인 소켓(Unix Domain Socket) 파일이다.
필자는 AI 한테 그냥 물어봐서 docker 명령어나 쓸 줄 알았지 구조까지는 관심이 그렇게 많지 않았다.
찾아보니 Docker는 클라이언트 서버 구조로 docker.sock 이라는 도커에서 사용하는 소켓을 통해서 도커의 명렁어를 dockerd, 즉 도커 데몬이 일을 하도록 명령하는 구조라고 한다.

클라이언트(docker CLI)와 서버(dockerd)는 별도의 프로그램이고, 둘은 REST API라는 규칙으로 /var/run/docker.sock이라는 유닉스 소켓을 통해 대화한다.
docker.sock 접근시 문제에 대해서 OWASP에서 Docker Seucrity 라는 섹션으로 이 문제점에 대해 소개하고 있다.

문제는 rule이 14개나 있는데 그렇게 까지 보기는 싫다 (어후 지겨워...)
이 14개중에서 필자의 문제는 OWASP Docker Security Rule # 1에 해당한다.
즉, Jenkins 컨테이너에 /var/run/docker.sock을 마운트해서, Jenkins가 사실상 미니 PC의 root 권한을 갖고 있다.
(OWASP RULE #1 위반)
문제점을 요약하겠다.
Jenkins 컨테이너가 뚫림 (취약한 플러그인, 탈취된 계정)
→ docker.sock으로 "호스트의 / 를 마운트한 컨테이너를 띄워줘" 요청
→ 호스트 전체 파일 시스템에 root로 접근
→ 컨테이너 격리가 의미 없어짐
아니 내가 언제그랬는데! ㅠㅠ... 확인해보자.

엇... 네... - /var/run/docker.sock:/var/run/docker.sock 하는 부분이 있다.
jenkins용 compose.yml
services:
jenkins:
build: .
ports:
# 127.0.0.1을 붙여야 Docker가 ufw를 우회해 포트를 여는 일이 없다
- "127.0.0.1:8080:8080"
volumes:
# 설정, 플러그인, Crdentials가 모두 여기 저장된다
- jenkins_home:/var/jenkins_home
# 호스트 Docker를 조종하기 위한 소켓. root 권한과 같으니 외부 노출 금지
- /var/run/docker.sock:/var/run/docker.sock # ← ① 미니 PC dockerd의 입구를 Jenkins 안에 연결
group_add:
# 소켓을 읽을 수 있도록 호스트의 docker 그룹 번호를 부여한다
- "${DOCKER_GID}" # ← ② 그 입구를 쓸 수 있는 권한(docker 그룹)을 줌
restart: unless-stopped
volumes:
jenkins_home:
정리하면 내 미니 pc가 jenkins에게 dockerd에게 모두 다 시킬 수 있는 권한이 생겨버렸다.
만약 jenkins가 털린다면 내 서버의 docker도 다 털려버리는것이다. 고로, 분리해야한단다.
그런데 필자는 학습용이며 이미 ssh + tailscale을 적용했고 key를 통해서 지정된 PC에서 접근하도록 막아놓았다.
또한 cloudflare와 국내접근만 허용하였으며 fail2ban 까지 도입해서 쉽지 않다.
정리를 하면 다음과 같다.
겹 1: Cloudflare, 국내 IP만 허용 ← 바깥에서 막음
겹 2: Jenkins는 127.0.0.1 + SSH 터널로만 ← Jenkins 화면에 닿기 어렵게
겹 3: (없음) Jenkins 안의 docker.sock ← 여기가 뚫리면 바로 미니 PC 전체
이전에 미니 PC 서버 구축으로 홀로 학습했던 것이 있는데 참고하면 좋다.
[인프라] 미니PC 서버 구축 1 - ubuntu 환경 세팅해서 원격으로 접속하기(feat. 리눅스와 SSH에 대한 이해)
[인프라] 미니PC 서버 구축 2 - 접속부터 응답까지 : Cloudflare 에서 도메인 구매와 내 미니 PC 연결하기 (feat. HTTPS통신과 HAProxy설치)
그래도! Jenkins가 뚫려도 피해가 미니 PC 전체로 번지지 않게, 권한을 쪼개고 줄인다. (최소 권한)
이것이 핵심이다. 나 편하겠다고 다 권한을 줘버리면 나중에 누구든지 막 들어와서 헤짚어놓는다 이말이다.
실전에서는 어떻게 하는지 AI에게 질문했다.
| 방법 | 끊는 지점 | 한 줄 이유 |
| 에이전트 분리 | 빌드 ↔ Jenkins 본체 | 위험한 코드 실행을 비밀이 있는 곳에서 떼어 냄 |
| Docker없이 빌드 | 빌드 ↔ dockerd | root 입구가 필요한 이유 자체를 없앰 |
| 소켓 프록시 | Jenkins ↔ dockerd 사이 | 다 주지 않고 필요한 것만 줌 |
| 대상 서버에서 배포 | Jenkins ↔ 운영 서버 | CI가 운영 서버 열쇠를 갖지 않음 |
이것을 좀 개발자스러운 말로 변경하면?
방어를 한 겹이 아니라 여러 겹으로 미리 쌓아 두고(심층 방어), 각 구성 요소에는 필요한 권한만 준다(최소 권한). 그러면 한 겹이 뚫리더라도 다음 겹이 막아 주고, 뚫린 곳의 피해도 그 칸 안에서 끝난다(피해 반경 최소화).
필자의 미니 PC는 다음과 같다.
결국에는 겹 4가 추가가 되면 좋다는 말이다. 최소권한이 아닌 슈퍼 권한을 jenkins에게 줘버렸으니 말이다.
2. 이미지 누적 해결하기
2.1) 개념 다시 잡고가기 : 이미지는 "실행용 포장 상자"
Jenkins가 빌드할 때마다 하는 일은 이렇다.
내 코드 → jar로 만들기 → jar + Java 실행 환경을 상자에 담기 = 이미지
이 상자에서 꺼내 실행한 것 = 컨테이너 (was1, was2, was-dev)
상자에는 커밋 번호 앞 7자리가 이름표(태그)로 붙는다. dev Jenkinsfile의 이 부분이다.
script { env.IMAGE = "myapp:${env.GIT_COMMIT.take(7)}" } // 빌드마다 새 이름표
sh 'docker build -t "$IMAGE" .' // 새 상자 생성
...
post { always { junit ... } } // 상자를 치우는 단계가 없다

새 상자를 만들어도 옛날 상자는 창고(미니 PC 디스크)에 그대로 남는다.
지우라고 시킨 적이 없기 때문이다. 이것이 이 장의 문제라는데... 글쎄 진짜일까?
2.2) 쌓여서 정말 문제가 되나요?
솔직히 나는 AI가 하라는대로 했다가 뭔가 이상해서 분석하라고 한거고, 그랫더니 지금 진짜인지 내 두 눈으로 확인하고싶다.
① 이미지가 몇 개 있나
② 그중 지금 쓰는 건 무엇인가
③ 꼭 남겨야 하는 것 정하기
$ docker images myapp -q | wc -l
8
$ for c in was1 was2 was-dev; do echo "$c: $(docker inspect $c --format '{{.Config.Image}}')"; done
was1: myapp:12e9b5f
was2: myapp:12e9b5f
was-dev: myapp:12e9b5f
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 13 4 3.453GB 94.47MB (2%)
Containers 6 6 5.337MB 0B (0%)
Local Volumes 9 2 909.2MB 118.8MB (13%)
Build Cache 70 0 4.22GB 2.046GB
$
하라는대로 하긴 했다. 그런데 무슨 말인지를 모르겠다. 보니까 was1, was2랑 was-dev는 같은 이미지를 사용하고 있다.
위에서 가로, 세로 항목이 무슨 수치로 뭘 말해주는지 확인하자.
TOTAL : 총 몇 개 있는지
ACTIVE : 그중 지금 쓰고 있는 게 몇 개인가
SIZE : 디스크를 얼마나 쓰고 있는가
RECLAIMABLE : 안 쓰는 걸 지우면 돌려받을 수 있는 용량
Images를 보니 작동중인 이미지는 4개라고 한다. Jenkins를 포함해서 위에 언급한 3개까지 4개가 맞다.
아니 아까는 뭐 이미지 지우면 쌓이는거 막고 효율적이라더니 안쓰는거 지워도 2%밖에 용량이 안돌아온다는 건가?
그건 아니다 RECLAIMABLE이 공유 레이어를 빼고 계산하기 때문에 공유레이어도 포함하면 더 많은 용량을 재사용 할 수 있다.
docker system df 를 통해서 실제 디스크 사용 현황을 보았다.
$ docker system df -v | grep -E 'REPOSITORY|myapp'
REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS
myapp 12e9b5f de82a827b202 10 days ago 498MB 2B 498.1MB 3
myapp 32d527a fc5382ce4c4d 11 days ago 508MB 466.4MB 41.83MB 0
myapp cfb3a42 4e5ed56b9201 11 days ago 508MB 508.2MB 2.522kB 0
myapp eb00f4a c57fc0c2a668 11 days ago 508MB 508.2MB 2.522kB 0
myapp d69eaad da282295f498 11 days ago 508MB 508.2MB 2.522kB 0
myapp 62bc9e3 2405a74ff468 11 days ago 508MB 508.2MB 2.521kB 0
myapp 2c968a8 25bc57c1bfa8 11 days ago 508MB 508.2MB 2.522kB 0
myapp 9d79c4f bf9154b7c509 11 days ago 508MB 508.2MB 2.522kB 0
8240ec9af2ff myapp:12e9b5f "java -jar app.jar" 0 69.6kB 10 days ago Up 2 hours was2
d5b1106d66f5 myapp:12e9b5f "java -jar app.jar" 0 69.6kB 10 days ago Up 2 hours was1
2b18ca2b3236 myapp:12e9b5f "java -jar app.jar" 0 69.6kB 10 days ago Up 2 hours was-dev
$
현재 가장 위에 있는 myapp:12e9b5f가 현재 실행중이다.
그 밑의 32d527a는 이전 버전같은데, SHARED SIZE를 보면 드라마틱하게 용량이 다르다.
1개 지울 때 → UNIQUE SIZE를 보면 된다
여러 개 지울 때 → "지울 것끼리만 쓰는 층"을 SHARED 차이로 계산해야 한다
RECLAIMABLE만 믿으면 실제보다 작게 보일 수 있다
실제 디스크 → 압축이 안 되는 층(jar)은 계산보다 크게 돌아올 수 있다
가장 확실한 방법 → 지우기 전후로 docker system df의 SIZE를 비교한다
그렇다고 하는데 학습중인 단계에서 이거까지 정확하게 계산해야하나... 싶다.
만약 내가 관리하는 관리자 입장이라면 메모리 관리에 대한 md를 잘 작성해서 skill로 가지고 있을 것이다.
하여간 지금 사용중인 이미지와 이전 직전의 이미지를 남기고 나머지는 사실 다 지우도록 하겠다.
# 실행 중(12e9b5f)과 롤백용(32d527a)을 뺀 나머지 목록 (아직 지우지 않음)
docker images myapp --format '{{.Tag}}' | grep -v -e 12e9b5f -e 32d527a
cfb3a42
eb00f4a
d69eaad
62bc9e3
9d79c4f
2c968a8
$docker images myapp --format '{{.Tag}}' | grep -v -e 12e9b5f -e 32d527a | xargs -r -I{} docker rmi myapp:{}
Untagged: myapp:d69eaad
Deleted: sha256:da282295f4989854471d1d72c9d2c39255dbe22a75066221e39f52ddbb1ed84b
Untagged: myapp:62bc9e3
Deleted: sha256:2405a74ff4685989483f6f2634c6125585f0720a29da6bc55dd7efeb5b081259
Untagged: myapp:cfb3a42
Deleted: sha256:4e5ed56b9201f1fe4ce5cab42ed8bc454c1a9888d163785b604eda73e15822e8
Untagged: myapp:eb00f4a
Deleted: sha256:c57fc0c2a6681aa38a71377ef7fb5a6e664cf6fe2268d5b5e315d0246286b0e9
Untagged: myapp:2c968a8
Deleted: sha256:25bc57c1bfa81351c8ba0236f326d09308d267270ea93fe97aa75038bc3957c6
Untagged: myapp:9d79c4f
Deleted: sha256:bf9154b7c5091caac4fae52c74456e4af6a4f647763266ffaa0da0a78cffa85c
docker images myapp # 2개만 남아야 한다
docker system df # 정리 전과 Images 용량 비교

삭제한 후 docker system df 명령어 결과를 비교해보면 다음과 같다.
| 비교 종목 | 정리전 | 정리 후 | 변화 |
| Images TOTAL | 13 | 7 | 6개 삭제 |
| Images SIZE | 3.453GB | 3.369GB | 약 84MB 줄었음 |
| Images RECLAIMABLE | 94.47MB | 560.8MB | 늘어남 (아래 설명) |
| Build Cache RECLAIMABLE | 2.046GB | 2.129GB | 늘어남 (아래 설명) |
정리 후 실제 사용량(SIZE)은 84MB 줄었다.
RECLAIMABLE이 늘어난 건 디스크를 더 쓰게 된 게 아니라, 여러 이미지가 같이 쓰던 층이 공유에서 풀리면서 "치울 수 있는 것"으로 보이기 시작했기 때문이다.
그런데 이걸 매번 일일히 할 수 없으니 파이프라인에 자동정리를 넣도록 하자.
// 컨테이너 하나를 새 이미지로 교체하고 헬스체크한다
def deploy(String name, String port) {
sh """
docker stop -t 30 ${name} || true
docker rm ${name} || true
docker run -d --name ${name} --restart unless-stopped -p 127.0.0.1:${port}:8080 ${env.IMAGE}
for i in \$(seq 1 30); do
if docker run --rm --network host curlimages/curl -fs http://127.0.0.1:${port}/actuator/health > /dev/null; then
sleep 3; exit 0
fi
sleep 2
done
exit 1
"""
}
pipeline {
agent any
triggers { pollSCM('H/5 * * * *') }
options { disableConcurrentBuilds() }
stages {
stage('Build & Test') {
steps {
sh 'chmod +x gradlew'
sh './gradlew clean build'
}
}
// 이미지는 여기서 한 번만 만든다. prod 작업은 이 이미지를 그대로 쓴다
stage('Docker Build') {
steps {
script { env.IMAGE = "myapp:${env.GIT_COMMIT.take(7)}" }
sh 'docker build -t "$IMAGE" .'
}
}
// was-dev를 만들거나 교체한다. 여기서 끝
stage('Deploy dev') {
steps { script { deploy('was-dev', '8181') } }
}
}
post {
always {
junit 'build/test-results/test/*.xml'
}
success {
// 개선5 - 최신 5개만 남기고 나머지 myapp 이미지를 지운다
// docker images는 최신순으로 출력하므로 tail -n +6 = 6번째부터 끝까지
// 실행 중인 컨테이너가 쓰는 이미지는 docker가 삭제를 거부하므로 안전하다 (|| true로 넘어감)
// 전부 지우지 않는 이유: prod 롤백(IMAGE_TAG)과 자동 롤백은 이전 이미지가 미니 PC에 있어야 동작한다
sh "docker images myapp --format '{{.Tag}}' | tail -n +6 | xargs -r -I{} docker rmi myapp:{} || true"
// 개선5 - 이름 없는(dangling) 이미지 정리
sh 'docker image prune -f'
}
}
}
물론 현재는 학습하는중이라서 딱히 뭐 없지만 실전에서는 레지스트리에 push한다고 한다.
지금: Jenkins 빌드 → 미니 PC 로컬에 이미지 → 같은 미니 PC에서 실행
실전: Jenkins 빌드 → 레지스트리에 push → 운영 서버들이 pull 해서 실행
들어보니 외부 Docker Hub 대신 사내에 Harbor나 Nexus 같은 레지스트리를 설치해서 쓰는 것이 일반적이라고 한다.
3. Poll SCM과 Webhook
3.1 두 방식의 차이
2탄부터 써 온 Jenkinsfile의 트리거 부분이다.
triggers {
pollSCM('H/5 * * * *') // 5분마다 GitHub에 새 커밋이 있는지 물어본다
}
이 한 줄이 Poll SCM 방식이다. 반대 방식인 Webhook과 비교하면 이렇다.
Poll SCM : Jenkins → GitHub "새 커밋 있어?" (5분마다 물어봄)
Webhook : GitHub → Jenkins "방금 push 됐어!" (push 순간 알려줌)
| 구분 | Poll SCM | Webhook |
| 시작 지연 | 최대 polling 간격(지금 5분) | 거의 즉시 |
| 불필요한 요청 | 변경이 없어도 계속 물어봄 | push 있을 때만 |
| 네트워크 조건 | Jenkins가 밖으로 나갈 수만 있으면 됨 | GitHub가 Jenkins로 들어올 수 있어야 함 |
| 설정 | Jenkinsfile 한 줄 | GitHub 저장소 설정 + Jenkins 공개 주소 필요 |
일반적으로는 Webhook이 더 빠르고 효율적이라 실무 기본값으로 많이 쓴다.
3.2 지금 환경에서는 Poll이 합리적이다.
여기서 네트워크 공부했던 내용이 연결된다.
미니 PC는 집 공유기 뒤에 있고, Jenkins는 127.0.0.1:8080에만 열려 있다.
GitHub가 Webhook을 보내려 함
→ 목적지는 우리 집 공인 IP
→ 공유기(NAT): "안에서 먼저 나간 연결이 아닌데? 누구한테 줘?" → 버림
→ 설령 통과해도 Jenkins는 127.0.0.1에만 열려 있어 받을 수 없음
반대로 Poll은 Jenkins가 안에서 밖으로 먼저 요청하는 방식이라 NAT 뒤에서도 그냥 동작한다.
즉 지금의 Poll SCM은 실수가 아니라 환경에 맞는 선택이었다. 다만 1~4탄에서 이유를 적지 않았을 뿐이다.
3.3 Webhook을 쓰고 싶다면
| 방법 | 내용 | 주의 |
| Cloudflare Tunnel로 Jenkins의 webhook 경로만 노출 | 미니 PC가 먼저 Cloudflare로 연결을 열어 두므로 포트 포워딩 불필요 | Jenkins 전체가 아니라 webhook 경로만 열고, GitHub webhook secret 검증 필수 |
| 사내망(폐쇄망) | 사내 GitLab과 Jenkins가 같은 내부망이면 NAT 문제 자체가 없음 | 실무에서 가장 흔한 구성 |
| Poll 간격 조정 | 1~2분으로 줄이기 | GitHub API 요청이 늘어남 |
그렇다고 한다...
필자는 원래 학습 방식이 이렇게 실습을 많이 하려는 타입은 아니었다.
그런데 이렇게 안하면 학습을 안하고, 무언가 강의만 들으면 실질적으로 동떨어진 지식만 듣고 흘리는 느낌이 강했다.
그래서 흥미가 생기는 주제에 대해서 바로 바로 선택을 해서 직접 학습 기록을 만드는 쪽으로 방향을 전환했다.
하여간 위의 내용이 정말 맞는지 실제로 테스트를 해보고 확인해야하는데 필자의 흥미가 이제 슬슬 떨어진다.
학습에서 가장 중요한 사항은 흥미다! 회사일이야 뭐... 억지로 깡으로 하면 되는건데 스스로의 학습은 그게 잘 안되는거같다.
CI/CD 맛보기는 여기까지 좀 하고 당분간 또 다른 주제로 학습을 이어가보도록 하겠다.
회사에서 미처 이해하지 못하거나 캐치하지 못한 내용들은 언제든 시간이 나면 정리를 할 것이다.
그러면서 다음 목표는 DVA-C02취득이 되지 않을까 생각한다.
참고
Understanding How the Docker Daemon and Docker CLI Work Together
OWASP : Docker Security Cheat Sheet