It failed, but the exit code was 0
So the retry does not cause a second outage
한국어 원문으로 표시합니다.
목표
외부 명령을 부르는 파이썬 도구가 "끝나지 않는 명령" 과 "두 번 실행되는 명령" 을 막게 만든다 — 타임아웃, 재시도, 멱등성, 잠금, 신호 처리, 시도 로그.
왜 중요한가
재시도는 명령이 언젠가 끝나고 다시 실행해도 안전하다는 두 전제 위에서만 안전하다. 그 전제 없이 붙인 재시도는 장애를 세 배로 키운다. 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 이면 실패).
단계
/root/pyops/sub/runner.py를 만든다.runner.py run -- <명령...>은 subprocess.run 으로 명령을 실행해 명령의 표준 출력을 그대로 표준 출력에 내고, 명령의 종료 코드로 끝난다.--timeout <초>를 붙인다. 시간이 지나면 명령은 죽고 표준 오류에timeout이 포함된 한 줄이 찍히며 종료 코드 124 로 끝난다. 명령 프로세스가 남아 있으면 안 된다.--retries <n> --backoff <초>를 붙인다. 종료 코드가 0 이 아니면 backoff × 2^(시도-1) 초를 기다렸다가 최대 n 번 다시 시도한다. 타임아웃(124)도 실패로 보고 재시도한다.runner.py apply <이름>을 만든다./root/pyops/sub/state/<이름>.done표식이 있으면already applied를 찍고 0 으로 끝나며 apply.sh 를 부르지 않는다. 없으면/opt/fixtures/pyops/bin/apply.sh <이름>을 부르고 성공(0)했을 때만 표식을 만든다. 실패하면 표식 없이 1 로 끝난다.apply는/root/pyops/sub/state/lock에 fcntl.flock(LOCK_EX | LOCK_NB) 잠금을 잡는다. 잠금을 못 잡으면 표준 오류에another run in progress를 찍고 종료 코드 3 으로 끝난다.- SIGTERM 을 받으면 실행 중인 명령에 SIGTERM 을 전달하고 끝나기를 기다린 뒤 종료 코드 143 으로 끝난다. 명령 프로세스가 남아 있으면 안 된다.
- 시도마다
/root/pyops/sub/runs.jsonl에 JSON 한 줄을 덧붙인다. 키는ts(ISO 8601 문자열)·cmd(문자열 목록)·attempt(1부터)·rc(정수)·duration_ms(정수)다. 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 로 문자열을 넘기는 것, 표식을 명령 실행 전에 만드는 것, 재시도 사이에 같은 간격으로 기다리는 것.
명령을 실행하고 종료 코드를 그대로
/root/pyops/sub/runner.py 를 만든다. runner.py run -- <명령...> 은 subprocess.run 으로 명령을 실행해 표준 출력을 그대로 내고 명령의 종료 코드로 끝난다.
argparse 의 subparsers 로 run 을 만들고 command 는 nargs=argparse.REMAINDER 로 받습니다. 목록으로 넘기고 shell=True 는 쓰지 마세요. r.returncode 를 돌려주면 됩니다.
끝나지 않는 명령을 끝낸다
--timeout <초> 를 붙인다. 시간이 지나면 명령이 죽고 표준 오류에 timeout 이 포함된 한 줄이 나오며 종료 코드 124 로 끝난다. 명령 프로세스가 남지 않아야 한다.
subprocess.run(timeout=...) 은 TimeoutExpired 를 던지기 전에 자식을 죽이고 기다립니다. Popen 을 쓴다면 start_new_session=True 로 띄우고 os.killpg 로 프로세스 그룹을 죽이세요 — hang.sh 의 손자 sleep 이 파이프를 쥐고 있으면 자식만 죽여서는 communicate() 가 돌아오지 않습니다.
지수 백오프 재시도
--retries <n> --backoff <초> 를 붙인다. 실패하면 backoff × 2^(시도-1) 초 기다렸다가 최대 n 번 다시 시도한다. 타임아웃도 실패로 본다.
range(1, retries + 2) 로 돌면서 rc == 0 이면 바로 돌려주세요. 기다리는 시간은 시도마다 두 배가 됩니다. FLAKY_STATE 파일을 지우고 flaky.sh 로 시험하세요.
표식으로 멱등한 apply
runner.py apply <이름> 은 /root/pyops/sub/state/<이름>.done 이 있으면 already applied 를 찍고 0 으로 끝난다. 없으면 apply.sh <이름> 을 부르고 성공했을 때만 표식을 만든다. 실패하면 표식 없이 1 로 끝난다.
표식은 명령이 0 으로 끝난 뒤에만 write_text 하세요. 실행 전에 만들면 실패한 변경이 '적용됨' 으로 남습니다. APPLY_FAIL=1 로 실패 경로를 시험하세요.
동시 실행을 잠금으로 막는다
apply 는 /root/pyops/sub/state/lock 에 fcntl.flock(LOCK_EX | LOCK_NB) 를 잡는다. 못 잡으면 표준 오류에 another run in progress 를 찍고 종료 코드 3 으로 끝난다.
잠금 파일을 open('w') 로 열어 두고 fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) 를 부릅니다. OSError 의 errno 가 EACCES 또는 EAGAIN 이면 다른 실행이 잡고 있는 것입니다.
SIGTERM 을 자식에게 전달한다
runner 가 SIGTERM 을 받으면 실행 중인 명령에 SIGTERM 을 보내고 끝나기를 기다린 뒤 종료 코드 143 으로 끝난다. 명령 프로세스가 남지 않아야 한다.
signal.signal(signal.SIGTERM, handler) 로 처리기를 달고, 처리기에서 Popen 객체의 send_signal(signal.SIGTERM) 을 부르세요. 처리기가 실행된 뒤 communicate() 가 돌아오면 143 을 돌려줍니다.
시도마다 JSON 한 줄
시도마다 /root/pyops/sub/runs.jsonl 에 JSON 한 줄을 덧붙인다. 키는 ts·cmd(문자열 목록)·attempt(1부터)·rc(정수)·duration_ms(정수) 다.
json.dumps(dict) + '\n' 을 append 모드로 씁니다. 타임아웃(124)도 한 시도이므로 기록합니다. time.monotonic() 차이를 1000 배 해 정수로 만드세요.
하지 않고 무엇을 할지만 말한다
apply --dry-run <이름> 은 apply.sh 를 부르지 않고 표식도 만들지 않으면서 이름을 포함한 계획을 표준 출력에 적고 0 으로 끝난다. 이미 적용된 이름이면 already applied 를 적는다.
dry-run 은 잠금 뒤가 아니라 앞에서 처리해도 됩니다 — 아무것도 바꾸지 않으니까요. 표식 유무만 보고 두 문장 중 하나를 고르세요.