왜 시간이 어긋나면 시스템이 무너지는가
서버 시계는 조용히, 그러나 꾸준히 틀어진다. 하드웨어 오실레이터의 온도 편차, 가상화 환경의 CPU steal, 라이브 마이그레이션으로 인한 타이머 점프가 원인이다. 이렇게 벌어진 시간 차이(clock skew)는 단순한 로그 타임스탬프 문제로 끝나지 않는다.
대표적으로 TLS 인증서 검증(notBefore/notAfter)이 실패하고, Kerberos·OAuth 토큰이 만료로 거부되며, TOTP 2차 인증이 어긋난다. 분산 DB에서는 시간 기반 순서(예: Cassandra의 last-write-wins)가 뒤집혀 데이터가 소실되고, JWT의 exp/iat 검증이 무너진다. 즉 시간 동기화는 보안·정합성의 전제 조건이다.
먼저 상태를 정확히 진단한다
장애 대응의 첫 단계는 "얼마나, 어느 방향으로 틀어졌는가"를 수치로 확인하는 것이다. chrony를 기준으로 살펴본다.
# 동기화 소스와 오프셋 확인
chronyc tracking # System time, Last offset, RMS offset 확인
chronyc sources -v # 각 서버의 reach/offset/jitter
chronyc sourcestats # 소스별 주파수 안정도
# ntpd 환경이라면
ntpq -p # * 표시가 현재 동기화 중인 피어
# 원격 기준 서버와의 실측 오프셋
chronyd -Q 'pool pool.ntp.org iburst'
Last offset가 수십 ms 이상이거나 reach가 0이면 동기화가 끊긴 상태다. Leap status가 Not synchronised면 다운스트림 서비스가 신뢰할 수 없는 시간을 쓰고 있다는 뜻이다.
NTP와 chrony, 무엇을 선택할까
| 항목 | ntpd | chrony |
|---|---|---|
| 초기 수렴 속도 | 느림(수십 분) | 빠름(수초~수분) |
| 간헐적 네트워크 | 취약 | 강함(오프라인 후 재동기 우수) |
| 가상머신/노트북 | 부적합 | 적합 |
| 큰 오프셋 보정 | step 제한적 | makestep으로 유연 |
클라우드·컨테이너·VM 환경이라면 chrony가 사실상 표준이다. 잦은 suspend나 steal time에도 빠르게 수렴하기 때문이다. 물리 장비 다수를 고정 네트워크로 운영하는 경우가 아니라면 chrony를 권한다.
안정적인 chrony 설정
핵심은 초기 부팅 시 큰 오프셋은 즉시 step으로 잡고, 이후에는 slew(점진 보정)로 시간이 역행하지 않게 하는 것이다.
# /etc/chrony/chrony.conf
pool time.google.com iburst maxsources 4
pool pool.ntp.org iburst maxsources 4
# 부팅 후 처음 3번의 업데이트에서 |offset| > 1s 이면 즉시 step
makestep 1.0 3
# 커널 RTC 동기화, 표류 기록
rtcsync
driftfile /var/lib/chrony/drift
# 로컬 stratum(상위 소스 모두 끊겨도 시간 제공)
local stratum 10
# 내부 서버에 시간 제공 시
allow 10.0.0.0/8
sudo systemctl restart chronyd
chronyc makestep # 강제 즉시 보정
chronyc waitsync 60 0.01 # 60초 내 오프셋 10ms 이하로 수렴 대기
step과 slew, 왜 구분해야 하는가
시간을 갑자기 뒤로 되돌리면(step backward) time.monotonic()이 아닌 wall clock에 의존하는 코드가 오작동한다. 예를 들어 만료 계산이 음수가 되거나, 락 타임아웃이 순간적으로 폭발한다. 그래서 운영 중에는 slew로 초당 미세하게 보정하고, step은 부팅 초기에만 허용한다.
애플리케이션 레벨에서도 duration 측정에는 절대 wall clock을 쓰지 말아야 한다.
import time
# 잘못된 예: NTP 보정으로 음수가 나올 수 있다
start = time.time()
do_work()
elapsed = time.time() - start # step 발생 시 왜곡
# 올바른 예: 단조 증가 시계 사용
start = time.monotonic()
do_work()
elapsed = time.monotonic() - start # 시간 동기화 영향 없음
운영 중 상시 모니터링
장애는 터진 뒤가 아니라 오프셋이 벌어지는 중간에 잡아야 한다. node_exporter 등으로 오프셋을 수집하고, 임계값 알람을 건다.
# Prometheus alert rule
groups:
- name: time-sync
rules:
- alert: ClockSkewHigh
expr: abs(node_timex_offset_seconds) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }} 시계 오프셋 100ms 초과"
- alert: NTPUnsynchronized
expr: node_timex_sync_status == 0
for: 10m
labels:
severity: critical
실무에서 흔히 밟는 함정
- 이중 데몬 충돌:
systemd-timesyncd와 chrony가 동시에 뜨면 서로 시간을 덮어쓴다. 하나만 남기고systemctl disable --now systemd-timesyncd로 정리한다. - 방화벽: NTP는 UDP 123을 쓴다. 아웃바운드가 막히면
reach가 영원히 0이다. - 단일 소스 의존: 서버를 하나만 지정하면 그 소스가 falseticker(잘못된 시간을 주는 서버)일 때 검증할 방법이 없다. 최소 3~4개 이상을 pool로 둬야 chrony가 다수결로 이상치를 걸러낸다.
- 컨테이너의 시계: 컨테이너는 호스트 커널 시계를 공유한다. 컨테이너 안에서 chrony를 돌리지 말고 호스트에서만 동기화한다.
- 윤초 처리: 갑작스러운 1초 삽입 대신
leapsecmode slew로 하루에 걸쳐 흡수하도록 설정하면 애플리케이션 충격을 줄인다.
시간 동기화는 한 번 설정하고 잊는 영역이 아니다. 오프셋을 지표로 계속 관찰하고, 애플리케이션은 duration에 단조 시계를 쓰도록 코드 레벨에서 방어하는 것이 근본 대책이다.