Ситуация знакома администратору любого уровня: открытая SSH-сессия до боевого сервера молча застыла, курсор не мигает, ввод не откликается, а ошибки "Connection reset" при этом нет. Сессия не разорвана формально, но и жизни в ней нет. Причина почти всегда одна: между клиентом и сервером есть промежуточное оборудование, будь то домашний роутер, корпоративный файрвол или NAT-провайдер, которое молча выбросило запись о трансляции из своих таблиц. Пакеты от клиента уходят в пустоту, а обе стороны искренне считают соединение живым. Лечится это не одним переключателем, а планомерной настройкой механизмов keepalive на трёх уровнях: TCP-стек ядра, протокол SSH и внешние инструменты вроде Mosh и tmux. Ниже разобраны реальные диагностические шаги, конфигурация ~/.ssh/config и sshd_config, роль таймаутов NAT и таблицы conntrack, а также практический план действий, который выводит зависшие сессии в прошлое.
Почему соединение замирает и как найти виновника в цепочке NAT
Классическое TCP-соединение не имеет периодического трафика. Если пользователь не печатает, по каналу не идёт ничего вообще. Для NAT-устройства это сигнал: трансляция не нужна, запись можно удалить. Типичный таймаут неактивности у домашних роутеров составляет 60-300 секунд, у корпоративных файрволов значения часто ставят в районе 300-900 секунд. После удаления записи исходящие пакеты клиента либо дропаются молча, либо получают в ответ RST от какого-нибудь промежуточного хоста, а серверная сторона вообще ничего не замечает.
На Linux-маршрутизаторе запись NAT живёт в таблице conntrack. Посмотреть её состояние можно прямо на шлюзе:
# Показать активные трансляции для конкретного сервера по порту 22
conntrack -L -p tcp --dport 22 | head -n 5
Образец вывода:
tcp 6 431980 ESTABLISHED src=192.168.1.50 dst=203.0.113.10 sport=51522 dport=22 src=203.0.113.10 dst=91.198.174.5 sport=22 dport=51522 [ASSURED] mark=0 use=1
Разбор по полям: число 6 это номер протокола TCP, 431980 показывает оставшееся время жизни записи в секундах, то есть почти пять суток, и это стандартный таймаут для ESTABLISHED в ядре Linux. Флажок ASSURED означает, что трафик шёл в обе стороны, и такую запись conntrack удаляет в последнюю очередь при переполнении таблицы. Ключевое наблюдение: пока по соединению идёт хоть какой-то трафик, таймер 431980 каждый раз сбрасывается на полное значение. Вывод напрашивается сам: если заставить SSH генерировать маленькие пакеты чаще, чем самый агрессивный NAT на пути успевает забыть трансляцию, зависание исчезает.
Проверить лимиты conntrack на шлюзе тоже полезно, особенно если зависания носят массовый характер:
# Сколько записей сейчас и каков потолок таблицы
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
# Таймаут для установленных TCP-соединений в секундах
sysctl net.netfilter.nf_conntrack_tcp_timeout_established
Типичный вывод "net.netfilter.nf_conntrack_count = 48213" при "nf_conntrack_max = 262144" говорит, что таблица не переполнена. Если же count подбирается к max, ядро начинает вытеснять записи, и в dmesg появляется строка "nf_conntrack: table full, dropping packet". Это отдельная болезнь больших шлюзов, и лечится она увеличением лимита через /etc/sysctl.conf плюс уменьшением таймаута established с 432000 секунд до чего-то разумного вроде 7200.
Разница между TCPKeepAlive и штатными keepalive-сообщениями протокола SSH
В sshd_config есть директива TCPKeepAlive, и многие путают её с ClientAliveInterval, хотя механизмы принципиально разные. TCPKeepAlive включает стандартный SO_KEEPALIVE сокета: ядро само шлёт пустые TCP-сегменты. Беда в том, что таймеры тут задаются глобально через sysctl, а значения по умолчанию негуманны:
# Три параметра TCP keepalive в ядре
sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
Вывод на типичной системе: "7200 75 9". Перевод: первый keepalive-пакет уйдёт только через два часа тишины, затем девять попыток с интервалом 75 секунд. За два часа любой NAT давно забудет про трансляцию, поэтому TCPKeepAlive как средство от зависания практически бесполезен. Хуже того, TCP-keepalive не защищён шифрованием и теоретически подделывается, поэтому OpenSSH предпочитает собственный механизм внутри шифрованного канала.
Директивы ClientAliveInterval и ServerAliveInterval работают на уровне протокола SSH. Сервер или клиент посылает через зашифрованный канал запрос, это сообщение уровня протокола с именем "
Настройка клиентской стороны через файл ssh config
На клиенте настройка лежит в ~/.ssh/config. Рабочий фрагмент выглядит так:
Host *
# Слать keepalive-запрос каждые 30 секунд молчания
ServerAliveInterval 30
# После трёх запросов без ответа считать сервер недоступным
ServerAliveCountMax 3
Логика тут такая: клиент отсчитывает 30 секунд отсутствия любого трафика в канале, затем отправляет запрос. Если ответа нет, счётчик увеличивается. Три подряд пропущенных ответа, то есть максимум около 90 секунд реальной потери связи, и клиент сам рвёт сессию с сообщением "Timeout, server not responding". Это важный психологический момент: сессия перестаёт висеть бесконечно, она честно завершается с диагнозом, и пользователь сразу переподключается вместо созерцания окаменевшего терминала.
Интервал 30 секунд выбран не с потолка. Самый нервный домашний роутер забывает трансляцию примерно за 60 секунд тишины, значит пакет раз в 30 секунд гарантированно освежает запись. Ставить ServerAliveInterval 5 ради понта не стоит: лишний шум в логах и рост счётчиков, а выигрыша почти нет. Для отдельных капризных серверов блок Host можно сузить:
Host legacy-db.example.com
# Особо ретивый файрвол перед этим хостом, чаще опрашиваем
ServerAliveInterval 15
ServerAliveCountMax 4
Проверить, что опции реально применились к соединению, помогает пробный прогон с отладкой:
# Печать итоговой вычисленной конфигурации без установки соединения
ssh -G legacy-db.example.com | grep -iE 'serveralive'
Команда "ssh -G" выдаёт эффективные значения всех опций. В ответ клиент напечатает "serveraliveinterval 15" и "serveralivecountmax 4", что подтверждает: наследование из блока звёздочки и точечный блок отработали правильно, ведь ssh применяет первое найденное значение, читая файл сверху вниз.
Настройка серверной стороны в sshd config и проверка службы
Симметричная настройка на сервере живёт в /etc/ssh/sshd_config:
# Сервер спрашивает клиента каждые 60 секунд молчания канала
ClientAliveInterval 60
# Три молчаливых ответа подряд, и сессия закрывается
ClientAliveCountMax 3
# TCP-уровень оставляем включённым как дополнительный детектор обрыва
TCPKeepAlive yes
Зачем keepalive с двух сторон сразу? Клиентский ServerAliveInterval спасает от забывчивого NAT рядом с клиентом, а серверный ClientAliveInterval подметает сирот: если ноутбук пользователя упал в сон или ушёл из зоны Wi-Fi, сервер через 60x3 секунды закроет сессию, освободит pty и не даст копиться сотням процессов sshd на неактивные вкладки. Администраторы многих площадок замечали, что без ClientAliveInterval на сервере за месяц накапливаются сотни "вечных" сессий от уволенных стажёров и забытых скриптов.
После правки обязательны два шага:
# Синтаксическая проверка конфигурации до перезагрузки
sshd -t
# Расширенная проверка с печатью итоговых значений
sshd -T | grep -iE 'clientalive|tcpkeepalive'
Первая команда молча отрабатывает при корректном файле и ругается с номером строки при опечатке. Вторая покажет "clientaliveinterval 60", "clientalivecountmax 3", "tcpkeepalive yes" уже в каноническом виде. Только после этого:
# Мягкая перезагрузка без разрыва текущих сессий
systemctl reload sshd
Reload, а не restart, сознательно: действующие соединения не пострадают. Ещё один нюанс из практики: если сервер отвечает через jump-хост или Ansible-сессии массово подвисают, проверьте директиву AllowTcpForwarding и общий лимит MaxStartups, потому что зависание иногда маскируется под keepalive-проблему, а на деле это исчерпание пула полуоткрытых соединений при старте.
Приём экстренного выхода и диагностика зависшей сессии на лету
Даже идеально настроенный клиент иногда застревает, например при смене сети с кабеля на мобильный интернет. Вместо убийства окна терминала есть штатная escape-последовательность OpenSSH. Последовательность действий администратора в такой момент:
- Нажать Enter, чтобы перевести строку, escape-символ тильда распознаётся только в начале строки;
- Ввести тильду и точку, то есть "~.", это немедленно разрывает сессию со стороны клиента;
- При сомнениях набрать "~?", клиент напечатает справку по всем escape-командам, включая "~#" со списком проброшенных соединений;
- Если сессия всё-таки отвечает, проверить путь и ретрансмиты командой ss из соседнего терминала.
Диагностика TCP-состояния на клиенте показывает, живо ли соединение по мнению ядра:
# Состояние сокета SSH-сессии и статистика ретрансмитов
ss -tin dst 203.0.113.10
Образец вывода:
ESTAB 0 36 192.168.1.50:51522 203.0.113.10:22
cubic wscale:7,7 rto:232 rtt:31.5/4.2 ato:40 mss:1448 cwnd:10 ssthresh:7 bytes_sent:18204 bytes_acked:17868 segs_out:312 segs_in:298 send 3.7Mbps lastsnd:120 lastrcv:120 lastack:120 rcv_space:62780
Ключевые поля: "36" в колонке Send-Q это неподтверждённые байты в буфере отправки, их рост при молчащей сессии показывает, что пакеты уходят и не возвращаются. Значение rtt 31.5 мс говорит о нормальной задержке. Если в выводе виден счётчик "retrans" с нарастающими числами при полном молчании канала, диагноз ясен: соединение потеряно физически, а зависший терминал просто ещё не дождался своего таймаута. Значительный объём неподтверждённых данных плюс растущее rto это классическая картина потерянного NAT посередине.
Mosh и tmux как запасной контур при нестабильных каналах
Когда канал гарантированно плохой, например работа с серверами через роуминг или LTE с постоянной сменой адреса, keepalive лишь отсрочивает неизбежное. Тут в игру вступает Mosh: он ходит по UDP, привязан к сессии, а не к адресу клиента, и переживает смену сети вообще без разрыва. Установка тривиальна:
# На сервере и клиенте ставим пакет
apt-get install mosh
# Подключение вместо ssh, диапазон UDP 60000-61000 должен быть открыт
mosh Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.
Mosh рисует локальное эхо набора с предсказанием ответа, поэтому даже на спутниковом канале с задержкой в 600 мс печатать комфортно. Минус честный: проброс портов и SSH-агента Mosh не поддерживает, так что для scp и git всё равно нужен классический ssh.
Второй контур это tmux на серверной стороне. Даже если клиент отвалился без предупреждения, все процессы, мониторинги и редакторы продолжают жить внутри tmux, и после переподключения достаточно одной команды:
# Подключиться обратно к существующей сессии с именем work или создать её
ssh -t Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. 'tmux attach -t work || tmux new -s work'
Опция -t выделяет псевдотерминал, иначе tmux не запустится в интерактивном режиме. Связка "ssh плюс tmux на сервере" вместе с "ServerAliveInterval 30 на клиенте" закрывает почти все бытовые зависания. В параноидальных сценариях добавляют autossh для автоматического переподключения туннелей: утилита сама следит за keepalive и поднимает упавший ssh без участия человека.
Точных цифр для всех сетей не существует, но рабочая отправная точка, проверенная на сотнях серверов, такова: ServerAliveInterval 30 и ServerAliveCountMax 3 на клиенте, ClientAliveInterval 60 и ClientAliveCountMax 3 на сервере, плюс tmux для длительных задач и Mosh для мобильной работы. Зависшая сессия перестаёт быть загадкой и превращается в штатное событие с понятным лечением, а терминал вместо окаменения честно сообщает о таймауте и предлагает войти снова. Именно такое поведение и отличает спокойную инфраструктуру от той, где администраторы привыкли просто закрывать окна и разводить руками.