Читать 6 мин

Отключение SSH и неизвестное удалённое состояние: как безопасно проверить результат

Как безопасно определить состояние удалённой SSH-команды после разрыва: постоянный ID запуска, запись статуса, проверка постусловий и правильные решения о повторе.

Отключение SSH и неизвестное удалённое состояние: как безопасно проверить результат

Отключение SSH не означает, что удалённая команда завершилась с ошибкой. Это означает, что клиент потерял данные, по которым можно было бы понять результат. Такая разница раздражает, когда вы запускали uname, и дорого обходится, когда выполняли миграцию базы данных, развёртывание релиза, замену учётных данных или команду, обращающуюся к внешнему API.

Решение не в увеличении тайм-аута. Нужна последовательность удалённой проверки с постоянным идентификатором запуска, явными переходами состояний и проверкой завершения, подходящей конкретному эффекту. Тогда после переподключения вопрос меняется с «Запускать ли это снова?» на «Что записано в журнале этого запуска и что он изменил?»

После разрыва соединения остаются три честных варианта

После сообщения SSH-клиента о сбросе, тайм-ауте, разрыве канала или неожиданном EOF у команды может быть одно из трёх состояний: она не запускалась, запустилась и ещё выполняется или завершилась. Код завершения на стороне клиента не позволяет надёжно различить эти варианты.

Между вашей оболочкой и удалённой программой есть несколько границ:

  • Локальная оболочка запускает ssh.
  • Клиент отправляет запрос канала SSH и байты команды.
  • Сервер принимает запрос и запускает удалённую оболочку или программу.
  • Эта программа выполняет фактическую работу.
  • Программа завершается, после чего sshd отправляет обратно вывод и статус завершения.

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

Поэтому локальное сообщение вроде Connection reset by peer говорит о состоянии транспорта, а не о бизнес-результате. Оно означает, что клиент не смог завершить диалог SSH. Оно не показывает, выполнялась ли удалённая операция.

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

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

Доставка команды и её завершение, это разные утверждения

Для удалённой команды стоит подтвердить как минимум четыре факта: отправку, запуск, завершение и эффект. Команды часто записывают только один из них и считают, что получили все четыре.

Отправка означает, что клиент попытался передать команду. Локальный терминал это знает, но такое утверждение слабее всего. Запуск означает, что удалённая обёртка создала постоянное свидетельство до начала работы. Завершение означает, что обёртка записала итог. Эффект означает, что нужное удалённое или внешнее состояние теперь соответствует запросу.

Список процессов доказывает меньше, чем кажется. PID может показать, что сейчас существует процесс с похожим именем. Он не доказывает, что процесс относится к вашему запросу, что важная часть ещё не была выполнена или что повторный запуск безопасен. После завершения процесса повторное использование PID делает старые записи ещё менее полезными.

У кодов выхода есть похожее ограничение. POSIX определяет wait как способ получить статус дочернего процесса, о котором знает оболочка. Эта связь существует внутри удалённой оболочки. Когда SSH-соединение исчезает, локальная оболочка теряет путь к этому статусу. Поздний SSH-сеанс не может восстановить его через wait, он должен прочитать запись, сохранённую первым запуском.

Разделяйте эти утверждения в инструкциях и выводе автоматизации:

  1. «Клиент не смог подтвердить завершение».
  2. «Запуск r-20260722-1842-a91f начался на удалённом хосте».
  3. «Этот запуск записал код выхода 0».
  4. «Маркер развёртывания сообщает о релизе 2026.07.22.3».

Четвёртое утверждение может быть единственным, которое отвечает на настоящий операционный вопрос. Для копирования файла нужна контрольная сумма или конечный путь с ожидаемым содержимым. Для миграции нужна версия схемы или запись в журнале миграций. Для запроса к платёжному или тикетинговому API нужна запись об идемпотентности на стороне API, а не просто строка в локальном журнале.

Сначала создайте идентификатор запуска на удалённом хосте

Постоянный идентификатор запуска превращает расплывчатую работу после переподключения в обычный поиск. Создайте его до вызова SSH, передайте удалённой обёртке и храните все артефакты в пути, построенном на его основе.

Не используйте только время. Два агента могут стартовать в одну секунду, часы могут расходиться, а временные метки плохо подходят для непрозрачных идентификаторов. Объедините временную метку со случайными данными или используйте доступный в вашей среде генератор UUID. Идентификатор нужно снова передавать при проверке, и он должен присутствовать в каждой значимой записи журнала.

