Новичок открывает первый кейс, о котором я рассказывал в первой статье., читает брифинг, и через пять секунд сайт пишет: «Готово, жми „Начать“». Он жмёт и смотрит на пустое окно терминала. Секунд через десять там ошибка, страница сама перезагружает терминал, и только потом появляется консоль.

Сервер при этом не падал, CPU и память в норме, алерты молчат. Были только строчки в журнале, которые я чуть не записал в «обычный шум».

180 строк, которые я чуть не записал в шум

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

Почти все были одной и той же: Terminal container did not become ready. Около 180 строк с последнего перезапуска сайта, плюс 16 падений WebSocket‑прокси терминала. Первая мысль — шум: игрок закрыл вкладку, окружение убрали, прокси не достучался. Я так и внёс их в исключения алертов.

Потом открыл код прокси. Сайт проксирует терминал ttyd из контейнера игрока и, если контейнер только что стартовал, ждёт его до 10 секунд:

def _fetch_upstream(url):
    """GET к ttyd; контейнер мог только что стартовать — ждём до 10 с."""
    deadline = time.monotonic() + 10
    while True:
        try:
            return requests.get(url, timeout=(0.5, 15), ...)
        except requests.ConnectionError as error:
            if time.monotonic() >= deadline:
                log.error("Terminal container did not become ready at %s: %s", url, error)
                abort(502, description="Не удалось подключиться к консоли сценария.")
            time.sleep(0.25)

До этого места запрос доходит, только если контейнер жив. Для удалённого контейнера игрок сразу получает «Сессия истекла», и в журнал ничего не пишется. Значит, каждая строка — живой контейнер, в котором терминал 10 секунд не отвечал. Это не шум.

И ещё деталь: 113 из ~180 строк — у гостей. А гостям без регистрации доступен только кейс 0.

Замер: когда терминал на самом деле готов

Чтобы не гадать, я замерил это напрямую. Скрипт внутри веб‑контейнера запускал окружение той же функцией, что и сайт, и каждые 100 мс пробовал подключиться к порту ttyd.

[embed: node/c28cc3ae‑b3f5]

В остальных кейсах терминал готов задолго до «Готово». В кейсе 0 он отвечает на 7,8 секунды позже, а прокси ждёт всего 10 секунд с момента «Начать». Кто жмёт сразу, укладывается впритык, а на загруженном сервере уже нет.

Почему именно кейс 0

Кейс 0 — обучение: ночная задача съела процессор, выгрузка забила диск, сервис упал. Всю поломку готовит entrypoint контейнера, а терминал запускается последней строкой:

sudo python3 /usr/local/lib/scenario/seed_uploads.py         # файлы магазина
sudo -u jobs dd if=/dev/zero of="/var/lib/shop/$FILE" bs=1M  # выгрузка забила раздел
sudo -u jobs nohup python3 "/opt/jobs/$JOB.py" &            # зависшая задача: 100% CPU
sudo pd-systemd-boot                                         # «загрузка» сервисов
exec /usr/local/bin/start-terminal.sh                        # ttyd

Контейнеру этого кейса разрешена четверть ядра (cpu_quota=25000 при периоде 100000). Накануне я урезал её с половины: дашборд считает CPU от лимита, и зависшая задача всё равно видна на 100%, а на сервер помещается больше игроков. Но задача стартует до «загрузки» сервисов и сразу съедает эту четверть: Python‑скрипты подготовки и сам ttyd стоят к процессору в очереди за ней.

Для сравнения: в кейсе 2 тот же лимит и тоже нарочная нагрузка на CPU, а терминал готов через 0,9 с. Дело не в лимите как таковом, а в том, сколько работы стоит перед ttyd и с кем она делит четверть ядра.

А сайт считал готовность так: контейнер стартовал — значит, через warmup_seconds инцидент готов. У кейса 0 это 5 секунд:

now = time.time()
# Инцидент развивается warmup_seconds, пока игрок читает брифинг.
attached = database.attach_container(
    run_id, info["container_id"], info["host"],
    now + SCENARIOS[scenario_id]["warmup_seconds"],
)

