They Slowed Down and We Died First
한국어 원문으로 표시합니다.
목표
상위 요청 하나에 주어진 시간을 예산으로 두고, 그 예산을 연결·읽기·재시도·하위 호출에 나눠 주는 클라이언트를 직접 만든다. 예산이 모자라면 걸지 않고 빨리 실패하고, 남은 예산을 헤더로 아래에 넘긴다.
왜 중요한가
타임아웃을 넉넉히 잡는 습관은 안전해 보이지만 반대다. 상대가 30초로 느려졌을 때 우리 상한이 60초라면 우리 워커는 30초 동안 묶이고, 우리를 부르는 쪽에서 보면 느려진 것은 우리다. 그 지연은 한 계층 위로 또 번진다. 그래서 타임아웃은 상대를 기다려 주는 배려가 아니라 우리를 지키는 차단기다. 그리고 하나의 값이 아니라 두 값이다 — 연결은 닿는 시간이라 몇 초가 걸리면 느린 것이 아니라 안 되는 것이고, 읽기는 상대가 일하는 시간이라 업무에 따라 다르다. 예산으로 생각하면 규칙은 세 줄이다. 상위 요청에 총 예산을 정하고, 각 호출의 읽기 상한은 그 순간 남은 예산으로 하고, 남은 예산이 하한보다 작으면 걸지 않는다. 재시도와 그 대기 시간도 같은 예산에서 나간다. 채점기는 여러분의 문장을 믿지 않는다. 느려지는 파트너를 채점기가 고른 포트에 직접 띄우고, 여러분의 호출기를 실제로 돌려 걸린 시간과 출력의 구간 이름을 함께 잽니다.
단계
- /root/budget/partner.py 를 만들어 포트 8014 에 띄우고, 실패 세 모양을 재어 /root/budget/probe.json 에 적으세요.
- /root/budget/call.py 를 만들어 연결과 읽기의 상한을 따로 잡고 어느 구간에서 끊겼는지 말하게 하세요.
- /root/budget/deadline.py 를 만들어 총 예산 하나로 여러 호출을 하고, 남은 예산이 다음 호출의 읽기 상한이 되게 하세요.
- deadline.py 에 하한(
--min-ms)을 붙여 남은 예산이 하한보다 작으면 걸지 않고no_budget으로 건너뛰게 하세요. - /root/budget/retry.py 를 만들어 재시도 대기를 지수로 늘리고 무작위로 흩뜨리되, 그 대기까지 예산에서 빼게 하세요.
- /root/budget/fanout.py 를 만들어 그때 남은 예산을
X-Budget-Ms헤더로 하위 호출에 넘기세요. - /root/budget/budget_plan.json 에 화면 하나의 예산표를 숫자로 적으세요.
- /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초입니다.
실패의 세 가지 모양 재 보기
/root/budget/partner.py 를 만들어 포트 8014 에 띄우고, 연결 거부·연결 타임아웃·읽기 타임아웃 세 가지를 직접 재어 /root/budget/probe.json 에 kind·target·elapsed_ms·error 세 줄로 적으세요.
느린 응답은 파트너가 sleep 으로 흉내 냅니다. 연결 거부는 아무도 듣지 않는 127.0.0.1 포트로, 연결 타임아웃은 어디로도 라우팅되지 않는 주소로 만듭니다. 세 경우의 걸린 시간이 서로 어떻게 다른지가 이 단계의 핵심입니다.
연결과 읽기를 나눠 잡기
/root/budget/call.py 를 만들어 --connect 와 --read 를 따로 받고, 실패했을 때 phase 에 connect 인지 read 인지 적게 하세요. 응답을 받았으면 상태 코드가 4xx·5xx 여도 phase 는 done 입니다.
requests 의 timeout 은 두 값을 담은 짝을 받습니다. 예외도 종류가 나뉘어 있어서 연결 쪽과 읽기 쪽을 구별할 수 있습니다 — 다만 연결 거부는 타임아웃 예외가 아니므로 따로 잡아야 하고, 그것도 상대에게 닿기 전에 끝난 일이라 connect 구간입니다.
남은 예산이 다음 호출의 상한이다
/root/budget/deadline.py 를 만들어 --budget-ms 하나로 여러 --url 을 차례로 부르게 하세요. 각 호출의 읽기 상한은 그 순간 남은 예산이어야 하고, 그 값을 read_timeout_ms 에 적어야 합니다.
앞 단계의 call.py 를 모듈로 불러 쓰면 같은 코드를 두 번 쓰지 않아도 됩니다. 시작 시각을 한 번 잡아 두고 매 호출 직전에 '예산 빼기 지금까지 쓴 시간' 을 계산하세요. 이렇게 하면 호출이 몇 개든 전체가 예산을 넘길 수 없습니다.
가망 없는 호출은 걸지 않는다
deadline.py 에 --min-ms 를 붙여 남은 예산이 그 값보다 작으면 호출을 걸지 않고 phase 를 no_budget 으로 기록하며 skipped 를 올리게 하세요. 건너뛴 호출의 elapsed_ms 는 0 입니다.
200ms 남았는데 평소 400ms 걸리는 호출을 거는 것은 실패를 200ms 뒤로 미루는 일입니다. 그 자리에서 실패하면 남은 시간으로 부분 응답이라도 만들 수 있습니다. 하한은 인자로 받아 상황마다 다르게 줄 수 있어야 합니다.
재시도도 예산에서 나간다
/root/budget/retry.py 를 만들어 실패한 호출을 다시 걸되, 대기 상한을 base-ms * 2^(시도-1) 로 늘리고 실제 대기는 0 부터 그 상한 사이에서 고르게 하세요. 기다린 뒤 한 번 더 걸 자리가 예산에 없으면 stopped_reason 을 budget 으로 하고 멈춥니다.
무작위를 섞지 않으면 같은 순간에 실패한 클라이언트들이 같은 순간에 다시 몰립니다. 그리고 대기 시간도 예산에서 나가므로, 자고 일어나서 호출을 걸 자리가 남아 있는지를 자기 전에 물어야 합니다. 멈춘 이유를 세 가지로 갈라 적으면 나중에 로그만 보고도 원인을 압니다.
남은 예산을 아래로 넘기기
/root/budget/fanout.py 를 만들어 <BASE>/budget?ms=200 을 --n 번 부르되, 매번 X-Budget-Ms 헤더에 그때 남은 예산을 실어 보내세요. 출력의 각 call 에 sent_budget_ms·received·elapsed_ms 를 적습니다.
우리가 1200ms 만 남았는데 하위 서비스가 자기 기준으로 5초를 기다리면 그 4초는 아무도 안 보는 답을 만드는 데 쓰입니다. 헤더 이름은 우리가 정하는 약속이고, 받는 쪽은 그 값을 자기 상한으로 삼습니다. 파트너의 /budget 은 받은 헤더 값을 그대로 돌려주므로 실제로 전달됐는지 확인할 수 있습니다.
화면 하나의 예산표
/root/budget/budget_plan.json 에 예산표를 적으세요. total_ms·reserve_ms·worst_case_ms 와 calls(이름·budget_ms·max_attempts) 3개 이상이 필요합니다. reserve_ms 는 100 이상, worst_case_ms 는 호출 예산의 합에 예비를 더한 값이며 total_ms 를 넘지 않아야 합니다.
예비(reserve)는 우리 쪽 직렬화·템플릿 렌더링처럼 호출이 아닌 시간입니다. 이것을 빼놓고 호출에 예산을 다 나눠 주면 화면은 늘 상한에서 아슬아슬합니다. 재시도가 있는 호출은 그 호출의 예산 안에서 재시도까지 마쳐야 한다는 뜻입니다.
예산 점검 보고서
/root/budget/budget_report.md 에 ## 지금 무엇이 시간을 먹는가 ## 구간을 어떻게 나눴나 ## 재시도가 먹는 몫 ## 예산을 넘겼을 때 무엇을 하나 네 절로 적으세요. probe.json 과 budget_plan.json 의 숫자가 본문에 들어가야 합니다.
읽는 사람은 '타임아웃을 좀 늘려 주세요' 라고 말한 사람입니다. 늘리면 무엇이 나빠지는지를 숫자로 보여 주세요. 재시도 몫은 시도 횟수와 대기 상한을 곱해 최악의 경우를 계산해 적습니다.