Этот фрагмент оболочки создаёт каталог запуска, записывает запрошенную операцию, ставит маркер начала и сохраняет оба потока вывода. Он ожидает команду после --. Держите обёртку в контролируемом месте, например /usr/local/sbin/run-recorded, а не вставляйте её каждый раз в строку команды.

#!/bin/sh
set -eu

run_id=$1
shift
[ "$1" = "--" ]
shift

base=/var/lib/recorded-runs
run_dir="$base/$run_id"

case "$run_id" in
  *[!A-Za-z0-9._-]*|'')
    printf '%s\n' "invalid run id" >&2
    exit 64
    ;;
esac

if ! mkdir "$run_dir" 2>/dev/null; then
  printf '%s\n' "run already exists: $run_id" >&2
  exit 75
fi

umask 077
printf '%s\n' "$*" > "$run_dir/request"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/started_at"
printf '%s\n' "started" > "$run_dir/state"
printf '%s\n' "$$" > "$run_dir/pid"

set +e
"$@" >"$run_dir/stdout" 2>"$run_dir/stderr"
status=$?
set -e

printf '%s\n' "$status" > "$run_dir/exit_status"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/finished_at"
printf '%s\n' "finished" > "$run_dir/state"
exit "$status"

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

Порядок действий важен. Обёртка записывает started_at, state и pid до запуска полезной нагрузки. Она записывает exit_status до перевода состояния в finished. Если проверяющий видит finished без кода выхода, запись нужно считать повреждённой, а не успешной. Каталог запуска без started_at означает неполную ошибку настройки.

Не записывайте finished через trap и не считайте задачу решённой. Внезапный сбой хоста, проблема с хранилищем, принудительное завершение или ошибка файловой системы могут не дать trap сработать. Финальный маркер служит свидетельством, когда он есть, но его отсутствие не доказывает, что полезная нагрузка не завершилась.

Проверяйте запуск в порядке, который не вводит вас в заблуждение

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

Хорошая проверка должна классифицировать запись как absent, running, finished или damaged. Этот пример использует описанный формат каталогов и выводит факты, которые может оценить человек или агент.

#!/bin/sh
set -eu

run_id=$1
run_dir="/var/lib/recorded-runs/$run_id"

if [ ! -d "$run_dir" ]; then
  printf '%s\n' 'state=absent'
  exit 0
fi

if [ ! -f "$run_dir/started_at" ]; then
  printf '%s\n' 'state=damaged reason=missing-start-marker'
  exit 2
fi

if [ -f "$run_dir/finished_at" ] && [ -f "$run_dir/exit_status" ]; then
  printf '%s\n' 'state=finished'
  printf 'exit_status=%s\n' "$(cat "$run_dir/exit_status")"
  printf 'started_at=%s\n' "$(cat "$run_dir/started_at")"
  printf 'finished_at=%s\n' "$(cat "$run_dir/finished_at")"
  exit 0
fi

if [ -f "$run_dir/pid" ]; then
  pid=$(cat "$run_dir/pid")
  if kill -0 "$pid" 2>/dev/null; then
    printf 'state=running pid=%s\n' "$pid"
    exit 0
  fi
fi

printf '%s\n' 'state=damaged reason=no-finish-record-and-pid-not-live'
exit 2

Запустите её новой SSH-командой:

ssh ops@host /usr/local/sbin/check-recorded-run r-20260722-1842-a91f

Вывод должен соответствовать одному из вариантов:

state=absent
state=running pid=48192
state=finished
exit_status=0
started_at=2026-07-22T18:42:19Z
finished_at=2026-07-22T18:47:03Z

Состояние damaged должно быть частью протокола. Если его не предусмотреть, проверка превратит отсутствие данных в бодрую догадку. Если хост перезагрузился во время выполнения, kill -0 завершится ошибкой, а финального маркера не будет. Правильный ответ, проверить целевой эффект и журналы приложения, а затем решить, нужна ли сверка состояния.

Не используйте в проверяющей программе ps | grep. Она найдёт посторонние процессы, имена команд меняются, а форматы вывода различаются. kill -0 лишь показывает, жив ли процесс с записанным PID. Это не доказательство завершения, поэтому сначала нужно проверять финальные артефакты, а уже затем PID.

Даже финальный код выхода не всегда подтверждает нужный эффект

Храните оба уровня доказательств
Записывайте запуск агента и каждый SSH-вызов в отдельных журналах, объединённых одним зашифрованным журналом аудита.

