실패했는데 종료 코드는 0 이었다 · 재시도가 두 번째 장애를 만들었다 · 실습
재시도가 두 번째 장애를 만들지 않게
목표
외부 명령을 부르는 파이썬 도구가 "끝나지 않는 명령" 과 "두 번 실행되는 명령" 을 막게 만든다 — 타임아웃, 재시도, 멱등성, 잠금, 신호 처리, 시도 로그.
왜 중요한가
재시도는 명령이 언젠가 끝나고 다시 실행해도 안전하다는 두 전제 위에서만 안전하다. 그 전제 없이 붙인 재시도는 장애를 세 배로 키운다. subprocess.run 의 timeout 은 자식을 죽이고 기다려 주고, 표식 파일은 성공 뒤에만 남기며, flock 은 동시 실행을 막고, SIGTERM 처리기는 고아 프로세스를 남기지 않는다. 이 실습은 그 네 가지를 작은 실행기 하나에 전부 넣는다.
재료 명령은 /opt/fixtures/pyops/bin/ 에 있다 — flaky.sh(처음 두 번 실패, 세 번째 성공; 시도 횟수는 환경변수 FLAKY_STATE 파일에), hang.sh(60초 동안 끝나지 않음), apply.sh <이름>(부를 때마다 APPLY_LOG, 기본 /root/pyops/sub/applied.log 에 한 줄 추가; APPLY_FAIL=1 이면 실패).
단계
1. /root/pyops/sub/runner.py 를 만든다. runner.py run -- <명령...> 은 subprocess.run 으로 명령을 실행해 명령의 표준 출력을 그대로 표준 출력에 내고, 명령의 종료 코드로 끝난다.
2. --timeout <초> 를 붙인다. 시간이 지나면 명령은 죽고 표준 오류에 timeout 이 포함된 한 줄이 찍히며 종료 코드 124 로 끝난다. 명령 프로세스가 남아 있으면 안 된다.
3. --retries <n> --backoff <초> 를 붙인다. 종료 코드가 0 이 아니면 backoff × 2^(시도-1) 초를 기다렸다가 최대 n 번 다시 시도한다. 타임아웃(124)도 실패로 보고 재시도한다.
4. runner.py apply <이름> 을 만든다. /root/pyops/sub/state/<이름>.done 표식이 있으면 already applied 를 찍고 0 으로 끝나며 apply.sh 를 부르지 않는다. 없으면 /opt/fixtures/pyops/bin/apply.sh <이름> 을 부르고 성공(0)했을 때만 표식을 만든다. 실패하면 표식 없이 1 로 끝난다.
5. apply 는 /root/pyops/sub/state/lock 에 fcntl.flock(LOCK_EX | LOCK_NB) 잠금을 잡는다. 잠금을 못 잡으면 표준 오류에 another run in progress 를 찍고 종료 코드 3 으로 끝난다.
6. SIGTERM 을 받으면 실행 중인 명령에 SIGTERM 을 전달하고 끝나기를 기다린 뒤 종료 코드 143 으로 끝난다. 명령 프로세스가 남아 있으면 안 된다.
7. 시도마다 /root/pyops/sub/runs.jsonl 에 JSON 한 줄을 덧붙인다. 키는 ts(ISO 8601 문자열)·cmd(문자열 목록)·attempt(1부터)·rc(정수)·duration_ms(정수)다.
8. apply --dry-run <이름> 은 apply.sh 를 부르지 않고 표식도 만들지 않으면서 무엇을 할지 표준 출력에 이름을 포함해 적고 0 으로 끝난다. 이미 적용된 이름이면 already applied 를 적는다.
참고
- subprocess.run(cmd, timeout=...) 은 TimeoutExpired 를 던지기 전에 자식을 죽이고 기다립니다. Popen 을 직접 쓰면
start_new_session=True로 띄우고os.killpg(os.getpgid(proc.pid), sig)로 그룹 전체에 신호를 보내세요 — hang.sh 는 셸이sleep 60을 띄우므로 셸만 죽이면 손자 sleep 이 파이프를 쥔 채 남습니다. - flock 은 열린 파일 디스크립터에 걸립니다. 잠금 파일은 실행이 끝날 때까지 열어 두세요. 프로세스가 죽으면 커널이 풀어 줍니다.
- 시험:
FLAKY_STATE=/tmp/f1 python3 runner.py run --retries 3 --backoff 0.2 -- /opt/fixtures/pyops/bin/flaky.sh - 흔한 실수: shell=True 로 문자열을 넘기는 것, 표식을 명령 실행 전에 만드는 것, 재시도 사이에 같은 간격으로 기다리는 것.
단계 8개
- 명령을 실행하고 종료 코드를 그대로
- 끝나지 않는 명령을 끝낸다
- 지수 백오프 재시도
- 표식으로 멱등한 apply
- 동시 실행을 잠금으로 막는다
- SIGTERM 을 자식에게 전달한다
- 시도마다 JSON 한 줄
- 하지 않고 무엇을 할지만 말한다