LabHub
배우기 러닝패스 코스

로그가 미래에서 왔다 — 시계가 만든 다섯 사건 · 한 번만 돌았어야 할 정산이 두 번 돌았다 · 이론

한 번만 돌았어야 할 정산이 두 번 돌았다

LabHub 에서 이어서 보기

한 줄 요약

되풀이되는 작업은 "몇 번 돌았는가" 로 정상을 판정할 수 없다. 건너뛴 한 번과 두 번
돈 한 번이 서로를 가려 총계가 맞아 버린다.

왜 이게 필요했나

정기 작업의 감시는 대개 두 가지만 본다. 실패했는가, 그리고 몇 번 돌았는가. 이 둘로
잡히지 않는 사고가 이 코스의 마지막 조각이다.

서머타임이 끝나는 날 새벽에 지역 시각 01:00 정산이 두 번 돈다. 둘 다 성공이므로
실패 알림은 없다. 서머타임이 시작되는 날 새벽에는 02:00 정산이 한 번도 돌지 않는다.
일어나지 않은 일이므로 역시 알림이 없다. 그리고 이 두 날의 실행 횟수를 합치면
기대치와 정확히 같아진다. 총계를 세는 감시는 이 사고를 구조적으로 못 잡는다.

어떻게 동작하나

문제의 뿌리는 작업이 처리할 기간에 이름을 붙이는 방식 이다. "01시 정산" 처럼
지역 시각으로 이름을 붙이면, 지역 시각이 두 번 오는 날 같은 이름이 두 번 나오고
없는 날에는 한 번도 안 나온다. 이름을 순간(UTC) 으로 붙이면 이름은 언제나
한 번씩만 나온다.

스케줄러 쪽에서도 같은 선택을 한다. 쿠버네티스의
[CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)은
.spec.timeZone 으로 어느 지역의 시각으로 일정을 읽을지 지정하게 했고(v1.27 부터
안정), 지정하지 않으면 컨트롤러 매니저의 지역 시각으로 읽는다. Etc/UTC 를 명시하면
서머타임과 무관해진다. 참고로 일정 문자열 안에 TZCRON_TZ 를 넣는 방식은
공식적으로 지원하지 않으며, 넣으면 리소스 생성이 검증 오류로 거절된다.

그러나 시간대를 UTC 로 못박아도 「정확히 한 번」은 보장되지 않는다. 같은 문서가
이렇게 적고 있다 — CronJob 은 일정마다 대략 한 번 Job 을 만들며, 두 개가
만들어지거나 하나도 만들어지지 않는 경우가 있고, 쿠버네티스는 그것을 피하려 하지만
완전히 막지는 못한다. 그래서 여러분이 정의하는 Job 은 멱등해야 한다 고 못
박는다. 이것이 이 코스의 마지막 문장이기도 하다.

겹침은 별개의 손잡이로 다룬다. .spec.concurrencyPolicy 는 세 값을 받는다.

Allow    (기본) 앞 실행이 안 끝났어도 새 실행을 만든다Forbid   앞 실행이 안 끝났으면 이번 실행을 건너뛴다Replace  앞 실행을 새 실행으로 갈아 끼운다

그리고 .spec.startingDeadlineSeconds 는 예정 시각보다 얼마나 늦게까지 시작해도
되는지를 정한다. 이 값을 넘겨 시작하지 못한 실행은 건너뛰고, 쿠버네티스는 그것을
실패한 Job 으로 취급한다. 문서가 따로 경고하는 것이 두 가지 있다 — 이 값을 10초보다
작게 두면 컨트롤러가 10초마다 확인하는 탓에 아예 스케줄되지 않을 수 있고, 놓친
일정이 100개를 넘으면 컨트롤러가 시작을 포기한다.

리눅스 쪽의 [systemd 타이머](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html)도
같은 구분을 갖고 있다. 벽시계에 맞추는 OnCalendar 와 경과 시간으로 세는
OnUnitActiveSec 계열이 나뉘어 있고, 앞의 것은 시계를 고치면 다음 발화 시각이
다시 계산된다.

현장에서 만나는 모습

멱등성을 실제로 만드는 방법은 대개 실행 열쇠 다. 그 실행이 무엇을 처리하는지를
유일하게 가리키는 문자열을 만들고, 그 열쇠로 이미 처리한 기록이 있으면 다시 쓰지
않는다. 열쇠를 고를 때의 기준은 하나다 — **같은 일을 두 번 하면 같은 열쇠가 나오고,
다른 일이면 다른 열쇠가 나와야 한다.** 실행 아이디나 시작 시각처럼 실행할 때마다
달라지는 값을 열쇠에 넣으면, 두 번 돈 것을 두 번 다 새 일로 본다. 실제로 이 실수가
가장 흔하다.

겹침을 막는 것과 멱등성은 서로 다른 문제라는 점도 자주 섞인다. 겹침 방지는 동시에
두 번 도는 것을 막고, 멱등성은 시간이 떨어져 있어도 두 번째 실행이 아무 해도
끼치지 않게 만든다. 서머타임 중복은 한 시간 떨어져 있으므로 겹침 방지로는 잡히지
않는다. 반대로 앞 실행이 길어져 생기는 겹침은 멱등성만으로는 자원 경합을 막지
못한다. 둘 다 필요하다.

마지막으로 감시의 형태를 바꿔야 한다. 실행 횟수 대신 **기대한 기간 목록과 실제
처리한 기간 목록을 맞춰 본다.** 빠진 기간과 두 번 나온 기간이 각각 보이게 하면,
총계가 서로를 가려 주는 일이 없어진다.

이 감시는 시계와 무관한 사고까지 함께 잡아 준다는 점에서 값어치가 더 있다. 노드가
죽어 한 번 건너뛴 것, 배포 중에 스케줄러가 멈춰 있던 것, 앞 실행이 너무 길어져
다음 것이 건너뛰어진 것 — 원인은 제각각이지만 증상은 전부 「그 기간이 처리되지
않았다」로 같다. 기간 목록을 맞춰 보는 감시는 원인을 묻지 않고 그 증상 하나만
본다. 원인마다 알림을 따로 만드는 것보다 훨씬 적은 수의 규칙으로 더 많은 사고를
덮는다.

그리고 이 코스 전체를 한 문장으로 줄이면 이렇게 된다. **시각이 적힌 기록은 사실이
아니라 그것을 적은 기계의 주장이고, 기한을 판정하는 코드는 그 주장을 믿고 남의
운명을 정한다.** 그래서 시각을 다루는 코드는 두 가지를 분명히 해야 한다. 무엇을
저장하고 무엇을 표시할 뿐인지, 그리고 어떤 계산이 「지금 몇 시인가」에 기대고 어떤
계산이 「얼마나 지났는가」에 기대는지. 이 두 구분만 지켜도 이 코스에서 본 다섯
사건 중 넷은 일어나지 않는다.

다음 실습에서 할 것

실습에서는 어긋난 시계로 찍힌 로그 묶음, 지역 시각으로만 남은 발송 기록, 벽시계와
단조 시계를 함께 남긴 타이머 기록, 유효 구간이 든 인증서, 정기 정산의 실행 이력을
차례로 읽는다. 어긋난 크기와 겹친 구간과 건너뛴 실행을 직접 재서 적고, 시각을
올바르게 다루는 함수 세 개를 직접 쓴다. 채점기는 그 숫자를 재료에서 다시 계산해
대조하고, 여러분이 쓴 함수를 직접 불러 성질을 확인한다.