통합과 배포 · 시간에도 예산이 있다 · 실습
상대가 느려졌는데 우리가 먼저 죽었다
목표
상위 요청 하나에 주어진 시간을 예산으로 두고, 그 예산을 연결·읽기·재시도·하위 호출에 나눠 주는 클라이언트를 직접 만든다. 예산이 모자라면 걸지 않고 빨리 실패하고, 남은 예산을 헤더로 아래에 넘긴다.
왜 중요한가
타임아웃을 넉넉히 잡는 습관은 안전해 보이지만 반대다. 상대가 30초로 느려졌을 때 우리 상한이 60초라면 우리 워커는 30초 동안 묶이고, 우리를 부르는 쪽에서 보면 느려진 것은 우리다. 그 지연은 한 계층 위로 또 번진다.
그래서 타임아웃은 상대를 기다려 주는 배려가 아니라 우리를 지키는 차단기다. 그리고 하나의 값이 아니라 두 값이다 — 연결은 닿는 시간이라 몇 초가 걸리면 느린 것이 아니라 안 되는 것이고, 읽기는 상대가 일하는 시간이라 업무에 따라 다르다.
예산으로 생각하면 규칙은 세 줄이다. 상위 요청에 총 예산을 정하고, 각 호출의 읽기 상한은 그 순간 남은 예산으로 하고, 남은 예산이 하한보다 작으면 걸지 않는다. 재시도와 그 대기 시간도 같은 예산에서 나간다.
채점기는 여러분의 문장을 믿지 않는다. 느려지는 파트너를 채점기가 고른 포트에 직접 띄우고, 여러분의 호출기를 실제로 돌려 걸린 시간과 출력의 구간 이름을 함께 잽니다.
단계
1. /root/budget/partner.py 를 만들어 포트 8014 에 띄우고, 실패 세 모양을 재어 /root/budget/probe.json 에 적으세요.
2. /root/budget/call.py 를 만들어 연결과 읽기의 상한을 따로 잡고 어느 구간에서 끊겼는지 말하게 하세요.
3. /root/budget/deadline.py 를 만들어 총 예산 하나로 여러 호출을 하고, 남은 예산이 다음 호출의 읽기 상한이 되게 하세요.
4. deadline.py 에 하한(--min-ms)을 붙여 남은 예산이 하한보다 작으면 걸지 않고 no_budget 으로 건너뛰게 하세요.
5. /root/budget/retry.py 를 만들어 재시도 대기를 지수로 늘리고 무작위로 흩뜨리되, 그 대기까지 예산에서 빼게 하세요.
6. /root/budget/fanout.py 를 만들어 그때 남은 예산을 X-Budget-Ms 헤더로 하위 호출에 넘기세요.
7. /root/budget/budget_plan.json 에 화면 하나의 예산표를 숫자로 적으세요.
8. /root/budget/budget_report.md 에 네 절로 보고하세요.
참고
- 파트너 실행 계약:
python3 /root/budget/partner.py --port <포트>./health는{"ok": true},/call?ms=<n>&fail=<k>&key=<s>는 n 밀리초 뒤에 답하고 같은 key 의 앞 k 번은 503 입니다./budget?ms=<n>은 n 밀리초 뒤에{"received": <X-Budget-Ms 헤더 값>}을 냅니다. - 실패 세 모양: 닫힌 포트(
http://127.0.0.1:9/)는 거의 즉시 거부되고, 어디로도 라우팅되지 않는 주소(http://192.0.2.1/— RFC 5737 이 문서용으로 예약한 대역)는 연결 상한까지 기다렸다 끊기고, 느린 응답은 읽기 상한까지 기다렸다 끊깁니다. probe.json 은{"kind": "refused"|"connect_timeout"|"read_timeout", "target": ..., "elapsed_ms": ..., "error": ...}세 줄의 리스트입니다. - 호출기 실행 계약:
python3 call.py --url <U> --connect <초> --read <초>는{"ok": ..., "status": ..., "phase": "done"|"connect"|"read", "elapsed_ms": ..., "error": ...}를 냅니다. 연결 거부처럼 상대에게 닿기 전에 끝난 실패도connect구간입니다. 4xx·5xx 응답은 받은 것이므로 phase 는done이고 ok 만 false 입니다. - 예산 호출기 실행 계약:
python3 deadline.py --budget-ms <n> --url <U> [--url <U> ...] [--connect <초>] [--min-ms <n>]. 출력은{"budget_ms": ..., "calls": [...], "skipped": k, "total_ms": ..., "exceeded": ...}이고 각 call 에는urlokphaseelapsed_msread_timeout_ms가 있습니다.read_timeout_ms는 그 호출을 걸 때 남아 있던 예산입니다.--min-ms는 3번 단계에서도 인자로 받아 두고(기본 0), 하한 판정은 4번에서 붙입니다.exceeded는 예산이 모자라 건너뛴 호출이 있거나 읽기가 예산에서 끊긴 경우 true 입니다. - 재시도기 실행 계약:
python3 retry.py --url <U> --budget-ms <n> --attempts <k> [--base-ms <b>] [--connect <초>] [--min-ms <n>]는{"ok": ..., "attempts": ..., "elapsed_ms": ..., "waits_ms": [...], "stopped_reason": "ok"|"attempts"|"budget"}를 냅니다. 대기 상한은base-ms * 2^(시도-1)이고 실제 대기는 0 부터 그 상한 사이에서 고릅니다. - 부채질기 실행 계약:
python3 fanout.py --base <BASE_URL> --budget-ms <n> --n <횟수> [--connect <초>]는 매번<BASE>/budget?ms=200을 부르되X-Budget-Ms헤더에 그때 남은 예산을 실어 보냅니다. 출력의 각 call 은sent_budget_msreceivedelapsed_ms입니다. - 예산표 형식:
{"total_ms": ..., "reserve_ms": ..., "worst_case_ms": ..., "calls": [{"name": ..., "budget_ms": ..., "max_attempts": ...}]}. 호출은 3개 이상,reserve_ms는 100 이상,worst_case_ms는 호출 예산의 합에 예비를 더한 값이며total_ms를 넘지 않아야 합니다. - 흔한 실수: 타임아웃을 한 값으로만 주기, 재시도 대기를 예산에서 빼지 않기, 남은 예산이 거의 없는데도 호출을 걸기, 하위 호출에 우리 예산을 안 알려 주기.
- 부하 시험을 만들지 마세요. 채점 하나의 예산은 60초입니다.
단계 8개
- 실패의 세 가지 모양 재 보기
- 연결과 읽기를 나눠 잡기
- 남은 예산이 다음 호출의 상한이다
- 가망 없는 호출은 걸지 않는다
- 재시도도 예산에서 나간다
- 남은 예산을 아래로 넘기기
- 화면 하나의 예산표
- 예산 점검 보고서