Как проверить, что SSH-команда действительно выполнила удалённую работу
Для проверки успешности SSH-команды недостаточно нулевого кода возврата. Узнайте, как проверять вывод, читать удалённое состояние, обрабатывать тайм-ауты и безопасно повторять операции.

SSH-команда с кодом завершения 0 выполнила одну узкую задачу: удалённая программа сообщила своей оболочке об успехе. Это полезное свидетельство, но не доказательство того, что деплой достиг нужного релиза, сервис остался работоспособным или изменение конфигурации вступило в силу.
Я не раз видел, как автоматизация объявляла победу, потому что ssh host command вернула ноль, а потом выяснялось, что команда записала данные не в тот каталог, поставила в очередь работу, которая позже завершилась ошибкой, или перезапустила сервис, сразу упавший после запуска. Решение не в более оптимистичных логах. Считайте удалённое изменение завершённым только после того, как совпали три независимых факта: SSH достиг удалённой программы, программа вернула ожидаемый результат, а отдельное чтение подтвердило созданное состояние.
Код завершения SSH сообщает только об одном уровне операции
Код завершения говорит о завершении процесса, но не обо всём операционном результате. Удалённая команда опирается на целый набор предположений: DNS и сетевое соединение, подлинность хоста, аутентификацию, поведение оболочки, разбор команды, зависимости, удалённые права и состояние, которое вы хотели изменить.
В руководстве OpenSSH ssh(1) сказано, что ssh завершается с кодом удалённой команды или с кодом 255 при ошибке. Это различие важно. Код 255 обычно означает, что SSH-клиент не смог установить или поддерживать нужную сессию. Код 1, 2 или другой ненулевой код обычно вернулся от удалённой команды. Нулевой код тоже вернулся от неё.
В этой формулировке нет утверждения, что ноль означает «изменение в продакшене корректно». Он не может этого означать. SSH не знает, указывает ли /srv/app/current на нужный релиз, принимает ли демон запросы после systemctl restart или записала ли миграция базы нужные приложению строки.
POSIX определяет нулевой код завершения как успешное выполнение команды. Важна узость этого определения. Оно описывает контракт команды. Если ваш контракт звучит как «выполнить эту строку оболочки», ноль доказывает очень мало. Сформулируйте для команды более точный контракт, а затем проверьте состояние за пределами этой команды.
Практично разделять сбои на три уровня:
- Сбой SSH: клиент не смог подключиться, пройти аутентификацию, проверить хост или завершить сессию.
- Сбой команды: удалённый процесс обнаружил ошибку и вернул ненулевой код.
- Сбой результата: процесс вернул ноль, но нужное удалённое состояние отсутствует, неверно, неполно или позже было отменено.
Команды часто смешивают последние два уровня. Из-за этого отчёты об инцидентах становятся расплывчатыми, а повторы опасными. Если вы знаете, на каком уровне произошёл сбой, то понимаете, что делать: проверять учётные данные и соединение, исправлять команду или восстанавливать удалённое состояние.
Текст успеха является свидетельством только при точном контракте
Проверка ожидаемого вывода позволяет заметить ошибки, невидимые по коду завершения, но лишь при наличии явного контракта. Поиск слов вроде success, complete или deployed в ориентированном на человека тексте даёт слабое свидетельство. Многие инструменты печатают такие слова до сбоя последующей команды, а обёртки могут вывести их сразу после постановки асинхронной работы в очередь.
Пусть удалённая команда выдаёт одну запись с идентификатором и состоянием, которые вы ожидаете увидеть. Часто удобно использовать JSON, но подойдёт и строка с фиксированными разделителями, если значения находятся под вашим контролем. Главное, чтобы скрипт печатал запись только после выполнения работы, о которой он сообщает.
Например, скрипт релиза переключает символическую ссылку на каталог релиза. Этот удалённый скрипт выводит конечную цель, а не расплывчатое сообщение о ходе работы:
#!/bin/sh
set -eu
release="$1"
base=/srv/example/releases
link=/srv/example/current
[ -d "$base/$release" ]
ln -sfn "$base/$release" "$link"
actual=$(readlink "$link")
[ "$actual" = "$base/$release" ]
printf 'RELEASE_TARGET=%s\n' "$actual"
Вызывающая сторона может потребовать точную строку вывода:
expected="RELEASE_TARGET=/srv/example/releases/2025.06.14"
output=$(ssh deploy@web-01 '/usr/local/sbin/activate-release 2025.06.14' 2>&1)
status=$?
if [ "$status" -ne 0 ]; then
printf 'remote command failed, status=%s\n%s\n' "$status" "$output" >&2
exit "$status"
fi
if ! printf '%s\n' "$output" | grep -Fxq "$expected"; then
printf 'remote command returned unexpected output:\n%s\n' "$output" >&2
exit 1
fi
Здесь важен grep -Fxq. Он проверяет одну полную фиксированную строку. Нестрогое выражение вроде grep deployed примет мусор, частичное совпадение и вводящие в заблуждение сообщения о ходе работы. Если в выводе есть динамические поля, разбирайте структурированный документ настоящим парсером, а не пытайтесь заставить регулярное выражение понимать вложенные данные.
Не превращайте проверку вывода во вторую копию всей команды. Она должна отвечать на один узкий вопрос: сообщила ли удалённая программа, что достигла названного состояния? Последующее чтение отвечает, остаётся ли это утверждение верным там, где это действительно важно.
После удалённой записи нужно прочитать состояние у его владельца
Последующее чтение даёт самое сильное подтверждение, потому что обращается к компоненту, которому принадлежит изменённое состояние. Нужный способ чтения зависит от изменения и часто отличается от команды, которая это изменение внесла.
Для переключения символической ссылки используйте readlink, чтобы проверить файловую систему. После установки пакета запросите его установленную версию. После перезапуска сервиса получите у менеджера сервисов активное состояние, а затем отправьте запрос самому сервису. После изменения базы прочитайте строку или используйте поддерживаемую приложением конечную точку состояния. Для задачи в очереди запрашивайте запись задачи, пока она не достигнет конечного состояния.
Плохой вариант выглядит так:
ssh deploy@web-01 'deploy-release 2025.06.14 && systemctl restart example'
Команда может вернуть ноль, даже если сервис «активен», но использует старый релиз, потому что скрипт деплоя записал данные в другой путь. Она также может вернуть ноль, когда менеджер сервисов принял запрос на перезапуск, но процесс через несколько мгновений завершился. Оболочка увидела два нулевых результата. Пользователи всё равно получили сломанный сервис.
Сделайте нужное условие наблюдаемым с помощью чтения:
ssh deploy@web-01 '
test "$(readlink /srv/example/current)" = /srv/example/releases/2025.06.14 &&
systemctl is-active --quiet example &&
curl --fail --silent --show-error http://127.0.0.1:8080/healthz
'
Этот вариант лучше, потому что проверяет три утверждения, которые исходная команда лишь предполагала. Он всё ещё не доказывает, что каждый внешний пользователь может достучаться до сервиса. Если изменение затрагивает публичную конечную точку, выполняйте подходящую проверку из той сетевой позиции, в которой находятся пользователи. Проверка через локальный loopback обнаруживает сбой процесса, но не ошибку в файрволе или балансировщике.
По возможности выполняйте чтение после записи независимым способом. Вторая функция внутри того же скрипта деплоя лучше, чем ничего, но она может использовать ту же ошибочную переменную, неправильный хост или замоканную зависимость. Отдельная команда, которая запрашивает файловую систему, менеджер сервисов, API или базу данных, уменьшает вероятность общей причины сбоя.
Команда должна явно сообщать, синхронна ли она
Многие удалённые команды возвращают ноль, потому что приняли работу, а не потому, что работа завершилась. Для постановки задачи в очередь, фоновой работы, запроса на перезагрузку сервиса или вызова API оркестрации это корректный результат. Ошибка возникает, когда вызывающая сторона принимает подтверждение приёма за завершение.
Разделяйте эти два контракта в именах и выводе. submit-backup может сообщить идентификатор задачи и вернуть ноль, когда сервер принял запрос. wait-backup может вернуть ноль только после завершения именно этой задачи. Не называйте оба действия backup, рассчитывая, что оператор вспомнит, какая версия запускается на каком хосте.
Удалённому скрипту, который запускает работу в фоне, требуется особое внимание. Эта строка оболочки возвращает успех сразу после запуска процесса, даже если тот немедленно завершится с ошибкой:
long-task >/var/log/long-task.log 2>&1 &
printf 'started\n'
Поздний код завершения процесса недоступен вызывающей стороне SSH. Сохраните его в надёжном месте и запрашивайте позже либо держите сессию открытой, пока задача не достигнет значимого состояния. Если вы отсоединяете задачу, запишите идентификатор операции в файл или базу и верните этот идентификатор. Затем вызывающая сторона сможет опрашивать запись операции или ждать её завершения.
Полезная запись о завершении содержит достаточно данных для сверки:
operation=4f2c1a status=accepted release=2025.06.14
Не принимайте такую запись за завершённый деплой. Запросите operation=4f2c1a и потребуйте конечный статус вроде completed, а также проверьте созданное состояние. Для простого скрипта это может казаться излишним. Но такая схема гораздо проще, чем гадать, безопасно ли повторять скрипт после тайм-аута.
Для тайм-аутов действует то же различие. Локальный тайм-аут сообщает, что вызывающая сторона перестала ждать. Он не сообщает, остановилась ли удалённая команда. Сеть могла оборваться уже после фиксации изменения на удалённом хосте. Перед повтором проверьте удалённое состояние или запросите идентификатор операции. Повторная активация релиза может быть безопасной, если она идемпотентна. Повторная оплата, ротация секрета или отправка письма способны увеличить ущерб.
Композиция команд оболочки скрывает сбои, если не учитывать их заранее
Синтаксис удалённой оболочки может превратить настоящий сбой в успешный итоговый код. Это один из старейших способов получить чистый результат SSH, оставив машину в повреждённом состоянии.
Рассмотрим команду:
ssh ops@db-01 'backup-db; upload-backup; prune-old-backups'
Удалённая оболочка возвращает статус prune-old-backups, последней команды. Если резервное копирование завершилось ошибкой, а очистка прошла успешно, вся SSH-команда вернёт ноль. В начале вывода может быть сообщение об ошибке, но система, которая смотрит только на статус, отметит запуск как успешный.
Используйте set -e в контролируемом вами скрипте либо связывайте зависимые команды через &&, если компактная запись остаётся понятной:
ssh ops@db-01 'backup-db && upload-backup && prune-old-backups'
set -e не волшебная кнопка. У оболочек есть исключения для условий, подстановок команд и некоторых составных конструкций. Не пишите длинную удалённую однострочную команду, полагаясь на одну опцию. Сложную работу выносите в удалённый скрипт, задавайте каждой операции ясное условие успеха и проверяйте поведение при сбоях.
Конвейеры создают ещё одну знакомую ловушку. Во многих POSIX-оболочках такая команда завершится успешно, если успешно завершилась последняя программа, даже при сбое предыдущего поставщика данных:
collect-metrics | format-report > /var/tmp/report.txt
Некоторые оболочки поддерживают set -o pipefail, но /bin/sh может его не поддерживать. Если удалённой среде нужна совместимость с POSIX, не используйте конвейер как единственную границу обработки ошибок. Запишите промежуточные данные во временный файл, проверьте статус поставщика, затем обработайте файл. Другой вариант, запускайте скрипт оболочкой, для которой поведение pipefail явно требуется.
Не завершайте удалённую команду диагностическим выводом, который может успешно выполниться после сбоя основной работы:
apply-config
printf 'finished\n'
Без set -e или явной проверки код printf станет итоговым статусом. Такая ошибка часто появляется в ручных командах во время инцидента, когда кто-то хочет вывести дружелюбное финальное сообщение. Печатайте его только после проверки условия или позвольте сбойной команде завершить скрипт.
Ошибки в кавычках могут заставить вас проверить не тот компьютер
Локальная оболочка раскрывает неэкранированные переменные до отправки команды через SSH. Из-за этого команда может работать с неправильным релизом, сравнивать локальный вывод с удалённым или раскрывать значения в списке локальных процессов и логах.
Так делать нельзя, если вы хотите, чтобы $release вычислялся на удалённом хосте:
ssh deploy@web-01 "test \"$(readlink /srv/example/current)\" = \"$release\""
Локальная оболочка выполнит $(readlink ...) до запуска SSH. Теперь вы сравнили /srv/example/current на своей рабочей станции, если он там существует, с локальной переменной, а затем отправили результат удалённой оболочке. Код завершения может оказаться нулевым. Но проверка не обращалась к целевому хосту.
Оставляйте удалённую программу в одинарных кавычках, если содержащийся в ней синтаксис оболочки должен выполняться удалённо. Ненадёжные или динамические значения передавайте как позиционные параметры, а не собирайте из них текст команды. Например:
release='2025.06.14'
ssh deploy@web-01 'sh -s -- "$1"' sh "$release" <<'REMOTE'
set -eu
release=$1
target=$(readlink /srv/example/current)
[ "$target" = "/srv/example/releases/$release" ]
printf 'verified=%s\n' "$target"
REMOTE
Кавычки вокруг разделителя heredoc не дают локальной оболочке раскрывать тело скрипта. Значение релиза передаётся как аргумент оболочки, где удалённая оболочка может корректно его экранировать. Само по себе это не делает произвольное имя релиза безопасным. До использования пользовательских значений в путях, командах или запросах к базе проверяйте допустимые символы и ожидаемый формат.
В автоматизации предпочитайте удалённый скрипт с параметрами растущей строке из вложенных кавычек. Ошибки экранирования трудно заметить при ревью, потому что команда на первый взгляд выглядит правдоподобно. Их гораздо легче диагностировать, когда в логах есть точная версия удалённого скрипта, безопасные для записи значения параметров и собственный вывод команды проверки.
Полезный контракт SSH содержит три отдельных результата
Рассматривайте каждое значимое действие через SSH как небольшой протокол с отдельными полями для транспорта, результата команды и наблюдаемого состояния. Чтобы решить, продолжать ли работу, повторять её или просить помощи, вызывающей стороне нужны все три значения.
Эта Bash-функция показывает форму такого контракта. Она объединяет stderr с stdout, чтобы при сбое у операции оставались диагностические данные, и отдельно сообщает о сбое SSH-транспорта и неожиданном успешном ответе.
run_remote_check() {
local host=$1
local expected=$2
shift 2
local output status
output=$(ssh "$host" "$@" 2>&1)
status=$?
if [ "$status" -eq 255 ]; then
printf 'ssh_transport=failed host=%s\n%s\n' "$host" "$output" >&2
return 255
fi
if [ "$status" -ne 0 ]; then
printf 'remote_command=failed host=%s status=%s\n%s\n' \
"$host" "$status" "$output" >&2
return "$status"
fi
if ! printf '%s\n' "$output" | grep -Fxq "$expected"; then
printf 'remote_result=unexpected host=%s expected=%s\n%s\n' \
"$host" "$expected" "$output" >&2
return 1
fi
printf 'remote_result=confirmed host=%s\n' "$host"
}
Вызывайте её с командой, которая выдаёт только контрактную запись после собственных внутренних проверок:
run_remote_check \
deploy@web-01 \
'RELEASE_TARGET=/srv/example/releases/2025.06.14' \
'/usr/local/sbin/activate-release 2025.06.14'
Затем выполняйте последующее чтение отдельным действием. Отражайте его отдельно в логах и статусах. Если активация прошла, а проверка сервиса завершилась ошибкой, оператор должен видеть именно эту границу. Один непрозрачный результат deploy failed заставит его повторно запускать команды, чтобы выяснить, что уже произошло.
Для команд, возвращающих структурированные данные, выдавайте небольшой JSON-объект и разбирайте его JSON-парсером. Не используйте grep для JSON, если только вывод намеренно не является однострочным маркером и вам не нужно интерпретировать его поля. Сопоставление строк с произвольным JSON ломается при изменении пробелов, порядка полей или экранированного содержимого.
В контракте также нужно указывать целевой объект. Одного status=ok недостаточно, чтобы отличить релиз 2025.06.14 от вчерашнего. Добавляйте идентификатор деплоя, имя хоста, если оно важно, версию объекта или идентификатор операции. Эта небольшая деталь предотвращает распространённый ложноположительный результат: проверка подтверждает наличие чего-то работоспособного, но не того, что изменил текущий запуск.
Наблюдаемость должна сохранять результат проверки
Лог, где записаны только удалённая команда и код завершения, оставляет без ответа главный вопрос: увидела ли вызывающая сторона нужное состояние независимо от команды? Записывайте действие проверки и его результат рядом с изменением.
Для деплоя полезная запись может содержать целевой хост, идентификатор релиза, статус SSH, статус команды, точную контрактную запись, статус последующей команды и краткий результат проверки состояния или работоспособности. Не записывайте секреты, полные заголовки авторизации или приватные аргументы команд только потому, что они помогают отладке. Проектируйте интерфейс команд так, чтобы полезные свидетельства можно было безопасно хранить.
Sallyport хранит журнал Activity для отдельных действий и журнал Sessions для запусков агентов. Оба журнала строятся из зашифрованного аудита с цепочкой хешей. Благодаря этому оператор может отличить выполненное действие SSH от действия проверки, подтвердившего результат, вместо того чтобы считать один успешный вызов полной историей.
Защита от подмены помогает понять, изменялась ли записанная операция задним числом. Она не превращает слабый контракт команды в доказательство хорошего результата. sp audit verify может офлайн проверить цепочку журнала по шифротексту, но дизайн действия всё равно должен включать явное чтение состояния.
Держите проверку достаточно близко к записи, чтобы другой участник не мог незаметно заменить нужное состояние в промежутке. Не всегда возможно устранить гонки в общей системе. Их можно уменьшить с помощью неизменяемых идентификаторов релизов, идентификаторов операций, проверок версий и API с условными обновлениями. Если состояние может измениться снова, записывайте наблюдаемую версию или временную метку и перед дальнейшим действием проверяйте её.
Перед повтором нужно сверить результат
Тайм-аут, разрыв соединения или прерванный запуск CI создают неизвестный результат. Удалённый хост мог завершить изменение, всё ещё выполнять его или остановиться после частичного сбоя. Одно лишь прекращение вывода не позволяет вызывающей стороне это определить.
Не устраняйте неопределённость слепым повтором. Сначала выполните команду сверки, которая только читает данные и классифицирует удалённое состояние. Для активации релиза проверьте текущую символическую ссылку и работоспособность сервиса. Для миграции запросите таблицу миграций. Для созданного ресурса выполните запрос по сгенерированному вызывающей стороной идентификатору операции. При ротации секрета проверьте, какую версию действительно используют потребители, прежде чем создавать ещё одну.
Хорошая команда сверки возвращает один из небольшого набора явных результатов:
completed: целевое состояние совпадает с запрошенной операцией.running: операция всё ещё выполняется, и вызывающей стороне нужно подождать.absent: свидетельств операции нет, поэтому повтор может быть уместен.conflict: существует другое состояние, и решение должен принять человек или контроллер более высокого уровня.
Не сводите каждый результат к успеху или сбою. running и conflict тоже полезны. Система, которая считает их ошибками, часто повторяет работу, которую следовало оставить без изменений.
Идемпотентность снижает стоимость оправданных повторов, но этим словом часто злоупотребляют. Команда идемпотентна, если повтор того же запроса после первого успешного выполнения не меняет нужное состояние. ln -sfn может быть идемпотентной для конкретной цели ссылки. «Создать новую резервную копию с текущим временем» идемпотентной не является. «Отправить письмо» тоже, если только система-получатель не удаляет дубликаты по стабильному идентификатору сообщения.
Сначала создайте проверку чтением, а уже потом добавляйте повторы. Если вы не можете описать, как определить завершённую операцию, повторять работу после неопределённости безопасно не получится. В этот момент автоматизация SSH перестаёт быть удобной оболочкой и начинает требовать модели операций.
Начните исправление с проверки успешных запусков
Начните с удалённых команд, которые меняют состояние продакшена и сейчас сообщают только о зелёном коде завершения. Для каждой запишите удалённый объект, который должен измениться, точное состояние успеха, компонент, способный прочитать это состояние, и то, что остаётся неизвестным после тайм-аута.
Затем измените интерфейс команды так, чтобы после собственных проверок она выдавала точную запись о результате. Добавьте отдельное действие чтения. Сохраняйте оба результата в записи запуска. Вы обнаружите команды, которые никогда не были синхронными, скрипты, где финальный printf скрывал предыдущий сбой, и проверки деплоя, которые обращались к машине, запустившей команду, а не к машине, которая её получила.
Нулевой код завершения по-прежнему важен. Это первый контрольный этап, но не окончательный вердикт. Такой подход позволяет автоматизации объяснить, что произошло, когда простой зелёный сигнал оказывается неверным.
Вопросы и ответы
Означает ли код 0, что SSH-команда сработала?
Нет. Нулевой код завершения означает, что удалённый процесс сообщил оболочке об успехе. Это не доказывает, что процесс изменил нужный ресурс, достиг всех зависимостей или оставил удалённую систему в требуемом состоянии.
Какой код возвращает SSH при сбое удалённой команды?
Обычно SSH возвращает код завершения удалённой команды. В руководстве ssh(1) код 255 зарезервирован для ошибок самого SSH, например проблем с подключением или аутентификацией. Поэтому 255 следует считать ошибкой транспорта или клиента, а не результатом работы приложения.
Когда нужно проверять вывод SSH-команды?
Проверяйте вывод, если у команды есть стабильный машиночитаемый результат успеха, который даёт больше информации, чем код возврата. Не ищите расплывчатые фразы вроде «готово». Выводите явный маркер или структурированную запись, где указаны ожидаемые объект и состояние.
Зачем выполнять последующее чтение после удалённого изменения?
Последующее чтение нужно, когда команда меняет постоянное состояние: активную версию релиза, состояние сервиса, строку в базе данных, содержимое файла или удалённую конфигурацию. После записи запросите систему, которая владеет этим состоянием, вместо того чтобы доверять описанию самой команды.
Как отличить ошибку SSH-транспорта от ошибки приложения?
Сначала проверьте собственный статус SSH, а уже потом интерпретируйте вывод. Если ssh вернул 255, сообщите об ошибке подключения, проверки хоста, аутентификации или клиента. Не называйте её сбоем деплоя или удалённой команды.
Можно ли безопасно повторить SSH-команду после тайм-аута?
Да, если удалённая команда рассчитана на безопасный повтор. Используйте идемпотентную операцию, проверьте требуемое конечное состояние и не повторяйте команды, которые создают платежи, отправляют сообщения, ротируют секреты или выполняют неидемпотентные изменения в базе без идентификатора операции и механизма сверки.
Надёжнее ли вывод команды, чем код завершения?
Команда может вывести успокаивающее сообщение, а затем завершиться с ошибкой, либо сообщить об успехе после одной лишь постановки работы в очередь. Сохраняйте вывод для диагностики, но принимайте его как доказательство только при наличии точного маркера, структурированного значения или независимого чтения состояния.
Стоит ли помещать сложные SSH-команды в удалённый скрипт?
Используйте удалённый скрипт, если кавычки, настройка окружения, очистка или несколько проверок делают однострочную команду сложной для ревью. Небольшой скрипт с set -eu, явным выводом и отдельными проверками безопаснее плотного фрагмента оболочки с несколькими уровнями кавычек.
Как агентам программирования проверять действия через SSH?
Да. Агент программирования может принять успешный локальный вызов SSH за доказательство того, что удалённое действие достигло цели, особенно если он не может проверить конечное состояние. Задайте агенту контракт, требующий кода возврата, ожидаемого результата и последующей проверки для операций, меняющих состояние.
Доказывают ли журналы аудита SSH успешность удалённого изменения?
Журналы помогают восстановить последовательность событий, но сами по себе не доказывают, что команда достигла нужного бизнес- или операционного результата. Записывайте команду, хост, статус, дайджест вывода и результат проверки, чтобы оператор видел, на каком уровне произошёл сбой.