self-hosted runner 디스크 고갈 - Docker 고아 컨테이너 누적

GitHub Actions self-hosted runner에서 ENOSPC로 CI 빌드가 실패했다. docker buildx가 매 빌드마다 builder를 생성하고 정리하지 않아 고아 컨테이너와 볼륨이 2.3GB 누적된 것이 원인이었다.

환경: GitHub Actions self-hosted runner (EC2, 8GB gp3 EBS), Docker buildx 날짜: 2026-02-02

상황

GitHub Actions self-hosted runner에서 CI 빌드가 ENOSPC 오류로 실패했다. 디스크 공간이 부족하다는 단순한 오류였지만, 이 runner는 최근에 프로비저닝한 머신이고 대용량 파일을 저장한 적이 없었다. 뭔가 예상치 못한 것이 디스크를 잡아먹고 있다는 뜻이었다.

관찰한 사실

디스크 사용량을 먼저 확인했다.

$ df -h
Filesystem        Size  Used Avail Use% Mounted on
/dev/nvme0n1p1    8.0G  7.5G  522M  94% /

8GB 중 7.5GB가 사용 중이고 가용 공간이 522MB뿐이었다. 이 runner에서 주로 돌아가는 건 Docker 빌드다. Docker가 디스크를 점유하고 있을 가능성이 높다고 판단하고 Docker 리소스 현황을 확인했다.

$ sudo docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          3         1         669.8MB   438.3MB (65%)
Containers      6         6         0B        0B
Local Volumes   6         6         1.839GB   0B (0%)
Build Cache     0         0         0B        0B

이미지가 670MB, 볼륨이 1.8GB를 차지하고 있었다. 그런데 눈에 띄는 건 컨테이너 6개가 전부 Active 상태라는 점이었다. CI runner에서 6개의 컨테이너가 동시에 돌아갈 이유가 없다. 빌드는 한 번에 하나만 실행되기 때문이다.

가설과 검증 과정

가설: 이미지 캐시가 누적되었다

self-hosted runner에서 Docker를 쓰면 이미지와 빌드 캐시가 쌓이는 게 일반적이다. 미사용 이미지에서 438MB를 회수할 수 있으니 정리하면 해결될 거라 생각했다.

하지만 438MB를 회수해도 가용 공간이 ~960MB밖에 안 된다. 근본적인 해결이 아니었다. 이미지보다 볼륨(1.8GB)이 훨씬 크고, 6개의 Active 컨테이너가 정체불명이라는 점이 더 의심스러웠다.

결과: 부분 채택. 이미지 캐시도 정리 대상이지만 핵심 원인이 아니다. 6개의 Active 컨테이너가 진짜 범인이었다.

전환점: docker ps와 docker buildx ls의 불일치

실행 중인 컨테이너가 뭔지 확인해봤다.

$ sudo docker ps
CONTAINER ID   IMAGE                           NAMES
15f64444195a   moby/buildkit:buildx-stable-1   buildx_buildkit_builder-5deeedd3-...0
ec379555992a   moby/buildkit:buildx-stable-1   buildx_buildkit_builder-c2f87100-...0
4472433fc19e   moby/buildkit:buildx-stable-1   buildx_buildkit_builder-56b7a07e-...0
... (총 6개, 전부 buildx builder)

6개 전부 moby/buildkit 이미지를 실행하는 buildx builder 컨테이너였다. 각각 UUID 기반 이름이 붙어 있었다. 그런데 buildx에서 이 builder들을 인식하는지 확인해보니:

$ sudo docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS  BUILDKIT PLATFORMS
default * docker
  default default         running v0.12.5  linux/arm64, linux/arm/v7, linux/arm/v6

buildx 관리 목록에는 아무것도 없었다. docker ps에서는 6개가 보이지만 docker buildx ls에서는 인식하지 못한다. 이 컨테이너들은 buildx 관리에서 이탈한 고아 상태였다.

