SSD Nodes Learn 🎉 VPS от $5.50/мес
Руководства Matt ConnorАвтор: Matt Connor

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

При разрыве SSH ядро отправляет сигнал SIGHUP, что приводит к завершению процессов. Используйте nohup, disown, tmux или systemd-run, чтобы сохранить работу при обрыве связи.

Почему команда завершается при разрыве SSH-соединения

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

Ваш сеанс работает через pty (псевдотерминал) — виртуальное терминальное устройство, которое sshd создает на сервере для вашей сессии. Это управляющий терминал для вашей оболочки и всех команд, которые вы из неё запускаете. Подробности этого процесса описаны в том, как SSH настраивает окружение при входе. Когда TCP-соединение разрывается, sshd закрывает свою сторону, и pty уничтожается. Ядро интерпретирует это как закрытие терминала и отправляет сигнал SIGHUP группе процессов переднего плана этого терминала и лидеру сессии, то есть вашей оболочке. Стандартное действие для SIGHUP — завершение процесса. Ваша команда находилась в группе процессов переднего плана, поэтому она завершается.

Фоновые задачи также не защищены. Задача, запущенная с помощью &, находится в собственной группе процессов, поэтому ядро не посылает ей сигнал напрямую. Это делает Bash. При получении SIGHUP интерактивный bash перед выходом пересылает SIGHUP каждой задаче из своей таблицы. Для вас результат выглядит одинаково: задача исчезает, а запись в лог-файл обрывается на полуслове.

Здесь существует асимметрия, которая часто сбивает с толку. Нажатие exit не завершает фоновые задачи, так как bash делает это только при включенной опции huponexit, которая по умолчанию выключена. Однако разрыв соединения завершает их. Задача, которая благополучно пережила закрытие терминала, может завершиться при потере Wi-Fi.

Из этого следуют два вывода, которые составляют суть проблемы. Процесс, который игнорирует SIGHUP или вообще не имеет управляющего терминала, не будет завершен. Но если стандартный вывод процесса всё ещё направлен в уничтоженный pty, процессу некуда писать: операция записи завершается с ошибкой EIO (input/output error), и большинство программ в этот момент прекращают работу. Вам нужно решить обе части задачи. Многие рецепты решают только первую, поэтому пользователи часто жалуются, что «nohup не сработал».

Если соединение обрывается несколько раз в день, устраните и эту причину. Параметр ServerAliveInterval 60 в ~/.ssh/config предотвращает сброс простаивающего сеанса из-за тайм-аута NAT (network address translation) на пути следования пакетов. Если сеанс не устанавливается вовсе, это другая неисправность с иными причинами, где важно понимать разницу между connection refused и connection timed out.

Какой метод позволяет команде продолжать работу после отключения SSH?

Четыре способа, упорядоченные по степени важности задачи.

  • nohup или setsid: для разовой задачи, которую вы запускаете сейчас, а результат просматриваете позже. Вывод необходимо перенаправить самостоятельно.
  • disown: для задачи, которую вы уже запустили, но забыли защитить от завершения. Этот инструмент позволяет «подхватить» процесс, но не возвращает его вывод в текущую сессию.
  • tmux или screen: для работы, которую нужно отслеживать, прерывать и возобновлять в течение нескольких дней.
  • systemd-run или полноценный unit file: для всего, что должно работать дольше вашего сеанса, например, шестичасовой rsync или ночной импорт базы данных.

Правило, которое стоит запомнить: если забыть о задаче — это проблема, значит, она должна управляться через systemd, а не через tmux. Окно tmux — это то, о чем человек должен помнить сам. У unit есть имя, статус, лог и политика перезапуска, которые может найти другой администратор без дополнительных указаний.

nohup и setsid: запуск и отключение от терминала

nohup ./import.sh > ~/import.log 2>&1 &
echo $! > ~/import.pid

