포스트

docker system prune이 배포를 망친 날

docker system prune이 배포를 망친 날

GitHub Actions 배포가 갑자기 실패했다. 에러:

1
2
failed to get destination image "sha256:454ddd...":
No such image: sha256:454ddd...

전날까지 잘 되던 배포가 왜?

원인

deploy.yaml 빌드 스텝 마지막에 이게 있었다:

1
docker system prune -f

디스크 정리 목적이었는데, docker system prune은 사용하지 않는 모든 것을 삭제한다. 빌드 캐시, 중간 레이어, 심지어 베이스 이미지까지.

다음 빌드에서 FROM python-base:3.13을 참조하는데 베이스 이미지가 날아가서 실패한 거다. 연속 배포하면 첫 번째는 성공하고 두 번째부터 깨진다.

이걸 두 번 겪었다.

해결 (1차 시도 — 실패)

1
2
3
4
5
# 이전 (위험)
docker system prune -f     # 빌드 캐시 + 베이스 이미지까지 삭제

# 1차 변경 (여전히 위험)
docker image prune -f      # dangling 이미지만 삭제... 하지만 베이스 레이어도 삭제됨

docker image prune -f도 베이스 이미지의 중간 레이어를 삭제했다. docker save 시 parent chain을 따라가다가 삭제된 레이어를 만나면 같은 에러가 난다.

해결 (2차 — 성공)

자동 prune을 완전히 제거했다. CI/CD 파이프라인에서 빌드 후 정리하는 코드를 전부 삭제. 디스크가 차면 수동으로 특정 이미지만 docker rmi로 지운다.

추가로 이미지 태그를 고정 버전에서 커밋 SHA로 자동 생성하는 구조로 전환했다. 매번 다른 태그이니 덮어쓰기가 없고, 롤백도 이전 SHA로 되돌리면 끝.

교훈

docker system prune도, docker image prune도, CI/CD에서 자동으로 돌리면 안 된다. docker save | k3s ctr images import 방식에서는 로컬 이미지 레이어가 온전해야 한다. 정리는 수동으로, 필요할 때만.

이후 업데이트 (2026.03.30): “정리는 수동으로”가 정답이 아니었다. 아래 속편 참조.

속편 — 안 지워서 서버가 죽었음 (2026.03.30)

같은 날 몇 시간 후, 서비스가 521로 죽었다.

1
2
$ kubectl describe node
DiskPressure   True   KubeletHasDiskPressure

/var/lib/docker가 41GB. 위에서 prune을 제거한 뒤, 화이트리스트 방식(docker rmi)으로 태그된 이미지만 정리하게 바꿨는데, 빌드할 때마다 생기는 <none>:<none> (dangling) 이미지는 태그가 없어서 이 로직을 통과했다. 서버 셋업 이후 누적된 dangling이 디스크 85%까지 채워서 kubelet이 모든 Pod를 evict.

1
2
3
4
5
6
7
8
$ du -sh /var/lib/docker
41G

$ docker images | grep '<none>' | wc -l
약 30개 (각 1.2~1.4GB)

$ docker image prune -f
Total reclaimed space: 32.89GB

뭘 틀렸나

기존 cleanup은 태그된 이미지만 정리했다:

1
2
3
4
# 기존 — 태그된 이미지 중 values에 없는 것만 삭제
docker images --format "..." | while read img; do
  echo "$KEEP" | grep -q "$TAG" || docker rmi $img
done

dangling 이미지(<none>:<none>)는 docker images에 나오지만 태그가 없어서 이 로직을 통과한다. 빌드할 때마다 1.3GB씩 쌓이는데 아무도 안 지움.

최종 해결

화이트리스트 정리 + dangling 정리를 같이 한다:

1
2
3
4
5
6
# 1) 태그된 이미지 중 values에 없으면 삭제
docker images --format "..." | while read img; do
  echo "$KEEP" | grep -q "$TAG" || docker rmi $img
done
# 2) dangling 이미지 삭제 (빌드 캐시/중간 레이어가 아닌, 태그 없는 완성 이미지만)
docker image prune -f

docker image prune -f<none>:<none> 이미지만 삭제한다. 태그가 붙은 베이스 이미지(python-base:3.13)나 빌드 캐시 레이어는 건드리지 않는다.

이전에 docker image prune이 문제였던 건, 같은 태그를 덮어쓰는 구조에서 이전 이미지가 dangling이 되어 베이스 이미지 레이어까지 삭제됐기 때문이다. SHA 태그로 전환한 지금은 매번 새 태그가 붙으니 이전 빌드 결과만 dangling이 되고, 베이스 이미지는 태그가 유지되어 안전하다.

kubelet DiskPressure 임계값

kubelet은 기본적으로 디스크 남은 공간이 15% 이하일 때 DiskPressure를 선언한다:

1
eviction-hard: nodefs.available<15%

75GB 디스크 × 15% = 11.25GB. 남은 공간이 11GB 아래로 떨어지면 모든 Pod 스케줄링 중단.

후일담 — parent chain 에러

dangling 정리를 cleanup 스텝(빌드 후)에만 넣었더니, 다음 빌드에서 또 실패했다:

1
failed to set parent: unknown parent image ID sha256:...

Docker legacy builder가 --no-cache 빌드를 할 때 이전 빌드의 중간 레이어를 참조하는데, cleanup에서 삭제되면 parent chain이 깨진다. 빌드 전에 prune을 넣어봤지만 같은 에러가 반복됐다 — legacy builder 자체의 한계였다.

왜 Docker + containerd 이중 구조인가

k3s는 컨테이너 런타임으로 containerd를 사용한다. 하지만 이미지 빌드는 containerd로 못 한다 — containerd는 런타임이지 빌더가 아니다. 그래서 Docker를 별도 설치해서 빌드하고, docker save | k3s ctr images import로 containerd에 넘기는 구조다. 이 이중 구조 때문에 Docker 쪽 정리를 안 하면 문제가 생긴다.

최종 해결 — buildx

Docker legacy builder를 버리고 BuildKit(buildx)으로 전환했다.

1
2
3
4
5
6
7
# 설치
curl -SL https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 \
  -o /usr/local/lib/docker/cli-plugins/docker-buildx
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx

# 사용
docker buildx build --no-cache --load -t image:tag -f Dockerfile .

--load는 빌드 결과를 로컬 Docker에 저장하는 옵션 (기존 docker build와 동일하게 docker save로 내보낼 수 있음). BuildKit은 멀티스테이지 빌드를 병렬로 처리하고, parent chain 문제가 없다.

교훈 (최종)

  • Docker legacy builder는 멀티스테이지 빌드에서 parent chain이 깨질 수 있다 → buildx 사용
  • docker image prune -f는 빌드 에만 (dangling 누적 방지)
  • docker system prune은 절대 금지
  • Docker + containerd 이중 구조에서는 Docker 정리가 필수
  • 디스크 모니터링 알림이 없으면 DiskPressure는 예고 없이 온다
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.