앞 글들에서 부분 갱신과 조용한 실패를 고쳤다. 그런데 검증 스위트를 실행할 때마다 429가 났다. 재시도를 6회, 10초 간격으로 늘려 넘기고 있었는데 증상만 가린 것이다. 프래그먼트(서버가 다시 렌더해 돌려주는 화면 일부) 갱신은 사용자가 화면을 열어 두면 계속 실행되므로 실제 사용자에게도 같은 일이 생긴다.
출처 확정
포털은 컨테이너 PaaS(Coolify) 뒤에 있고 그 PaaS의 API를 호출한다. 429는 프록시가 포털로 들어오는 요청을 제한할 때도 나올 수 있고 포털이 PaaS API로 나가는 호출이 제한에 걸릴 때도 나올 수 있다.
인바운드부터 확인했다. PaaS API를 호출하지 않는 /health에 100회를 연속으로 보냈다.
== /health 60회 연속 ==
200=60 429=0
이어서 40회를 더 보내도 429가 없었다. 프록시 제한이 아니다. 그러면 나가는 호출 쪽이다.
한도 값을 재려고 무효 토큰으로 PaaS API를 150회 호출했다.
150회 모두 401 (무인증 요청은 예산을 쓰지 않는다)
인증이 throttle보다 먼저 걸리므로 이 방법으로는 한도를 측정할 수 없다. 서버 설정을 직접 읽으려 했는데 VPN 문제로 바스티온 경유 접속이 되지 않았다. 401 응답에는 X-RateLimit-* 헤더도 없었다.
한도 값을 모르는 채로 넘지 않는 구조로 가기로 했다.
원인: 목록이 슬롯마다 배포 이력을 조회했다
호출량을 셌다. 슬롯 목록을 한 번 그리는 데 필요한 호출은 앱 목록 1회와 슬롯마다 배포 이력 1회다.
// 목록에서는 배포 이력을 다 긁지 않는다(슬롯 수 x API 호출이 되어 느려진다).
if (app && !opts.deep) {
const raw = await coolify.listDeployments(app.uuid);
deployments = list.slice(0, 3);
}
주석은 목록에서 배포 이력을 조회하지 않는다고 적혀 있는데 코드는 조회한다. 위쪽 상세용 분기와 API 호출이 같고 뒤에서 3건으로 자르기만 다르다. 주석이 코드와 반대였다.
슬롯이 11개면 한 번에 12회다. 프래그먼트 갱신이 15초 주기로 세 종류(홈, 목록, 대시보드) 돌면 분당 144회가 된다. 탭을 두 개 열면 그만큼 곱해진다. 사용자 한 명으로도 넘긴다.
배포 이력은 목록에서도 필요하다. 요약 배지의 "마지막 배포 실패" 표시와 스타터 여부 판정, 유휴 판단이 모두 거기서 나온다. 그래서 호출을 없애는 대신 호출량을 조절하는 방향으로 갔다.
세 겹으로 막는 관문
나가는 호출을 전부 거치게 하는 모듈을 만들었다.
graph TB
A["서비스 계층"] --> B{"캐시 수명 안?"}
B -->|"예"| C["저장된 값 반환"]
B -->|"아니오"| D{"같은 조회가
진행 중?"}
D -->|"예"| E["그 결과에 합류"]
D -->|"아니오"| F{"토큰 있나?"}
F -->|"아니오"| G["큐에서 대기"]
G --> F
F -->|"예"| H["PaaS API 호출"]
H -->|"429"| I["예산 절반으로
Retry-After 만큼 정지"]
H -->|"200"| J["캐시에 저장"]
캐시는 같은 조회를 수명 안에서 한 번만 보낸다. 전역이라 보고 있는 사용자가 몇 명이든 호출량이 같다. 합류는 동시에 들어온 같은 조회를 하나로 묶어 결과를 나눠 준다. 프래그먼트 갱신과 화면 요청이 같은 순간에 겹치는 일이 흔해서 이 부분이 실제로 차이를 만든다. 예산은 토큰 버킷으로 속도를 제한하고 토큰이 없으면 큐에서 기다린다.
캐시 수명은 "이 값이 얼마나 늦게 반영돼도 괜찮은가"로 정했다.
| 조회 | 수명 | 근거 |
|---|---|---|
| 앱 목록 | 10초 | 상태 배지가 그 정도 늦어도 된다 |
| 배포 이력(목록) | 60초 | 배지만 바뀐다. 슬롯 수만큼 나가므로 수명이 곧 분당 호출량이다 |
| 배포 이력(상세) | 5초 | push 직후 반영이 보여야 한다 |
| 앱 로그 | 6초 | 폴링 주기와 비슷하게 두면 여러 화면이 한 호출에 합류한다 |
| 진행 중인 배포 | 없음 | 캐시하면 상태가 멈춘 것처럼 보인다 |
캐시 키가 같아서 상세가 갱신하면 목록도 그 값을 함께 쓴다. 짧은 수명으로 조회한 쪽이 캐시를 새로 채운다. 긴 수명으로 조회하는 쪽은 그것을 그대로 읽는다.
한도를 모르는 채로 지키는 부분
토큰 버킷의 분당 예산을 40으로 잡았다. 실제 한도를 모르니 보수적으로 잡은 값이다. 이것만으로는 보장이 되지 않는다. 관리자 스크립트와 다른 도구가 같은 한도를 함께 쓰기 때문이다.
그래서 429를 받으면 예산을 절반으로 낮추고 서버가 Retry-After 로 지정한 대기 시간만큼 실제로 정지한다.
export function penalize(retryAfterMs) {
ratePerMin = Math.max(RATE_FLOOR, Math.floor(ratePerMin / 2));
tokens = 0;
lastRefillAt = Date.now();
penaltyAt = Date.now();
hardPauseUntil = Date.now() + (retryAfterMs > 0 ? retryAfterMs : DEFAULT_PAUSE_MS);
}
회복은 경과 시간에 비례시킨다. 벌칙이 시작된 시각과 그때의 예산을 기억해 두고 5분에 걸쳐 원래 값으로 되돌린다.
이 구조라면 한도가 얼마든 몇 번의 429를 거쳐 그 아래로 수렴한다. 사용자가 보는 것은 조금 느려진 갱신이고 429 오류 화면은 아니다.
측정 결과
fetch를 가로채 실제로 나가는 호출을 셌다. 슬롯 11개 기준이다.
| 상황 | 호출 |
|---|---|
| 첫 요청(캐시 없음) | 12 |
| 이어지는 요청 | 0 |
| 동시 4요청 | 12 (한 벌로 합쳐짐) |
| 15초 주기 x 4, 프래그먼트 3종 | 144에서 15로 |
15회는 앱 목록 재조회 몇 회와 배포 이력 11회의 합이다. 수명이 60초이므로 분당 11회로 고정되고 보고 있는 사용자 수와 무관하다. 슬롯이 20개로 늘어도 26회 수준이라 예산 40 아래에 남는다.
관문 자체의 경계 결함
이 관문을 넣은 뒤 코드 검수를 실행했는데 경계에서 여섯 가지가 나왔다. 아이디어는 맞았고 문제는 전부 경계에 있었다.
자체 503을 재시도했다
예산을 기다리다 포기하면 503을 던지게 했다. 그런데 재시도 판정이 5xx를 전부 일시적 실패로 본다.
if (status >= 500 && status <= 599) return true;
takeToken이 재시도 루프 안쪽에 있어서, 20초를 기다려 포기하고 400ms 뒤 다시 20초를 기다리고 또 반복한다. 한 번의 조회가 1분 넘게 걸리고 그동안 같은 요청이 큐에 세 개 쌓여 혼잡을 스스로 키운다. 기다려서 안 된 것을 다시 기다릴 이유가 없어서 재시도 대상에서 제외했다.
무효화가 진행 중인 요청을 고려하지 않았다
쓰기가 성공하면 캐시를 전부 비운다. 그런데 그 시점에 이미 나가 있던 조회가 있다.
t=0.00 프래그먼트의 env 조회 출발(응답 지연 200ms)
t=0.05 deleteEnv 성공 → 캐시 비움
t=0.10 createEnv 성공 → 캐시 비움
t=0.20 0.00 의 응답 도착 → 쓰기 이전 목록이 캐시에 들어간다
t=0.21 저장 검증 조회가 그 캐시를 읽는다 → 새 값이 없다 → 검증 실패
값은 정상으로 저장됐는데 검증 결과가 거짓 실패로 나온다. 무효화에 세대 번호를 두었다. 조회가 시작된 시점의 세대가 바뀌었으면 결과를 캐시에 쓰지 않게 했다.
force가 캐시를 갱신하지 않았다
앱을 막 만든 뒤에는 실제 현재 상태를 봐야 해서 캐시를 건너뛰는 경로를 뒀다. 그런데 수명을 0으로 넘기는 방식이라 읽기도 쓰기도 건너뛰었다.
관리자가 상태 맞추기를 누르면 신선한 목록으로 앱을 찾아 DB를 고치고 목록 화면으로 리다이렉트한다. 그 화면은 캐시를 읽으므로 최대 10초 동안 동기화 이전 목록을 본다. 그러면 정합성 검사가 앱이 없다고 판단해 "PaaS에 앱이 없습니다. 포털 밖에서 삭제됐을 수 있습니다" 라는 정반대 경고를 표시한다. 방금 맞췄다고 한 화면이다.
읽기만 건너뛰고 결과는 캐시에 채우는 refresh 모드로 바꿨다. 이미 나가 있는 요청에 합류하지도 않는다. 그 결과는 우리가 알고 싶은 "지금"보다 오래됐을 수 있다.
회복이 호출 횟수에 비례했다
if (ratePerMin < configuredRate && nowMs > penaltyUntil) {
ratePerMin = Math.min(configuredRate, ratePerMin * 1.5);
}
이 코드가 refill() 안에 있고 refill()은 토큰을 기다리는 루프에서 초당 여러 번 호출된다. 바쁜 포털은 벌칙이 몇 밀리초 만에 풀려 다시 429를 맞는다. 조용한 포털은 계속 낮은 상태로 남는다. 주석에 적은 "천천히 되돌린다"가 성립하지 않았다.
관측이 대상을 바꾸는 문제도 함께 있었다. 상태를 읽는 budget()이 refill()을 호출하고 있어서, 관리자가 대시보드를 열어 두는 것만으로 회복이 빨라졌다. 회복을 시간 비례로 바꾸고 budget()은 상태를 바꾸지 않게 했다.
Retry-After를 대기에 반영하지 않았다
예산만 낮추고 실제로 멈추지는 않았다. 예산이 낮아도 시간이 지나면 토큰은 채워지므로 서버가 Retry-After 로 지정한 대기 시간 동안 계속 호출이 나간다. hardPauseUntil을 두고 그 시각까지 토큰을 발급하지 않게 했다.
처음에는 최소 5초를 강제했는데 테스트에서 걸렸다. 서버가 1초를 요청해도 5초를 멈춰서 대기 한도를 넘긴 요청들이 503이 됐다. 알려 준 값을 그대로 지키고 알려 주지 않았을 때만 기본값을 쓴다.
예산 하한이 스스로 죽는 값이었다
429를 반복해서 받으면 예산이 계속 절반이 되는데 하한을 4로 뒀다. 분당 4회면 토큰 하나에 15초이고 목록 한 장을 그리는 데 12개가 필요하므로 한 페이지 렌더에 3분이 걸린다. 그 3분 동안 폴링이 계속 큐에 쌓인다. 관문이 혼잡을 줄이는 대신 키우는 값이었다. 하한을 화면 한 장은 그릴 수 있는 수준으로 올렸다.
설정 오타 하나가 CPU 스핀이 됐다
새로 만든 숫자 설정 다섯 개에 검증이 없었다. Number('20s')는 NaN이다.
maxWaitMs: Number(process.env.COOLIFY_MAX_WAIT_MS || 20000), // "20s" → NaN
deadline이 NaN이면 Date.now() >= NaN은 항상 false라 포기하지 않는다. sleep(Math.min(waitMs, NaN))은 setTimeout(fn, NaN)이고 이건 0ms다. 토큰이 없는 동안 초당 수천 번 도는 바쁜 대기가 된다. 요청은 끝나지 않고 CPU는 100%가 되고 폴링이 계속 새 요청을 넣는다.
ratePerMin이 NaN이면 tokens >= 1이 항상 false가 되어 모든 PaaS 호출이 영구 실패한다. 배포는 성공한 것처럼 보인다.
숫자 환경변수를 읽는 함수를 만들어 범위까지 검사하게 했다. 잘못된 값은 경고를 남기고 기본값이나 경계로 맞춘다.
[config] COOLIFY_RATE_PER_MIN 이 숫자가 아닙니다(20s). 기본값 40 을 씁니다.
[config] COOLIFY_MAX_WAIT_MS 이 숫자가 아닙니다(abc). 기본값 20000 을 씁니다.
[portal] listening on :18099
관측 장치
예산이 깎이면 관리자 대시보드에 표시한다. 이것이 없으면 포털이 스스로 속도를 낮춘 것을 아무도 모른 채 "화면이 느리다"로만 겪는다. 예산 대기로 포기한 횟수와 마지막 포기 시각도 함께 노출한다. 429가 한 번도 없어도 우리 예산만으로 대량의 503이 발생할 수 있는데 그 상태가 화면에 드러나지 않으면 폴링 설계가 예산을 넘고 있다는 사실을 찾을 수 없다.
검증에는 연속 40요청에 429와 5xx가 없는지, 예산이 깎인 상태가 아닌지 확인하는 항목을 넣었다.
남은 문제
예산 하한을 올렸지만 폴링 요청과 사용자 조작 요청의 우선순위가 같다. 예산이 마르면 대화형 요청도 함께 기다린다. 프래그먼트 폴링은 토큰이 없으면 기다리지 않고 바로 포기하게 하는 편이 맞는데 아직 넣지 않았다.
캐시 무효화가 전부 비우는 방식이다. 환경변수 저장 한 번에 삭제, 생성, 재배포로 세 번 비워지고 그 직후 목록 프래그먼트는 12회를 한꺼번에 내보낸다. 활동이 많은 시간대에 호출량이 가장 커진다. 경로 접두사 단위로 지우면 줄어든다.