지난 글에서는 CPU 크레딧이 고갈되어 CI가 느려진 사례를 다뤘다. 이번 글은 그 다음 이야기다. VM 종류를 바꾼 뒤에도 CI가 다시 느려졌고, 원인을 따라가다 보니 Kubernetes 스케줄링부터 Vitest 격리, GitHub Actions job 구성, DB teardown까지 손을 대게 됐다.
처음 이 문제를 볼 때는 runner를 더 늘리면 끝날 거라 생각했다. 결과적으로 runner 수를 늘리는 것보다 runner가 하는 일을 줄이는 쪽의 효과가 컸다. 다만 그 결론까지 가는 동안 꽤 많이 헤맸다. 단독으로 돌리면 통과하는 테스트, 경고만 남기고 무시되는 설정, 로컬에서는 3.4배 빨랐지만 CI에서는 1.6배에 그친 병렬화가 있었다.
- job, runner, shard, worker, K가 각각 무엇인지
- 느린 CI에서 queue와 test 시간을 나눠 보는 법
- Web 테스트 스텝을 626초에서 90초로 줄인 과정
- no-isolate 전환이 설정 한 줄로 끝나지 않았던 이유
- API migration과 DB cleanup 최적화
- 효과가 없었거나 오히려 나빴던 실험
먼저 알아둘 CI 실행 구조
GitHub Actions 화면에는 여러 job이 보인다. 각 job은 runner 하나를 배정받아 실행된다. GitHub가 제공하는 VM을 쓰면 GitHub-hosted runner이고, 직접 운영하는 VM이나 Kubernetes pod를 쓰면 self-hosted runner다.
이 사례에서는 AKS에 Actions Runner Controller, 줄여서 ARC를 설치했다. GitHub Actions에 job이 생기면 ARC가 runner pod를 만들고, pod는 runner 전용 node에 올라간다. job이 끝나면 pod도 사라지는 ephemeral 구조다. GitHub도 Kubernetes에서 self-hosted runner를 자동 확장할 때 ARC와 ephemeral runner 사용을 권장한다.
Pull Request
├─ static job ───────── runner pod 1
├─ test shard 1/3 ──── runner pod 2
├─ test shard 2/3 ──── runner pod 3
└─ test shard 3/3 ──── runner pod 4
↓
Kubernetes node
자주 나오는 용어
| 용어 | 의미 | 이 글의 예시 |
|---|---|---|
| workflow | GitHub Actions 실행 정의 | CI Web, CI API |
| job | 독립적으로 runner를 배정받는 실행 단위 | static, test 1/3 |
| runner | job 하나를 실행하는 프로세스와 환경 | ARC가 만든 runner pod |
| node | runner pod가 올라가는 VM | 4 vCPU, 16GB VM |
| shard | 테스트 파일을 여러 job으로 나눈 조각 | Web 테스트를 3개 job으로 분할 |
| worker | job 안에서 테스트를 병렬 실행하는 process나 thread | Vitest maxWorkers=3, pytest -n 3 |
| K | node 한 대에 동시에 배치하는 runner 수 | K=1이면 runner가 node를 사실상 단독 사용 |
| maxRunners | 동시에 만들 수 있는 runner 상한 | 전체 CI 동시 실행량의 한계 |
shard와 worker는 모두 병렬화지만 위치가 다르다. shard를 3개로 나누면 runner가 3개 필요하다. 각 shard에서 worker를 3개 띄우면 runner 하나 안에서 테스트 process나 thread가 3개 돈다.
전체 병렬도 = shard 수 × shard 내부 worker 수
이 숫자를 무작정 키우면 빨라질 것 같지만, CPU는 늘지 않는다. 3.5 CPU를 받은 runner 안에서 worker를 8개 띄우면 8개가 같은 CPU를 나눠 쓴다. setup과 import가 worker마다 반복되면 총 작업량은 오히려 늘 수 있다.
느린 CI에는 서로 다른 시간이 섞여 있다
CI가 10분 걸렸다는 숫자만으로는 어디를 고쳐야 할지 알 수 없다. 이번에는 시간을 다음처럼 나눠 봤다.
| 구간 | 무엇을 기다리나 | 주로 확인한 곳 |
|---|---|---|
| queue | 빈 runner | GitHub job queued, started 시각 |
| pickup | runner pod 생성과 GitHub 등록 | Kubernetes event, job 시작 시각 |
| setup | checkout, package 설치, cache 복원 | GitHub step별 시간 |
| test | collection, import, environment, assertion | Vitest와 pytest 출력 |
| teardown | DB 정리, resource dispose | user/system time, warning stack |
| aggregate | shard 결과를 required check 하나로 모음 | 집계 job 시간과 pickup |
PR을 기다리는 사람에게 중요한 값은 가장 늦게 끝나는 critical path다. 비용과 전체 처리량을 볼 때는 모든 runner가 사용한 시간을 합쳐야 한다. 두 값은 다르다.
예를 들어 shard를 4개에서 3개로 줄였을 때 각 job은 조금 더 많은 테스트를 맡는다. 그래도 가장 늦은 shard의 완료시간이 같다면 PR 속도는 그대로이고 runner 하나를 다른 PR에 돌려줄 수 있다.
- 이 PR 하나를 더 빨리 끝내고 싶은가?
- 같은 시간에 더 많은 PR을 처리하고 싶은가?
첫 번째는 latency, 두 번째는 throughput 문제다. K와 shard 수의 답이 달라질 수 있다.
시작점: 626초가 된 Web 테스트
어느 오후 Web 테스트 job이 평소 5분에서 10분으로 늘었고 queue는 최대 20분까지 길어졌다. 이틀 동안 첫 번째 병목을 걷어낸 결과는 다음과 같았다.
626s runner 설정이 회귀한 장애 상태
394s CPU request 복구
294s Vitest 설정 정리와 worker 수 고정
90s vmThreads 전환과 node 팩킹 재조정
이 숫자는 모두 CI의 Web test step 기준이다. 뒤에서 나오는 로컬 전체 suite나 API 수치와는 측정 환경이 다르므로 한 비율로 합치지 않았다.
수동으로 넣은 값은 다음 apply까지만 산다
발단은 Terraform apply였다. 한 달 전 runner pod의 CPU request를 Helm으로 직접 올려뒀는데, 그 값이 Terraform 코드에는 반영되지 않았다. 전혀 다른 리소스를 추가하려고 apply를 돌리자 Terraform은 코드값으로 정확하게 수렴시켰다. 수동 튜닝은 조용히 사라졌고 CI는 2배 느려졌다.
apply를 돌린 사람의 실수가 아니다. Terraform이 관리하는 리소스에 손으로 넣은 값의 수명은 누가 어떤 이유로든 다음 apply를 돌릴 때까지다. 급해서 클러스터에 직접 넣었다면 같은 날 코드에도 반영해야 한다.
원인을 특정할 때는 Helm 리비전과 GitHub job 시간을 나란히 봤다.
helm history aipmo-runner -n arc-runners
helm get values aipmo-runner -n arc-runners -o yaml
08-13과 08-18의 job을 비교하니 API test 실행시간은 거의 같았는데 queue만 크게 늘었다. 코드가 느려진 게 아니라 runner 공급이 줄어든 신호였다.
| 지표 | 회귀 전 | 회귀 후 |
|---|---|---|
| CI Web 중앙 | 7.3분 | 13.0분 |
| CI API 중앙 | 7.5분 | 15.9분 |
| queue 중앙 | 0.3분 | 3.2분 |
| queue 최대 | 1.1분 | 20.8분 |
node 증설 판단에는 request가 중요하다
회귀 상태에서 node CPU는 포화였지만 node가 기대만큼 늘지 않았다. Kubernetes scheduler와 node autoscaler가 새 pod를 배치할 수 있는지 판단할 때 container의 resource request가 핵심 입력이기 때문이다.
runner 하나가 실제로는 약 1.9 CPU를 쓰는데 request가 500m이면 장부에는 0.5 CPU짜리 pod로 보인다. 여러 runner를 한 node에 넣어도 request 합은 낮고 실제 CPU만 포화되는 상태가 된다.
request는 단순한 예약값이 아니라 node당 몇 개의 runner를 실을지 결정하는 팩킹 값이었다.
node runner 가용 CPU
= allocatable CPU - DaemonSet request 합
= 3860m - 459m
= 3401m
스케줄 가능한 K
= floor(3401m / runner CPU request)
여기서 한 번 계산을 잘못했다. 1750m runner 두 개면 3500m이므로 3401m에 들어가지 않는다. 처음에는 DaemonSet 몫을 빼지 않고 1750m이 K=2라고 기록했지만 실제로는 K=1이었다.
경계에 바짝 붙이는 것도 위험하다. K=2의 수학적 경계는 1700.5m인데 request를 1700m으로 잡으면 여유가 1m뿐이다. DaemonSet 하나가 추가되면 node당 runner 수가 알람 없이 2개에서 1개로 바뀔 수 있다.
container 안의 자동 병렬도를 믿기 어려웠다
runner pod 안에서 확인한 값은 서로 달랐다.
nproc → 4 node vCPU
/sys/fs/cgroup/cpu.max → 3.5 CPU container limit
실제 배분 → 약 1.9 CPU
nproc은 이 runner가 이웃 runner와 나눠 쓰는 CPU 몫을 말해주지 않았다. Vitest는 CPU 수를 보고 worker를 자동으로 정하므로 fork worker 4개가 약 1.9 CPU를 나눠 썼다.
같은 shard 1/4를 default worker 4개와 fast worker 3개로 번갈아 세 번씩 실행했다. 로컬 부하 영향을 줄이기 위해 wall이 아니라 user+system CPU-초 중앙을 비교했다.
| 구성 | CPU-초 3회 | 중앙 |
|---|---|---|
| default, worker 4 | 437 / 499 / 463 | 462.7s |
| fast, worker 3 | 248 / 251 / 261 | 250.9s |
worker 하나를 줄였는데 CPU 작업량은 46% 줄었다. worker마다 React, testing-library, setup을 포함한 module graph를 다시 준비했기 때문이다. pytest의 -n auto에서도 같은 문제가 생길 수 있어 CI에서는 worker 수를 명시적으로 고정했다.
첫 단발 측정은 같은 조건에서도 231초에서 314초까지 흔들렸다. 한 번만 쟀다면 로컬에서 우연히 한가했던 결과를 설정 효과로 오인했을 것이다.
테스트 시간의 71%는 assertion이 아니었다
worker 수를 고쳐도 Web 테스트는 회귀 전보다 느렸다. Vitest가 출력하는 단계별 시간을 봤다.
| 구간 | 시간 | 비율 |
|---|---|---|
| import | 148.7s | 71% |
| environment | 28.1s | 13% |
| tests | 23.5s | 10% |
무거운 라이브러리를 의심해 dependency graph를 확인했지만 recharts나 exceljs를 쓰는 파일은 일부뿐이었다. 비용은 특정 library가 아니라 파일마다 반복되는 격리에서 나왔다.
기본 forks 구성은 test 파일마다 새 process 환경을 만들고 React, testing-library, setup을 다시 import했다. 작은 test 파일 하나도 import에 1초가 들었다. 파일이 650개를 넘으니 이 고정비가 실제 assertion 시간보다 커졌다.
Vitest의 vmThreads는 worker를 유지하면서 파일마다 새 VM context를 만들어 격리를 제공한다. 공통 module 준비 비용을 줄이면서 파일별 window는 새로 받을 수 있었다. 로컬 shard wall은 6480초에서 2127초가 됐고, 실제 CI test step 중앙은 294초에서 90초가 됐다.
첫 vmThreads 시도는 runner를 죽였다
vmThreads가 바로 성공한 것은 아니다. VM에서 import한 ESM은 memory가 안정적으로 회수되지 않을 수 있다. 실제 suite는 runner당 약 6.6Gi까지 사용했다.
메모리 상한을 넣었다고 생각했지만 Vitest 4에서 제거된 test.poolOptions 아래에 값을 뒀다. Vitest는 실행을 막지 않고 경고만 남긴 채 설정을 무시했다. CI에서는 cache가 계속 자라 runner pod가 OOM으로 사라졌다.
runner가 사라지니 GitHub job은 일반 test failure가 아니라 in_progress 상태로 남았다. 취소 신호를 받아줄 runner process가 없어서 일반 cancel도 처리되지 않았다. GitHub REST API의 force-cancel endpoint로 정리한 뒤에야 같은 concurrency group의 다음 run을 시작할 수 있었다.
최상위 vmMemoryLimit 2.5GB와 runner memory limit 10Gi를 적용하고 실제 CI 4-shard를 다시 실행했다.
| shard | test step |
|---|---|
| 1/4 | 90s |
| 2/4 | 81s |
| 3/4 | 97s |
| 4/4 | 91s |
설정 파일이 파싱됐다는 것과 설정이 실제 적용됐다는 것은 달랐다. 이후에는 warning, process 수, peak memory와 live pod spec까지 함께 확인했다.
K=2가 더 싸지만 현재는 K=1을 택했다
vmThreads를 위해 K=1로 바꾼 뒤, 테스트 구조를 더 개선하면 K=2로 돌아갈 수 있는지 비교했다. 같은 Web test 두 작업을 독립 node와 공유 node에서 실행했다.
| 구성 | critical path | node-time |
|---|---|---|
| K=1, 작업별 독립 node | 약 51초 | 약 84.7 node-s |
| K=2, 두 작업이 node 공유 | 약 79초 | 약 79 node-s |
K=2는 node-time을 약 6.7% 아꼈지만 PR 완료시간은 약 55% 늘었다. 비용이 목표라면 K=2가 맞을 수 있다. 개발자의 대기시간과 부하 변동을 줄이는 것이 목표였기 때문에 현재는 K=1을 유지했다.
이 비교에서 중요한 것은 K 하나만 바꾼 것이다. runner request, shard 수, worker 수를 동시에 바꾸면 빨라지거나 느려진 원인을 구분할 수 없다.
8초짜리 job이 40초를 기다렸다
ARC ephemeral runner는 job마다 새 pod를 만든다. node가 이미 떠 있어도 pod 시작과 GitHub 등록에 30~40초가 걸렸다. 분 단위 test에서는 작은 비중이지만 실작업 8초짜리 집계 job에는 실작업의 몇 배다.
특히 집계 job은 required check였다. test가 모두 끝난 뒤 runner를 기다리기 때문에 merge 가능 시점을 그대로 늦췄다. PR 경로에서는 문자열 비교나 git diff만 하는 짧은 job을 GitHub-hosted runner로 옮겼다. pickup은 2~5초로 줄었고, 내부망에서는 self-hosted runner로 바꿀 수 있는 입력을 남겼다.
나중에 public deploy를 다시 확인하며 한 가지를 더 발견했다. reusable workflow의 runner 입력 기본값이 채워져 있어 PR에서는 hosted였던 짧은 job도 main push에서는 self-hosted로 돌아가고 있었다. 일반 full-stack PR의 self-hosted job은 9개인데 같은 CI를 재호출하는 public deploy 구간은 13개였다. 같은 YAML이라도 호출 방식에 따라 effective config가 달랐다.
90초 이후에는 격리 자체를 줄였다
vmThreads는 파일별 격리를 유지해 안전했지만 memory 비용이 컸다. threads에서 isolate를 끄면 worker 안에서 module과 DOM을 공유하므로 더 빠르고 memory 동작도 단순해진다. 대신 테스트가 전역 상태를 깨끗하게 정리해야 한다.
처음 isolate:false를 켰을 때 실행은 약 3배 빨랐지만 43개 파일이 실패했다. 이상한 점은 실패 파일이 단독으로는 모두 통과했다는 것이다. 실행 순서와 shard가 바뀔 때마다 실패 파일도 달라졌다.
실패하는 파일만 고치며 몇 번을 반복했지만 victim이 계속 바뀌었다. 앞에서 상태를 오염시킨 polluter는 통과하고 뒤 파일이 실패했기 때문이다.
92개 파일을 절반씩 줄였다
실패하는 victim 앞에 실행된 파일 92개를 절반씩 붙여 실행했다. 실패하면 앞 절반에 polluter가 있고, 통과하면 뒤 절반에 있다고 보고 범위를 줄였다. 마지막에는 네 파일로 원리를 재현했다.
dependency
↓
consumer module
↑ ↑
test A mock test B mock
test A가 mock 아래에서 consumer를 먼저 import하면 consumer module은 A의 factory를 잡은 채 worker cache에 남았다. 이후 test B가 같은 dependency를 다른 factory로 mock해도 이미 평가된 consumer는 A의 값을 사용했다.
실패 stack은 B를 가리키지만 원인은 A였다. 그래서 단독 실행은 통과했고 실패 파일만 수정해서는 끝나지 않았다.
mock을 module 경계에서 network 경계로 옮겼다
HTTP client module을 test마다 vi.mock하는 대신 MSW로 network request와 response를 제어했다. production hook, react-query, fetch 경로는 실제 코드를 사용하고 외부 경계만 가짜로 만들었다.
이 작업은 test 파일 단위가 아니라 module 단위로 해야 했다. 같은 client module을 mock하는 파일이 하나라도 남으면 그 consumer cache가 다른 test에 영향을 줄 수 있었다. HR, opportunity, evaluation, analysis, generated client처럼 module 묶음별로 옮기며 매번 전체 suite를 실행했다.
HTTP mock 약 110개 파일을 옮긴 뒤에도 no-isolate에서 10~17개가 실패했다. 이번에는 router, toast, store, current-user 같은 비-HTTP module mock이 남아 있었다. 한 종류를 전부 고쳤다고 공유 cache 문제가 끝난 것은 아니었다.
최종적으로 다음 장치를 같이 넣었다.
- 전역 fetch stub을 매 test 뒤 강제 원복
- MSW server를 worker별 singleton으로 유지
- DOM portal과 prototype stub 정리
- module mock 예외 test는 resetModules 뒤 대상 module을 동적 import
- 위 규칙을 어기는 새 test를 정적 검사
- rAF, MutationObserver, 문서 전역 상태가 얽힌 test는 isolated project로 분리
전체 667파일을 2회 연속 통과시키기 전까지 누적 24회 순서를 바꿔 실행했다. 로컬 전체 suite wall은 vmThreads 40초에서 no-isolate 17초로 줄었다.
설정 한 줄을 바꾸는 데 드는 시간은 짧았다. 공유 가능한 test 구조로 바꾸는 데 대부분의 시간이 들었다. 전체 suite, shard, worker 수, 실행 순서를 바꿔 검증하지 않으면 단독 green은 격리의 증거가 아니다.
shard는 많을수록 좋은가
no-isolate 전환 뒤 Web test의 shard 수를 4, 3, 2개로 비교했다. 같은 commit에서 cold test critical path와 필요한 self-hosted job 수를 함께 봤다.
| 구성 | critical path | Web self-hosted job |
|---|---|---|
| 4-shard | 약 79.3초 | 5 |
| 3-shard | 약 79.2초 | 4 |
| 2-shard | 약 94.6초 | 3 |
3-shard는 4-shard와 완료시간이 같으면서 runner 하나를 덜 썼다. 2-shard는 runner를 하나 더 아꼈지만 19% 느렸다. 그래서 3개를 택했다.
이 실험에서 준비 상태 test 하나가 간헐적으로 실패했다. 2-shard와 실제 3-shard CI에서 같은 파일이 실패해 shard 수보다는 공유 worker의 timing과 상태가 원인이었다. 해당 파일을 isolated project로 옮긴 뒤 실제 3-shard CI가 모두 통과했다.
Web 다음 병목은 API였다
Web이 빨라지자 API와 AI job이 runner pool을 점유하는 시간이 눈에 들어왔다. 병목은 테스트 코드 하나가 아니라 migration, stale duration, 짧은 job의 slot 점유, DB teardown에 나뉘어 있었다.
migration test 병렬화
migration test 약 90파일은 각자 빈 DB에서 Alembic chain 전체를 재생했다. 직렬 실행을 pytest -n 3으로 바꾸고 worker마다 별도 Postgres container를 사용해 격리를 유지했다.
로컬 pytest는 568초에서 약 170초로 3.4배 빨라졌다. 실제 CI job은 612초에서 392초, pytest 구간은 568초에서 351초로 1.6배 개선에 그쳤다. runner 3.5 CPU 안에서 pytest worker 3개와 Postgres 3개가 CPU를 나눠 쓴 결과였다.
로컬은 가설을 확인하는 곳이고 실제 CI는 채택을 결정하는 곳이라는 차이가 여기서 분명해졌다.
경량 job 통합
API lint, typecheck, package test는 각각 4055초였지만 runner를 3개 점유했다. 정작 critical path인 test shard는 이 job들 뒤에서 6672초를 기다렸다.
세 검증을 static job 하나로 합치고, 첫 step이 실패해도 뒤 검증은 취소되지 않도록 각 step 조건을 유지했다. test 실행시간은 거의 같았지만 shard queue가 16~18초로 줄었다. code가 빨라진 것이 아니라 중요한 job이 먼저 runner를 잡게 된 효과였다.
stale duration이 엉뚱한 파일을 가리켰다
API shard는 과거 test duration을 기준으로 파일을 나눴다. 그런데 duration 파일이 3주간 갱신되지 않아 삭제된 항목 954개가 남아 있었고 새 test는 평균값으로 배치됐다.
처음 분석에서는 test_scm.py의 과거값 110초를 병목으로 봤다. clean refresh 뒤 실제 최중량은 test_rm_evaluations.py의 33.6초였다. 측정 입력이 오래됐는데 분할부터 시도해 순서를 한 번 뒤집었다.
이후 duration 갱신 명령에는 삭제된 항목을 지우는 clean 옵션을 기본으로 넣었다.
DB cleanup에서 전체 API 시간의 3분의 1을 썼다
마지막으로 API test의 system time을 봤다. 모든 test가 끝날 때마다 clean_db fixture가 185개 table에 DELETE를 실행하고 있었다. 실제 write가 없는 순수 계산 test도 디렉터리 marker 때문에 같은 cleanup을 냈다.
SQLAlchemy Engine event에서 INSERT, UPDATE, DELETE 대상 table을 기록하고, teardown에서는 실제 write가 있었던 table만 FK 역순으로 지웠다. marker 누락과 상관없이 write가 있으면 지우므로 격리 안전성도 나아졌다.
| 측정 | 변경 전 | 변경 후 |
|---|---|---|
| 전체 suite, -n 6 | 67.6s | 42.2s |
| system time | 73.1s | 21.2s |
| infra/mki 직렬 | 32.1s | 3.9s |
| infra 직렬 | 341.5s | 270.1s |
전체 suite는 38% 빨라졌다. 기본 순서와 별도 random seed를 모두 실행해 이전 test의 data가 남지 않는 것도 확인했다.
병렬 process들의 system time은 누적값이라 wall보다 클 수 있다. 여기서는 wall 단축뿐 아니라 kernel에서 DELETE와 SQLite 왕복에 쓰던 누적 시간이 얼마나 줄었는지 보려고 함께 기록했다.
실제로 어떻게 측정했나
같은 commit에서 A/B를 번갈아 실행
한 번의 wall time은 신뢰하지 않았다. 다른 app의 CPU 사용과 cache 상태가 섞이기 때문이다. 같은 commit에서 A와 B를 번갈아 최소 세 번 실행하고 중앙값과 분포가 겹치는지 확인했다.
cd web
/usr/bin/time -p npm run test:run:noiso -- --shard=1/3 --maxWorkers=3
POSIX time의 real은 wall, user+sys는 CPU-초다. CPU 제한 환경에서 code 작업량을 비교할 때는 CPU-초가 유용하지만 최종 채택은 실제 CI wall로 다시 확인했다.
cold와 warm을 구분
fresh worktree에서 npm ci 후 첫 실행을 cold 표본으로, 같은 명령의 직후 실행을 warm 표본으로 기록했다. cache를 임의로 지우는 명령보다 실험 조건을 설명하기 쉬웠다.
GitHub job과 step 시각 분리
GitHub CLI로 job 시작과 종료 시각을 받아 queue와 실행시간을 나눴다.
gh run view RUN_ID --repo OWNER/REPO --json jobs
workflow 전체 시간만 보면 runner pickup과 test 실행이 섞인다. 짧은 job을 hosted로 옮긴 효과도 job 시각을 분리한 뒤에야 보였다.
Kubernetes event와 같은 시각으로 맞추기
test가 전부 통과한 뒤 cleanup에서 runner가 사라진 적이 있었다. test 로그만 보면 cleanup 실패였지만 같은 시각 cluster autoscaler의 ScaleDown event가 있었다.
kubectl top pods -n arc-runners
kubectl describe nodes -l agentpool=cirunner
kubectl get events -A --sort-by=.lastTimestamp
runner log, pod event, node event, GitHub job timestamp를 같은 시간축에 놓아야 code failure와 infrastructure lifecycle 문제를 구분할 수 있었다.
잘 안 됐던 접근
성공한 설정만 남기면 다음에도 같은 곳에서 시간을 쓰게 된다. 실제로 효과가 없거나 조건에 맞지 않았던 실험은 다음과 같다.
| 접근 | 측정 결과 | 판단 |
|---|---|---|
| Web CSS 처리 끄기 | css=true 291.6s, false 292.4s | 차이 없음 |
| 처음부터 duration shard 도입 | 기존 4-shard 편차 9%, 기대효과 약 4% | 큰 병목부터 처리 |
| CPU request만 높이기 | node당 runner 수가 2개에서 1개로 감소 | 작업량 감소와 함께 판단 |
| K=2 | node-time 6.7% 절감, latency 55% 증가 | K=1 유지 |
| Web 2-shard | 3-shard보다 19% 지연 | 3-shard 유지 |
| node 유휴 10분을 30분으로 연장 | 조기 종료의 실제 원인은 실행 중 eviction | 변경 취소 |
| stale duration 기준 파일 분할 | 실제 병목 파일을 잘못 지목 | clean refresh를 먼저 수행 |
| timeout만 증가 | 전역 observer와 공유 상태 오염은 남음 | 원인 제거 또는 isolated project |
| PR green 결과로 deploy CI skip | merge tree가 같은 비율이 약 15% | 단순 skip 보류 |
특히 node 유휴시간 실험은 증상과 원인을 혼동한 사례였다. node가 너무 빨리 사라지는 것처럼 보여 10분을 30분으로 늘리려 했지만, 실제 문제는 job 실행 중 autoscaler가 node를 drain한 것이었다. runner pod에 safe-to-evict=false를 적용하는 것이 맞았고 idle 시간을 늘리는 PR은 닫았다.
결과를 한 번에 비교하면
| 영역 | 변경 전 | 변경 후 | 조건 |
|---|---|---|---|
| Web CI test step | 626s | 90s | 장애 상태부터 vmThreads 안정화까지 |
| Web 로컬 전체 suite | 40s | 17s | vmThreads와 no-isolate 비교 |
| Web shard | 4개 | 3개 | critical path 약 79초 유지 |
| API migration CI job | 612s | 392s | pytest -n 3 |
| API 일반 shard queue | 66~72s | 16~18s | 경량 job 통합 |
| API 로컬 전체 suite | 67.6s | 42.2s | write table만 cleanup |
모든 숫자가 같은 환경은 아니다. CI step, 로컬 wall, queue를 일부러 따로 적었다. 40초에서 17초가 626초에서 90초의 후속 비율이라는 식으로 계산하면 안 된다.
이틀 동안 가장 많이 헤맨 이유
첫째, 느리다는 한 문장에 여러 시간이 섞여 있었다. queue가 긴데 test code를 최적화하거나, teardown이 비싼데 shard를 늘리는 식으로 다른 문제를 풀 뻔했다.
둘째, 선언한 설정과 실제 설정이 달랐다. nproc는 container의 실질 CPU 몫을 몰랐고, Vitest 설정은 경고만 내고 무시됐고, Helm live 값과 Terraform 값은 달랐다. 설정 파일을 읽는 것만으로는 부족했다.
셋째, 공유 상태 오염은 원인이 아닌 victim에서 실패했다. 단독 실행이 green이라 test 자체는 정상처럼 보였다. 순서를 고정하고 앞 파일을 이분 탐색하지 않았다면 원리를 찾기 어려웠을 것이다.
넷째, 최적화가 끝날 때마다 병목이 이동했다. runner request를 고치자 import가 보였고, Web을 줄이자 API queue가 보였고, shard를 맞추자 DB teardown이 보였다. 처음 만든 병목 목록을 끝까지 믿지 않고 매 단계 다시 측정해야 했다.
마치며
이번 작업에서 가장 큰 개선은 새로운 도구를 추가해서 나오지 않았다. 이미 있던 runner, Vitest, pytest, GitHub Actions의 effective behavior를 실제 숫자로 확인하면서 나왔다.
다음에 CI가 느려지면 이 순서로 볼 생각이다.
- workflow wall을 queue, pickup, setup, test, teardown으로 나눈다.
- 가장 많은 runner 시간을 쓰는 job부터 본다.
- 같은 commit에서 A/B를 반복하고 CPU-초와 wall을 함께 기록한다.
- local 결과는 가설 확인에 쓰고 실제 CI에서 채택 여부를 정한다.
- full suite, shard, worker 수, 순서를 바꿔 격리 안전성을 확인한다.
- 변경 뒤 전체 runner 점유율을 다시 집계해 다음 병목을 찾는다.
CI 최적화는 한 job의 시간을 줄이는 작업으로 시작했지만, 결국 제한된 runner pool을 어떻게 나눠 쓰는지 이해하는 작업이었다. 실패한 가설과 측정 조건을 같이 남겨둬야 다음 최적화가 같은 시행착오에서 시작하지 않는다.