Выдержка задумывалась как время, пока сервер «успевает сломаться», а игрок читает брифинг. А стала обещанием, которое никто не проверял. Дальше срабатывала страховка на странице: если в окне терминала ошибка, она перезагружает его каждые 3 секунды. Поэтому в итоге всё «работало», просто первые 10–20 секунд выглядело сломанным.

Таймер — не проверка готовности

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

  • sleep 10 в скрипте деплоя перед миграциями или smoke‑тестом;

  • depends_on в docker compose без condition: service_healthy: compose ждёт только запуска контейнера, а не готовности сервиса в нём;

  • pod в Kubernetes без readinessProbe получает трафик, как только стартовал контейнер; initialDelaySecondsу пробы — тот же таймер;

  • юнит systemd с Type=simple считается запущенным сразу после старта процесса, и After= у соседних юнитов ждёт именно этого, а не готовности.

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

И ломается таймер не громко. Никакого падения, только редкие 502 и перезапуски, которые все привыкли считать шумом.

Исправление: ждать то, что увидит игрок

Правило простое: «готово» — только после проверки того, чем будет пользоваться человек. Для терминала это порт ttyd: принял подключение — можно звать игрока.

TERMINAL_READY_TIMEOUT_SECONDS = 30

def wait_for_terminal(host, timeout=TERMINAL_READY_TIMEOUT_SECONDS):
    """Дождаться, пока ttyd в контейнере примет подключение; False — не дождались."""
    deadline = time.monotonic() + timeout
    while True:
        try:
            socket.create_connection((host, TERMINAL_PORT), timeout=1).close()
            return True
        except OSError:
            if time.monotonic() >= deadline:
                return False
            time.sleep(0.2)

При запуске окружения контейнер теперь привязывается к игроку только после этой проверки. До неё игрок видит «Поднимаем окружение…», а не «Готово»:

if not containers.wait_for_terminal(info["host"]):
    log.warning("Terminal of %s did not answer in %d s; letting the player in anyway", ...)
now = time.time()
# Инцидент развивается warmup_seconds; ожидание терминала входит в это время.
attached = database.attach_container(
    run_id, info["container_id"], info["host"],
    max(now, info["started_at"] + SCENARIOS[scenario_id]["warmup_seconds"]),
)

Три решения в этих строках:

  1. Выдержка считается от старта контейнера и включает ожидание. Быстрые кейсы не ждут дольше прежнего, медленные не ждут дважды.

  2. У ожидания есть потолок — 30 секунд. Если терминал так и не ответил, игрока всё равно пускают, а в журнал уходит предупреждение. Медленная консоль лучше вечного экрана загрузки.

  3. Проверка — TCP‑подключение к порту, а не HTTP‑запрос. ttyd открывает порт, когда уже готов отдавать страницу, и проверка стоит миллисекунды. Для сервиса с базой и кэшами порта было бы мало — там нужен настоящий запрос.

Отдельно починил сам кейс 0: зависшая задача теперь запускается последней, рядом с терминалом. Подготовка больше не делит с ней четверть ядра, а для игрока ничего не меняется: к началу инцидента она уже первая в top.

sudo pd-systemd-boot                                         # сервисы — сначала
sudo -u jobs nohup python3 "/opt/jobs/$JOB.py" &            # нагрузка — последней
exec /usr/local/bin/start-terminal.sh

На всё это есть тесты: пока терминал молчит, запуск остаётся «поднимается»; медленный терминал не удваивает выдержку; молчащий не запирает игрока. И прогон всех трёх вариантов кейса 0 целиком: поломка на месте, проверка до починки падает, после — проходит.

Что получилось

Теперь к моменту «Готово» терминал уже отвечает, и первый запрос к нему идёт сотые доли секунды вместо 8–9. Замеры на тестовой машине, кейс 0, по несколько запусков на каждую строку:

До

После

Терминал принимает подключения, с от старта контейнера

