데이터 파이프라인 · 실행 원장과 원자적 커밋 · 이론
도중에 죽는다 — 실행 원장과 원자적 교체
한 줄 요약
파이프라인은 반드시 도중에 죽는다. 그때 남는 것이 반쯤 쓰인 산출물이냐 어디까지 했는지 적힌 기록이냐가 다음 날 아침을 가른다.
왜 이게 필요했나
새벽 3시에 정산 작업이 죽었다. 아침에 출근해 보니 결과 파일은 있다. 크기도 그럴듯하다. 그런데 숫자가 이상하다. 파일을 열어 보면 마지막 줄이 중간에서 끊겨 있다 — 쓰는 도중에 프로세스가 사라진 것이다.
여기서 두 번째 사고가 난다. "그냥 다시 돌리면 되겠지" 하고 재실행한다. 그리고 그날의 매출이 두 배로 집계된다. 앞 실행이 어디까지 갔는지 아무도 모르고, 다시 돈 실행은 처음부터 끝까지 다 하기 때문이다.
세 번째 사고는 더 조용하다. 죽은 실행이 남긴 임시 파일이 다음 단계의 목록에 함께 잡혀, 같은 조각이 두 번 더해진다. 아무도 오류를 보지 못한다.
이 세 가지는 코드가 틀려서 생기는 것이 아니다. 성공한 경우만 생각하고 쓴 코드에서 생긴다.
어떻게 동작하나
막는 장치는 세 개다.
첫째, 원자적 교체. 목적지 파일에 직접 쓰지 않는다. 같은 디렉터리에 임시 이름으로 다 쓴 다음, 이름을 바꿔 달아 목적지로 만든다. 파이썬에서는 [os.replace](https://docs.python.org/3/library/os.html) 다. 밑에서 부르는 것은 [rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html) 이고, 그 문서는 새 이름이 이미 있으면 원자적으로 교체되어 "다른 프로세스가 그 이름을 찾았을 때 없는 순간이 존재하지 않는다" 고 못 박는다. 그래서 읽는 쪽은 언제 보든 옛 파일 전체 아니면 새 파일 전체를 본다. 반쯤 쓰인 상태는 볼 수가 없다.
다만 조건이 있다. 임시 파일과 목적지가 같은 파일 시스템 안에 있어야 한다. 같은 문서가 두 경로가 서로 다른 마운트에 있으면 실패한다고(EXDEV) 적어 두었다. /tmp 에 쓰고 결과 디렉터리로 옮기는 코드가 개발 기계에서는 잘 돌다가 운영에서 죽는 이유가 이것이다. 그래서 임시 파일은 목적지 바로 옆에 만든다.
임시 이름에도 규칙이 있다. 읽는 쪽이 part-*.json 으로 목록을 잡는다면 임시 이름이 거기 걸려서는 안 된다. 점으로 시작하거나 다른 접미사를 붙인다.
둘째, 실행 원장. 실행 한 번을 한 줄로 적는다. 언제 시작했고, 어떤 조각까지 끝냈고, 성공인지 실패인지, 실패라면 어디서 죽었는지. 덧붙이기만 하고 고치지 않는다. 그래야 "어제 그 실행" 을 나중에 따라갈 수 있다. [SQLite 의 원자적 커밋 문서](https://www.sqlite.org/atomiccommit.html)가 설명하는 것도 결국 같은 구조다 — 쓰는 도중의 상태를 따로 두고, 다 되었을 때 한 번에 표시를 바꾼다.
셋째, 조각 단위 표식. 다시 돌릴 때 처음부터 하지 않으려면 어디까지 했는지가 있어야 한다. 별도의 표식 파일을 두어도 되고, 조각별 산출물 자체를 표식으로 삼아도 된다. 후자가 더 안전하다 — 산출물이 원자적으로 교체되므로, 산출물이 있다는 것은 그 조각이 확실히 끝났다는 뜻이기 때문이다. 표식과 산출물이 따로 놀면 표식만 남고 산출물은 반쯤 쓰인 상태가 생긴다.
나쁜 순서 좋은 순서 결과 파일을 열고 쓴다 임시 파일에 다 쓴다 ... 여기서 죽음 ... 여기서 죽어도 목적지는 멀쩡 닫는다 os.replace 로 바꿔 단다 => 반쯤 쓰인 파일이 남는다 => 옛 파일 아니면 새 파일현장에서 만나는 모습
첫째, 재개와 재실행은 다른 말이다. 재실행은 처음부터 다시 도는 것이고, 재개는 끝난 조각을 건너뛰는 것이다. 재개를 지원하려면 "이 조각은 끝났다" 를 읽을 수 있어야 한다. 로그에 적어 놓는 것으로는 부족하다. 로그는 사람이 읽는 것이고, 재개는 프로그램이 판단하는 것이다.
둘째, 총계는 다시 모은다. 재개한 실행이 이번에 처리한 것만 더해 총계를 내면 건너뛴 조각이 빠진다. 총계는 언제나 조각 산출물 전체를 다시 훑어서 낸다. 그래야 재개한 실행과 한 번에 끝낸 실행의 답이 같아진다.
셋째, 두 번 더해지는 자리는 대개 마지막이다. 조각 처리는 덮어쓰기라 여러 번 해도 같지만, 하루치 장부에 한 줄 붙이는 일은 덧붙이기라 부를 때마다 늘어난다. 그래서 붙이기 전에 이 실행을 이미 붙였는지 본다. 판단 기준은 시각이 아니라 실행의 이름이다. 시각으로 판단하면 같은 날 두 번 돈 실행을 가르지 못한다.
넷째, 죽은 자리를 모르면 아무것도 못 한다. 원장에 실패한 조각 이름이 없으면, 다음 사람이 할 수 있는 일은 처음부터 다시 돌리는 것뿐이다. 여섯 시간짜리 작업에서 그 차이는 크다.
실무에서 진짜 중요한 것
- 목적지에 직접 쓰지 않는다. 임시 이름으로 쓰고 같은 파일 시스템 안에서 바꿔 단다.
- 임시 이름이 읽는 쪽의 목록에 걸리지 않게 한다.
- 실행마다 이름을 준다. 시각이 아니라 이름으로 중복을 가른다.
- 원장에 실패한 조각을 적는다. 그 한 칸이 재개를 가능하게 한다.
- 총계는 산출물에서 다시 모은다. 이번 실행이 한 일만 더하지 않는다.
다음 실습에서 할 것
야간 정산 입력을 조각으로 만들고, 실행기 runner.py 를 한 단계씩 키운다. 조각 산출물을 임시 이름으로 썼다가 바꿔 달게 하고, 일부러 쓰는 도중에 죽여 목적지가 멀쩡한지 확인한다. 실행 원장을 붙이고, 가운데 조각에서 죽여 원장에 실패한 조각이 남는지 보고, 그 자리부터 재개한다. 마지막으로 같은 실행을 두 번 커밋해도 하루치 장부가 늘지 않게 만든다. 채점기는 매번 다른 조각 수와 금액으로 자기 입력을 차려 여러분의 실행기를 실제로 돌리고, 죽인 뒤 무엇이 남았는지까지 확인한다.