Финальная запись обёртки доказывает, что наблюдала обёртка, но не обязательно то, что принял внешний мир. Особенно заметно это у команд, отправляющих запросы.

Представьте удалённый скрипт, который создаёт DNS-запись через API, а затем записывает exit_status=0. API мог сообщить об успехе до того, как резолвер увидел новую запись. Скрипт развёртывания мог успешно завершиться после отправки запроса на раскатку, которая позже не прошла проверки работоспособности. Инструмент базы данных мог успешно подключиться, пока один оператор многоэтапной процедуры зафиксировал изменения, а следующий завершился ошибкой.

Для каждой операции нужна постпроверка, соответствующая её эффекту. Её должно быть безопасно выполнять многократно, а результат должен быть достаточно точным, чтобы отсеять старый или посторонний результат.

Для релиза запишите идентификатор запуска в манифест и запросите активную версию у сервиса. Для изменения базы данных проверьте таблицу миграций по идентификатору миграции и её контрольной сумме. Для созданного артефакта сравните заранее рассчитанный SHA-256 после размещения файла по конечному пути. Для API-запроса передайте токен идемпотентности поставщика, если он поддерживается, а затем найдите созданный ресурс по этому токену или сохранённому идентификатору запроса.

Хуже всего скрипт, который печатает «готово» сразу после отправки запроса и принимает это слово за доказательство. Файл stdout показывает, что напечатал один процесс. Постпроверка показывает, что теперь содержит система.

Это различие также показывает, когда операцию нельзя безопасно автоматизировать одним SSH. Если удалённая команда обращается к стороннему сервису без идемпотентности и без возможности найти предыдущий запрос, прерванный вызов может быть невозможно классифицировать. Нужны ручное подтверждение или компенсирующий процесс. Дополнительные повторы не создадут отсутствующие доказательства.

Идемпотентность лучше показного восстановления

Протокол проверки уменьшает неопределённость. Идемпотентный дизайн команд уменьшает цену этой неопределённости. Нужны оба подхода.

Идемпотентная операция приводит к одному и тому же желаемому состоянию при повторном применении того же запроса. mkdir -p /srv/app/cache близок к этой модели. useradd deploy идемпотентным не является, если скрипт сначала не проверяет свойства существующей учётной записи. curl -X POST /orders тоже не идемпотентен, если сервис не понимает токен идемпотентности и не считает повтор с тем же токеном тем же запросом.

Не путайте идемпотентность с предположением, что «при втором запуске команда, скорее всего, ничего не сделает». Развёртывание может дважды одинаково перезаписать файл, но дважды вызвать перезапуск. Инструмент миграции может знать свою историю, но всё равно выполнить опасную инициализацию до проверки. Изучите поведение команды и протестируйте прерывание.

Стройте запросы вокруг стабильного идентификатора операции. Передавайте один и тот же ID удалённой обёртке и, когда возможно, целевой системе. Обёртка релиза может создавать /var/lib/recorded-runs/$run_id/effect только после того, как конечная точка активного релиза сообщит о запрошенной версии. Вызов API подготовки ресурсов может использовать run_id как значение идемпотентности. Тогда после сбоя SSH можно запросить состояние одной и той же работы в обеих системах.

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

Фоновый запуск переносит проблему в другое место

Быстро отзывать запуск
Мгновенно отзывайте запуск агента, когда его SSH-действия больше не должны иметь доступ.

nohup, &, disown, tmux, screen и менеджеры сервисов решают разные задачи. Ни один из них не превращает неопределённый удалённый запрос в проверенный результат.

nohup помогает процессу пережить сигнал разрыва в распространённых конфигурациях оболочки. Обычная команда nohup task & всё равно оставляет вам файлы вывода, PID и отсутствие структурированной записи завершения, если вы не добавите её сами. Появляется и новая неопределённость: успела ли удалённая оболочка запустить nohup или соединение оборвалось раньше?

tmux и screen сохраняют интерактивное окружение. Они удобны, когда оператору нужно переподключиться и вручную проверить долгую команду. Для автоматизации они неудобны: имена сеансов могут совпадать, история терминала не является схемой результата, а отсоединённый терминал не говорит другой системе, произошёл ли нужный эффект.

Менеджер сервисов лучше подходит, когда работа действительно является сервисом или очередной задачей. Например, удалённая команда может отправить именованный блок, а поздний запрос проверить его жизненный цикл и журналы. Используйте эту модель, если на хосте уже есть ответственный за задачи. Не подключайте менеджер сервисов к пятиминутной административной команде лишь для того, чтобы не написать небольшую запись запуска.