12,8

7,2–8,3

Игрок видит «Готово», с

~6, терминал ещё молчит

6,6–7,4, терминал уже отвечает

Первый запрос к терминалу после «Начать», с

8,4–9,2

0,004–0,2

Ошибки в журнале сайта

есть

нет

Перестановка нагрузки дала примерно 5 секунд. Остальные 7 — сама подготовка в четверти ядра, и они всё ещё дольше пятисекундной выдержки. Поэтому настоящее исправление — проверка, а перестановка лишь сокращает ожидание: таймер в 5 секунд врал бы и после неё.

На тестовой машине рядом никто не играет, и первый запрос там ещё укладывался в 10 секунд прокси. На проде, где процессор делят живые игроки, судя по журналу, уже регулярно нет.

Открытый вопрос: сколько таких ошибок на проде за первые сутки после выкатки (до неё — около 9 в час).

Чек‑лист: как не врать «готово»

  • Найдите все места, где «готово» означает «прошло N секунд»: sleep, initialDelaySeconds, выдержки в коде.

  • Проверяйте то, чем пользуется человек или следующий сервис: порт, HTTP‑ответ, тестовый запрос. «Контейнер запущен» — не проверка.

  • Дайте ожиданию потолок и заранее решите, что делать, если не дождались: пустить с предупреждением или честно показать ошибку.

  • Мерьте время старта с настоящими лимитами CPU и памяти. И заново — после каждой правки лимитов.

  • Перечитайте ошибки, которые привыкли считать шумом. Для каждой ответьте: при каком условии код вообще доходит до этой строки?

docker compose: depends_on с условием и healthcheck у того, кого ждут.

services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 2s
      timeout: 2s
      retries: 30
  worker:
    depends_on:
      api:
        condition: service_healthy

Kubernetes: startupProbe для медленного старта вместо initialDelaySeconds, readinessProbe — чтобы трафик шёл только в готовый pod.

startupProbe:              # до 60 × 2 с на старт; liveness пока не трогает
  httpGet: {path: /health, port: 8080}
  periodSeconds: 2
  failureThreshold: 60
readinessProbe:            # трафик — только когда отвечает
  httpGet: {path: /ready, port: 8080}
  periodSeconds: 5

systemd: Type=notify, если сервис умеет сказать READY=1; иначе — ожидание в ExecStartPost, чтобы юниты с After= стартовали после настоящей готовности.

[Service]
ExecStart=/usr/local/bin/api
ExecStartPost=/usr/bin/timeout 30 sh -c 'until curl -fsS http://localhost:8080/health; do sleep 0.5; done'

Скрипт деплоя: цикл с потолком вместо sleep 10.

timeout 30 sh -c 'until curl -fsS http://localhost:8080/health >/dev/null; do sleep 0.5; done' \
  || { echo "api не поднялся за 30 с" >&2; exit 1; }

Вместо вывода

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

А какую ошибку из «обычного шума» вы последний раз приняли всерьёз? Пишите в комментариях.

Комментарии (2)


  1. alekssamos
    27.10.2026 20:11

    Вот здесь прям реально статья писалась черезискусственный интеллект. Обычно такие статьи Я воспринимаю нормально, но здесь всё-таки хотелось бы чтобы человек объяснил своими словами. Хотя, если человек – вайб-кодер, то и статью он тоже на вайб-кодил.

    Что за система, что за сервер, что за сайт? Никакого введения я не получил. То есть одно дело статья про Линукс, nginx Или с массовой пользовательской проблемой. А тут как будто просто вывалили кусок какого-то проекта. Зачем, кому оно нужно? Как оно мне может быть полезно? Осталось недосказанным


    1. verin_is Автор
      27.10.2026 20:11

      Добрый вечер! Все статьи пишутся постепенно, описывая проблемы с которыми я столкнулся делая свой сервис. Я во всех постах оставляю ссылку на самый первый пост, в котором описано что там, как утроено и что происходит, а последующие, повторюсь, все то, с чем столкнулся.