nohup устанавливает игнорирование сигнала SIGHUP и затем выполняет вашу команду, поэтому сигнал завершения работы терминала (hangup) от ядра не оказывает никакого эффекта. Перенаправление вывода вы должны настроить самостоятельно. Если оставить стандартный вывод направленным в терминал, nohup перенаправит его в файл nohup.out в текущем каталоге (или в $HOME/nohup.out, если запись в первый невозможна) и выведет сообщение:

nohup: ignoring input and appending output to 'nohup.out'

Этот файл легко потерять из виду, поэтому лучше задать имя самостоятельно. Переменная $! содержит PID (идентификатор процесса) последней фоновой задачи, и сохранение этого значения позволит вам проверить состояние задачи после повторного входа в систему.

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

setsid --fork ./import.sh > ~/import.log 2>&1

Используйте --fork. Без этого флага setsid вызывает setsid() на месте, если процесс не является лидером группы процессов (что происходит при выполнении внутри shell-скрипта), и ваш скрипт зависает в ожидании. С флагом --fork поведение остается одинаковым как в скрипте, так и при вводе в командной строке.

Проверьте результат:

ps -o pid,ppid,sid,tty,stat,cmd -p "$(cat ~/import.pid)"

Значение TTY в столбце ? означает, что у процесса нет управляющего терминала, поэтому ничто не может его завершить при закрытии сессии. В nohup столбец TTY все еще может показывать что-то вроде pts/0, пока вы остаетесь в системе, и меняется на ? после уничтожения pty. Оба результата означают, что процесс работает корректно. Задача успешно пережила отключение.

disown: спасение уже запущенной задачи

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

# press Ctrl-Z to suspend the job first
bg
jobs -l
disown -h %1

Ctrl-Z приостанавливает задачу, bg переводит её в фоновый режим, а jobs -l выводит номер задачи рядом с её PID. disown -h %1 помечает задачу, чтобы bash не отправлял ей SIGHUP. Обычная команда disown %1 полностью удаляет задачу из таблицы bash, что дает тот же эффект при разрыве соединения, но после этого jobs перестает её отображать.

Чего disown не может сделать, так это перенаправить вывод. Процесс по-прежнему использует pty в качестве стандартного вывода, и когда pty закрывается, следующая операция записи возвращает EIO. Поэтому disown надежно сохраняет «тихую» задачу, например, компиляцию, которая пишет в файл, но часто приводит к потере «разговорчивой» задачи. Процесс либо продолжает работу, не имея места для вывода данных, либо завершается при попытке вывести следующую строку.

Существует инструмент для спасения файловых дескрипторов. reptyr переносит запущенный процесс в ваш текущий терминал: установите его с помощью sudo apt install -y reptyr, а затем запустите reptyr <pid> из окна tmux. Он работает через ptrace, а в Ubuntu поставляется kernel.yama.ptrace_scope = 1, который разрешает отладку только собственных дочерних процессов, поэтому для процесса, который вы унаследовали, требуется sudo reptyr <pid>. Используйте это как аварийный инструмент. Не стройте на нём повседневные рабочие процессы.

tmux: работа, за которой нужно следить и к которой нужно возвращаться

tmux (терминальный мультиплексор) решает эту задачу иначе. Вместо защиты процесса от pty, он предоставляет процессу pty, который не привязан к вашей SSH-сессии. Сервер tmux работает вне этой сессии и владеет терминалами всех запущенных внутри процессов. Ваше SSH-соединение выступает лишь в роли средства просмотра. Разорвите соединение, и сервер этого даже не заметит.

sudo apt update && sudo apt install -y tmux
tmux new -s import

Запустите задачу в этом окне, затем нажмите Ctrl-b, а после — d, чтобы отсоединиться. Войдите в систему позже и вернитесь к работе:

tmux ls
tmux attach -t import

Команда tmux ls должна вывести строку, начинающуюся с import: 1 windows. Если выводится no server running on /tmp/tmux-1000/default, значит, сессии для подключения нет: либо она не была создана, либо что-то завершило работу сервера.

