IT/CI CD

새벽에 GHA self-hosted runner가 디스크로 뻗은 이야기

gfrog 2026. 8. 15. 03:18

지난주 화요일 새벽 3시 반쯤, 슬랙 알림에 깼다. "CI 전체 실패 중" 이라는 온콜 메시지. 눈 비비고 노트북 열었더니 GitHub Actions 화면이 온통 빨간색이었다. 로그를 열어보니 다들 같은 메시지를 뱉고 있었다.

No space left on device

우리 팀은 self-hosted runner를 EC2 위에 올려서 쓴다. 처음 세팅했을 때부터 "언젠가 디스크 문제 터진다"는 거 알고 있었는데, 대응을 미루다가 결국 새벽에 얻어맞은 케이스였다. 그날 겪은 삽질을 정리해둔다.

상황

우리 러너 구성은 대략 이랬다.

  • c6i.2xlarge EC2 3대, ephemeral 아니고 long-running
  • gp3 100GB 루트 볼륨 하나
  • Docker in Docker로 이미지 빌드/테스트
  • 하루 평균 400개 정도 job

df -h 쳐보니 루트가 99% 찼다. 100GB 중에 남은 게 800MB 정도. 어디서 잡아먹었나 봤더니:

/var/lib/docker         62G
/home/runner/_work      21G
/var/log/journal        4.2G

Docker가 62GB. 예상은 했는데 생각보다 많이 먹었다. 새벽이라 그냥 손이 먼저 나갔다. docker system prune -af --volumes. 이걸 3대에 다 돌리니 40GB 정도 확보되면서 큐에 밀려있던 job이 다시 돌기 시작했다. 일단 급한 불은 껐다.

그런데 여기서 끝났으면 이 글을 안 썼을 거다.

왜 이렇게 쌓였나

아침에 다시 커피 들고 앉아서 진짜 원인을 팠다. docker system df 로 보면:

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          847       12        58.3GB    54.1GB (92%)
Containers      12        3         120MB     40MB
Local Volumes   234       0         2.1GB     2.1GB (100%)
Build Cache     0         0         0B        0B

이미지가 847개. 이 중 실제로 살아있는(태그 붙어있고 컨테이너에 물린) 게 12개. 나머지 835개는 다 dangling 이거나 예전 CI job이 pull 해놓고 안 지운 것들이었다.

원인은 세 가지였다.

첫 번째, docker build 를 반복하면서 중간 레이어가 계속 쌓였다. 우리 workflow는 매 PR마다 새 태그로 이미지를 만드는데, 이걸 정리하는 스텝이 없었다. 그러니 하루 400개 job이 도는 동안 400개 정도 새 이미지가 생기고 하나도 안 지워졌다.

두 번째, GHA cache action이 ~/.cache/에 파일을 계속 밀어넣는데, self-hosted runner에서는 이게 GitHub 서버로 안 가고 로컬에 남는 케이스가 있었다. 정확히는 workflow YAML의 cache 설정이 잘못돼서 그런 거였다. 팀 내부 논의 끝에 이 부분은 결국 workflow 쪽에서 손보기로 했다.

세 번째, journal 로그. 4.2GB. 이건 그냥 journalctl --vacuum-size=500M 로 정리하고 rotation 정책을 걸었다. 여기는 별로 어렵지 않았다.

해결책 두 방향

바로 다음 sprint에 두 가지를 병행했다.

임시 대응: 주기적 청소

crontab에 청소 스크립트 하나 물렸다. 새벽 4시에 도는데, 그 시간대에는 job이 거의 없어서 안전하다.

#!/bin/bash
# /usr/local/bin/runner-cleanup.sh
set -euo pipefail

LOG=/var/log/runner-cleanup.log
exec >> "$LOG" 2>&1

echo "=== $(date -Iseconds) cleanup start ==="

# 현재 실행 중인 job 있는지 확인 (있으면 스킵)
if pgrep -f "Runner.Worker" > /dev/null; then
  echo "runner busy, skip"
  exit 0
fi

# Docker 정리 - 3일 이상 안 쓴 것만
docker system prune -af --volumes --filter "until=72h"

# _work 디렉토리 중 7일 이상 된 것
find /home/runner/_work -maxdepth 2 -type d -mtime +7 \
  -not -path "*/\.*" -exec rm -rf {} + 2>/dev/null || true

# 남은 용량 로깅
df -h / | tail -1

echo "=== cleanup done ==="

주의할 점 하나. pgrep으로 러너 워커 확인하는 걸 반드시 넣어야 한다. 처음에는 이거 없이 돌렸다가 실행 중인 job의 컨테이너를 정리해버려서 CI가 이상하게 실패하는 사고를 냈다. --filter "until=72h" 도 마찬가지 이유로 넣었다. 지금 막 pull한 이미지를 지워버리면 재실행할 때 또 pull 해야 하니까 캐시 효율이 떨어진다.

근본 대응: ephemeral runner로 전환

사실 이게 정답이다. 알고는 있었는데 도입을 미뤄왔다. 이 일 있고 나서 ARC(Actions Runner Controller)로 EKS 위에 ephemeral runner를 붙이는 건 진행 중이다. Job 하나마다 pod 뜨고 끝나면 없어지니까 디스크 누적이 원천적으로 없다.

다만 이것도 은총알은 아니었다. ephemeral로 가면 이미지 pull 시간이 매번 새로 걸리기 때문에, registry mirror 캐시를 별도로 붙여줘야 CI가 안 느려진다. 이 부분은 아직 검증 중이다. Harbor를 pull-through cache 모드로 세팅해서 실험하고 있는데, 결과 나오면 다음에 따로 정리해보려고 한다.

하나만 기억한다면

Self-hosted runner를 long-running으로 운영할 거면, 디스크 모니터링 알람을 반드시 80% 시점에 걸어놔야 한다. 우리는 이걸 안 걸어놔서 새벽에 얻어맞았다. Prometheus node_exporter에 node_filesystem_avail_bytes 알람 하나 추가하는 거, 5분이면 된다. 진짜 5분이면 된다. 그걸 미룬 결과가 새벽 3시 반 페이지였다.

혹시 비슷하게 self-hosted runner 굴리시는 분들 중에, 청소 스크립트 대신 다른 방식으로 잘 관리하시는 분 있으면 댓글로 알려주세요. ARC 말고 다른 방향으로도 궁금하네요.

참고


태그: GitHub Actions, self-hosted runner, DevOps, CI/CD, 삽질기, docker, EC2