Разделение простое. Интерактивное исправление оставляйте в терминальном мультиплексоре. Плановые и долгоживущие задачи помещайте под управление сервисов. Для императивных команд, ответ на которые должен оставаться надёжным после переподключения, используйте обёртку с записью результата.

Keepalive сокращает ожидание, но не закрывает окно неопределённости

Keepalive клиента OpenSSH быстрее обнаруживает мёртвое соединение. Он не гарантирует, что команда не была принята до сбоя сетевого пути.

Для хостов, где зависший клиент напрасно тратит время, подойдёт такая настройка:

Host production-*
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive yes

ServerAliveInterval отправляет сообщения уровня приложения через зашифрованный канал SSH, когда данные не поступают. Если клиент не получает достаточно ответов, он завершает работу, вместо того чтобы ждать бесконечно. OpenSSH отдельно описывает этот механизм и TCP keepalive, который действует на транспортном уровне.

Используйте настройку, чтобы ограничить ожидание перед началом проверки. Не называйте её гарантией доставки команды. Разрыв всё ещё может произойти после принятия команды удалённым хостом и до получения клиентом результата.

К мультиплексированию относится то же предупреждение. ControlMaster и ControlPersist могут использовать существующее сетевое соединение для нескольких SSH-команд. Это уменьшает расходы на установку соединения, но сбой master-соединения может одновременно затронуть несколько вызовов. В руководстве OpenSSH сказано, что сохранённый master остаётся в фоне после завершения исходного клиента. Это удобно, но не добавляет доказательств завершения отправленной через него команды.

Для автоматизации задайте явный тайм-аут подключения, ограничения живости, подходящие среде, и независимый от исходного сеанса SSH путь проверки. Быстрый отказ полезен только тогда, когда следующим действием становится запрос статуса, а не слепой повтор.

Проверьте отказ до того, как он понадобится в два часа ночи

Скройте SSH-ключи от агентов
Направляйте SSH-команды агентов через Sallyport, а SSH-ключ храните в зашифрованном хранилище.

Протокол, который никогда не прерывали, остаётся наброском дизайна. Проверьте его на безопасной, но достаточно медленной операции, чтобы оборвать соединение на разных этапах.

Начните с полезной нагрузки, которая записывает пронумерованный файл прогресса, делает паузу между этапами и создаёт финальный маркер эффекта. Запустите её через обёртку. Завершите локальный SSH-клиент после появления удалённого маркера started_at, снова подключитесь и запустите проверку. Повторите тест, завершив удалённую полезную нагрузку до записи finished_at. Затем, если среда позволяет, смоделируйте перезапуск хоста.

Ожидаемые классификации должны быть явными:

  • До заявки обёртки на идентификатор запуск возвращает absent.
  • Во время выполнения полезной нагрузки проверка возвращает running.
  • После обычного завершения она возвращает finished с записанным статусом.
  • После принудительного прерывания или потери хоста она возвращает damaged, после чего выполняется проверка эффекта.

Проверьте и повторную отправку. Запустите один и тот же идентификатор почти одновременно дважды. Один вызов должен выиграть заявку на каталог, а другой вернуть однозначный результат дубликата, не запуская полезную нагрузку. Затем попробуйте два разных идентификатора, нацеленных на один ресурс. Если возникает гонка, обёртке нужен блокировщик конкретного ресурса или целевой системе нужно ограничение уникальности.

Храните записи запусков достаточно долго, чтобы покрыть рабочее окно повторных попыток. Если задачу можно повторить через день, а хост удаляет записи через час, вы встроили таймер в собственную неопределённость. Защитите записи и от случайного изменения. Учётная запись, проверяющая запуск, не должна иметь возможности редактировать exit_status или подменять журнал полезной нагрузки. В общих системах, если это допускает рабочая модель, разделите роли отправителя, исполнителя и читателя.

Когда AI-агенты вызывают SSH, изоляция учётных данных и проверка команд должны работать вместе. Sallyport хранит SSH-учётные данные в своём хранилище и может записать действие, запрошенное агентом, а удалённая обёртка даёт постоянный ответ о самой задаче. Запись действия показывает, какой процесс запросил команду, а удалённый ID запуска показывает, что произошло после того, как соединение стало неопределённым.

Безопасное решение о повторе имеет четыре результата

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

