Сводил хронологию инцидента из четырёх источников: веб-сервер, балансировщик, приложение и почтовый шлюз. Собрал события, отсортировал, посмотрел, что за чем шло, - работа механическая.
Так продолжается, пока не выясняется, что порядок событий физически невозможен: ответ приходит раньше запроса, сессия закрывается до того, как открылась, а письмо оказывается доставлено на два часа раньше отправки. Ошибки в логах при этом нет. Каждый источник записал своё время правильно - по своим часам и в своих обозначениях.
Три причины, по которым метки времени не сходятся
Их удобно разделять, потому что чинятся они по-разному.
Расхождение хода. Часы на узле идут неточно и уходят. Без синхронизации типичный дрейф - секунды в сутки; за месяц набегает минута-другая. Для корреляции «вход и следом запуск процесса» этого достаточно, чтобы поменять эти два события местами.
Разные точки отсчёта. Один узел пишет по UTC, другой по местному времени, третий - по времени того, кто настраивал контейнер. Часы у всех верные. Числа несопоставимые.
Момент записи вместо момента события. Метка ставится не тогда, когда событие произошло, а тогда, когда событие дошло до конвейера. При буферизации и повторной отправке это разные вещи, и разница плавает.
Первое лечится синхронизацией, второе - форматом, а третье - тем, что вы вообще понимаете, чью метку читаете; оно же обычно и оказывается самым неприятным.
Формат, который ничего не говорит
Классический syslog в форме BSD, он же RFC 3164, выглядит так:
Sep 6 03:14:07 gw-01 sshd[2411]: Accepted publickey for svc-backup from 203.0.113.44
В строке нет года и нет часового пояса. Год восстанавливается по имени файла и по ротации, часовой пояс - никак: чтобы его узнать, надо обратиться к узлу, который эту строку написал, и посмотреть его настройки. На момент разбора этого узла может уже не быть.
Формат объявлен устаревшим, и утилиты об этом предупреждают:
$ logger --help | grep -i rfc --rfc3164 use the obsolete BSD syslog protocol --rfc5424[=<snip>] use the syslog protocol (the default for remote)
Замена - RFC 5424, где метка времени полная и со смещением (в начале - приоритет и номер версии, они входят в формат и в реальном сообщении присутствуют):
<38>1 2026-09-06T03:14:07.221394+05:00 gw-01 sshd 2411 - - Accepted publickey for svc-backup
Год есть, доли секунды есть, смещение есть. Такую строку можно нормализовать, не зная ничего про узел, который её написал.
+05:00 в конце - не украшение и не избыточность. Это единственное, что делает метку самодостаточной. Строка без смещения задаёт время только вместе с настройками узла, а они к ней не приложены.
Почему «поставим всем UTC» решает не всё
Совет правильный, я его и даю. Но он про источники, которые вы настраиваете, а в разборе участвуют и другие.
Заголовки почты содержат смещение отправителя, и оно чужое. Логи внешнего провайдера приходят в его поясе. Экспорт из SaaS-панели отдаёт время в поясе учётной записи того, кто нажал кнопку экспорта, - и при повторной выгрузке другим человеком числа будут другими. Скриншот из интерфейса показывает время браузера смотрящего.
Поэтому UTC как единый формат хранения - это про вашу часть, а нормализация на входе в систему сбора нужна в любом случае. Правило простое: пояс определяется в момент приёма события, когда ещё известно, откуда оно пришло. Позже этой информации уже нет, и восстанавливать её будет неоткуда.
Отдельно стоит помнить про переход на летнее время в тех источниках, где он есть. Один час в году повторяется дважды, и метка без смещения внутри этого часа неоднозначна принципиально: два разных момента записываются одинаково. Сортировка внутри такого интервала - угадывание.
Служба синхронизации запущена - это ещё не синхронизация
Клиент NTP на узле обычно установлен и включён. Это ещё ничего не значит: он может месяцами не получать ответа от сервера, и в норме об этом никто не узнает.
$ journalctl -u systemd-timesyncd -n 2 -o short-iso 2026-09-06T00:11:35+00:00 gw-01 systemd-timesyncd[495]: Timed out waiting for reply from 192.0.2.31:123 (ntp.example.com). 2026-09-06T00:12:40+00:00 gw-01 systemd-timesyncd[495]: Contacted time server 198.51.100.123:123 (ntp2.example.com).
Здесь всё закончилось хорошо, но в первой строке сервер не ответил. Если бы вторая не появилась, часы продолжили бы идти сами по себе, а служба бы работала и считалась настроенной.
Смотреть надо не на факт запуска службы, а на факт синхронизации:
$ timedatectl show -p NTP -p NTPSynchronized NTP=yes NTPSynchronized=yes
NTP=yes - служба включена. NTPSynchronized=yes - она действительно получила время от сервера. Это два независимых утверждения, и мониторить надо второе.
Две оговорки, без которых проверка даёт неверный результат. Она годится для systemd-timesyncd: если синхронизацию обеспечивает chrony или ntpd, NTP будет no при полностью исправных часах, и вы получите ложную тревогу - там смотрят chronyc tracking. И NTPSynchronized отражает флаг ядра, который не сбрасывается в ту же секунду, как сервер перестал отвечать, - то есть yes не означает «синхронизировано прямо сейчас». Поэтому в мониторинге держат ещё и саму величину расхождения. В типовых наборах проверок для Zabbix и Prometheus этого обычно нет, дописывают руками.
Виртуальные машины добавляют своих сложностей: после приостановки и возобновления гостевые часы прыгают, и прыгают они разом, а не постепенно. В логах это выглядит как минуты, которых не было, или как повторно прожитый интервал.
Чем CLOCK_MONOTONIC отличается от CLOCK_REALTIME
Раз уж речь зашла о времени, стоит развести два источника времени, которые в коде путают регулярно.
CLOCK_REALTIME - календарное время. Его правит NTP, оно может скакнуть назад, и именно оно нужно в логах.
CLOCK_MONOTONIC - счётчик, который не прыгает и назад не идёт; NTP подстраивает его только скоростью хода. Отсчёт ведётся от загрузки, но время сна системы в него не входит - для этого есть CLOCK_BOOTTIME, а полностью неподстраиваемый вариант называется CLOCK_MONOTONIC_RAW.
$ python3 -c " import time print('REALTIME :', time.clock_gettime(time.CLOCK_REALTIME)) print('MONOTONIC:', time.clock_gettime(time.CLOCK_MONOTONIC))" REALTIME : 1788664447.221394 MONOTONIC: 104857.318204411
Число справа - около 29 часов работы, к календарю оно отношения не имеет. Ошибка в обе стороны стоит по-разному: календарное время для измерения таймаута даёт отрицательную длительность в момент коррекции часов, а монотонное в логе даёт метку, которую не с чем сопоставить.
Что делать до инцидента
Договориться о формате и записать его в требования к логированию: RFC 5424 или ISO 8601 со смещением, доли секунды - обязательны. Секундной гранулярности не хватает: события внутри одной секунды в разборе встречаются постоянно, и порядок между ними теряется.
Нормализовать на приёме, сохраняя оригинал. Приведённое к UTC время - рабочее поле для сортировки; исходная строка остаётся рядом, потому что при спорном выводе смотреть придётся именно на неё.
Мониторить синхронизацию как обычную метрику. Не «служба запущена», а «расхождение с сервером меньше порога».
Проверять сквозную согласованность. Раз в какое-то время прогонять по своим источникам событие с известным временем и смотреть, как каждый его записал. Расхождение чаще всего находится у самого старого сетевого оборудования, которое поддерживает только BSD-формат и другого не поддержит.
Про источники, которые вам не подчиняются, записать, чью метку они ставят. Одна страница текста, которую пишут один раз и читают в момент разбора.
Что посмотреть у себя
Взять по одной строке из каждого источника, который попадает в систему сбора, и выписать их в столбик. Быстро становится видно, у скольких из них в метке нет смещения.
Проверить NTPSynchronized на узлах, куда давно не заходили руками. Один-два узла обычно находятся.
И посмотреть, что происходит с сортировкой в вашем интерфейсе, если у двух событий совпали секунды. Иногда порядок берётся из идентификатора записи, а он не связан со временем события вовсе - и хронология, на которую вы смотрите, собрана не по тому полю.
otezvikentiy
Хорошая статья, и я добавлю вторую половину задачи - со стороны приёмника. Дисклеймер: я делаю self-hosted-мониторинг, то есть ровно ту штуку, которая эти логи принимает, так что смотрю с этой колокольни.
Все рекомендации в статье адресованы отправителю, и они верные. Проблема в том, что приёмник не может на них рассчитывать: в него льёт парк, где половина источников старше вас, а ключ приёма лежит в клиентском приложении, и прислать может кто угодно что угодно. Поэтому у записи в хранилище должно быть не одно время, а два: время события, как его назвал отправитель, и время наблюдения, которое приёмник ставит сам. Второе он не берёт у клиента никогда.
Неочевидное место в OTLP: там кроме TimeUnixNano есть ObservedTimeUnixNano, и его страшно хочется использовать как запасное значение, когда основное поле пустое. Так делать нельзя - его заполняет клиент, то есть те же самые сбитые часы. Мы его не читаем: observed_ts всегда серверное время приёма, а если TimeUnixNano нулевой, туда же уезжает и время события. Иначе защита от кривых часов протекает ровно там, где вы её ставили.
Дальше вопрос, что делать с записью, время которой физически невозможно. Мы пришли к разным ответам для разных сигналов, и это не непоследовательность, а разные читатели.
Логи клампятся в окно [now-90d, now+24h], а не отбрасываются: лог со сбитым временем всё ещё несёт тело и severity, и потерять запись хуже, чем показать её на границе окна. Диапазон выборки при чтении логов всё равно задаёт человек, и он видит, что запись прижата к краю.
Точки метрик со временем из будущего приводятся к моменту приёма. Здесь читатель не человек, а пороговый эвалюатор и графики, и они режут выборку по времени сервера - для них точка "из будущего" эквивалентна потерянной. У нас на этом хост со спешащими часами был молча невидим: данные приходили, в базу ложились, на графике их не было, порог по ним не срабатывал.
И последнее, ради чего окно вообще существует, - оно не про эстетику. Таблица логов партиционирована по месяцу. Один батч, размазанный по десяткам месяцев, упирается в max_partitions_per_insert_block ClickHouse, вставка всего батча падает и встаёт в голове буфера писателя - приём логов останавливается для всего инстанса. То есть кривое время у одного отправителя без окна становится отказом в обслуживании для всех.
К совету про мониторинг реального состояния NTP есть парный совет для приёмника: нормализация обязана быть видимой. Мы завели отдельный счётчик расхождения часов и предупреждение в журнал с именем хоста, не чаще раза в минуту. Без этого приёмник аккуратно чинит время у себя и своими руками прячет ровно ту проблему, о которой статья.