2026년 9월 15일 오후, 앱 리포의 dev CI가 Docker VM 안에서 로그를 멈췄다. 시작 4분 뒤였고 빨간불은 안 떴다. 마지막 줄 다음이 그냥 없었고, 41분을 침묵하다 cancelled가 됐다. 오전에 같은 파이프라인이 9분에 두 번 통과한 날이다
앱 CI와 서버 CI가 같은 맥을 나눠 쓰니 서로 밀어내는 거라고 봤다. 그래서 CI 시간을 나누는 쪽으로 처방을 짰다. 나중에 gh run list로 그 시간대를 다시 재 보니 서버 리포의 러너 잡은 0건이었다
그날 침묵은 32분, 45분, 그리고 1시간 50분짜리 실행 안의 실패 두 번이다. 진짜로 하루를 먹은 건 그 시간이 아니라, 그 침묵이 메모리 부족이라는 걸 몰라서 기다린 시간이다. 18:03에 상주 컨테이너 셋을 내리고 같은 코드를 다시 밀었더니 8분 30초에 통과했다
출력이 4분 만에 끊기고 41분 조용했다
터미널을 열어 둔 채로 다른 걸 했다. 20분쯤 지나 다시 봤을 때도 마지막 줄이 그대로였다. 그래서 더 기다렸다
기다린 이유는 단순하다. 빌드가 느린 것과 빌드가 죽은 것이 화면에서 같은 모양이었기 때문이다. 스텝은 러닝 상태고, 스피너는 돌고, 로그만 없다. 이 상태에서 사람이 할 수 있는 판단은 “조금 더 기다린다”뿐이다
그날 실행을 KST로 환산해서 다시 쟀다. createdAt에서 updatedAt까지다
| 시작 → 끝 (KST) | 결과 | 걸린 시간 |
|---|---|---|
| 10:40 → 10:49 | success | 9분 |
| 10:50 → 10:59 | success | 9분 |
| 14:49 → 15:21 | cancelled | 32분 (내가 끊었다) |
| 15:21 → 16:06 | cancelled | 45분 (타임아웃) |
| 16:26 → 18:16 | success | 1시간 50분 |
| 18:23 → 18:32 | success | 9분 |
16:26 → 18:16 줄이 초록인데 1시간 50분이다. 여기서 계측기에 한 번 속았다
gh run list가 주는 conclusion은 마지막 attempt의 결과다. 34941746972는 초록으로 뜨지만 그 안에 죽은 attempt가 둘 들어 있고, 통과한 건 18:08에 시작한 세 번째 attempt다. 소요는 8분 30초였다
즉 초록만 세면 그날 죽은 판이 목록에서 사라진다. 그래서 지금은 결론을 먼저 보지 않고 간격을 먼저 본다
# 정상 실행이 6~10분인 파이프라인에서, 간격이 크게 벌어진 run의 attempt를 편다 gh api repos/:owner/:repo/actions/runs/34941746972/attempts/1 \ --jq '.conclusion, .created_at, .updated_at'
createdAt에서 updatedAt까지가 평소의 몇 배면 그 안을 열어야 한다. run의 결론은 run이 무사했다는 뜻이 아니다
그 시간대 서버 CI 잡은 0건이었다
체감은 “앱과 서버의 CI가 서로 막는다”였다. 두 리포가 같은 맥에 자체 호스팅 러너를 붙여 두고 있으니 자연스러운 그림이다. 그 그림 위에서 나는 “CI 실행 시간을 나누자”를 처방으로 잡았다
처방을 다 짜고 나서야 서버 리포의 실행 목록을 그 구간으로 좁혀 떠 봤다
gh run list --repo <api-repo> --created 2026-09-15 --limit 50 \ --json databaseId,createdAt,updatedAt,conclusion # → 05:49Z ~ 09:16Z 구간 0건 # 마지막 서버 실행은 09-14T22:41Z
침묵이 난 구간에 서버 CI는 한 판도 안 돌았다. 처방이 겨눈 대상이 그 시간에 존재하지 않았다
자리를 다툰 건 서버의 CI가 아니라 서버의 compose 컨테이너 셋이었다. 로컬 개발용으로 낮에 띄워 놓고 그대로 둔 것이고, 그중 kafka가 -Xmx1G -Xms1G로 힙을 선점하고 있었다. 여기에 로컬 푸시 게이트가 얹혔다
“앱과 서버 사이”는 맞았다. 틀린 건 “CI끼리”다. 범위가 틀리면 처방은 존재하지 않는 대상을 겨누게 되고, CI 시간을 나누자는 안이 안 들었던 이유가 정확히 그것이었다. compose는 CI가 아니라 상시 떠 있다
이걸 가르는 데는 gh run list 한 번, 10초면 됐다. 그 10초를 여러 시간 뒤에야 썼다
죽인 것은 VM 안의 global OOM 넷이었다
호스트에서 본 그림은 페이징이었다. 스왑이 22,528 MB 중 21,489 MB 차 있었으니 “메모리가 밀려서 느린 것”으로 읽혔다. 그 방향으로 한참 팠다
축이 틀렸다. Docker Desktop의 컨테이너는 호스트가 아니라 리눅스 VM 안에서 돌고, 그 안의 커널 로그는 호스트에서 안 보인다
docker run --rm --privileged --pid=host alpine \ nsenter -t 1 -m -u -i -n dmesg | tail -50
VM 커널 로그를 뜨니 global OOM이 넷 있었다. 14:53, 15:26, 16:31, 17:25다. 실패한 판의 수와 시각이 맞는다
Out of memory: Killed process 4417 (frontend_server) total-vm:..., anon-rss:1216184kB, file-rss:0kB oom_memcg=/ task_memcg=/docker/... constraint=CONSTRAINT_NONE
희생자가 네 번 다 dart:frontend_server였고, 죽는 순간의 RSS는 1.11에서 1.18 GiB 사이였다. constraint=CONSTRAINT_NONE이 축을 확정한다. 컨테이너에 걸린 cgroup 한도에 부딪힌 게 아니라 VM 전체 메모리가 고갈된 것이다
마지막 건은 컨테이너 시작 4분 17초 뒤다. 앱 로그의 마지막 줄이 4분 26초 지점이다. 9초 차이고, 같은 사건이다
여기서 침묵의 정체가 나왔다. 컴파일러 프로세스가 죽었는데 flutter_tools가 그 프로세스를 계속 기다렸다. 부모가 자식의 죽음을 실패로 올리지 않으면 파이프라인은 실패하지 않고 멈춘다. 그리고 멈춘 파이프라인은 사람에게 느린 파이프라인으로 보인다
그 전에 컨테이너의 memory.events를 떠서 oom_kill이 0인 걸 보고 “OOM은 아니다”로 한 번 닫았다. 그게 오답이었다. cgroup v2 문서는 oom_kill을 “이 cgroup에 속한 프로세스가 어떤 종류든 OOM 킬러에 죽은 횟수”로 정의한다(Control Group v2). 즉 global OOM도 세어야 맞다. 0이 나왔다면 다른 cgroup을 읽었을 공산이 크다. 이건 추정이고 어느 경로를 잘못 읽었는지는 재지 않았다
다만 판정 축은 확정됐다. memory.max가 아니라 VM의 MemTotal이다
방아쇠는 내가 옆 리포에서 옳게 내린 처방이었다
같은 코드가 오전에는 9분에 통과하고 오후에는 죽었다. 사이에 무엇이 바뀌었는지 찾다가 Docker Desktop 설정 파일의 수정 시각을 봤다.
stat -f '%Sm %N' ~/Library/Group\ Containers/group.com.docker/settings-store.json # → Sep 15 12:40:47 ... settings-store.json
12:40:47에 Docker VM 메모리가 8.2 GB에서 4 GB로 내려가 있었다. 그 설정을 바꾼 게 나였다는 걸 확인하고 손이 멈췄다
그 시각에 나는 서버 리포 쪽에서 로컬 푸시 게이트가 OOM으로 죽는 걸 고치던 중이었다. VM이 호스트 메모리를 과하게 물고 있어서 게이트가 밀린다고 보고 할당을 낮췄다. 서버 쪽에서는 그 처방이 들었다
그런데 VM 여유가 줄면서 dart:frontend_server의 1.1 GB짜리 자리가 없어졌다. 서버 리포에서 옳았던 처방 하나가 앱 리포의 게이트를 깼다
문제는 그다음이다. 나는 그 변경을 어디에도 안 적었다. 커밋도 아니고 문서도 아니고 설정 파일 하나의 mtime으로만 남아 있었다. 그래서 앱 쪽에서 “어제와 같은 코드가 왜 오늘 죽나”를 아무리 파도 원리적으로 안 풀렸다. 앱 리포 안에 답이 없었기 때문이다
두 리포가 기계 하나를 쓰는데 그 기계에 대한 계약이 없었다. 각자의 리포 안에서는 둘 다 맞는 결정이었고, 기계 위에서 부딪혔다
VM 할당을 올리는 것은 처방이 아니었다
그럼 VM을 다시 8.2 GB로 올리면 되는 것 아닌가. 그쪽을 먼저 계산했고, 실측에서 기각됐다
| 층 | 값 |
|---|---|
| 호스트 | 16 GB · 10코어 · 스왑 22,528 MB 중 21,489 MB 사용 |
| Docker VM 설정 | 4,096 MiB |
VM 안에서 본 free -m total | 3,919 MB |
| VM 안 실사용 | 1,190 MB |
호스트에서 본 VM 프로세스 phys_footprint | 4,107 MB |
마지막 두 줄이 답이다. VM 안에서 쓰는 건 1,190 MB인데 호스트 쪽 실물 점유는 4,107 MB다. 할당분이 사실상 전부 상주한다
VM을 4 GB 키우면 호스트가 그만큼 잃는다. 그 호스트에는 JVM 둘, Chrome, 그리고 작업 세션 열다섯 개가 2,180 MB RSS로 떠 있었다. 스왑이 이미 95% 차 있는 기계에서 호스트 쪽을 더 깎는 건 다른 자리에서 같은 증상을 만드는 것이다
여기가 이 방법의 경계이기도 하다. 아래에 쓴 크기 부등식은 VM 우변이 고정이라는 전제 위에 서 있다. 메모리를 늘릴 수 있는 기계라면 부등식을 세우는 것보다 늘리는 게 싸다. 나는 맥 한 대에 두 리포를 얹은 상태라 우변을 못 올렸다
docker stop 하나로 같은 코드가 8분 30초에 통과했다
18:03에 서버 쪽 compose 셋을 내렸다. 다섯 시간째 떠 있던 것이다
18:08에 앱 CI를 다시 밀었더니 18:16에 통과했다. 8분 30초다. 그 사이 코드는 한 글자도 안 바뀌었고, 오전에 통과한 판과 같은 트리였다
이 8분이 다른 어떤 측정보다 싸게 진단을 확정했다. 바뀐 변수가 VM 여유 메모리 하나뿐인 실행이기 때문이다. 반대로 말하면, 이 실행 하나를 하루 중 아무 때나 해 봤으면 그 시점에 진단이 끝났다는 뜻이기도 하다
그리고 여기서 닫을 뻔했다. 증상이 멎었으니까. 구조는 아무것도 안 바뀌었는데도 끝난 것처럼 보인다
닫지 않은 이유는 하나다. 다음 사람이 docker stop을 기억해야 한다는 조건이 붙기 때문이다. 기억은 집행이 아니다. 앞에서 내가 처방으로 잡았던 “CI 시간을 나누자”도 같은 계급이다. 사실 그 합의는 이 사건 전부터 있었고, 무너진 건 합의가 아니라 그걸 거절하는 도구가 0개라는 쪽이었다
잠금을 세우던 중, 잠금을 든 판이 또 죽었다
처방으로 잡은 건 레인 잠금이었다. 이 맥에서 무거운 걸 돌리는 주체마다 레인을 주고, 파일 잠금 하나를 잡아야만 들어가게 한다
# L1 앱 dev CI · L2 서버 CI · L3 서버 Deploy · L4 서버 로컬 게이트 lockf -k -t 900 /tmp/seniorhub-ci.lock ./run-gate.sh
-k는 명령이 끝난 뒤에도 잠금 파일을 지우지 않게 한다(lockf(1)). 대기가 넘치면 lockf는 EX_TEMPFAIL로 빠진다. 셸에서는 75다
문서를 절반쯤 쓴 19:27:40에 잠금을 들고 있던 판이 또 global OOM으로 죽었다. 쓰던 문서를 닫고 dmesg부터 다시 열었다. 그 시각에 서버 러너 잡은 0건이었고, compose는 여전히 1 GB 힙으로 떠 있었다
순서를 세워도 죽는다. 잠금은 동시에 도는 것을 막지, 동시에 존재하는 것을 못 막는다. compose는 잠금을 잡지 않는다. 그냥 상주한다
그래서 계약이 둘이 됐다. 순서를 가르는 잠금과, 크기를 가르는 부등식이다
P(앱 CI 봉우리) + C(compose 상한) + M(여유 마진) ≤ VM MemAvailable
P는 앱 CI가 그 안에서 찍는 최대치고, C는 상주 컨테이너가 쓰는 상한이며, M은 커널이 쓸 여유다. 이 식이 깨지면 순서를 아무리 잘 세워도 죽는다
이 반증이 없었으면 잠금만 만들고 닫았을 것이다. 그리고 다음 주에 같은 날이 다시 났을 것이다
집행 쪽은 kafka 힙을 -Xmx256m으로 눌러서 C를 401~421 MiB로 내렸다. compose 셋 합계가 907 MiB였으니 500 MiB 가까이가 앱 쪽으로 돌아온다
부등식 우변을 VM total로 적었다가 틀렸다
이 식을 처음 적을 때 우변에 free -m의 total인 3,919 MB를 넣었다. 검증 단계에서 뒤집혔다
total과 available은 다르다. 커널과 버퍼가 먼저 가져가는 몫이 이 VM에서 380~430 MB였다. 그걸 빼지 않으면 식이 실제보다 그만큼 헐거워진다. 죽은 판의 실측값을 그 식에 대입해 봤더니 “통과”로 나왔다. 식이 사건을 재현 못 하면 그 식은 게이트로 못 쓴다
같은 계급의 오답이 하나 더 있었다. 크기를 스냅샷 하나로 셌다
| 잰 시점 | compose 셋 합계 (MiB) |
|---|---|
| 1 | 462 |
| 2 | 907 |
| 3 | 515 |
| 4 | 311 |
| 5 | 401 |
같은 컨테이너 셋이 하루 안에 311에서 907 MiB 사이를 오갔다. dart 프로세스는 평상시에도 967과 1,388 MB 사이를 오간다. 421 MB 폭이다. 앞에서 적은 1.11~1.18 GiB는 죽는 순간에 커널이 기록한 값이고, 이쪽은 정상 실행 중에 docker stats로 훑은 값이다. 축이 다르다
한 번 떠서 본 값은 상한이 아니다. 봉우리는 시계열로만 잡힌다. 그래서 지금은 docker stats --no-stream을 15초 간격으로 여러 번 돌려서 최대치를 쓴다. 한 번만 뜨면 어느 수를 뽑아도 근거가 안 된다
가를 수 있는 곳은 이 맥의 파일 하나뿐이었다
잠금을 왜 호스트 파일로 만들었는지가 여기서 갈린다. GitHub 쪽에 쓸 수 있는 도구가 없었다
자체 호스팅 러너는 한 번에 잡 하나를 받으므로, 러너가 하나면 자연히 줄이 선다. 그런데 이 맥에는 서버 리포용 mac-arm64와 앱 리포용 app-mac-arm64가 둘 다 등록돼 있다. 개인 계정에서는 리포마다 러너를 따로 등록해야 하기 때문이다. 러너가 둘이면 잡 둘이 큐를 안 타고 동시에 붙는다
concurrency 키워드는 이걸 못 막는다. 공식 문서는 같은 그룹 이름을 쓰는 워크플로가 한 번에 하나만 돈다고만 적고, 그룹의 범위가 어디까지인지는 명시하지 않는다(Control the concurrency of workflows and jobs). 대신 조직 안 다른 리포와 concurrency를 공유하게 해 달라는 기능 요청이 따로 열려 있다(community discussion #40907, #78332). 요청이 열려 있다는 건 지금은 안 된다는 뜻이다
러너 그룹도 후보였다. 여기서 내가 처음 적은 값이 문서 확인에서 뒤집혔다. 나는 “Team 플랜 이상의 조직 기능”으로 적어 뒀는데, 공식 문서는 “Enterprise accounts, organizations owned by enterprise accounts, and organizations using GitHub Team or GitHub Free plans”라고 적는다(Managing access to self-hosted runners using groups). GitHub Free 조직도 쓸 수 있다. 다만 목록에 개인 계정이 없다. 내 결론은 살았고 근거는 틀렸다
남는 건 이 맥의 파일시스템 하나다. 두 리포가 공유하는 건 GitHub 계정이 아니라 기계이므로, 가르는 자리도 기계여야 했다
45분 침묵을 대체할 장치를 만들고 한 번 물렸다
계약의 두 번째 조항은 “실패는 10초 안에 말한다”였다. 이게 이 사건에서 값이 제일 컸다
세 가지를 넣었다
| 조건 | 종료 코드 | 무엇을 대체하나 |
|---|---|---|
| 잠금 대기 초과 | 75 (EX_TEMPFAIL) | 앞 판이 안 끝났는데 조용히 같이 도는 상태 |
| VM 여유 미달 | 76 | 들어가면 죽을 게 뻔한데 일단 시작하는 상태 |
| 출력이 N분 멈춤 | 감시가 컨테이너를 죽인다 | 45분 침묵 |
75는 lockf가 이미 쓰는 값이라 그대로 받았고, 76은 그 옆 자리를 내가 골라 쓴 값이다
여유 미달은 실행 전에 한 번 잰다
FREE=$(docker run --rm alpine free -m | awk '/^Mem:/{print $NF}')
[ "$FREE" -ge 2600 ] || exit 76
문턱 2,600은 앞의 부등식에서 나온 수다. 앱 CI 봉우리에 여유 마진을 더한 값이고, 기계가 바뀌면 다시 재야 한다
감시 장치는 일부러 실패시켜 확인한 게 아니다. 만든 날 저녁에 실전에서 물렸다. 19:40 무렵, 출력이 멈춘 판을 감시가 17분 34초에 잡아서 컨테이너를 죽였다. 종료 코드가 올라왔고 화면이 빨개졌다. 같은 증상이 낮에는 45분짜리 침묵이었다
세 장치 중 실제로 발화한 건 이 하나뿐이다. 75와 76은 아직 한 번도 안 물렸다. 잠금 대기가 900초를 넘은 적이 없고, 여유 점검은 매번 통과 쪽으로 찍혔다. 즉 두 코드가 제대로 빠지는지는 합성 입력으로만 확인했고 실전 발화 기록은 없다
17분 34초는 아직 길다. 문턱을 더 내리려면 정상 실행에서 로그가 가장 오래 비는 구간을 재야 하는데, 그건 안 쟀다. 지금 값은 “45분보다는 낫다”까지만 말한다
밤에 붉게 죽은 다섯 판은 메모리와 무관했다
낮이 풀리고 저녁에 두 판이 정상으로 통과했다. 그런데 22:11부터 22:40 사이에 다섯 판이 붉게 죽었다
같은 날이니 같은 원인이라고 읽고 싶어진다. 그쪽으로 가면 낮의 처방으로 밤을 고치려 든다. 증상이 다르면 원인도 다르다. 낮은 침묵이었고 밤은 빨간 실패였다
다섯 판 중 로그가 죽은 자리까지 남은 건 넷이다
| 소요 | 죽은 자리 | rc |
|---|---|---|
| 6분 55초 | Cannot open file '/app/.flutter-plugins-dependencies' | 1 |
| 2분 47초 | fatal: unknown error occurred while reading the configuration files | 128 |
| 3분 55초 | Cannot open file '/app/ios/Flutter/Generated.xcconfig' | 1 |
| 4분 13초 | Cannot create file '/app/ios/Flutter/flutter_export_environment.sh' | 1 |
매번 다른 파일이다. 그리고 넷 다 잠금과 여유 점검을 통과한 뒤에 죽었다. 로그에 VM 여유 3007~3059 ≥ 2600 — 진행이 찍혀 있다. 메모리 축은 여기서 닫힌다
남은 설명은 Docker Desktop의 호스트 마운트 쓰기가 불안정했다는 것이다. 이건 추정이고 확정이 아니다. VM 커널 로그를 뒤져도 virtiofs나 EIO가 0건이었는데, 0건을 “없다”로 읽으면 안 된다. 사용자 공간 FUSE 계층의 실패는 커널 로그에 안 남는다. 이 축에서는 안 보인다까지가 관측이다
가른 관측은 한 판이었다. Docker Desktop을 재시작하고 다시 밀었더니 7분 4초에 통과했다. 재시작 말고 다른 처방 후보가 셋 더 있는데, 무엇이 원인을 지우는지는 안 쟀다
대신 판별 낱말을 하나 찾았다. 통과하는 판의 로그에는 Got dependencies!가 있고, 이 결함이 난 판에는 Got dependencies.만 있다. 마침표와 느낌표의 차이다
# 정상 Resolving dependencies... Got dependencies! # 이 결함 Resolving dependencies... Got dependencies. Cannot open file '/app/ios/Flutter/Generated.xcconfig'
flutter pub get이 의존성 해석까지는 끝내고 생성 파일 쓰기에서 빠진다. 마침표 쪽은 해석만 됐다는 신호다. 이 한 글자로 메모리 축과 쓰기 축이 갈린다
그리고 이 결함이 보인 이유가 있다. 잠금과 여유 점검이 먼저 서 있었기 때문이다. 그게 없었으면 다섯 판을 “또 OOM이겠지”로 묻었을 것이다. 먼저 세운 장치가 다음 결함을 드러냈다
CI가 이상할 때 10분 안에 보는 순서
이 하루에서 건진 순서다. 원인 이름으로 시작하지 않고 증상으로 가른다
출력이 멈췄다 (몇 분째 0줄, 빨간불도 없다)
├ ① VM 커널 로그를 본다 ← 호스트에서 보면 안 보인다
│ docker run --rm --privileged --pid=host alpine \
│ nsenter -t 1 -m -u -i -n dmesg | tail -50
│ global_oom / Out of memory 가 있으면 ▶ 메모리
├ ② 없으면 VM 여유를 본다
│ docker run --rm alpine free -m
│ MemAvailable 이 문턱 아래면 ▶ 메모리
└ ③ 둘 다 정상이면 ▶ 쓰기 결함 또는 네트워크
붉게 죽었는데 매번 다른 파일이다
└ Cannot open/create file 계열이고 여유 점검을 통과했다면
판별: Got dependencies 뒤에 ! 가 있는가
처방: Docker Desktop 재시작 후 rerun
메모리로 갈렸다
├ docker stats --no-stream 을 15초 간격으로 여러 번 ← 한 번은 안 된다
├ docker ps 로 상주 컨테이너를 찾는다
└ 가장 싼 처방은 상주 컨테이너를 내리는 것 (VM 할당 증가가 아니다)
memory.events의 oom_kill로 판정하지 않는다. 0이 나와도 죽었을 수 있다. 축은 VM의 MemTotal이다
그리고 한 단계가 더 있다. 기계를 여러 리포가 쓴다면 “무엇이 맞나”보다 “어느 리포인가”를 먼저 묻는다. 이 사건 뒤에 잠금 보유자를 기록하는 holder 파일이 0건으로 뜬 적이 있다. 잠금이 깨졌다고 읽었다. 실제로는 상대 리포의 검증 명령이 그 파일을 rm하고 있었고, 내 리포 안에서는 아무리 봐도 답이 없는 종류였다
다시 안 나게 하는 것은 아직 안 정해졌다
지금 선 것은 다시 나면 10초 안에 말하는 장치다. 다시 안 나게 하는 것과는 다르다
재발 관찰을 다섯 판으로 잡아 뒀고 지금 한 판 찼다. “출력 0줄 → cancelled”와 “마침표만 있고 느낌표 없음”이 다섯 판 안에 0건이어야 한다. 여기에 조건이 하나 붙는다. 문서만 바뀐 푸시는 CI가 안 돌아서 표본이 안 찬다. 코드 푸시로만 센다
구조를 바꾸는 선택 넷은 아직 안 정했다. 러너를 리포별로 전용화할지, timeout-minutes를 워크플로에 박을지, 동시 세션 수에 상한을 둘지, VM 크기를 어디에 맞출지다. 넷 다 되돌리는 비용이 있어서 급하게 정할 일이 아니다
저녁 결함의 원인도 확정 못 했다. 재시작이 들었다는 관측 한 건이 있고, 그게 원인을 지운 건지 가린 건지는 모른다
남은 것
이 하루에서 내 판단이 뒤집힌 자리가 넷이다
앱과 서버의 CI가 겹친다고 봤는데, 그 시간대 서버 CI 잡은 0건이었다. 스왑이 21 GB 차 있는 걸 보고 페이징이라고 봤다. 축이 호스트가 아니라 VM 안이었다. oom_kill이 0이니 OOM이 아니라고 읽었는데, 그 값은 원래 global OOM까지 세는 값이라 0이 나온 쪽이 이상한 것이었다. 마지막은 잠금이다. 순서를 세우면 이 사건이 안 난다고 봤고, 잠금을 든 판이 그날 저녁에 또 죽었다
넷을 나란히 놓으면 공통점이 보인다. 전부 한 축에서 한 번 뜬 값을 판정으로 쓴 것이다. 한 축만 재는 도구는 그 축이 틀렸을 때 아무 말도 안 한다
그래서 CI가 침묵하면 나는 이 순서로 본다
먼저 실행 시간이 아니라 실행의 결론이 어디서 온 값인지 본다. gh run list의 conclusion은 마지막 attempt다. 간격이 평소를 크게 넘으면 attempt를 연다
그다음 로그가 있는 쪽이 아니라 로그가 없는 쪽을 본다. 컨테이너가 호스트 위가 아니라 VM 안에서 돈다면 커널 로그도 VM 안에 있다
크기를 재야 하면 한 번 뜨지 않는다. 같은 컨테이너 셋이 하루 안에 311에서 907 MiB 사이를 오갔다. 한 번 떠서 본 값으로 부등식을 세우면 죽은 판이 통과로 나온다
마지막으로, 침묵을 줄이는 장치를 해결보다 먼저 만든다. 자원이 모자란 건 고칠 수 있는 종류의 문제다. 모자란다는 사실이 안 보이는 건 고치기 전에 알아채는 데만 하루가 든다
용어 정리
메모리와 OOM
global OOM — 컨테이너에 걸린 메모리 한도가 아니라 커널 전체의 메모리가 고갈돼서 커널이 프로세스를 골라 죽이는 것이다. dmesg에 constraint=CONSTRAINT_NONE으로 찍힌다. cgroup 한도 초과로 죽는 것과는 다른 사건이다
memory.events의 oom_kill — cgroup v2가 제공하는 카운터다. 해당 cgroup 소속 프로세스가 어떤 종류든 OOM 킬러에 죽은 횟수를 센다. 0이 나왔다고 OOM이 없었다는 뜻이 아니라, 읽은 cgroup이 틀렸을 수 있다는 뜻이다
free -m의 total과 available — total은 커널이 인식한 전체 물리 메모리고, available은 새 프로세스가 스왑 없이 실제로 쓸 수 있는 추정치다. 둘의 차이가 커널과 버퍼 몫이다. 용량 계산의 우변으로는 available을 쓴다
phys_footprint — macOS에서 프로세스가 실제로 점유한 물리 메모리다. Docker Desktop의 VM 프로세스에서 이 값을 보면 VM에 할당한 크기가 거의 그대로 잡힌다. VM 안이 한가해도 호스트는 그만큼 잃는다
잠금과 종료 코드
lockf — 명령을 파일 잠금 아래에서 실행하는 macOS·BSD 유틸리티다. 잠금을 이미 다른 프로세스가 들고 있으면 대기하거나 빠진다. -k는 명령이 끝난 뒤에도 잠금 파일을 남긴다
EX_TEMPFAIL — sysexits.h가 정의한 종료 코드 75다. 일시적 실패라는 뜻이다. lockf는 잠금을 못 잡았을 때 이 값으로 빠지므로, 셸에서 “대기가 넘쳤다”를 다른 실패와 갈라 받고 싶으면 이 코드를 보면 된다
CI 계측
attempt — 하나의 workflow run 안에서 재실행된 각각의 시도다. gh run list가 보여 주는 conclusion은 마지막 attempt의 결과라서, 죽은 attempt를 포함한 run도 초록으로 보인다. gh api .../attempts/N으로 개별 시도를 연다
자체 호스팅 러너 — 내 기계에 러너 에이전트를 설치해 잡을 받는 방식이다. 러너 하나가 한 번에 잡 하나를 받으므로, 러너가 둘이면 잡 둘이 동시에 붙는다
러너 그룹 — 여러 러너를 묶어 접근을 제어하는 기능이다. Enterprise 계정과 조직(GitHub Free 조직 포함)에서 쓸 수 있고, 개인 계정에는 없다
concurrency — 같은 그룹 이름을 쓰는 워크플로나 잡을 한 번에 하나만 돌게 하는 키워드다. 리포 경계를 넘는 조율에는 쓸 수 없다