screen выполняет ту же задачу с другими сочетаниями клавиш. screen -S import создает сессию, а Ctrl-a, затем d отсоединяют от неё. screen -ls выводит список существующих сессий, а screen -r import позволяет вернуться в одну из них. Оба инструмента подходят для этой цели. Клавиша отсоединения — это то, что пользователи часто забывают.

Мультиплексор также является подходящим инструментом для интерактивной работы, которая должна пережить разрыв соединения. Именно поэтому запуск Claude Code на VPS внутри tmux является стандартной настройкой, и именно это делает управление серверной сессией с телефона удобным в мобильных сетях, где соединение может прерываться каждые несколько минут.

systemd-run: передача задачи PID 1

Если задача не должна зависеть от вашего сеанса, передайте её системе инициализации.

sudo systemd-run --unit=bigsync --collect /usr/bin/rsync -aH --stats /srv/data/ /mnt/backup/

Эта команда создает временный юнит службы с именем bigsync.service. Он получает собственную cgroup, не имеет управляющего терминала и не связан с вашим сеансом входа в систему. Команда завершается немедленно и выводит Running as unit: bigsync.service. Отслеживайте выполнение одним из следующих способов:

systemctl status bigsync
journalctl -u bigsync -f

Флаг --collect указывает systemd удалить юнит после завершения, даже если произошел сбой. Без него неудачный временный юнит остается загруженным, а его имя остается занятым, поэтому следующий запуск завершится ошибкой с сообщением о том, что юнит уже существует. Вывод перенаправляется в журнал с метками времени для каждой строки. Записи в журнале сохраняются после перезагрузки только при наличии каталога /var/log/journal, поэтому выполните sudo mkdir -p /var/log/journal и перезапустите systemd-journald, если вам это необходимо.

При вызове systemd-run от имени обычного пользователя без sudo система запрашивает авторизацию через polkit и выводит ==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===. Используйте sudo для системных юнитов.

Вы также можете запустить задачу под управлением вашего пользовательского менеджера:

systemd-run --user --unit=bigsync --collect /usr/bin/rsync -aH /srv/data/ /mnt/backup/

Здесь есть нюанс. Ваш пользовательский менеджер user@1000.service обычно завершает работу при закрытии последнего сеанса, и вместе с ним завершаются все пользовательские юниты. Включите режим постоянной работы (lingering) один раз:

loginctl enable-linger "$USER"
loginctl show-user "$USER" --property=Linger

Вторая команда должна вывести Linger=yes. При включенном режиме lingering ваш пользовательский менеджер запускается при загрузке системы и продолжает работать независимо от того, вошли ли вы в систему. Без этого параметра systemd-run --user не дает преимуществ перед nohup.

systemd-run --scope — это другой инструмент. Он запускает команду в интерактивном режиме, привязывая её к вашему терминалу, поэтому в данном случае он не поможет.

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

Постоянный юнит для задачи, которую вы будете запускать повторно
[Unit]
Description=Nightly data sync
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/srv/data
ExecStart=/usr/local/bin/nightly-sync.sh

Сохраните это как /etc/systemd/system/nightly-sync.service, выполните sudo systemctl daemon-reload, затем запустите службу с помощью sudo systemctl start nightly-sync и проверьте её состояние через journalctl -u nightly-sync. Добавьте соответствующий файл .timer, если задача должна выполняться по расписанию, а не по требованию.

Написание systemd service unit и таймера подробно описывает формат файла и синтаксис расписания.

Куда направляется вывод и почему он пропадает

Порядок перенаправления имеет значение. > file 2>&1 направляет стандартный вывод в файл, а затем направляет стандартный поток ошибок в то же место. 2>&1 > file делает это в обратном порядке: стандартный поток ошибок продолжает идти в терминал, а терминал — это то, что вот-вот исчезнет. Bash также поддерживает &> file для обоих потоков одновременно.

