Введение: «вчера работало, сегодня сертификат недействителен»

Есть класс инцидентов, который умеет маскироваться лучше любого другого. Скрипт, который месяцами ходил через прокси к десяткам API, вдруг начинает падать на каждом запросе. В логе — certificate has expired или certificate is not yet valid. Инженер открывает сертификат целевого сайта в браузере на своем ноутбуке: срок действия в порядке, цепочка доверия зеленая. Он перезапускает скрипт без прокси со своего ноутбука — работает. Возвращает прокси — снова отказ. Вывод напрашивается сам: виноват прокси.

И этот вывод почти всегда ошибочен. Дело не в том, что прокси «подменяет сертификат» или «ломает TLS». Дело в том, что скрипт запускается на другой машине — на сервере, в виртуалке, в контейнере — и на этой машине системные часы уехали. На несколько минут, часов или дней. Прокси просто оказался самой заметной переменной в уравнении и принял на себя удар.

Мы разберем эту тему целиком. Вы узнаете, в каких именно местах современные протоколы опираются на текущее время и почему сдвиг часов в разные стороны дает разные симптомы. Увидите конкретные тексты ошибок в curl, Python и Node.js, чтобы узнавать проблему по первой строке трейса. Поймете, почему часы уезжают именно на виртуальных машинах и в контейнерах и почему установленный демон синхронизации не гарантирует, что он работает. Получите процедуру диагностики на одну минуту, рабочие конфигурации chrony и systemd-timesyncd и чеклист, который стоит пройти до того, как писать в поддержку прокси-провайдера.

Сразу оговорим границы. Эта статья не про выпуск сертификатов, не про цепочки доверия и не про корневые хранилища. Мы рассматриваем единственную причину отказов — неверное системное время — и все, что из нее вытекает при работе через прокси. Это узкая тема, но именно она сжигает часы отладки у команд, которые занимаются интеграциями, мониторингом, сбором открытых данных и автоматизацией через прокси-инфраструктуру вроде Proxeon.

Основы: где время критично и почему протоколы вообще смотрят на часы

Начнем с простого вопроса. Зачем сетевым протоколам знать, который сейчас час? Ответ один: чтобы ограничивать срок жизни доверия. Любой секрет — сертификат, токен, подпись, одноразовый код — рано или поздно может утечь. Если у него есть срок действия, ущерб от утечки ограничен. Но ограничение срока работает только тогда, когда проверяющая сторона знает текущее время. И она берет его из одного места — из системных часов машины, на которой выполняется проверка.

Отсюда фундаментальное следствие: каждая проверка времени локальна. Сервер не спрашивает у клиента, который час. Клиент не спрашивает у сервера. Каждый смотрит на свои часы и делает вывод. Если часы двух сторон расходятся, они видят разную реальность, и одна из них считает другую нарушителем.

Проверка срока действия сертификата в TLS

Каждый X.509-сертификат содержит два поля: notBefore (действителен с) и notAfter (действителен до). При TLS-рукопожатии клиент сравнивает текущее время своих часов с этим интервалом. Момент выпуска сертификата сервером здесь ни при чем — важно только то, что думает клиент о текущем времени.

Направление сдвига определяет симптом. Если часы клиента отстают, недавно выпущенные сертификаты выглядят так, будто их еще не выпустили: ошибка «certificate is not yet valid». Если часы клиента спешат, сертификаты кажутся истекшими: «certificate has expired». Это первый диагностический признак, который стоит запомнить.

Почему тема обострилась именно сейчас? Потому что сертификаты становятся короткоживущими. Многие публичные центры сертификации выдают сертификаты сроком 90 дней, появились варианты на 6 дней, а отраслевые требования планомерно сокращают максимальный срок: с марта 2026 года — 200 дней, далее 100 дней, а к 2029 году — 47 дней. Раньше отстающие на две недели часы почти никогда не попадали на свежий сертификат. Теперь целевой сайт может перевыпускать сертификат каждые несколько дней, и машина, отставшая на трое суток, будет отвергать его как «еще не действительный».

К той же группе относятся сопутствующие механизмы: ответы OCSP и списки отзыва имеют собственные окна действительности, метки Certificate Transparency содержат временные штампы, а билеты сессий TLS 1.3 живут ограниченное время. Все они проверяются по локальным часам.

JWT: поля exp, nbf и iat

JSON Web Token — самый распространенный формат токенов доступа в API. В его теле есть три временных поля в формате Unix epoch: iat (когда выдан), nbf (not before — с какого момента действителен) и exp (когда истекает). Сторона, принимающая токен, сравнивает их со своими часами, обычно с небольшим допуском (leeway) в 30-60 секунд.

Здесь возникает интересная асимметрия. Обычно токен выдает сервер по своим часам, а клиент кэширует его до наступления exp — но уже по своим часам. Если клиент отстает на пять минут, он будет считать токен живым еще пять минут после того, как сервер начал его отвергать. Вы получаете необъяснимые 401 в конце жизни каждого токена. Если же клиент сам подписывает JWT — например, для аутентификации сервисного аккаунта — и его часы спешат, сервер видит iat в будущем и отклоняет токен как еще не действительный.

Подписи запросов с меткой времени

Многие API требуют подписывать запрос с помощью HMAC, включая в подписываемую строку текущее время. Самый известный пример — схема Signature Version 4, которую используют объектные хранилища, совместимые с S3. Сервер принимает запрос, если его метка времени отличается от серверных часов не более чем на 15 минут; иначе возвращает ошибку RequestTimeTooSkewed. Платежные шлюзы, биржевые и брокерские API, корпоративные шины данных используют похожие схемы с окнами от 30 секунд до нескольких минут. Кербер-аутентификация в корпоративных сетях по умолчанию терпит расхождение в 5 минут.

