러너를 옮기고 CI를 돌렸더니 1시간 넘게 돌다 죽었다. 한 줄 고치고 재실행, 또 실패, 또 한 줄. 하루가 지나고 보니 PR 번호가 #129에서 #144까지 올라가 있었다. 결과편("CI 46초")이 아니라, 그 과정의 실패 일지다.
0. 배경: 러너를 옮겼다
AIFid 저장소의 GitHub 조직을 옮기면서(pushwing → aivance-kr) CI 러너도 macOS ARM64에서 self-hosted Linux X64로 바꿨다. #129는 단순한 PR이었다. 수동 docker run과 정리 스텝을 services: 블록으로 바꾸고, runs-on만 갈아 끼웠다.
문제는 이 변경이 feature → dev 머지 때는 CI가 돌지 않는다는 점이다. 이 저장소의 CI는 dev → main 배포 PR(#130)에서 처음 돈다. 그러니 #129의 검증은 전부 #130에서 일어났고, 이후 PR 열댓 개는 사실상 "#130을 초록불로 만들기 위한 수정"이었다.
이하 시각은 모두 2026-09-04(KST)다.
1. 12:47 · #131 ci-needrestart-fix — 30분 넘게 아무 일도 안 일어난다
#130의 백오피스 잡이 setup-php 단계에서 멈췄다. 30분이 지나도 로그가 없었다. 러너 Worker 로그를 보니 apt 설치 도중 needrestart의 대화형 프롬프트가 입력을 기다리고 있었다. CI에는 키보드가 없으니 영원히 기다린다.
NEEDRESTART_MODE: a를 잡 환경변수에 넣었다. 프롬프트 자체를 없애는 한 줄이다.
2. 13:38 · #132 ci-php-native-install — 이번엔 PPA다
다음 실행도 같은 자리에서 멈췄다. 러너 박스에서 직접 재현해 보니 이유가 달랐다. 러너 OS가 Ubuntu 26.04(코드명 resolute)였는데, ondrej/php PPA가 아직 이 코드네임을 지원하지 않았다. apt-get update가 "Release 파일 없음"으로 실패하고, setup-php는 그걸 무한 재시도했다.
그런데 resolute 네이티브 저장소에 이미 PHP 8.5, composer, 필요한 확장 대부분이 들어 있었다. setup-php를 걷어내고 apt로 직접 설치하도록 바꿨다. 러너 박스에 잘못 등록된 PPA 소스 파일도 지웠다.
이 PR의 위험 항목에는 "PHP 타겟이 사실상 8.2에서 8.5로 올라간다"고 적었다. 8.5의 새 deprecation 때문에 PHPStan이나 PHPUnit이 새로 걸릴 수 있다고 봤는데, 다행히 이 걱정은 맞지 않았다. 진짜 문제는 다른 곳에 있었다.
3. 16:14 · #133 ci-add-sqlite3 — composer가 확장을 요구한다
설치 단계가 드디어 끝나고 composer install까지 갔다. 그리고 바로 실패했다.
setup-php가 기본으로 깔아 주던 확장이 apt 네이티브 설치로 바꾸면서 빠진 것이다. 설치 목록에 php-sqlite3를 추가했다. 이 교훈은 이후 다른 저장소의 CI 문서에도 옮겨 적었다. 확장 목록은 감으로 쓰지 말고 composer.lock의 ext-* 요구에서 뽑는다.
4. 17:33 · #134 ci-mysql-healthcheck-fix — 765개가 전부 Connection refused
처음으로 PHPUnit이 실제로 돌았다. 그리고 765개가 전부 Connection refused로 죽었다.
가설은 그럴듯했다. MySQL 8 이미지는 초기화 중에 소켓만 여는 임시 서버를 먼저 띄운다. 소켓 기반 healthcheck가 이 임시 서버에서 먼저 통과하고, 진짜 서버로 재시작하는 짧은 틈에 PHPUnit이 접속을 시도해 거부당했다는 설명이다. --health-cmd에 -h 127.0.0.1을 붙여 TCP 접속을 강제했다.
결과는 같았다. 이 가설은 틀렸다.
5. 17:49 · #135, 18:07 · #136 — 진단용 PR이 두 개
컨테이너 로그를 보면 healthy 판정 전에 이미 진짜 서버가 3306에서 떠 있었다. 임시 서버 가설로는 설명이 안 됐다. 추측을 멈추고 CI 안에서 직접 보기로 했다.
- #135 (임시):
getenv()값과 실제 TCP 접속 가능 여부를 출력하는 진단 스텝을 넣었다. 결과는 이상했다. 순수getenv()와 PDO 접속은 성공하는데,bootstrap.php의 PDO 접속만 실패했다. - #136 (임시):
bootstrap.php가 예외 클래스명만 찍고 메시지를 안 찍고 있었다.$e->getMessage()출력을 추가했다.
CI에서 "한 줄 찍고 재실행"은 한 번에 5~10분이 든다. 진단 PR을 둘 올리는 동안 한 시간이 지나갔다. 이 시점부터는 가설을 세우기 전에 로컬에서 재현하는 쪽이 싸다는 걸 체감했다.
6. 18:30 · #137 — 반쪽짜리 원인
로그를 읽다 보니 단서가 나왔다. phpunit.dist.xml의 <env>는 force="true"가 아니어도 PHP 프로세스의 $_ENV와 $_SERVER에는 항상 XML 값을 심는다. CI4의 env()와 bootstrap.php의 $read()는 둘 다 $_SERVER → $_ENV → getenv() 순으로 읽는다. CI가 export한 값(13306, root/root)이 있어도 XML의 로컬 기본값(3307, aifid/aifid)이 항상 이긴다.
XML에서 hostname, port, username, password <env>를 지웠다. 그럴듯한 설명이었고 머지까지 했다. 그런데 이것도 절반만 맞았다.
7. 18:38 · #138 ci-db-target-guard — 진짜 원인
로컬에서 재현했다. 이번에는 PR 본문에 "로컬 재현으로 확정"이라고 쓸 수 있었다.
CI4는 database.tests.hostname처럼 점이 들어간 이름으로 설정을 읽는다. 그런데:
- PHP는 점이 들어간 이름을
$_SERVER와$_ENV에 넣지 않는다.getenv()로만 보인다. - Composer는 자식 프로세스 환경을
array_intersect_key(getenv(), $_SERVER)로 만든다. - 그래서
composer test로 PHPUnit을 띄우는 순간 점 이름은 통째로 사라진다.
즉 CI 워크플로가 넘긴 접속 정보는 PHPUnit 프로세스에 한 번도 도달한 적이 없었다. 테스트는 phpunit.dist.xml의 로컬 기본값(3307)으로 폴백했고, 맥 러너에서는 거기에 개발자의 로컬 개발 DB가 떠 있었다. 그래서 초록불이었다.
여기서 소름 돋는 부분이 있다. 이 테스트 베이스는 $refresh = true라서 클래스마다 regress → migrate를 돌린다. 과거의 CI는 초록불을 내는 동시에 로컬 개발 DB를 지웠다 다시 만들고 있었을 가능성이 높다. 러너를 옮기지 않았다면 영영 몰랐을 버그다.
수정은 세 가지였다.
ci.yml: 접속 정보를 밑줄 이름(database_tests_*)으로 넘긴다. 유효한 변수명이라 Composer를 거쳐도 살아남는다.- : 밑줄 이름을 CI4가 읽는 점 이름으로 옮긴다. 이미 점 이름이 있으면 건드리지 않는다.
DatabaseTargetTest: 환경이 요청한 접속 정보와 프레임워크가 실제 쓰는 값이 다르면 실패시킨다. CI인데 아무것도 전달되지 않은 경우도 실패시킨다. 이번 사고가 정확히 그 모습이었다.
마지막 항목이 이날 가장 값진 결과물이다. 고친 것보다 "다시 조용히 틀어지면 빨간불이 켜진다"는 장치를 남긴 것이 오래 간다.
8. 18:52 · #139 fix-cs-bootstrap — PSR-12 한 줄
#138을 머지한 직후 bootstrap.php의 PSR-12 정렬이 CS 검사에 걸렸다. 로컬에서 composer check를 안 돌리고 올린 대가다. 내용은 공백 정렬이 전부라 쓸 말이 없다. 그래서 PR 번호 하나가 더 늘었다.
9. 19:24 · #140 ci-mysql-instrumentation — 이제 테스트가 돈다, 그런데
접속이 풀리고 테스트가 실제로 돌기 시작했다. 250개 넘게 정상 통과했는데, 그 뒤로 나머지가 전부 MySQL server has gone away로 죽었다.
OOM은 아니었다(30GB 중 24GB 여유, dmesg에 kill 없음). 디스크도 아니었고(817GB 여유), 잡 동시 실행도 아니었다. 서버가 죽은 건지 커넥션이 바닥난 건지 로그만으로는 갈리지 않아서, 5초마다 MySQL 상태를 샘플링하는 계측을 넣었다. 또 한 번의 임시 PR이다.
10. 23:01 · #141 fast-test-schema — 범인은 DB가 아니었다
계측 결과가 나왔다.
- MySQL은 한 시간 내내 멀쩡했다. 커넥션 2~3개 고정, abort 0, 종료 시점에도
mysqld is alive. - 테스트도 378개까지 전부 통과 중이었다.
- 실패 원인은
The process "phpunit" exceeded the timeout of 3600 seconds였다. Composer의 프로세스 타임아웃이다.
"gone away"는 타임아웃으로 프로세스가 죽는 순간 따라 나온 증상이었다. 진짜 문제는 스위트가 너무 느리다는 것이었다. CI4의 $refresh = true는 클래스가 아니라 테스트 메서드마다 전체 regress → migrate를 돈다. 마이그레이션 38개에 DB를 쓰는 테스트가 수백 개라 DDL이 수만 건이고, InnoDB의 DROP/CREATE는 파일 조작이라 건당 수십 ms가 든다. 실측으로 MySQL이 567초 동안 테이블을 4,707번 열었다. 맥에서도 같은 비용이었고(7월 배포 run이 18분 45초), 디스크가 빨라서 덜 아팠을 뿐이다.
스키마는 프로세스당 한 번만 만들고($migrateOnce = true), 테스트마다 TRUNCATE로 데이터만 비우는 트레이트를 만들었다. "빈 DB로 시작한다"는 기존 전제가 그대로라 테스트 코드는 한 줄도 고치지 않았다.
같은 작업이 병렬로 한 번 더 진행되고 있었다. #142(
fast-db-test-reset)가 같은 날 같은 시각에 같은 아이디어로 열려 있었고, #141이 먼저 머지되어 #142는 닫혔다. 세션을 나눠 돌리면 이런 중복이 생긴다.
11. 23:14 · #143 — 같은 패턴, 다른 곳
#141 덕에 1시간 넘게 돌던 잡이 9분 40초에 끝까지 갔다. 915개 중 실패는 4개였고, 원인은 하나였다. ReassignToReturningAgentJobTest가 Redis 포트를 6389(로컬 docker-compose 값)로 하드코딩하고 $_ENV/$_SERVER까지 그 값으로 덮어쓰고 있었다. 맥에서는 6389에 로컬 개발 Redis가 떠 있어서 우연히 통과했고, 지금까지는 DB 단계에서 먼저 죽어서 여기까지 온 적이 없었다.
DB 접속에서 겪은 것과 정확히 같은 패턴이다. 접속 대상은 환경 변수가 정하고, 지정이 없을 때만 로컬 기본값으로 폴백하도록 고쳤다.
12. 23:28 · #144 ci-xdebug-coverage — 915개 통과하고도 실패
에러가 686개에서 4개로, 4개에서 0개로 줄었다. 그런데 잡은 여전히 빨간불이었다.
경고는 XDEBUG_MODE=coverage has to be set 하나였다. phpunit.dist.xml이 failOnWarning="true"라서 경고가 곧 실패다. 예전 macOS 러너에서는 setup-php의 coverage: xdebug가 켜 줬는데, apt 설치로 바꾸면서 같이 사라진 설정이었다. XDEBUG_MODE: coverage를 env에 추가했다.
이제 정말 초록불이다. 9분 24초.
13. 다음 날 · #145 — 이왕 보는 김에 28초까지
초록불이 되자 "9분은 아직 길다"는 생각이 들었다. 이번에는 추측 대신 프로파일링을 했다. 병목은 특정 테스트가 아니라 모든 DB 테스트의 setUp에 깔린 두 가지였다.
- Argon2id 해시 111초(전체의 55%). 스위트 한 번에
password_hash()가 612회 불렸고, 운영 파라미터(64MB, 반복 4회)라 1회에 190ms였다. 비용 파라미터를Config\Password로 분리하고phpunit.dist.xml에서만 낮췄다(190ms → 0.66ms). 설정 기본값은 그대로라 운영 해시 강도는 바뀌지 않는다. TRUNCATE53초. InnoDB의TRUNCATE는 테이블스페이스를 다시 만드는 파일 조작이라DELETE보다 훨씬 비싸다. 실측에서TRUNCATE는 81초,DELETE후 AUTO_INCREMENT 리셋은 81초,DELETE만은 28초였다.DELETE로 바꾸고, "첫 행의 id는 1"이라고 가정한 테스트 하나를 고쳤다.
3분 22초가 28초가 됐다. 처음 시작점이던 1시간 이상에서는 100배 넘게 줄어든 셈이다.
14. 하루를 돌아보며: 표로 정리
| 시각 | PR | 가설 / 증상 | 결과 |
|---|---|---|---|
| 12:47 | #131 | needrestart 프롬프트 대기 | 맞음, 다음 단계로 |
| 13:38 | #132 | PPA가 resolute 미지원 | 맞음, apt 네이티브로 |
| 16:14 | #133 | ext-sqlite3 누락 |
맞음 |
| 17:33 | #134 | healthcheck가 임시 서버에서 통과 | 틀림 |
| 17:49 | #135 | (진단) env/TCP 직접 출력 | 단서 확보 |
| 18:07 | #136 | (진단) 예외 메시지 출력 | 단서 확보 |
| 18:30 | #137 | phpunit XML env가 덮어씀 | 절반만 맞음 |
| 18:38 | #138 | 점 이름 env가 Composer에서 소실 | 진짜 원인 + 가드 |
| 18:52 | #139 | PSR-12 정렬 | 로컬 검증 누락 |
| 19:24 | #140 | (진단) MySQL gone away 계측 | 원인은 타임아웃 |
| 23:01 | #141 | 메서드마다 regress/migrate | 1시간+ → 9분 |
| 23:14 | #143 | Redis 포트 하드코딩 | DB와 같은 패턴 |
| 23:28 | #144 | xdebug coverage 모드 | 초록불 |
| 다음 날 | #145 | Argon2id, TRUNCATE | 28초 |
15. 남은 교훈
- "한 줄 수정 → 재실행"은 CI에서 가장 비싼 루프다. 한 번에 5
10분이 든다. #134#137 네 번의 추측이 그 값을 치렀고, #138은 로컬 재현으로 한 번에 끝났다. 가설이 두 번 틀리면 CI를 그만 돌리고 로컬에서 재현한다. - 초록불을 의심한다. 예전 CI는 초록불이었지만 엉뚱한 DB에 붙어 있었다. "CI가 아무것도 전달받지 못했으면 실패"하는 가드 테스트가 이날 가장 오래 남을 산출물이다.
- 환경이 바뀌면 숨어 있던 가정이 드러난다. DB 접속(#138)과 Redis 포트(#143)는 같은 병이었다. 하나를 고치고 나면 "같은 패턴이 또 어디 있나"를 바로 찾는다.
- 진짜 병목은 처음 의심한 곳에 없었다. MySQL이 죽었다고 생각했지만 느려서 타임아웃이 났을 뿐이다. 추측하기 전에 계측부터 하는 쪽이 결국 빨랐다.
- 로컬에서
composer check를 돌리고 올린다. #139 같은 PSR-12 한 줄이 PR 번호를 하나 낭비한다. - CI가
dev → main에서만 도는 구조는 검증이 늦다. 러너 이전 같은 CI 변경은 로컬 docker로 미리 재현해 두면 이 하루의 절반을 줄일 수 있었다.
에필로그: 한 달 뒤 같은 길을 한 번 더
10월에 러너를 docker 라벨의 컨테이너형으로 다시 옮기면서 같은 종류의 실패가 반복됐다(#147~#150). 이번에는 러너 자체가 컨테이너라 호스트에 게시한 포트(-p)가 127.0.0.1로 닿지 않았다. 실행별 도커 네트워크를 만들고 컨테이너 이름으로 접속하는 방식으로 풀었고, 테스트 DB 컨테이너는 --tmpfs와 durability 완화 옵션으로 체크포인트 스톨을 없앴다. 그날 정리한 내용은 CLAUDE.md의 "CI 러너 주의사항" 항목에 남겨 두었다. 일지를 남기는 이유다. 같은 실수를 두 번째에는 PR 한 개로 끝내려고.
댓글 0개