Второй неожиданный момент — буферизация. Когда стандартный вывод направлен в терминал, библиотека C выполняет сброс буфера на каждой строке. Когда стандартный вывод направлен в файл, он переключается на блочный буфер размером в несколько килобайт, поэтому tail -f ~/import.log может не показывать ничего в течение нескольких минут, и задача будет выглядеть зависшей. Принудительно включите построчную буферизацию с помощью stdbuf -oL ./import.sh > ~/import.log 2>&1 или используйте собственный флаг программы, например python3 -u или grep --line-buffered.

Избегайте следующего шаблона:

nohup ./import.sh 2>&1 | tee ~/import.log &

nohup защищает только import.sh и ничего больше. tee является отдельным процессом в том же конвейере, и он по-прежнему завершается при разрыве соединения. После этого import.sh пытается писать в канал, у которого нет читателя, поэтому он получает SIGPIPE и останавливается. Поместите весь конвейер внутрь setsid bash -c '...' или записывайте данные напрямую в файл и запускайте tail -f для него при повторном подключении.

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

Почему задача, работающая в вашей оболочке, завершается с ошибкой в systemd или cron

Ваша интерактивная оболочка считывает /etc/profile, ~/.profile и ~/.bashrc, поэтому она содержит ваш PATH, прослойки (shims) менеджера версий и экспортированные переменные. Юнит systemd не считывает ничего из этого. Cron также не считывает эти файлы: в Debian и Ubuntu cron запускает задачи с SHELL=/bin/sh и PATH=/usr/bin:/bin.

Симптомом в systemd является сообщение systemctl status о (code=exited, status=203/EXEC), что означает, что systemd вообще не смог выполнить файл из-за неверного пути или отсутствия у файла прав на выполнение. В cron это обычно command not found, доставляемое локальной почтой или не доставляемое никуда, если почтовая система не установлена.

Выясните, каково окружение, прежде чем тратить час на догадки:

sudo systemd-run --collect --wait --unit=envtest /usr/bin/env
journalctl -u envtest --no-pager

Эта команда выведет точное окружение, в котором будет выполняться ваша задача. Затем устраните разрыв. Используйте абсолютные пути для всех ваших файлов, так как systemd разрешает обычную команду rsync относительно фиксированного системного списка путей, а не относительно PATH вашей оболочки. Передавайте необходимые переменные с помощью -p Environment="KEY=value" в командной строке или через EnvironmentFile=/etc/default/myjob в файле юнита. Если задаче действительно требуется ваше окружение входа в систему, запускайте её как /bin/bash -lc 'my-command' и учитывайте, что теперь задача зависит от ваших dotfiles.

Что еще может завершить фоновый процесс

  • Перезагрузка. Состояние tmux не сохраняется при перезагрузке, так как сервер является обычным процессом, а сессии хранятся в оперативной памяти. Обновления ядра требуют перезагрузки, поэтому задачи, которые нельзя легко перезапустить, следует оформлять в виде юнита systemctl enable.
  • OOM killer (Out-of-memory killer). dmesg -T | grep -i 'killed process' отображает его действия, включая имя выбранного процесса. Тяжелые операции импорта на VPS с малым объемом памяти часто становятся его целью.
  • Очистка через logind. Если в /etc/systemd/logind.conf установлено значение KillUserProcesses=yes, оставшиеся процессы завершаются при закрытии последней сессии, включая сервер tmux. Проверьте текущую настройку с помощью loginctl show --property=KillUserProcesses и исключите своего пользователя командой loginctl enable-linger "$USER".
  • Переполнение диска. Задача останавливается, потому что файл лога, в который вы перенаправляли вывод, заполнил файловую систему, а не из-за вашего выхода из системы. Выполните df -h, прежде чем винить сигналы завершения.

Запуск задачи по SSH без поддержания соединения

ssh vps 'sudo systemd-run --unit=import --collect /usr/local/bin/import.sh'