Обратите внимание: подпись формирует ваш клиент по своим часам. Прокси в этом процессе не участвует физически не может — он лишь передает байты. Поэтому ошибка «неверная метка времени в подписи» через прокси — это всегда вопрос к часам вашей машины.

Одноразовые коды TOTP

Коды двухфакторной аутентификации по стандарту TOTP вычисляются из общего секрета и номера 30-секундного интервала. Проверяющая сторона обычно допускает плюс-минус один интервал. Если ваши часы уехали на минуту, коды перестают приниматься полностью. Это касается и собственных интеграций, где сервис-аккаунт генерирует TOTP программно для входа в корпоративный портал.

Менее очевидные потребители времени

  • HTTP-кэширование. Заголовки Date, Expires и max-age сравниваются с локальными часами; при сдвиге кэш либо не работает, либо отдает устаревшее.
  • Cookies с Expires. Клиент с отстающими часами хранит их дольше, со спешащими — выбрасывает сразу.
  • HSTS и pinning. Директивы max-age тоже считаются локально.
  • Ротация сертификатов внутри кластеров. Компоненты оркестраторов перевыпускают сертификаты по расписанию и при сдвиге часов на узле сами перестают доверять друг другу.

Глубокое погружение: чьи часы важны в цепочке клиент — прокси — сервер

Теперь наложим эту картину на реальную топологию. При работе через прокси в цепочке минимум три машины с тремя наборами часов: ваш клиент, прокси-сервер и целевой сервер. Ключевой вопрос диагностики: на какой из них происходит проверка, которая падает?

HTTP CONNECT и SOCKS5: прокси не видит TLS

При запросе к HTTPS-ресурсу через HTTP-прокси клиент отправляет команду CONNECT с именем хоста и портом. Прокси устанавливает TCP-соединение с целью и дальше слепо передает байты в обе стороны. TLS-рукопожатие происходит сквозь прокси, между вашим клиентом и целевым сервером. Прокси не видит сертификат, не может его изменить и не участвует в проверке. То же справедливо для SOCKS5.

Практический вывод: ошибка «certificate is not yet valid» или «certificate has expired» при работе через прозрачный туннель физически не может быть вызвана прокси. Ее источник — либо часы вашей машины, либо хранилище доверенных корней на ней, либо действительно истекший сертификат на цели. Первые два варианта локальные. И первый из них проверяется за десять секунд.

Когда часов проверяется больше: TLS до самого прокси

Если вы подключаетесь к прокси по защищенному каналу (схема https:// для самого прокси), клиент проверяет два сертификата: сертификат прокси-сервера и сертификат целевого сайта. Обе проверки выполняются по часам клиента. Симптоматика та же, но в подробном выводе curl будет видно, на каком этапе — «Proxy certificate» или «Server certificate» — произошел отказ.

Что зависит от часов самого прокси

Часы прокси-сервера влияют на ограниченный набор вещей: корректность его собственных логов, работу его TLS-слушателя (например, получение OCSP-ответов), а также проверку авторизационных данных, если провайдер использует токены с ограниченным сроком. Инфраструктура Proxeon синхронизирует время на своих узлах штатно, и в подавляющем большинстве инцидентов часы прокси ни при чем: они не участвуют ни в проверке сертификата цели, ни в формировании ваших подписей, ни в валидации ваших JWT. Все это делает клиент и целевой сервер.

Часы целевого сервера

Изредка часы уезжают на другой стороне. Признак: ошибки возникают только с одним хостом, а к остальным доступ нормальный; заголовок Date в ответе этого хоста заметно отличается от Date других серверов. Сделать с этим вы ничего не можете, кроме как сообщить владельцу и, если API позволяет, компенсировать сдвиг в своих запросах — некоторые библиотеки умеют вычислять поправку по заголовку Date из ответа с ошибкой и подписывать следующий запрос с учетом смещения.

Латентность и «протухание в очереди»

Прокси добавляет задержку — от десятков миллисекунд до секунд при переподключении мобильных каналов и ротации IP. Для окна в 15 минут это ничтожно. Но есть тонкая ловушка в логике повторов. Если вы подписали запрос, положили его в очередь, а отправили через несколько минут после серии ретраев с ожиданием, метка времени в подписи устаревает. Через медленный или перегруженный канал это происходит чаще. Правило: подписывайте непосредственно перед отправкой и переподписывайте при каждом повторе, а не переиспользуйте готовый подписанный объект.

Смещение: величина и направление

Соберем зависимость симптома от сдвига в одну таблицу-список — она пригодится при чтении логов:

  • Часы клиента отстают: «certificate is not yet valid», JWT с ошибкой nbf или iat при самоподписании, свои кэшированные токены кажутся живыми, но сервер отвечает 401 «expired», подписи отвергаются как слишком старые, TOTP не принимается.
  • Часы клиента спешат: «certificate has expired», собственные JWT отвергаются как с iat из будущего, подписи отвергаются как «из будущего», токены сбрасываются из кэша раньше срока, TOTP не принимается.
  • Сдвиг меньше секунды: ни один из перечисленных механизмов не заметит.
  • Сдвиг 30 секунд — 5 минут: ломаются TOTP, JWT с малым leeway, строгие HMAC-схемы.
  • Сдвиг больше 15 минут: ломаются практически все подписи запросов и Kerberos.
  • Сдвиг в дни: начинают падать TLS-соединения к сайтам с короткоживущими сертификатами.

Как ошибка выглядит в разных клиентах: тексты, которые нужно узнавать с первой строки

Умение узнать проблему по тексту ошибки экономит больше времени, чем любая методология. Ниже — характерные формулировки для популярных клиентов. Они приведены так, как их печатают библиотеки с OpenSSL под капотом; номера строк в скобках будут отличаться от версии к версии.

curl

Базовые сообщения при неверном времени:

curl: (60) SSL certificate problem: certificate is not yet valid
curl: (60) SSL certificate problem: certificate has expired

Код 60 — это CURLE_PEER_FAILED_VERIFICATION, общий для всех проблем проверки сертификата. Отличить временную причину от проблемы с корнями можно только по тексту после двоеточия. С флагом -v появляется полезная строка с числовым кодом проверки OpenSSL: 9 соответствует «not yet valid», 10 — «has expired»:

* SSL certificate verify result: certificate is not yet valid (9), continuing anyway.

Строка «continuing anyway» появляется только при флаге -k; без него соединение будет разорвано. На Windows с бэкендом Schannel текст иной:

curl: (60) schannel: SEC_E_CERT_EXPIRED (0x80090328) - The received certificate has expired.

Пример полной проверки через прокси с подробным выводом, чтобы увидеть и этап (прокси или цель), и результат верификации:

curl -v --proxy http://LOGIN:PASSWORD@PROXY_HOST:PORT https://example.com/ 2>&1 | grep -iE 'verify|expire|valid|date'

Python: ssl, requests, httpx, aiohttp

Стандартный модуль ssl бросает исключение с говорящим суффиксом:

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate is not yet valid (_ssl.c:1010)

Библиотека requests оборачивает его в SSLError и добавляет обертку пула соединений, из-за чего ключевые слова оказываются в самом конце длинной строки:

requests.exceptions.SSLError: HTTPSConnectionPool(host='example.com', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)')))

Важная деталь для работы через прокси: при туннеле CONNECT ошибка проверки сертификата цели приходит именно как SSLError, а не как ProxyError. ProxyError появляется, если не удалось установить соединение с самим прокси. Так что тип исключения уже подсказывает, где искать.

httpx формулирует короче:

httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)