이 시점에서 원인이 명확해졌다. 매 빌드마다 docker buildx가 새로운 builder 컨테이너를 UUID 기반으로 생성하는데, 빌드 완료 후 이전 builder를 정리하지 않는다. 빌드가 반복될수록 고아 컨테이너와 볼륨이 누적되는 것이다.

왜 self-hosted runner에서만 발생하는가?

GitHub-hosted runner는 매 빌드마다 새 VM이 생성되고, 빌드 후 VM 자체가 삭제된다. Docker 잔여물이 남을 수가 없다. 반면 self-hosted runner는 같은 VM을 계속 재사용하므로, 정리하지 않으면 누적이 쌓인다.

sequenceDiagram
    participant CI as CI 워크플로우
    participant BX as docker buildx
    participant D as 디스크 (8GB)

    CI->>BX: 빌드 #1 (builder-aaa 생성)
    BX->>D: 컨테이너 + 볼륨 (~300MB)
    Note over BX: 빌드 완료, builder 미정리

    CI->>BX: 빌드 #2 (builder-bbb 생성)
    BX->>D: 컨테이너 + 볼륨 (~300MB)
    Note over BX: 빌드 완료, builder 미정리

    CI->>BX: 빌드 #N
    BX--xD: ENOSPC (디스크 포화)

빌드당 약 300MB의 볼륨이 생성되므로, 8GB 디스크 기준 약 20~25회 빌드 후 디스크가 가득 찬다.

근본 원인

docker buildx가 매 빌드마다 새로운 builder 컨테이너를 생성하고 정리하지 않아, 고아 컨테이너와 볼륨이 누적됐다. self-hosted runner의 VM 재사용 특성과 결합되어, CI 워크플로우에 cleanup 로직이 없으면 반드시 디스크가 고갈된다. 총 낭비: 미사용 이미지 438MB + 고아 볼륨 1.8GB = 약 2.3GB (8GB 디스크의 29%).

해결 방법

즉시 조치: 수동 정리

고아 컨테이너와 볼륨을 수동으로 정리했다.

# 고아 builder 컨테이너 중지 및 삭제
sudo docker stop $(sudo docker ps -q --filter "name=buildx_buildkit")
sudo docker rm $(sudo docker ps -aq --filter "name=buildx_buildkit")
 
# 고아 볼륨 및 미사용 이미지 정리
sudo docker volume rm $(sudo docker volume ls -q)
sudo docker system prune -af --volumes

정리 후 실패했던 워크플로우를 re-run하니 빌드가 성공했다.

근본 수정: daily cleanup cron

같은 문제가 재발하지 않도록 runner에 daily cron job을 추가했다.

sudo tee /etc/cron.daily/docker-cleanup << 'EOF'
#!/bin/bash
docker buildx rm --all-inactive --force || true
docker system prune -af --volumes || true
EOF
sudo chmod +x /etc/cron.daily/docker-cleanup

워크플로우별로 cleanup 단계를 추가하는 방법도 있었지만, cron을 선택한 이유는 레포마다 워크플로우를 수정할 필요 없이 모든 프로젝트에 일괄 적용되기 때문이다. 나중에 필요하면 이 cron job을 runner AMI에 포함시켜 새 runner를 띄울 때 자동으로 설정되게 할 수 있다.

버퍼 확보 차원에서 EBS도 8GB에서 30GB로 증설했다(사용률 67% → 19%).

교훈

  • self-hosted runner에서 CI가 갑자기 실패하면 df -h를 먼저 확인해라. ENOSPC는 코드 문제가 아닌 환경 문제다. 같은 코드가 어제는 빌드되고 오늘 안 된다면 디스크를 의심해라.
  • docker psdocker buildx ls 결과가 다르면 고아 컨테이너다. buildx 관리 목록에서 이탈한 컨테이너는 자동 정리 대상에서도 빠진다. 수동으로 삭제해야 한다.
  • GitHub-hosted runner와 self-hosted runner는 근본적으로 다른 환경이다. GitHub-hosted는 VM이 일회용이라 cleanup이 불필요하지만, self-hosted는 누적이 쌓인다. self-hosted를 쓴다면 cleanup 자동화를 반드시 설정해야 한다.