systemd-run завершается сразу после запуска юнита, поэтому команда ssh также возвращает управление, а задача теряет связь с сессией, из которой она была запущена. Это корректный способ.

Вариант с nohup требует больше внимания:

ssh vps 'nohup ./import.sh > ~/import.log 2>&1 < /dev/null &'

Без перенаправления потоков выполнение команды кажется зависшим. sshd удерживает канал открытым, пока любой процесс использует стандартный вывод или стандартный поток ошибок удаленной команды, а фоновая задача наследует оба этих потока. Один лишь nohup не решает проблему, так как nohup перенаправляет вывод только в том случае, если он направлен в терминал, а в данном случае это канал (pipe), ведущий обратно к вашему клиенту. Добавление < /dev/null также закрывает входной поток. ssh -n выполняет ту же задачу со стороны клиента.

FAQ

Почему моя команда завершается при разрыве SSH-соединения?

Pty (псевдотерминал), который использовала ваша сессия, уничтожается, и ядро отправляет SIGHUP группе процессов переднего плана на этом терминале. Действие по умолчанию для SIGHUP — завершение процесса. Фоновые задачи также завершаются, так как bash перед выходом отправляет SIGHUP всем задачам из своей таблицы. Команда, которая игнорирует SIGHUP (например, запущенная через nohup), или процесс, который изначально не был связан с вашей сессией (например, systemd unit), продолжат работу.

Что лучше для шестичасового rsync: tmux или systemd-run?

systemd-run. Сессия tmux зависит от серверного процесса, который вы запустили, поэтому она завершится при следующей перезагрузке и будет невидима для любого, кто не знает о команде tmux ls. Использование sudo systemd-run --unit=bigsync --collect /usr/bin/rsync -aH --stats /srv/data/ /mnt/backup/ предоставляет systemctl status bigsync для отслеживания состояния и journalctl -u bigsync для вывода; их увидит любой администратор без дополнительных пояснений. Используйте tmux для задач, где вам нужно наблюдать за экраном и вводить данные.

Как увидеть вывод задачи, если я забыл перенаправить его в файл?

Обычно это невозможно, так как вывод был направлен на терминал, который больше не существует. Пока процесс запущен, вы можете изучить его открытые файлы с помощью sudo ls -l /proc/<pid>/fd или отследить системные вызовы через sudo strace -p <pid>, но текст, который уже был выведен, утерян. reptyr <pid> позволяет перенести процесс на новый терминал, но в Ubuntu kernel.yama.ptrace_scope = 1 требует sudo для процесса, который не является вашим дочерним. Чтобы избежать этого, всегда перенаправляйте вывод в файл в начале работы и используйте tail -f для чтения этого файла.

Сохраняется ли отсоединенная сессия tmux после перезагрузки?

Нет. Сервер tmux — это обычный процесс, а сессии хранятся в его оперативной памяти, поэтому перезагрузка завершает и то, и другое. Он также завершается, когда /etc/systemd/logind.conf устанавливает KillUserProcesses=yes и вы выходите из последней сессии, что предотвращает loginctl enable-linger "$USER". Для задач, которые должны автоматически возобновляться после перезагрузки, создайте systemd unit и systemctl enable его.

Почему мой скрипт работает в оболочке, но не запускается как systemd unit?

Unit не считывает /etc/profile или ~/.bashrc, поэтому у него нет ни ваших дополнений в PATH, ни экспортированных переменных. systemctl status с сообщением (code=exited, status=203/EXEC) означает, что systemd не смог выполнить файл; используйте абсолютный путь и проверьте наличие бита исполнения. Запустите sudo systemd-run --collect --wait --unit=envtest /usr/bin/env, прочитайте результат через journalctl -u envtest, и вы увидите точное окружение, которое получает ваша задача. Добавьте недостающие параметры через Environment= или EnvironmentFile=.

#ssh#tmux#nohup#systemd#long-running-jobs