aiohttp — своим классом:

aiohttp.client_exceptions.ClientConnectorCertificateError: Cannot connect to host example.com:443 ssl:True [SSLCertVerificationError: (1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate is not yet valid (_ssl.c:1010)')]

Ошибки JWT в PyJWT при проверке токена на вашей стороне:

jwt.exceptions.ExpiredSignatureError: Signature has expired
jwt.exceptions.ImmatureSignatureError: The token is not yet valid (nbf)

А вот минимальный воспроизводимый пример через прокси, который стоит держать под рукой как тест-кейс:

import requests, ssl, sys
PROXY = "http://LOGIN:PASSWORD@PROXY_HOST:PORT"
try:
r = requests.get("https://example.com/", proxies={"https": PROXY}, timeout=15)
print(r.status_code, r.headers.get("Date"))
except requests.exceptions.SSLError as e:
msg = str(e)
if "not yet valid" in msg or "has expired" in msg:
print("Похоже на сдвиг системных часов, проверьте date -u")
print(msg)

Небольшое пояснение: символ переноса строки в этом и следующих примерах показан HTML-сущностью, чтобы разметка оставалась одной строкой; при копировании в редактор он превратится в обычный перевод строки.

Node.js: https, fetch, undici

Node печатает короткие сообщения и, что ценно, снабжает ошибку полем code:

Error: certificate is not yet valid
code: 'CERT_NOT_YET_VALID'
Error: certificate has expired
code: 'CERT_HAS_EXPIRED'

Ловушка встроенного fetch: снаружи вы видите только TypeError: fetch failed, а настоящая причина спрятана в свойстве cause. Логируйте его явно:

try {
 const res = await fetch("https://example.com/");
 console.log(res.status, res.headers.get("date"));
} catch (e) {
 console.error(e.message, e.cause && e.cause.code, e.cause && e.cause.message);
}

Для маршрутизации fetch через прокси в Node используется ProxyAgent из пакета undici. Ошибка сертификата цели при этом придет с тем же кодом CERT_HAS_EXPIRED или CERT_NOT_YET_VALID в cause. Для классического модуля https и библиотек вроде axios применяют агент из пакета https-proxy-agent — коды ошибок те же, поскольку их порождает один и тот же OpenSSL внутри Node.

Библиотека jsonwebtoken при проверке токенов различает два временных случая:

TokenExpiredError: jwt expired
NotBeforeError: jwt not active

Ошибки на стороне сервера при подписях

Здесь текст задает API, но типовые формулировки узнаваемы. У S3-совместимых хранилищ:

<Code>RequestTimeTooSkewed</Code>
<Message>The difference between the request time and the current time is too large.</Message>

У OAuth-серверов — заголовок WWW-Authenticate с error="invalid_token" и описанием вида «The token expired» или «Token used before issued». У финансовых API — сообщения про timestamp outside of recvWindow или «Timestamp for this request is outside of the allowed window». Если вы видите любую из этих фраз, часы — первое, что нужно проверить.

Почему часы уезжают: виртуализация, контейнеры, отсутствие демона, ручная правка

Кварцевый генератор материнской платы — не эталон. Его типичная погрешность 10-100 ppm, то есть до 1-9 секунд дрейфа в сутки без коррекции. За месяц без синхронизации накапливаются минуты. Это фон, который есть у любой машины. Но основные инциденты вызваны не медленным дрейфом, а скачками. Разберем источники.

Виртуальные машины: пауза, снапшот, миграция

Виртуальная машина не владеет физическими часами. Она получает время через паравиртуальный источник (kvm-clock, Hyper-V reference time, аналоги у других гипервизоров). Когда гость приостановлен, сохранен в снапшот или мигрирует между хостами, его внутренние часы «замирают». После возобновления ядро либо получает от гипервизора корректировку, либо нет — зависит от настроек гостевых агентов. Классический сценарий: команда откатила тестовый сервер на снапшот двухнедельной давности, и он проснулся с часами, отставшими на 14 дней. Все сертификаты, выпущенные за эти две недели, для него «еще не действительны».

Отдельная беда — конфликт двух источников правды. Гостевой агент гипервизора периодически «подтягивает» время к хосту, а внутри гостя работает свой NTP-демон. Если хост сам не синхронизирован, гость получает два противоречивых сигнала и часы могут прыгать взад-вперед. Рекомендация большинства руководств по виртуализации: либо синхронизация от гипервизора, либо NTP внутри, но не оба одновременно.

Ноутбуки разработчиков: сон и десктопные виртуалки

Docker Desktop и WSL2 работают внутри легкой виртуальной машины. После сна или гибернации ноутбука часы этой ВМ нередко отстают ровно на время сна — час, ночь, выходные. Хостовая ОС время подтянет, а виртуалка — не всегда. Итог: утром понедельника все скрипты в контейнерах падают на TLS, а в терминале хоста все в порядке. Именно эта ситуация чаще всего порождает жалобу «работает без прокси с ноутбука, не работает через прокси в контейнере» — при том что прокси в обоих случаях мог вообще не участвовать в проверке.

Контейнеры

Контейнер не имеет собственных часов реального времени — он использует часы хоста. Подробно это разберем в отдельном разделе, здесь зафиксируем главное: если хост уехал, уехали все контейнеры, и починить это изнутри контейнера нельзя.

Отсутствие демона синхронизации или его неработоспособность

Минимальные образы, «голые» установки и самосборные системы нередко идут вообще без NTP-клиента. А там, где он есть, он может не работать по десятку причин: заблокирован исходящий UDP на порт 123 файрволом; не резолвятся имена пулов; сервис установлен, но замаскирован; одновременно установлены chrony и timesyncd и мешают друг другу; смещение при старте превысило порог, при котором классический ntpd отказывается корректировать и завершается. Особенно уязвимы серверы, у которых единственный выход в интернет — через HTTP-прокси корпоративной сети: NTP по UDP через такой прокси не пройдет, и часы предоставлены сами себе.

Отсутствие RTC и разряженная батарея

Одноплатные компьютеры вроде Raspberry Pi и многие мини-ПК не имеют аппаратных часов с батарейкой. При загрузке они стартуют либо с эпохи, либо с даты последнего сохранения, и живут с этим до первой удачной синхронизации. У старых серверов садится батарея CMOS, и после каждого выключения BIOS сбрасывает дату на заводскую. Если синхронизация недоступна, машина работает с датой десятилетней давности, и любое TLS-соединение обречено.

Ручная правка и путаница с часовыми поясами

Команда date -s, выполненная «чтобы проверить гипотезу» и забытая, — частый сюжет. Еще один — двойная загрузка Windows и Linux на одной машине: Windows хранит в аппаратных часах локальное время, Linux по умолчанию ожидает UTC, и после каждой перезагрузки в другую ОС время сдвигается на величину часового пояса, для московского времени — на три часа. И, наконец, путаница между временем и часовым поясом: смена переменной TZ или файла /etc/localtime меняет отображение, но не системное время. Сравнивать нужно только UTC.

Диагностика за минуту: сравнение с внешним источником и допустимый разброс

Цель этой процедуры — за 60 секунд ответить на один вопрос: правильно ли идут часы на машине, с которой выполняются запросы через прокси. Все команды выполняются на той самой машине или в том самом контейнере, где падает клиент, а не на вашем ноутбуке.

Шаг 1. Посмотрите UTC и сравните с любым доверенным источником

date -u

Сравните с часами телефона, синхронизированного с оператором связи. Расхождение в минуты видно сразу. Для секунд нужны шаги ниже.

Шаг 2. Возьмите время из HTTP-заголовка Date через тот же прокси

Любой корректный HTTP-сервер отдает заголовок Date по своим (обычно синхронизированным) часам. Хитрость: запрашивайте по обычному HTTP, а не HTTPS, потому что при сильном сдвиге TLS не установится и вы не получите заголовка вообще:

date -u; curl -sI --proxy http://LOGIN:PASSWORD@PROXY_HOST:PORT http://example.com/ | grep -i '^date'

Две строки должны совпасть с точностью до секунды-двух (заголовок имеет секундную гранулярность, плюс задержка канала). Заметили минуту и больше — диагноз готов. Этот прием особенно ценен тем, что не требует UDP, работает через любой прокси и не зависит от TLS.

Тот же прием на Python — удобно вшить в healthcheck сервиса, который ходит через Proxeon или любой другой прокси:

import email.utils, time, requests
PROXY = "http://LOGIN:PASSWORD@PROXY_HOST:PORT"
r = requests.head("http://example.com/", proxies={"http": PROXY}, timeout=10)
server_ts = email.utils.parsedate_to_datetime(r.headers["Date"]).timestamp()
skew = time.time() - server_ts
print(f"Смещение локальных часов: {skew:+.1f} с")
if abs(skew) > 30:
raise SystemExit("Часы уехали, синхронизация обязательна")

Шаг 3. Спросите систему, считает ли она себя синхронизированной

timedatectl

Ищите две строки: «System clock synchronized: yes» и «NTP service: active». Любое «no» или «inactive» — повод идти в раздел про настройку. Если стоит chrony:

chronyc tracking

Смотрите на «System time» (величина и знак смещения), «Stratum» (не должен быть 0) и «Leap status» (должен быть Normal, а не «Not synchronised»).

Шаг 4. Разовый запрос к NTP-серверу без изменения часов

Если UDP наружу открыт, можно измерить смещение точно, ничего не меняя:

chronyd -Q 'pool pool.ntp.org iburst'

Или, если установлен ntpdate:

ntpdate -q pool.ntp.org

Вывод покажет offset в секундах со знаком.

Шаг 5. Подтвердите, что именно время ломает TLS

Утилита openssl умеет ходить через HTTP-прокси и печатает результат верификации с числовым кодом:

openssl s_client -connect example.com:443 -proxy PROXY_HOST:PORT -servername example.com </dev/null 2>/dev/null | grep 'Verify return code'

«Verify return code: 9 (certificate is not yet valid)» или «10 (certificate has expired)» при живом сертификате — исчерпывающее доказательство. Учтите, что s_client в большинстве сборок не поддерживает авторизацию на прокси по логину и паролю; для этой проверки удобно использовать авторизацию по IP, которую поддерживает Proxeon. Даты самого сертификата вытащите так:

openssl s_client -connect example.com:443 -proxy PROXY_HOST:PORT -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

Какой разброс считать нормой

  • До 100 миллисекунд — здоровая машина с работающим NTP. Ничего делать не нужно.
  • 100 мс — 1 секунда — приемлемо для всех обсуждаемых протоколов, но говорит о плохом источнике или редком опросе. Стоит проверить конфигурацию.
  • 1 — 30 секунд — желтая зона. TLS и подписи с широким окном еще работают, TOTP и JWT с малым leeway уже могут спотыкаться. Чинить сегодня.
  • Больше 30 секунд — красная зона. Считайте, что любая ошибка аутентификации или TLS вызвана часами, пока не доказано обратное.

Как воспроизвести проблему намеренно

Чтобы убедиться, что ваша обработка ошибок корректно распознает временной сбой, не нужно трогать системные часы. Библиотека libfaketime подменяет время для одного процесса:

faketime '2024-01-01 12:00:00' curl -sS https://example.com/ -o /dev/null

Вы получите «certificate is not yet valid» для любого сайта с сертификатом, выпущенным позже указанной даты. Для проверки сертификата без сети OpenSSL поддерживает флаг -attime с Unix-временем, что удобно для тестов в CI.

Настройка синхронизации: systemd-timesyncd и chrony, и как убедиться, что это реально работает

Установка пакета — не синхронизация. Ниже две рабочие схемы и, что важнее, методика верификации.

Выбор: timesyncd или chrony

systemd-timesyncd — минималистичный SNTP-клиент, входящий в systemd. Опрашивает один сервер из списка, умеет корректировать часы скачком и плавно, не работает как сервер, не поддерживает аппаратные источники. Достаточен для рабочих станций и некритичных серверов, где нужна точность порядка десятков миллисекунд.

chrony — полноценная реализация NTP. Комбинирует несколько источников, быстро сходится после долгого простоя, аккуратно ведет себя при прерывистой сети, поддерживает NTS (NTP с аутентификацией через TLS), может раздавать время внутри сети. Это выбор по умолчанию для серверов, виртуальных машин и любых узлов, через которые идут подписанные запросы и работа с прокси.

Важно: два демона одновременно — источник хаоса. При установке chrony дистрибутивы, как правило, отключают timesyncd, но проверьте это явно.

Вариант A: systemd-timesyncd

Конфигурация в файле /etc/systemd/timesyncd.conf:

[Time]
NTP=ntp.your-provider.internal 0.pool.ntp.org 1.pool.ntp.org
FallbackNTP=2.pool.ntp.org 3.pool.ntp.org
PollIntervalMinSec=32
PollIntervalMaxSec=2048

Включение и проверка:

sudo timedatectl set-ntp true
sudo systemctl restart systemd-timesyncd
timedatectl timesync-status

В выводе timesync-status должен быть заполнен Server, Stratum не равен 0, а поле Offset — в миллисекундах. Если сервер не указан или стоит «Packet count: 0», ответы от NTP не приходят — почти наверняка закрыт UDP 123.

Вариант B: chrony

Файл /etc/chrony/chrony.conf (на некоторых системах /etc/chrony.conf):

server ntp.your-provider.internal iburst prefer
pool pool.ntp.org iburst maxsources 4
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
logdir /var/log/chrony

Разберем ключевые директивы, потому что именно они определяют поведение при сбоях:

  • iburst — при старте отправляет серию запросов подряд и синхронизируется за секунды, а не за минуты.
  • makestep 1.0 3 — если смещение больше 1 секунды, в первых трех обновлениях разрешен скачок, дальше только плавное подведение. Без этой директивы после снапшота или сна chrony будет «догонять» часы неделями. Для серверов, которые часто засыпают или мигрируют, некоторые ставят makestep 1.0 -1, разрешая скачок всегда.
  • rtcsync — периодически записывает время в аппаратные часы, чтобы после перезагрузки стартовать с близкого значения.
  • Внутренний сервер провайдера первым — облачные площадки обычно предоставляют NTP-адрес внутри сети; он доступен даже при закрытом внешнем UDP и имеет минимальную задержку. Возьмите адрес из документации своего провайдера.

Запуск:

sudo systemctl enable --now chrony
sudo chronyc makestep
chronyc tracking
chronyc sources -v

Как понять, что синхронизация реально работает

Это главная часть раздела. Признаки работающей синхронизации в chrony:

  1. В chronyc tracking поле Reference ID не равно нулю и Stratum находится в диапазоне 2-4 для типичного сервера. Stratum 0 или 10 с пустым Reference ID означает «работаю сам с собой».
  2. Leap status: Normal. Значение «Not synchronised» — часы никем не корректируются.
  3. System time показывает смещение в миллисекундах или микросекундах, а не в секундах.
  4. В chronyc sources хотя бы один источник помечен звездочкой (текущий выбранный) или плюсом (участвует в комбинации). Знак вопроса у всех источников — ответы не приходят, ищите файрвол или DNS.
  5. Last offset и RMS offset уменьшаются со временем, а не растут.

Для timesyncd эквивалентная проверка — «System clock synchronized: yes» в timedatectl и ненулевой Packet count в timesync-status.

Когда NTP заблокирован файрволом

Если на сервере разрешен только исходящий HTTP(S) через прокси, у вас три пути. Первый — попросить открыть UDP 123 хотя бы к внутреннему NTP-серверу; это стандартная и безопасная просьба к сетевым администраторам. Второй — синхронизироваться от гипервизора или хоста, если это виртуалка или контейнер. Третий — компромисс через утилиту htpdate, которая выставляет часы по заголовку Date HTTP-ответов; точность порядка секунды, для TLS, JWT и подписей запросов этого достаточно, и она умеет работать через HTTP-прокси. Не самый элегантный, но рабочий вариант для изолированных сегментов.

Windows

На Windows-хостах служба времени проверяется так:

w32tm /query /status
w32tm /resync

В доменной среде источником служит контроллер домена, вне домена — настроенный NTP-сервер. Поле Last Successful Sync Time должно быть свежим.

Контейнеры и часы: что наследуется от хоста, а что нет

Контейнер — это набор изолированных пространств имен над общим ядром. Изоляция касается процессов, сети, файловой системы, точек монтирования. Но часы реального времени (CLOCK_REALTIME) не изолируются. Пространства имен времени, появившиеся в Linux 5.6, позволяют сдвигать только монотонные часы и время с момента загрузки — это нужно для миграции контейнеров и не влияет на календарное время. Отсюда несколько жестких следствий.

Что наследуется

  • Календарное время. Все контейнеры видят ровно то же время, что и хост. Если хост уехал — уехали все.
  • Синхронизация. Работающий на хосте chrony автоматически «чинит» время во всех контейнерах.
  • Скачки. Если хост скорректировал часы скачком, приложения в контейнерах увидят этот скачок мгновенно.

Что не наследуется

  • Часовой пояс. Файл /etc/localtime и переменная TZ живут внутри образа. В минимальных образах Alpine пакет tzdata может отсутствовать, и контейнер всегда показывает UTC. Это не ошибка времени, а ошибка отображения, но она регулярно вводит в заблуждение при чтении логов.
  • Хранилище корневых сертификатов. Не тема этой статьи, но напомним: оно тоже внутри образа.
  • Право менять время. Системный вызов установки времени требует CAP_SYS_TIME, которой у контейнера по умолчанию нет. Команда date -s внутри контейнера завершится ошибкой «Operation not permitted». Запускать NTP-демон внутри обычного контейнера бессмысленно, а давать ему CAP_SYS_TIME — плохая практика: он начнет управлять часами всего хоста.

Практические следствия

Диагностику из предыдущего раздела можно и нужно выполнять внутри контейнера — date -u и запрос заголовка Date через прокси покажут реальную картину. Но исправление всегда происходит на хосте. Если контейнер работает в Kubernetes, нужный хост — конкретный узел, на котором запланирован под; убедитесь, что chrony входит в базовый образ узлов и что мониторинг снимает метрику смещения с каждого узла.

Docker Desktop и WSL2 на ноутбуках — отдельная история, потому что «хостом» для контейнеров там является скрытая виртуальная машина, склонная отставать после сна. Для WSL2 быстрое исправление — подтянуть время из аппаратных часов, которые гипервизор держит в порядке:

sudo hwclock -s

Для Docker Desktop радикальный, но надежный способ — перезапуск приложения; после старта ВМ синхронизируется с хостом. В свежих версиях проблема встречается реже, но полностью не исчезла, поэтому healthcheck на смещение часов в dev-окружении — не паранойя, а экономия утреннего часа.

Пример: healthcheck в compose-файле

Идея — не поднимать рабочий контейнер, если часы хоста уехали дальше допустимого. Проверка использует тот же прием с заголовком Date по обычному HTTP через прокси, чтобы не зависеть от TLS:

healthcheck:
 test: ["CMD", "python", "/app/clock_check.py"]
 interval: 5m
 timeout: 20s
 retries: 1

Скрипт clock_check.py — тот, что мы написали в разделе диагностики, с порогом 30 секунд. Оркестратор пометит контейнер как нездоровый и вы увидите причину в статусе, а не в куче ошибок TLS в логах приложения.

Типичные ошибки и кейсы из практики

Раздел о том, чего не делать, полезнее раздела о том, что делать. Здесь собраны решения, которые кажутся логичными в момент инцидента и дорого обходятся потом, а также несколько характерных историй с цифрами.

Ошибка 1: отключить проверку сертификата

Флаг -k, verify=False, NODE_TLS_REJECT_UNAUTHORIZED=0. Скрипт снова «работает», задача закрыта. Но подписи запросов и JWT по-прежнему не сходятся, TOTP не принимается, а вы отключили единственный механизм, который защищал канал. Часы при этом остаются кривыми и через неделю сломают что-нибудь еще. Отключение проверки допустимо ровно на одну команду в рамках диагностики, и никогда — в коде.

Ошибка 2: увеличить leeway или окно подписи «с запасом»

Поднять допуск проверки JWT до 10 минут — значит расширить окно для повторного использования перехваченного токена в 20 раз. Реальная проблема (часы) при этом никуда не уходит. Правильный допуск для JWT — 30-60 секунд, для HMAC-подписей — то, что задает провайдер API.

Ошибка 3: править время руками вместо синхронизации

date -s исправляет симптом на пару часов. Дрейф и следующий снапшот вернут все обратно, только теперь никто не будет помнить, что время правили вручную.

Ошибка 4: два демона времени одновременно

chrony и timesyncd, или chrony и гостевой агент гипервизора с включенной синхронизацией, тянут часы в разные стороны. В логах chrony это видно как постоянные крупные корректировки. Оставьте один источник правды.

Ошибка 5: сравнивать локальное время с UTC

Разница ровно в 3, 5 или 7 часов — почти всегда не сбой часов, а часовые пояса. Всегда сравнивайте date -u с UTC-источником, а в логах пишите время с явным указанием зоны.

Ошибка 6: доверять статусу «installed»

Ansible-роль поставила chrony, задача зеленая. А UDP 123 закрыт, и chrony синхронизирован «сам с собой». Проверка в конфигурации инфраструктуры должна смотреть на Leap status и смещение, а не на факт наличия пакета.

Ошибка 7: переиспользовать подписанный запрос при ретраях

Особенно через прокси с переподключениями. Каждая попытка должна получать новую метку времени и новую подпись.

Кейс A: снапшот тестового стенда

Команда сбора открытых данных о ценах держала пул воркеров в виртуальных машинах и раз в неделю откатывала стенд на «чистый» снапшот. После одного из откатов все воркеры, ходившие через прокси, стали падать с «certificate is not yet valid» на трети целевых сайтов — именно тех, что использовали 90-дневные сертификаты и успели их перевыпустить за 12 дней жизни снапшота. С ноутбука инженера без прокси сайты открывались. Полтора часа ушло на переключение пулов и тарифов у прокси, прежде чем кто-то выполнил date -u и увидел 12-дневное отставание. Исправление: chrony с makestep в базовом образе стенда и проверка смещения в скрипте пост-восстановления. Повторов не было.

Кейс B: изолированный сегмент и мини-ПК

Мини-ПК без аппаратных часов стоял в сегменте сети, где наружу разрешен только HTTP через корпоративный прокси. После планового отключения электричества он загрузился с датой сборки образа — на 14 месяцев в прошлом. Ни одно TLS-соединение не устанавливалось, что выглядело как полный отказ прокси. Решение: разрешен UDP 123 к внутреннему NTP-серверу компании, chrony получил директиву makestep 1.0 -1, а на случай отсутствия NTP добавлен запуск htpdate через прокси при старте.

Кейс C: выгрузка в объектное хранилище из Docker Desktop

Разработчик отлаживал выгрузку файлов в S3-совместимое хранилище через прокси из контейнера на ноутбуке. После обеденного перерыва все запросы стали возвращать RequestTimeTooSkewed. Смещение часов виртуальной машины Docker Desktop — 23 минуты, чуть больше 15-минутного окна. Перезапуск Docker Desktop решил вопрос за минуту; в проект добавлен healthcheck смещения, который сразу показывает причину.

Кейс D: 50 секунд и TOTP

Сервис автоматизировал вход в собственный корпоративный портал сервис-аккаунтом с двухфакторной аутентификацией по TOTP. Коды перестали приниматься: сервер отвечал, что код неверен. Смещение часов узла — 50 секунд, потому что timesyncd был установлен, но замаскирован при переходе на chrony, который так и не включили. При допуске в один 30-секундный интервал этого хватило. Ноль изменений в коде, только запуск chrony.

Инструменты и чеклист «перед тем как винить прокси»

Соберем все в форму, которую можно прикрепить к тикету или вшить в runbook. Сначала инструменты, потом последовательность.

Инструменты

  • date -u, timedatectl, chronyc, hwclock — состояние часов и синхронизации.
  • curl -v --proxy, openssl s_client -proxy — проверка TLS через прокси с кодом верификации.
  • chronyd -Q, ntpdate -q — разовое измерение смещения без изменения часов.
  • Заголовок Date по HTTP — универсальный источник времени, работающий через любой прокси.
  • libfaketime, openssl -attime — воспроизведение сбоя и тестирование обработки ошибок.
  • htpdate — аварийная синхронизация в сегментах, где закрыт UDP.
  • Метрики — экспортеры для chrony или timesyncd отдают смещение в систему мониторинга; порог алерта 1 секунда, критический — 30 секунд.

Чеклист

  1. Где именно выполняется падающий код? Ноутбук, сервер, ВМ, контейнер, под. Все проверки делаем там.
  2. date -u совпадает с телефоном? Расхождение в минуты — стоп, чините часы.
  3. Заголовок Date по http:// через прокси совпадает с локальным UTC в пределах пары секунд? Если нет — часы.
  4. timedatectl говорит synchronized: yes, chronyc tracking показывает Leap status Normal? Если нет — синхронизация не работает, независимо от того, что установлено.
  5. Текст ошибки содержит not yet valid, has expired, RequestTimeTooSkewed, jwt expired, jwt not active, timestamp? Каждое из этих слов — про время.
  6. Без прокси с той же машины ошибка такая же? Если да — прокси не при чем.
  7. Через прокси с другой машины ошибки нет? Если да — проблема в первой машине.
  8. openssl s_client через прокси показывает Verify return code 9 или 10 при живых датах сертификата? Диагноз подтвержден.
  9. Ошибки только с одним целевым хостом? Сравните его Date с другими — возможно, уехали часы на его стороне.
  10. Подписи переформируются при каждом повторе? Если нет — исправьте логику ретраев.
  11. Был ли недавно снапшот, сон, миграция, перезагрузка после долгого выключения? Любое «да» — почти наверняка причина.
  12. Только после всех «нет» — обращение к провайдеру прокси с приложенными выводами date -u, chronyc tracking и curl -v. Так поддержка Proxeon или любого другого сервиса решит вопрос за один круг переписки, а не за пять.

FAQ: частые вопросы о времени, прокси и отказах

Может ли прокси сам исказить время в моих запросах или в TLS?

При туннелировании через CONNECT или SOCKS5 — нет. TLS-рукопожатие проходит между вашим клиентом и целевым сервером, прокси видит только зашифрованные байты. Метки времени в подписях и JWT формирует ваш код на вашей машине. Единственное, что прокси может добавить, — задержку, и она измеряется миллисекундами, а не минутами.

Почему с ноутбука без прокси работает, а с сервера через прокси нет?

Потому что это две разные машины с двумя разными наборами часов. Сравнение некорректно по построению: изменились сразу две переменные. Чтобы изолировать влияние прокси, запустите запрос с сервера без прокси. Чтобы изолировать влияние машины — запустите через прокси с ноутбука. Почти всегда окажется, что ошибка привязана к машине.

Насколько точным должно быть время для работы через прокси?

Для TLS хватает точности в часы, если сертификаты долгоживущие, и в дни для короткоживущих. Для подписей запросов типовое окно 5-15 минут. Для JWT — 30-60 секунд допуска. Для TOTP — плюс-минус 30 секунд. Работающий NTP дает миллисекунды, поэтому практический стандарт — смещение менее 1 секунды, а алерт при превышении 30 секунд.

Могу ли я запустить NTP-клиент внутри контейнера?

Технически — только с CAP_SYS_TIME, и тогда он будет управлять часами всего хоста, что нарушает изоляцию и вступает в конфликт с демоном на хосте. Правильный подход — синхронизация на хосте или узле кластера. В контейнере оставьте только проверку смещения.

Что делать, если UDP 123 наглухо закрыт, а выход только через HTTP-прокси?

Запросите открытие UDP хотя бы к внутреннему NTP компании или облачного провайдера — это стандартная инфраструктурная потребность. Пока решается, используйте синхронизацию от гипервизора или htpdate по заголовкам Date через прокси. Учитывайте, что NTS защищает NTP через TLS на TCP-порту 4460, но сами временные пакеты по-прежнему идут по UDP, так что NTS не обходит блокировку UDP.

Часы спешат на несколько минут, но TLS работает. Почему тогда падают запросы к API?

Потому что у TLS окно — дни, а у подписей и токенов — секунды и минуты. Это нормальная ситуация: сертификаты проверяются успешно, а HMAC-подпись с меткой времени, ушедшей на три минуты в будущее, отвергается сервером с жестким окном. Смотрите текст ошибки от API, а не только на успешность соединения.

Как понять, что уехали часы целевого сервера, а не мои?

Сравните заголовок Date из ответа проблемного хоста с Date двух-трех других хостов и с вашим date -u. Если ваши часы совпадают с большинством, а один хост отклоняется — проблема на его стороне. Некоторые клиентские SDK умеют автоматически компенсировать серверное смещение, вычисляя его из ответа с ошибкой времени.

Нужно ли переподписывать запрос при повторной попытке через прокси?

Да. Подпись включает метку времени и действительна в пределах окна. Если между первой попыткой и повтором прошли минуты ожидания и переподключений, старая подпись может выйти за окно. Формируйте подпись в момент отправки каждой попытки.

Как заложить проверку часов в мониторинг?

Снимайте смещение с chrony или timesyncd в систему метрик и заведите два порога: предупреждение при 1 секунде, критический при 30 секундах. Дополнительно — healthcheck сервиса по заголовку Date через рабочий прокси; он ловит ситуации, когда демон времени формально жив, а часы все равно уехали, например после восстановления снапшота.

Изменилось ли что-то в 2026 году?

Главное изменение — сокращение сроков жизни публичных сертификатов и массовое распространение автоматического перевыпуска. Это делает даже небольшие отставания часов, в несколько дней, реальной причиной отказов TLS, тогда как раньше такие сбои были редкостью. Одновременно растет доля коротких JWT и строгих окон подписей в API. Иными словами, требования к точности часов у клиентских машин растут, и синхронизация времени перестает быть «настройкой по умолчанию, о которой не думают».

Заключение: время как первый пункт диагностики

Подведем итог. Проверка срока действия сертификата, полей exp и nbf в JWT, метки времени в подписи запроса и одноразового кода — все это локальные сравнения с системными часами. При работе через прокси в цепочке появляется еще одна машина, и внимание естественным образом смещается на нее, хотя ни TLS-рукопожатие, ни подпись, ни валидация токена через туннель прокси не проходят. Прокси в этих сценариях — лишь транспорт. Часы же уезжают по вполне земным причинам: снапшоты и пауза виртуальных машин, сон ноутбуков с Docker Desktop и WSL2, отсутствие или неработоспособность демона синхронизации, закрытый UDP, разряженная батарея, ручная правка.

Практическая программа проста и укладывается в три шага. Первый — привыкнуть узнавать временные ошибки по тексту: not yet valid, has expired, RequestTimeTooSkewed, jwt expired, jwt not active. Второй — сделать проверку смещения часов первым пунктом любого разбора инцидента с прокси: date -u, заголовок Date по HTTP через прокси, timedatectl или chronyc tracking. Это минута, которая экономит часы. Третий — сделать так, чтобы проблема не возвращалась: chrony с iburst и makestep в базовых образах, один источник времени на машину, синхронизация на хосте для контейнеров, метрика смещения в мониторинге и healthcheck в сервисах, которые ходят через прокси.

Когда все это выполнено, обращение к провайдеру прокси становится осмысленным: вы приходите с исключенной гипотезой и готовыми выводами команд, и разбор идет по существу. А в большинстве случаев обращение просто не понадобится — потому что часы окажутся первым, что вы проверите, и последним, что вас удивит.