TCP_USER_TIMEOUT: как одна опция стека устранила даунтайм СтримМедиа

Инцидент, который стоил 4 часов даунтайма

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

Сценарий повторялся стабильно: при падении одного из узлов кластера (аппаратный сбой, kernel panic, потеря сети) все сервисы, обращавшиеся к упавшему узлу, зависали на 15 и более минут. За это время исчерпывались пулы воркеров, накапливались очереди, и в итоге падал весь видеоконвейер — от транскодирования до стриминга.

Хронология типичного инцидента:

  • 00:00 — Узел с сервисом A теряет питание. Нет FIN, нет RST — TCP-соединения остаются «полуоткрытыми»
  • 00:01 — Сервисы B, C, D продолжают отправлять gRPC-запросы в «чёрную дыру»
  • 00:05 — Пул воркеров сервиса B исчерпан, входящие запросы начинают отклоняться
  • 00:10 — Каскадный отказ: сервисы E и F, зависящие от B, тоже деградируют
  • 00:15 — Полная недоступность платформы
  • 00:30-04:00 — Восстановление: ручной рестарт всех зависших сервисов

Наша команда из 2 сетевых инженеров получила задачу: найти корневую причину и исключить повторение. Бюджет проекта — 3 недели.

Корневая причина: TCP retransmission timeout по умолчанию

Мы провели анализ сетевого стека Linux на узлах кластера и обнаружили проблему в поведении TCP по умолчанию.

Когда удалённый узел «умирает» без отправки FIN-пакета (что происходит при аппаратном сбое, kernel panic или обрыве сети), TCP-соединение остаётся в состоянии half-open. Клиент, пытающийся отправить данные, запускает механизм ретрансмиссии.

Как работает ретрансмиссия по умолчанию

При параметре net.ipv4.tcp_retries2 = 15 (значение по умолчанию в Linux) ядро выполняет 15 попыток ретрансмиссии с экспоненциально растущими интервалами:

# Интервалы ретрансмиссии (приблизительно):
# Попытка 1:  0.2 с  (RTT-based)
# Попытка 2:  0.4 с
# Попытка 3:  0.8 с
# Попытка 4:  1.6 с
# Попытка 5:  3.2 с
# Попытка 6:  6.4 с
# Попытка 7:  12.8 с
# Попытка 8:  25.6 с
# Попытка 9:  51.2 с
# Попытка 10: 102.4 с
# ... и так далее до попытки 15
# Суммарное время ожидания: ~15 минут

Это означает, что при отказе удалённого узла приложение будет ждать до 15 минут, прежде чем TCP-стек закроет соединение с ошибкой ETIMEDOUT. Всё это время воркер заблокирован.

Почему conntrack усугубляет проблему

В Kubernetes ситуация дополнительно усложнялась поведением iptables и conntrack. При отказе узла conntrack-таблица очищалась, и ядро начинало отбрасывать пакеты с состоянием «invalid». Это блокировало даже RST-пакеты, которые в нормальных условиях мгновенно закрыли бы соединение:

# Проверка conntrack rules на узле:
iptables -L -v -n | grep -i invalid
# Chain KUBE-FORWARD
# DROP  all  --  *  *  0.0.0.0/0  0.0.0.0/0  ctstate INVALID

Таким образом, даже после перезапуска упавшего узла, он не мог отправить RST на уже установленные соединения.

Решение: TCP_USER_TIMEOUT

После анализа альтернатив мы выбрали TCP_USER_TIMEOUT — опцию сокета, доступную начиная с Linux 2.6.37 (RFC 5482). Эта опция ограничивает общее время, в течение которого TCP-стек пытается ретрансмиссию, прежде чем закрыть соединение.

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

Программная настройка (Go)

Для gRPC-сервисов СтримМедиа (написаны на Go) мы реализовали установку TCP_USER_TIMEOUT через syscall:

package tcputil

import (
    "fmt"
    "net"
    "syscall"
    "time"

    "golang.org/x/sys/unix"
)

// SetTCPUserTimeout устанавливает максимальное время ожидания
// подтверждения отправленных данных перед закрытием соединения.
func SetTCPUserTimeout(conn net.Conn, timeout time.Duration) error {
    tcpconn, ok := conn.(*net.TCPConn)
    if !ok {
        return fmt.Errorf("not a TCP connection")
    }
    rawConn, err := tcpconn.SyscallConn()
    if err != nil {
        return fmt.Errorf("error getting raw connection: %v", err)
    }
    var setsockoptErr error
    err = rawConn.Control(func(fd uintptr) {
        setsockoptErr = syscall.SetsockoptInt(
            int(fd),
            syscall.IPPROTO_TCP,
            unix.TCP_USER_TIMEOUT,
            int(timeout/time.Millisecond),
        )
    })
    if err != nil {
        return err
    }
    return setsockoptErr
}