Если запуск absent, удалённая обёртка не создала запись. Можно отправить ту же операцию с тем же ID, если вы доверяете поведению «создать один раз». Если команда могла выполниться вне обёртки, сначала проверьте цель: обёртка не докажет, что происходило в обход неё.

Если запуск running, подождите или отмените его через специальный для операции путь управления. Не запускайте вторую копию. Политика тайм-аута должна находиться в полезной нагрузке или менеджере задач, а не во втором SSH-вызове, который соревнуется с первым.

Если запуск finished, оцените его код выхода, а затем проверьте постусловие, когда операция имеет значимые внешние эффекты. Нулевой код выхода вместе с неудачной постпроверкой означает неудачную операцию. Решающее значение имеет проверка эффекта.

Если запуск damaged, прекратите считать это проблемой повторной попытки. Это работа по сверке состояния. Проверьте журналы, записи журнала событий, состояние цели и запись идемпотентности. Решите, нужно ли исправить частичный эффект, отметить операцию как завершённую или создать новый запуск, явно учитывающий наблюдаемое состояние. Может потребоваться человек, потому что повреждённая запись убрала доказательство, на котором основана автоматизация.

Полезное изменение невелико: каждой команде, повтор которой может причинить вред, нужны ID запуска, удалённая запись начала, удалённая финальная запись и проверка состояния цели. SSH всё ещё будет отключаться. Но автоматизации больше не придётся притворяться, что она знает, что произошло.

Вопросы и ответы

Что означает неизвестное удалённое состояние после отключения SSH?

Это значит, что SSH-клиент потерял соединение после того, как мог отправить запрос, но до получения достоверного результата выполнения. Удалённый хост мог вообще не запустить команду, всё ещё выполнять её или уже завершить. Считайте возможными все три варианта, пока удалённые данные не исключат их.

Запустилась ли удалённая команда, если SSH отключился?

Нет. Успешная локальная запись доказывает лишь то, что клиент передал байты локальному сетевому стеку. Тайм-аут или сброс соединения после этого не показывает, принял ли sshd запрос канала и запустила ли удалённая оболочка команду.

Как проверить, выполняется ли команда после разрыва SSH?

Подключитесь снова и проверьте сохранённую запись запуска, а не только список процессов. Найдите каталог запуска, маркер начала, файл результата, ожидаемый вывод и проверку конкретного эффекта, например строку в базе данных, версию релиза или контрольную сумму объекта.

Безопасно ли повторять SSH-команду после тайм-аута?

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

Решает ли nohup проблемы с отключением SSH?

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

Стоит ли использовать tmux или screen для долгих SSH-команд?

tmux и screen помогают сохранить интерактивную оболочку при переподключении, что удобно для ручного обслуживания. Они не доказывают, что внешний эффект произошёл, и плохо подходят как журнал автоматизированной работы.

Могут ли SSH keepalive предотвратить неизвестное удалённое состояние?

Используйте keepalive клиента, чтобы быстрее обнаруживать неработающий сетевой путь, а не чтобы гарантировать доставку или завершение команды. ServerAliveInterval и ServerAliveCountMax в OpenSSH помогают прекратить ожидание мёртвого соединения, но не устраняют уже возникшую неопределённость запроса.

Почему PID не доказывает завершение удалённой задачи?

PID идентифицирует один процесс в определённый момент и после завершения может быть переиспользован. Храните его как диагностическое свидетельство вместе с идентификатором запуска, временем начала, версией команды, файлом результата и записью о конкретном эффекте.

Когда нужна обёртка для удалённых команд?

Используйте удалённую обёртку, когда команда меняет данные, разворачивает ПО, меняет учётные данные, управляет инфраструктурой или связана с расходами. Простая удалённая команда подходит для безопасного чтения, но обёртка оправдана, как только повтор может причинить вред.

Может ли AI-агент безопасно выполнять SSH-команды, которые могут прерваться?

Sallyport выполняет SSH-действия, не раскрывая агенту SSH-учётные данные, и записывает отдельное действие, но удалённой программе всё равно нужен протокол завершения. Контроль учётных данных и неизвестное удалённое состояние, это разные задачи. Если принять одно за замену другого, возникнет ложная уверенность.

Sallyport

Sallyport выполняет API-вызовы и SSH-команды за вашего ИИ-агента. Ключи остаются в локальном хранилище на вашем Mac; вы подтверждаете каждый запуск, и каждое действие попадает в запечатанный журнал.

© 2026 Sallyport · Открытый код по лицензии Apache-2.0 · Oleg Sotnikov