서버 운영 방식을 바꾸는 검토를 하면서 자동 확장을 붙일 수 있는지 봤다. 이 서비스는 AsyncLimiter 를 두고 외부 API 를 부르는데 그쪽에 호출 속도 제한이 걸려 있다.
Table of contents
Open Table of contents
검증 — 제한기를 봤다
속도 제한 구현을 열었다.
# 모듈 전역
_limiter = AsyncLimiter(max_rate=9, time_period=1)
async def call_vendor(...):
async with _limiter:
return await client.get(...)
_limiter 가 모듈 전역 변수이고 공유 저장소가 없다.
max_rate 계산이 프로세스 메모리 안에만 있는 상태라는 뜻이다. 그러면 프로세스가 여럿일 때 무슨 일이 일어나는지가 곧바로 따라 나온다.
원인 — 프로세스마다 따로였다
uvicorn 워커마다 자기 _limiter 를 따로 갖는다.
프로세스 1 → 자기 제한기 (초당 9)
프로세스 2 → 자기 제한기 (초당 9)
...
각자 max_rate 인 초당 9까지 보내므로 합치면 프로세스 수만큼 곱해진다.
AsyncLimiter 는 자기 프로세스 안에서만 정확하다. 인스턴스를 늘리기 전에 이미 그 안에서 전제가 깨져 있었다.
결과 — 최대 72 대 한도 19
실행 설정을 봤다.
--workers 8
--workers 가 8이므로 최댓값이 이렇게 된다.
초당 9 × 8 = 최대 72
외부 API 가 허용하는 실효 한도는 약 19였다.
이미 세 배 넘게 넘길 수 있는 상태였다. 확장을 붙이기 전부터 단일 인스턴스라는 전제가 내부에서 무너져 있었던 것이다.
주의 — 안 터진 이유
그런데 지금까지 안 터지고 있었다.
전체 약 8/초
워커당 약 1/초
워커당 실트래픽이 낮아서 async with _limiter 에서 대기할 일이 없었다.
_limiter 가 있으나 마나인 상태로 돌고 있었고 트래픽이 늘면 그때 터진다. 안 터지는 것이 설계 때문이 아니라 운 때문이었다.
제약 — 확장이 해로운 이유
원래 물음으로 돌아가면 자동 확장은 붙일 수 없었다.
인스턴스를 늘린다
↓
제한기가 그만큼 더 곱해진다
↓
외부 한도를 확실히 초과
↓
차단당한다
늘리면 처리량이 는다는 기대가 이 구조에서는 반대로 작동한다.
배포 설정에는 복제본을 1로 고정하고 호출자가 단일 주소를 부르게 하는 전제가 있었다. 그런데 그 하나 안에 워커가 8개라서 전제가 이미 내부적으로 깨져 있었다.
결론 — 선결 과제와 중립이 된 축
확장이 필요해지면 AsyncLimiter 부터 바꿔야 한다.
[지금] 프로세스별 메모리
[필요] 공유 저장소 기반
--workers 나 인스턴스 수 조정으로 해결할 문제가 아니었다.
원래 검토는 어느 운영 방식이 나은가였고 확장 가능이 한쪽의 장점이었다. 이 앱에는 확장이 무의미하므로 그 축이 중립이 됐고 다른 기준으로 정해야 했다.
나가는 경로도 함께 봤는데 고정 IP 가 필요한지가 쟁점이었다. 나가는 트래픽이 별도 로드밸런서를 경유하고 있어서 앱 자체의 고정 주소는 필요 없었고 인스턴스가 바뀌어도 나가는 주소는 그대로라 그 축도 중립이 됐다.
이 조사에서 지킨 것이 하나 있다. 제한기가 있으니 지켜지겠지로 넘기지 않고 전역 변수이고 프로세스별이라는 것을 코드로 재확인했는데 있다와 동작한다는 다르다.
정리
- 속도 제한기가 프로세스별 메모리면 워커 수만큼 곱해진다
- 단일 인스턴스여도 그 안의 워커가 여럿이면 전제가 깨진다
- 안 터지는 것이 트래픽이 낮아서일 수 있다
- 운으로 버티는 것과 설계로 보장되는 것은 다르다
- 그런 앱에는 자동 확장이 무의미하거나 해롭다
- 확장하려면 제한기를 분산으로 바꾸는 것이 선결이다
- 이 확인으로 배포 결정의 한 축이 중립이 된다
- 제한기가 있다는 것과 동작한다는 것은 다르다