LogoSkills

dev-machine-disk-hygiene

개발 워크스테이션이 동시에 self-hosted 러너 호스트일 때 디스크가 고갈되는 구조와 안전한 회수 절차 — 비정상 종료한 dart test 가 $TMPDIR 에 남기는 고아(개당 최대 23GB·하루 33GB), 격리·워밍 규칙이 만든 러너별 캐시 사본(SDK 195GB·Gradle 102GB), 프로세스 미참조 + N시간 미변경 2중 삭제 기준,...

개발 머신 디스크 고갈 — 사본을 만든 규칙은 지우지 않는다#

한마디로#

개발용 컴퓨터 한 대가 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·기기 심볼·구버전 SDK29.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-<러너>15GBpub 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 listparentWorktreeId 를 보세요. 자식이 매달린 워크트리는 남깁니다(실측: 머지 완료된 워크트리에 활성 자식 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>.plistStartInterval 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)
  • 자동 정리가 등록·동작하는가

관련#