// Использование с gRPC:
// conn, _ := grpc.Dial(addr, grpc.WithKeepaliveParams(...))
// SetTCPUserTimeout(conn, 30*time.Second)

Настройка через Service Mesh

Для сервисов за Envoy proxy (используемого в их Service Mesh) мы настроили TCP_USER_TIMEOUT через socket options в конфигурации кластера:

# Envoy cluster configuration с TCP_USER_TIMEOUT
clusters:
  - name: upstream_service
    connect_timeout: 5s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    upstream_connection_options:
      tcp_keepalive:
        keepalive_probes: 3
        keepalive_time: 10
        keepalive_interval: 5
    transport_socket_matches:
      - name: tcp_user_timeout
        transport_socket:
          name: raw_buffer
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.transport_sockets.raw_buffer.v3.RawBuffer
    # Socket option: TCP_USER_TIMEOUT = 30000ms
    # Опция 18 (TCP_USER_TIMEOUT) на уровне IPPROTO_TCP (6)

Для Linkerd поддержка TCP_USER_TIMEOUT была добавлена в edge-24.10.1 через PR #13024 и #3174.

Системный fallback через sysctl

Как дополнительный уровень защиты мы уменьшили tcp_retries2 на всех узлах кластера:

# Уменьшение максимального числа ретрансмиссий (системно)
# Было: 15 (~15 минут ожидания)
# Стало: 7 (~25 секунд ожидания)
sysctl -w net.ipv4.tcp_retries2=7

# Для сохранения после перезагрузки:
echo "net.ipv4.tcp_retries2 = 7" >> /etc/sysctl.d/99-tcp-tuning.conf
sysctl -p /etc/sysctl.d/99-tcp-tuning.conf

Важное ограничение: sysctl применяется глобально — ко всем TCP-соединениям на узле. Для внутрикластерного трафика 7 ретрансмиссий — оптимально, но для внешних соединений через нестабильные каналы может быть слишком агрессивно. Поэтому мы рекомендуем комбинировать sysctl с per-socket TCP_USER_TIMEOUT.

Почему мы отвергли альтернативные решения

Перед выбором TCP_USER_TIMEOUT мы рассмотрели и протестировали несколько альтернатив:

РешениеПроблемаПочему не подошло
gRPC Keepalive (HTTP/2 PING)Каскадная конфигурацияТребует согласованной настройки EnforcementPolicy на всех серверах до включения на клиентах. При рассогласовании — гарантированный сбой. 47 сервисов, 120 инстансов — слишком высокий риск
Application-level timeoutsУниверсальный таймаут не существуетТранскодирование видео: запрос длится 30-300 секунд. Стриминг: часы. API метаданных: 50 мс. Единый таймаут наказывает легитимные медленные операции
TCP Keepalive (SO_KEEPALIVE)Медленное обнаружениеСтандартная конфигурация: probe через 2 часа. Даже с агрессивными параметрами (idle=10s, interval=5s, count=3) — 25 секунд обнаружения, но работает только для idle-соединений, не помогает при активной отправке данных
Уменьшение tcp_retries2Глобальный параметрИспользуем как дополнительный слой, но не как основное решение

Оптимальные параметры для production

На основе тестирования на стенде СтримМедиа (16 узлов, ~50 сервисов) мы определили оптимальные параметры для разных сценариев:

# Внутрикластерный трафик (gRPC между сервисами)
TCP_USER_TIMEOUT = 30000  # 30 секунд
# Обоснование: RTT внутри кластера < 1 мс, 
# 30 секунд — достаточно для нескольких ретрансмиссий,
# но быстрее 15-минутного дефолта в 30 раз

# TCP keepalive (для idle-соединений)
TCP_KEEPIDLE = 10     # Первый probe через 10 секунд idle
TCP_KEEPINTVL = 5     # Интервал между probes: 5 секунд
TCP_KEEPCNT = 3       # Максимум 3 probe
# Итого: idle-соединение закрывается через 25 секунд

# Системный fallback
net.ipv4.tcp_retries2 = 7  # ~25 секунд для активных соединений

Комбинация TCP_USER_TIMEOUT (для активных соединений) и TCP Keepalive (для idle-соединений) обеспечивает обнаружение мёртвого узла за 25-30 секунд в любом сценарии.

Внедрение и тестирование

Мы внедряли решение в три этапа:

Этап 1: стендовое тестирование

На тестовом кластере из 4 узлов воспроизвели сценарий инцидента:

# Имитация аппаратного сбоя (kill -9 без graceful shutdown)
# На узле с сервисом A:
iptables -A OUTPUT -p tcp --sport 8443 -j DROP
iptables -A INPUT -p tcp --dport 8443 -j DROP

# Наблюдаем за клиентами сервиса A:
# Без TCP_USER_TIMEOUT: зависание 924 секунд (15.4 минуты)
# С TCP_USER_TIMEOUT=30s: ошибка через 31 секунду, переподключение к здоровой реплике

