LabHub
배우기 러닝패스 코스

실패했는데 종료 코드는 0 이었다 · 재시도가 두 번째 장애를 만들었다 · 이론

재시도가 두 번째 장애를 만들었다

LabHub 에서 이어서 보기

한 줄 요약

외부 명령을 부르는 도구는 끝나지 않는 명령두 번 실행되는 명령이라는 두 사고를 미리 막아야 한다. 타임아웃은 첫 번째를, 멱등성과 잠금은 두 번째를 막는다. 재시도는 그 둘이 갖춰진 뒤에만 안전하다.

왜 이게 필요했나

배포 스크립트가 restart-service 를 부르는데 응답이 없었다. 담당자가 "재시도" 를 넣었다. 다음 장애 때 스크립트는 응답 없는 명령을 세 번 부르며 30분을 기다렸고, 실제로는 세 번 다 뒤에서 실행되어 서비스가 세 번 재시작됐다. 재시도가 두 번째 장애를 만든 것이다. 문제는 재시도가 아니라, 재시도가 전제하는 두 가지가 없었다는 것이다 — 명령이 언젠가 끝난다는 보장(타임아웃)과, 다시 실행해도 결과가 같다는 보장(멱등성).

어떻게 동작하나

[subprocess](https://docs.python.org/3/library/subprocess.html) 의 run() 은 명령을 실행하고 끝날 때까지 기다린 뒤 CompletedProcess 를 돌려준다. 세 인자가 운영 도구의 뼈대다.

import subprocesstry:    r = subprocess.run(cmd, timeout=30, capture_output=True, text=True)except subprocess.TimeoutExpired:    return 124            # coreutils timeout 과 같은 코드를 쓰면 셸 사용자가 바로 안다return r.returncode

재시도는 실패의 종류를 가려야 한다. 네트워크가 끊겨 1 로 끝난 명령은 다시 부를 만하지만, 잘못된 인자로 2 로 끝난 명령은 백 번 불러도 같다. 그리고 기다리는 간격은 늘려야 한다 — 상대가 과부하로 실패한 것이라면 같은 간격의 재시도는 과부하를 유지한다. backoff * 2 ** (attempt - 1) 처럼 지수로 늘리는 것이 관례다.

멱등성(idempotency)은 "한 번 실행한 것과 여러 번 실행한 것의 결과가 같다" 는 성질이다. 외부 명령이 멱등하지 않다면 도구가 그 앞에 확인 단계를 둔다 — 이미 적용됐는지 표식(marker) 파일을 보고, 됐으면 건너뛴다. 표식은 명령이 성공한 뒤에만 만든다. 실패했는데 표식이 생기면 다음 실행이 "이미 됐다" 고 믿는다.

같은 도구가 동시에 두 개 돌면 표식 확인이 경합한다. 둘 다 "아직 안 됐다" 를 보고 둘 다 실행한다. [fcntl](https://docs.python.org/3/library/fcntl.html) 의 flock()LOCK_EX | LOCK_NB 를 주면 잠금을 못 잡을 때 기다리지 않고 OSError(errno 는 EACCES 또는 EAGAIN — 문서는 이식성을 위해 둘 다 확인하라고 적는다)를 던진다. 도구는 그 예외를 "다른 실행이 진행 중" 이라는 뜻으로 받아 물러난다. 잠금 파일은 프로세스가 죽으면 커널이 풀어 주므로 오래된 잠금이 남는 문제가 없다.

한 가지 함정이 더 있다. 명령이 셸 스크립트이고 그 안에서 sleep 이나 다른 명령을 띄웠다면, 자식(셸)만 죽여도 손자는 남는다. 손자가 표준 출력 파이프를 쥐고 있으면 파이프가 닫히지 않아 communicate() 가 돌아오지 않는다. Popen(..., start_new_session=True) 로 자식을 새 프로세스 그룹의 리더로 띄우고 os.killpg() 로 그룹 전체에 신호를 보내면 손자까지 함께 정리된다. run() 은 타임아웃 뒤 wait() 만 하므로 이 문제를 비켜 가지만, 손자는 그대로 남는다.

마지막은 종료 신호다. cron 이 시간을 넘긴 작업을 죽이거나, 쿠버네티스가 파드를 내릴 때 도구는 SIGTERM 을 받는다. [signal](https://docs.python.org/3/library/signal.html) 의 signal.signal(signal.SIGTERM, handler) 로 처리기를 달면, 도구는 자식에게 같은 신호를 전달하고 기다린 뒤 종료 코드 143(128 + 15)으로 끝날 수 있다. 처리기가 없으면 도구만 죽고 자식은 고아가 되어 계속 돈다 — 재시작 명령이 세 번 실행된 사고의 또 다른 형태다.

현장에서 만나는 모습

subprocess.run(cmd, shell=True) 로 문자열을 넘기는 습관이 두 사고를 부른다. 인자에 공백이나 따옴표가 섞이면 다른 명령이 되고, 타임아웃이 걸렸을 때 죽는 것은 셸이지 셸이 띄운 진짜 명령이 아닐 수 있다. 목록으로 넘기고 shell=False(기본값)를 쓴다. 두 번째는 "성공 기록을 명령 실행 전에 남기는" 순서 실수다. 표식·로그·DB 갱신은 반드시 성공 뒤다. 세 번째는 재시도 횟수를 기록하지 않는 것이다. 세 번째 시도에서 성공했다는 사실이 로그에 없으면, 그 명령이 매번 두 번씩 실패하고 있다는 것을 아무도 모른다. 시도마다 한 줄씩 JSON 으로 남기면 그 로그가 그대로 지표가 된다.

다음 실습에서 할 것

외부 명령 실행기 runner.py 를 만든다. 명령을 실행해 종료 코드를 그대로 돌려주는 것에서 시작해, 타임아웃(124), 지수 백오프 재시도, 표식 파일로 만드는 멱등한 apply, flock 단일 실행 잠금, SIGTERM 전달, 시도마다 남기는 JSON 로그, 그리고 --dry-run 까지 붙인다. 재료 명령 셋(처음 두 번 실패하는 flaky.sh, 끝나지 않는 hang.sh, 부를 때마다 적용되는 apply.sh)은 /opt/fixtures/pyops/bin/ 에 있다.