Инцидент, который стоил 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: некритичные сервисы (логирование, аналитика) — 12 инстансов
- Неделя 2: бэкенд-сервисы (каталог, рекомендации) — 24 инстанса
- Неделя 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
Оставить комментарий