Этап 2: поэтапный rollout

Развернули изменения на production-кластере по группам:

  1. Неделя 1: некритичные сервисы (логирование, аналитика) — 12 инстансов
  2. Неделя 2: бэкенд-сервисы (каталог, рекомендации) — 24 инстанса
  3. Неделя 3: критичные сервисы (транскодирование, стриминг) — 14 инстансов

Этап 3: chaos engineering

После полного rollout мы провели серию контролируемых экспериментов с отказами узлов. Результат: каскадного отказа не происходит. Сервисы переключаются на здоровые реплики за 30-35 секунд, деградация заметна только для запросов, «застрявших» в момент отказа.

Результаты проекта

За 3 месяца после внедрения на платформе СтримМедиа произошло 4 инцидента с аппаратными отказами узлов. Ни один из них не привёл к каскадному отказу:

МетрикаДо TCP_USER_TIMEOUTПосле
Время обнаружения мёртвого узла~15 минут~30 секунд
Каскадный отказ при падении узла100% случаев0%
Время полного восстановления30-240 минут30-45 секунд
Потеря запросов при инцидентеТысячиЕдиницы (в момент отказа)
Необходимость ручного вмешательстваВсегдаНет

Суммарная экономия от предотвращения даунтайма за 3 месяца составила ~12 млн рублей (расчёт по среднему RPM платформы). Подробнее о настройке сетевого стека для высоконагруженных систем — на itfresh.ru.

Рекомендации по внедрению

На основе опыта с СтримМедиа и тремя другими клиентами мы сформировали следующие рекомендации:

  • Начните с sysctl: уменьшите tcp_retries2 с 15 до 7 на всех узлах кластера. Это самый быстрый и безопасный первый шаг
  • Добавьте per-socket TCP_USER_TIMEOUT для критичных сервисов. 30 секунд — разумное значение для внутрикластерного трафика
  • Не забывайте про idle-соединения: TCP_USER_TIMEOUT работает только для активных соединений. Для idle нужен TCP Keepalive
  • Используйте Service Mesh: Envoy и Linkerd позволяют настроить TCP_USER_TIMEOUT централизованно, без изменения кода приложений
  • Тестируйте с chaos engineering: после внедрения убедитесь, что система ведёт себя корректно при имитации отказов
# Минимальный набор sysctl для Kubernetes-узла
# /etc/sysctl.d/99-tcp-hardening.conf

# Сокращение ретрансмиссий (дефолт 15 → 7)
net.ipv4.tcp_retries2 = 7

# Ускорение определения мёртвых соединений
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 5

# Увеличение размера conntrack таблицы
net.nf_conntrack_max = 1048576

Часто задаваемые вопросы

TCP Keepalive отправляет пустые probe-пакеты для обнаружения мёртвых idle-соединений (когда данные не передаются). TCP_USER_TIMEOUT ограничивает время ожидания подтверждения для уже отправленных данных. Если приложение активно отправляет запросы в мёртвое соединение, Keepalive не поможет — сработает только TCP_USER_TIMEOUT. Для полной защиты нужны оба параметра.
Application-level timeout задаёт максимальное время ожидания ответа от сервера. Проблема в том, что разные типы запросов имеют разное нормальное время выполнения: API-запрос — 50 мс, транскодирование видео — 5 минут. Универсальный таймаут либо слишком короткий (ложные срабатывания), либо слишком длинный (медленное обнаружение отказа). TCP_USER_TIMEOUT работает на уровне ниже и не зависит от семантики запроса.
Значение 7 (вместо 15 по умолчанию) означает, что TCP будет пытаться ретрансмиссию примерно 25 секунд вместо 15 минут. Для внутрикластерного трафика с RTT < 1 мс это более чем достаточно. Для внешних соединений через нестабильные каналы рекомендуется использовать per-socket TCP_USER_TIMEOUT вместо системного sysctl.
Да, TCP_USER_TIMEOUT работает на уровне TCP-стека ядра Linux, ниже HTTP/2 и gRPC. Он применяется к любому TCP-соединению независимо от прикладного протокола. Для gRPC на Go можно установить его через SyscallConn().Control(), на Java — через ExtendedSocketOptions.TCP_USER_TIMEOUT.
Conntrack отслеживает состояние TCP-соединений для NAT. Когда узел падает, его conntrack-таблица очищается. Входящие пакеты от клиентов получают статус INVALID и отбрасываются iptables. TCP_USER_TIMEOUT позволяет клиенту быстро обнаружить эту ситуацию (отсутствие ACK на ретрансмиссии) и закрыть соединение, не дожидаясь 15 минут.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#TCP_USER_TIMEOUT#tcp tuning linux#tcp_retries2#half-open connection#gRPC keepalive#сетевой стек linux#отказоустойчивость сервисов#downtime prevention
Комментарии 0

Оставить комментарий

загрузка...