변수 하나만 바꾼 카나리를 같은 타깃그룹에 동시 투입하면 부하 교란 없이 회귀 원인을 격리한다
요약
- 배포가 여러 변수(런타임 버전 + OS + 의존성)를 한 번에 바꾸면 "배포 전 vs 후" 비교로는 원인을 못 가린다. 시간대·부하가 교란 변수로 섞이기 때문.
- 변수 하나만 바꾼 카나리를 운영 트래픽과 같은 로드밸런서 타깃그룹에 넣어 동시에 돌리면, 모든 변형이 같은 부하·같은 시각의 트래픽을 받으므로 교란이 상쇄되고 그 변수의 순수 효과만 남는다.
- 단, gevent처럼 부하에 따라 측정값이 증폭되는 시스템은 회귀가 나타난 부하 조건(피크) 에서 비교해야 한다. 저부하 카나리는 효과가 노이즈에 묻혀 거짓 음성을 낸다.
본문
왜 before/after가 안 되나. 한 배포에 런타임(3.10→3.11) + OS(Debian bullseye→bookworm) + 의존성(wrapt 등)이 동시에 바뀌면, "어제 vs 오늘" 비교는 (1) 어느 변수 탓인지 못 가리고 (2) 어제와 오늘의 시간대별 트래픽이 달라 부하가 교란된다. 특히 gevent는 I/O 시간(DB/external)을 "소켓 wall-clock = 실제 시간 + hub가 다른 greenlet 처리하느라 콜백 재개를 미룬 시간"으로 재므로, 측정값이 동시성(부하)에 비례해 부풀어 오른다. 그래서 부하가 다르면 같은 코드도 다른 수치가 나온다.
핵심 기법 — 동일 타깃그룹 동시 카나리. ECS(Fargate) 기준:
- 프로덕션과 딱 한 가지 변수만 다른 이미지를 빌드한다 (예: OS만 bullseye로, 또는 greenlet 핀만 bump —
pyproject에 핀 추가 후uv lock으로 lock의 해당 패키지만 갱신, diff가 그 한 줄인지 확인). - 프로덕션 task definition을 복제해 이미지만 교체한 새 서비스를 만들고, 기존과 같은 ALB 타깃그룹에 등록한다(
--load-balancers targetGroupArn=...). ALB가 prod N대 + 카나리 M대에 라운드로빈하므로 카나리는 운영 트래픽의 M/(N+M)을 동시에 받는다. - 여러 변형(예: py310-bullseye, py311-bullseye, py311-bookworm)을 동시에 띄우면 한 화면에서 비교된다. 이때 변형 간 차이는 시각·부하·요청믹스가 모두 같으므로 순수 변수 효과다.
New Relic에서 변형 구분 — FACET host. APM 에이전트가 같은 appName으로 보고하면 FACET CASES(WHERE host IN (...))로 그룹을 가른다. host는 컨테이너의 FQDN(ip-<dashed-private-ip>.<region>.compute.internal)이라, ECS task의 privateIPv4Address를 조회해 매핑한다. NEW_RELIC_APP_NAME 환경변수로 appName을 분리하려 했으나 newrelic.ini의 app_name을 못 덮어써서, host 식별로 fall back했다.
측정 함정들 (각각 사유 분리):
-
워밍업 — 갓 뜬 task는 ~10–15분간 app·DB·external이 전부 부풀려진다. 코드 캐시·커넥션 풀·(3.11) 적응형 인터프리터가 콜드라서다. 신호: per-call의 median은 낮은데 average가 큼(꼬리가 무거움). 대응: average 말고 median을 보고, median-avg 격차가 닫힐 때까지 기다린다.
-
n=1 task 노이즈 — task 1대는 ALB 라우팅·Fargate 배치 운에 따라 들쭉날쭉하다. 2–3대로 늘리면 수렴한다.
-
공정성 기준의 함정 — 같은 런타임끼리 비교할 땐
app_median(순수 CPU 시간)이 같아야 공정하다. 그러나 다른 Python 버전을 비교하면 app은 원래 다르다(3.11이 CPU ~30% 빠름). 이땐 "app 같음"을 워밍 완료 기준으로 쓰면 안 되고, 그룹 자체 수치가 시간에 따라 안정됐는지로 판단한다. -
부하 의존성 — 가장 중요 — 회귀가 부하 증폭으로 나타난 거라면(gevent attribution), 저부하 시간대 카나리는 효과를 못 본다. 실제로 로컬 마이크로벤치(arm64)에서 greenlet 3.2.4→3.5.1이 switch +60%를 해소했지만, 같은 bump를 prod(amd64) 오후 저부하에 카나리로 띄우니 DB per-call이 stock과 동일했다 — switch 절약(ns)이 쿼리 시간(ms) 옆에서 무의미하고 hub가 한가해 증폭이 안 일어나서다. 즉 로컬 벤치 예측이 prod에서 재현 안 됨. 회귀가 난 조건(아침 피크)에서 비교해야 결론이 난다.
부산물 — 서버 측 교차검증. 클라이언트 측 측정(NR DB time)이 늘었을 때, DB 서버 자체가 느린지(RDS storage latency/CPU/Performance Insights wait events)와 대조하면 "서버가 느림 vs 클라이언트 I/O 경로 회귀"를 가른다. 서버 지표가 평행이면 회귀는 앱 컨테이너 쪽이다.
관련 노트
- python 3.11 이후부턴 gevent 성능이 악화될 수 있다.
- 반사실 시뮬레이션은 모델로 그것이 없었을 때를 재현해 인과 기여도를 추정한다
- Docker Buildx provenance는 이미지를 OCI Index로 만들어 SageMaker와 Lambda에서 깨진다
- enhanced Container Insights는 TaskId로 series를 폭증시키지만 CloudWatch proration이 비용을 수렴시킨다