개발 머신 디스크 고갈 — 사본을 만든 규칙은 지우지 않는다#
한마디로#
개발용 컴퓨터 한 대가 CI 서버까지 겸하면, 디스크는 세 방향에서 동시에 채워집니다. 테스트가 남긴 임시 파일, 실행 슬롯마다 따로 받아 둔 SDK, 슬롯마다 분리해 둔 패키지 캐시. 셋 다 각자의 이유로 옳게 도입된 것이고, 문제는 아무도 그것들을 언제 지울지 정하지 않았다는 데 있습니다. 그래서 디스크는 서서히, 그러나 확실히 찹니다.
무엇을·언제#
⚠️ 먼저 어느 파일시스템인지 가르세요#
"디스크가 없다" 는 증상은 같아도 대상이 둘입니다. 섞으면 엉뚱한 곳을 정리합니다.
| 대상 | 증상 | 가드 |
|---|---|---|
| 컨테이너 런타임 게스트 (Colima/Docker VM) | 컨테이너가 No space left on device 로 죽는다 |
ensure_docker_ready.sh 류의 임계 기반 prune 사다리 |
| 호스트 파일시스템 | df 여유가 없다, 빌드·테스트가 전반적으로 실패 |
이 스킬 |
게스트 가드가 있다고 호스트가 안전한 것이 아닙니다 — 이번 실사고가 정확히 그 경우였습니다.
| 상황 | 이 스킬이 주는 것 |
|---|---|
| 디스크 여유가 급감했다 | 어디부터 재야 하는가 (홈이 아니다) |
| 캐시를 지워도 되나 | 실행 중인 CI 를 죽이지 않는 2중 판정 기준 |
| SDK 버전을 bump 했다 | 직전 버전 사본을 회수하는 절차 |
| 정리를 자동화하고 싶다 | launchd 스크립트와 안전 기준 |
실사고 — 994GB 볼륨에서 여유 6.1GB (2026-08-24)#
정리로 회수한 415GB 의 내역이 원인 구조를 그대로 보여줍니다. 러너 8개 환경입니다.
| 축 | 회수 | 만든 주체 |
|---|---|---|
$TMPDIR 고아 임시 디렉터리 |
137.4 GB | 비정상 종료한 dart test |
| 러너별 구버전 Flutter SDK 40개 | 157.9 GB | 워밍 규칙의 "한 번 받으면 영구" |
| 러너별 Gradle·pub-cache 사본 | 86.2 GB | 격리 규칙의 인스턴스별 분리 |
| Xcode DerivedData·기기 심볼·구버전 SDK | 29.0 GB | 로컬 개발 |
| 머지 완료 워크트리 | 4.4 GB | 방치 |
⚠️ 어느 항목도 결함이 아닙니다. 격리는 캐시 덮어쓰기 사고를, 워밍의 영구 캐시는 cold 다운로드 타임아웃을 막으려고 도입됐습니다. 각각 옳았고, 그 대가인 사본의 수명을 정한 규칙만 없었습니다. 이 스킬이 그 빈칸입니다.
① $TMPDIR 고아 — 하루 ~33GB#
dart test 가 비정상 종료하면 $TMPDIR/dart_test.kernel.* 가 남습니다.
개당 최대 23GB, 실측 25개 = 165GB(5일치). flutter_tools.* 도 39개 8.9GB.
du -x -k -d 1 " $(getconf DARWIN_USER_TEMP_DIR) " | sort -rn | head
러너 여러 개와 병렬 세션이 상시 테스트를 돌리면 재발은 예외가 아니라 기본값입니다. 정리 자체를 자동화해야 합니다(아래 §자동 정리).
② 러너 사본 — 격리·워밍의 대가#
실행 슬롯이 N개면 사본도 N개입니다. 버전 하나가 남을 때마다 N배로 잠깁니다.
| 자원 | 실측(러너 8개) | 삭제하면 |
|---|---|---|
_work/_tool/flutter/<버전> |
6버전 × 8 = 195GB | 재다운로드 — 실패가 아니라 5~25분 지연 |
$HOME/.gradle-<러너> | 102GB | 의존성 재다운로드 |
$HOME/.pub-cache-<러너> | 15GB | pub get 재수행 |
🔴 보존 목록은 러너를 공유하는 모든 리포의 합집합이다#
⚠️ 이 스킬의 이전 판은 여기서 틀렸습니다 — "SDK 버전 SSOT(.fvmrc + 워크플로의
FLUTTER_VERSION)를 확인하세요" 라고만 적어 어느 리포의 것인지를 빠뜨렸습니다.
그대로 따르면 사고가 재현됩니다.
러너 fleet 는 대개 여러 리포가 공유합니다.
ls ~/actions/ < 러너 > /_work/
# climb coui good-teacher kobic open-board unibook ← 6개 리포
실측에서 리포마다 현행 버전이 달랐습니다: 두 리포는 3.47.0, 하나는 3.44.8, 둘은 3.41.6. 한 리포(3.47.0) 기준으로만 판정해 나머지 셋의 현행 SDK 를 지웠고, 삭제한 3.41.6 이 같은 날 저녁 러너 2곳에 재생성됐습니다 — 그 리포들의 CI 가 재다운로드한 흔적입니다.
⚠️ 실패는 아닙니다(타임아웃 헤드룸이 cold 다운로드를 흡수). 그러나 예방 가능한 5~25분 지연을 러너 수만큼 유발했고, 원인은 판정 범위를 좁게 잡은 것입니다.
⇒ 보존 목록을 손으로 적지 마세요. 매 실행마다 러너에서 수집해 합집합을 만듭니다.
for fvmrc in " $ACTIONS " /*/_work/*/*/.fvmrc; do
sed -n ' s/.* " flutter " [[:space:]]*:[[:space:]]* " \([0-9][0-9.]*\) " .*/\1/p ' " $fvmrc "
done > > " $KEEP "
for wf in " $ACTIONS " /*/_work/*/*/.github/workflows/*.y*ml; do
grep -hoE " (FLUTTER_VERSION|flutter-version):[[:space:]]*[\ " ' ]?[0-9]+\.[0-9]+\.[0-9]+ " " $wf " \
| grep -oE " [0-9]+\.[0-9]+\.[0-9]+ "
done > > " $KEEP "
sort -u " $KEEP " -o " $KEEP "
# ⭐ 수집 실패 시 폭주 방지 — 이 가드가 없으면 " 보존할 게 없으니 전부 삭제 " 가 된다
[ -s " $KEEP " ] || { echo " ⛔ 보존 버전 0건 — 중단 " ; exit 1; }
⛔ 로컬 SDK 관리자의 기본 버전은 지우지 마세요. 실측 당시 ~/fvm/default 가 가리키던
버전을 MCP 서버·분석기 등 55개 프로세스가 실행 중이었습니다. 러너 툴캐시와 로컬
SDK 는 별개입니다.
D-1. 삭제 기준은 2중이다 — 하나로는 부족하다#
삭제 대상이 지금 이 순간에도 생기고 사라집니다. 실측 중 한 디렉터리가 조회 사이에 나타났다 없어졌습니다.
① 프로세스·lsof 가 참조하지 않을 것 (스냅샷 — 레이스가 있다)
② N시간 이상 내용이 변경되지 않았을 것 (레이스를 덮는다)
①만으로는 조회와 삭제 사이의 창을 못 막고, ②만으로는 오래 도는 잡을 죽입니다. 테스트 임시 디렉터리는 6시간, 러너 캐시는 72시간이 실측상 안전했습니다.
⚠️ 활성 항목을 지우면 남의 CI 가 죽습니다 — 실측 당시 23GB짜리 하나가 실행 중이었고, 그것이 가장 큰 항목이었습니다. "제일 큰 것부터" 는 위험한 순서입니다.
D-2. du -sh ~/* 로는 원인이 안 보인다#
이번 179GB 는 /private/var/folders/... 에 있었고, 홈의 숨김 디렉터리
(~/.gradle* 126GB, ~/.dartServer 26GB)도 그 글롭에서 빠집니다.
du -x -k -d 1 " $HOME " # 숨김 포함
du -x -k -d 1 " $(getconf DARWIN_USER_TEMP_DIR) "
첫 조사에서 ~/* 합계와 df 사용량이 487GB 어긋났는데, 그 차이가 곧 원인이었습니다.
⭐ 합이 안 맞으면 그 차이를 쫓으세요.
D-3. macOS 에 timeout 이 없다 — 전부 조용히 실패한다#
timeout 90 du -sh "$d" 를 17개 디렉터리에 돌렸더니 17개 전부 빈 결과였습니다.
"측정했는데 안 나왔다" 로 읽힐 뻔했고, 원인은 timeout 명령 자체의 부재였습니다.
which timeout gtimeout || echo " 없음 — 백그라운드 + 파일로 우회 "
D-4. zsh 는 단어 분할을 하지 않는다#
TARGETS= " 758 2971 2979 "
for p in $TARGETS; do kill " $p " ; done # ⛔ zsh: 통째로 한 인자 → 전부 실패
출력이 KILL 758 2971 2979 한 줄로 나오고, 이어진 ps -p "$p" 확인도 같은 이유로
오판해 "전부 잔존"이라고 잘못 보고했습니다. set -- 758 2971 2979 + "$@"
로 고칩니다.
D-5. 중단한 스크립트의 자식이 살아남아 다음 판정을 오염시킨다#
pkill -f <스크립트> 는 셸만 죽입니다. 그 자식 find <경로> -exec stat 가 계속 돌았고,
커맨드라인에 그 경로가 있어서 다음 실행의 "사용 중" 검사가 오탐했습니다.
⇒ 중단 후 ps 로 자식까지 확인하고, 판정 함수에서 자기 자신을 제외하세요.
inuse(){ ps -eo command | grep -F " $1 " | grep -v grep | grep -qv " $(basename " $0 " ) " ; }
D-6. find -exec stat {} \; 는 큰 트리에서 끝나지 않는다#
파일마다 프로세스를 fork 하므로 Gradle 캐시(수십만 파일)에서 사실상 정지합니다.
| 목적 | 쓸 것 |
|---|---|
| 최근 변경 여부만 | find "$d" -newermt "-${H}H" -print -quit (첫 발견 즉시 중단) |
| 디렉터리 단위 판정 | stat -f '%m' "$d" — 러너 캐시는 이걸로 충분하다 |
D-7. 자식 프로세스는 커맨드라인에 경로가 없을 수 있다#
삭제된 워크트리의 좀비 7개를 정리할 때, DDS 프로세스만 커맨드라인에 경로가 없어 "무관" 으로 분류될 뻔했습니다. 포트와 부모 체인으로 소속을 확정합니다.
lsof -nP -iTCP: < vm-service-port > # 어느 프로세스에 붙어 있나
ps -p < pid > -o ppid= # 고아면 ppid=1
그 좀비들은 3일 23시간 살아 있었고 워크트리는 이미 삭제된 뒤였습니다. 디스크는 점유하지 않았지만 DB 포트를 잡고 있었습니다.
워크트리 정리는 디스크 대책이 아니다#
워크트리는 개당 0.5~3.8GB 입니다(실측 14개 합계 29.5GB). 디스크를 위해 지울 대상이 아니며, 정리정돈 목적임을 알고 판단하세요. 그리고:
-
dirty=0은 유효기간이 있습니다. 조사 때 0이던 워크트리가 40분 뒤 미커밋 239개였습니다 — 그 사이 다른 세션이 새 브랜치로 작업을 시작했습니다. 삭제 직전에 다시 재세요 (parallel-session-collision). -
orca worktree list의parentWorktreeId를 보세요. 자식이 매달린 워크트리는 남깁니다(실측: 머지 완료된 워크트리에 활성 자식 2개). -
orca worktree rm은 미커밋 변경이 있으면 거부합니다 — 이 거부가 마지막 안전망이라--force는 위 재확인을 통과한 뒤에만 씁니다.
자동 정리 (launchd)#
수동 정리는 재발을 막지 못합니다(하루 33GB). 위 D-1 기준을 그대로 구현한 스크립트를 주기 실행합니다.
#!/bin/sh
# $TMPDIR 의 dart_test.kernel.* / flutter_tools.* 고아 회수
# 안전 기준 2중: ① 프로세스·lsof 미참조 ② MIN_AGE_H 시간 이상 미변경
T=$(getconf DARWIN_USER_TEMP_DIR); [ -d " $T " ] || exit 0
MIN_AGE_H=${MIN_AGE_H:-6}; NOW=$(date +%s)
INUSE=$(mktemp) || exit 1; trap ' rm -f " $INUSE " ' EXIT
ps -eo command | grep -oE ' (dart_test\.kernel|flutter_tools)\.[A-Za-z0-9_]+ ' | sort -u > " $INUSE "
lsof -n 2 > /dev/null | grep -oE ' (dart_test\.kernel|flutter_tools)\.[A-Za-z0-9_]+ ' | sort -u > > " $INUSE "
sort -u " $INUSE " -o " $INUSE "
for d in " $T " /dart_test.kernel.* " $T " /flutter_tools.*; do
[ -d " $d " ] || continue
grep -qx " $(basename " $d " ) " " $INUSE " & & continue
m=$(find " $d " -exec stat -f ' %m ' {} \; 2 > /dev/null | sort -rn | head -1)
[ -z " $m " ] & & continue
[ $(( (NOW - m) / 3600 )) -lt " $MIN_AGE_H " ] & & continue
rm -rf " $d "
done
러너 캐시(SDK·Gradle·pub-cache)는 별도 스크립트·별도 주기로 나눕니다 — 임시 디렉터리는 하루 단위로 쌓이지만(실측 33GB/일) 캐시는 주 단위라, 한 스크립트에 묶으면 무거운 쪽이 가벼운 쪽의 주기를 지배합니다. 실측 구성은 임시 6시간 · 러너 캐시 24시간입니다.
~/Library/LaunchAgents/<label>.plist 에 StartInterval 21600(6시간)·RunAtLoad false·
LowPriorityIO true 로 등록하고 launchctl bootstrap gui/$(id -u) <plist>.
등록 전에 한 번 손으로 실행해 활성 항목이 보존되는지 확인하세요.
⚠️ 정리 자동화는 다음 조사의 용의자가 됩니다.
$TMPDIR에서 파일이 사라지는 다른 증상(레이스·EDR·러너 정리 등)을 누군가 조사할 때, 이 launchd 가 가장 먼저 의심받습니다. 무엇을·얼마나 오래된 것만 지우는지를 스크립트 헤더와 이슈에 명시해 두면 그 배제가 1분 만에 끝납니다.
체크리스트#
디스크가 급하다고 할 때
$TMPDIR부터 쟀는가 — 홈이 아니다 (D-2)du합계와df사용량의 차이를 확인했는가timeout이 실제로 있는지 확인했는가 (D-3)
지우기 전
- 프로세스·lsof 미참조 와 N시간 미변경을 둘 다 봤는가 (D-1)
- 러너 캐시라면 공유 리포 전체의 현행 버전 합집합을 보존했는가 — 손으로 적지 말고 수집 스크립트의 출력을 볼 것
- 로컬 SDK 관리자의 기본 버전을 제외했는가
- 워크트리라면 삭제 직전에 dirty 를 다시 쟀는가
지운 뒤
- 중단한 정리 스크립트의 자식 프로세스가 남지 않았는가 (D-5)
- 자동 정리가 등록·동작하는가
관련#
- ci-runner-isolation — 러너별 캐시 격리. 이 스킬은 그 격리가 만든 사본의 수명을 다룬다 (상보 — 그쪽 없이는 캐시가 깨지고, 이쪽 없이는 디스크가 찬다)
- self-hosted-runner-warming — tool 캐시는 "한 번 받으면 영구". 그 영구성이 곧 누적이다
-
parallel-session-collision — 워크트리
dirty=0이 20분 만에 뒤집히는 이유 - orca-worktree-lifecycle — 워크트리 생성·정리 절차