SSD Nodes Learn 🎉 VPS od $5.50/mies.
Przewodniki Matt ConnorAutor: Matt Connor

Jak utrzymać proces po rozłączeniu sesji SSH

Dowiedz się, jak zapobiec przerwaniu zadań po zerwaniu połączenia SSH. Wykorzystaj nohup, disown, tmux lub systemd-run, aby uniknąć sygnału SIGHUP i zapewnić ciągłość pracy procesów.

Dlaczego polecenie kończy działanie po rozłączeniu SSH

Aby polecenie działało po rozłączeniu sesji SSH, musi znaleźć się w miejscu, do którego nie dotrze sygnał rozłączenia. Każda z poniższych metod realizuje to w inny sposób, dlatego należy zacząć od zrozumienia mechanizmu.

Sesja logowania działa na pty (pseudo-terminalu), czyli wirtualnym urządzeniu terminalowym, które sshd tworzy na serwerze dla danej sesji. Jest to terminal sterujący dla powłoki oraz każdego polecenia uruchomionego z jej poziomu. Szczegółowy opis tego procesu znajduje się w tym, co SSH konfiguruje podczas logowania. Gdy połączenie TCP zostaje zerwane, sshd zamyka swój koniec, a pty zostaje zniszczone. Jądro systemu traktuje to jako rozłączenie terminala i wysyła sygnał SIGHUP do grupy procesów pierwszego planu oraz do lidera sesji, którym jest powłoka. Domyślną akcją dla SIGHUP jest zakończenie procesu. Polecenie znajdowało się w grupie procesów pierwszego planu, więc również zostaje zakończone.

Zadania działające w tle również nie są bezpieczne. Zadanie uruchomione za pomocą & znajduje się we własnej grupie procesów, więc jądro nie wysyła do niego sygnału bezpośrednio. Robi to Bash. Po otrzymaniu SIGHUP, interaktywny Bash przed zakończeniem pracy wysyła SIGHUP do każdego zadania w swojej tabeli. Z perspektywy użytkownika efekt jest identyczny: zadanie znika, a plik dziennika przestaje być aktualizowany.

Występuje tu asymetria, która często wprowadza w błąd. Wpisanie exit nie powoduje zakończenia zadań w tle, ponieważ Bash wykonuje tę akcję tylko wtedy, gdy włączona jest opcja huponexit, która domyślnie jest wyłączona. Zerwane połączenie natomiast powoduje ich zakończenie. Zadanie, które przetrwało grzeczne zamknięcie terminala, może zostać przerwane w momencie utraty zasięgu Wi-Fi.

Wynikają z tego dwie konsekwencje, które stanowią sedno problemu. Proces, który ignoruje SIGHUP lub nie posiada terminala sterującego, nie zostanie przerwany. Jednak proces, którego standardowe wyjście nadal wskazuje na zniszczone pty, nie ma gdzie zapisywać danych: operacja zapisu kończy się błędem EIO (input/output error), co powoduje zakończenie większości programów. Należy rozwiązać obie te kwestie. Wiele poradników rozwiązuje tylko pierwszą z nich, dlatego użytkownicy często zgłaszają, że "nohup nie zadziałał".

Jeśli połączenie zrywane jest wielokrotnie w ciągu dnia, należy również wyeliminować przyczynę tego stanu. Parametr ServerAliveInterval 60 w pliku ~/.ssh/config zapobiega rozłączaniu bezczynnej sesji przez mechanizm NAT (network address translation) na trasie połączenia. Sytuacja, w której sesja w ogóle nie może zostać nawiązana, jest innym rodzajem awarii o odmiennych przyczynach; w takim przypadku istotne jest rozróżnienie między connection refused a connection timed out.

Która metoda pozwala na kontynuowanie działania polecenia po rozłączeniu sesji SSH?

Cztery odpowiedzi, uporządkowane według stopnia ważności zadania.

  • nohup lub setsid: jednorazowe zadanie uruchamiane doraźnie, którego logi sprawdzane są później. Przekierowanie wyjścia należy wykonać samodzielnie.
  • disown: zadanie, które zostało już uruchomione, ale nie zostało zabezpieczone. Narzędzie to przejmuje proces, jednak nie przywraca dostępu do jego wyjścia.
  • tmux lub screen: praca wymagająca monitorowania, przerywania i powracania do niej przez kilka dni.
  • systemd-run lub właściwy plik jednostki (unit file): każde zadanie, które musi działać niezależnie od sesji użytkownika, takie jak sześciogodzinny rsync lub nocny import bazy danych.

Zasada warta zapamiętania: jeśli zapomnienie o zadaniu stanowi problem, powinno ono być zarządzane przez systemd, a nie przez tmux. Okno tmux jest obiektem, o którym człowiek musi pamiętać. Jednostka systemd posiada nazwę, status, logi oraz politykę restartu, które kolejna osoba może odnaleźć bez dodatkowych instrukcji.

nohup oraz setsid: uruchom i rozłącz

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

nohup ustawia dyspozycję SIGHUP na ignorowanie, a następnie uruchamia polecenie, dzięki czemu sygnał hangup jądra systemu nie wywołuje żadnego efektu. Przekierowanie wyjścia należy skonfigurować samodzielnie. Jeśli standardowe wyjście pozostanie skierowane na terminal, nohup przekieruje je do pliku nohup.out w bieżącym katalogu, w razie potrzeby korzystając z $HOME/nohup.out, oraz wyświetli komunikat:

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

Łatwo stracić kontrolę nad tym plikiem, dlatego warto nadać mu własną nazwę. $! przechowuje PID (identyfikator procesu) ostatniego zadania uruchomionego w tle, a jego zapisanie pozwala na monitorowanie zadania po ponownym zalogowaniu.

setsid rozwiązuje ten sam problem od innej strony. Uruchamia polecenie w nowej sesji bez terminala sterującego, więc nie istnieje terminal, który mógłby wysłać sygnał hangup.

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

Należy użyć --fork. Bez tego parametru setsid wywołuje setsid() w miejscu, gdy proces nie jest liderem grupy procesów, co ma miejsce wewnątrz skryptu powłoki, przez co skrypt zostaje zablokowany. Z użyciem --fork zachowanie jest identyczne zarówno w skrypcie, jak i w wierszu poleceń.

Weryfikacja rezultatu:

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

Kolumna TTY z wartością ? oznacza, że proces nie posiada terminala sterującego, więc nic nie może go przerwać. W systemie nohup kolumna TTY nadal pokazuje wartość taką jak pts/0, dopóki użytkownik pozostaje zalogowany, a zmienia się na ? po zniszczeniu pty. Oba wyniki są poprawne. Zadanie przetrwało.

disown: ratowanie już uruchomionego zadania

Uruchomiono dwugodzinne zadanie na pierwszym planie i przypomniano sobie o tym problemie. Nie należy go przerywać, aby uruchamiać ponownie.

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

Ctrl-Z wstrzymuje zadanie, bg wznawia je w tle, a jobs -l wyświetla jego numer zadania obok PID. disown -h %1 oznacza zadanie w taki sposób, aby bash nie wysyłał do niego sygnału SIGHUP. Zwykłe disown %1 całkowicie usuwa zadanie z tabeli basha, co daje ten sam efekt w przypadku rozłączenia, ale wtedy jobs przestaje je wyświetlać na liście.

Czego disown nie potrafi, to przeniesienie wyjścia. Proces nadal utrzymuje pty jako swoje standardowe wyjście, a gdy pty znika, kolejna operacja zapisu zwraca EIO. Dlatego disown niezawodnie zapisuje ciche zadanie, takie jak kompilacja zapisująca do pliku, ale często traci zadanie generujące dużo komunikatów. Zadanie albo przetrwa bez miejsca do wypisywania danych, albo zakończy się przy próbie wypisania kolejnej linii.

Istnieje narzędzie ratunkowe dla deskryptorów plików. reptyr przenosi działający proces na bieżący terminal: należy zainstalować je za pomocą sudo apt install -y reptyr, a następnie uruchomić reptyr <pid> z wnętrza okna tmux. Narzędzie działa poprzez ptrace, a system Ubuntu dostarcza kernel.yama.ptrace_scope = 1, który pozwala na śledzenie tylko własnych procesów potomnych, więc proces odziedziczony wymaga sudo reptyr <pid>. Należy traktować to jako narzędzie awaryjne. Nie należy opierać na nim rutynowych działań.

tmux: praca wymagająca monitorowania i powrotu

tmux (terminal multiplexer) rozwiązuje ten problem w inny sposób. Zamiast chronić proces przed pty, udostępnia procesowi pty, które nie jest przypisane do sesji SSH. Serwer tmux działa poza tą sesją i zarządza terminalami wszystkich uruchomionych w niej procesów. Połączenie SSH pełni jedynie rolę podglądu. Przerwanie połączenia nie wpływa na działanie serwera.

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

Uruchom zadanie w oknie, a następnie naciśnij Ctrl-b, a po nim d, aby odłączyć sesję. Zaloguj się ponownie później i przywróć sesję:

tmux ls
tmux attach -t import

Polecenie tmux ls powinno wyświetlić linię zaczynającą się od import: 1 windows. Jeśli wyświetli no server running on /tmp/tmux-1000/default, oznacza to brak sesji, do której można się podłączyć – sesja nie została utworzona lub serwer został zamknięty.

screen realizuje to samo zadanie przy użyciu innych skrótów klawiszowych. screen -S import tworzy sesję, a Ctrl-a, a następnie d, odłącza od niej użytkownika. screen -ls wyświetla listę istniejących sesji, a screen -r import przywraca wybraną sesję. Oba narzędzia są poprawne. Kluczowym elementem, o którym często zapominają użytkownicy, jest skrót odłączający.

Multiplekser jest również właściwym środowiskiem dla pracy interaktywnej, która musi przetrwać utratę sygnału. Dlatego uruchamianie Claude Code na VPS wewnątrz tmux jest standardową konfiguracją. Pozwala to również na obsługę sesji serwerowej z telefonu w sieciach mobilnych, gdzie połączenie jest zrywane co kilka minut.

systemd-run: przekazanie zadania do PID 1

W przypadku zadania, które nie może być w żaden sposób zależne od bieżącej sesji, należy przekazać je do systemu init.

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

Polecenie to tworzy tymczasową jednostkę usługi o nazwie bigsync.service. Otrzymuje ona własną grupę kontrolną (cgroup), nie posiada terminala sterującego i nie jest powiązana z sesją użytkownika. Polecenie kończy działanie natychmiast i wypisuje Running as unit: bigsync.service. Monitorowanie przebiegu zadania umożliwiają poniższe polecenia:

systemctl status bigsync
journalctl -u bigsync -f

Flaga --collect nakazuje systemd usunięcie jednostki po zakończeniu jej pracy, nawet w przypadku błędu. Bez niej nieudana jednostka tymczasowa pozostaje załadowana, a jej nazwa zajęta, co powoduje, że kolejna próba uruchomienia kończy się błędem informującym o istnieniu jednostki. Dane wyjściowe trafiają do dziennika (journal) wraz ze znacznikami czasu dla każdego wiersza. Wpisy w dzienniku przetrwają restart systemu tylko wtedy, gdy istnieje katalog /var/log/journal; w tym celu należy wykonać sudo mkdir -p /var/log/journal i zrestartować systemd-journald.

W przypadku użytkownika bez uprawnień roota, wywołanie systemd-run bez sudo wymaga autoryzacji przez polkit i wypisuje ==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===. Do obsługi jednostek systemowych należy użyć sudo.

Zadanie można również uruchomić w ramach menedżera użytkownika:

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

Wiąże się to z pewnym ograniczeniem. Menedżer użytkownika user@1000.service zazwyczaj kończy działanie wraz z zamknięciem ostatniej sesji, co powoduje wyłączenie wszystkich jednostek użytkownika. Aby temu zapobiec, należy jednorazowo włączyć trwałość sesji (lingering):

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

Drugie polecenie powinno wypisać Linger=yes. Dzięki włączonej trwałości menedżer użytkownika uruchamia się podczas startu systemu i działa niezależnie od tego, czy użytkownik jest zalogowany. Bez tego ustawienia systemd-run --user nie oferuje żadnych korzyści względem nohup.

systemd-run --scope działa inaczej. Uruchamia polecenie na pierwszym planie, podłączone do terminala, więc w tym przypadku nie znajduje zastosowania.

W przypadku zadań uruchamianych wielokrotnie, należy utworzyć plik jednostki zamiast każdorazowego wpisywania polecenia tymczasowego.

Trwała jednostka dla zadania uruchamianego ponownie
[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

Zapisz powyższą treść jako /etc/systemd/system/nightly-sync.service, wykonaj sudo systemctl daemon-reload, a następnie uruchom usługę za pomocą sudo systemctl start nightly-sync i sprawdź jej stan poleceniem journalctl -u nightly-sync. Jeśli zadanie ma być uruchamiane według harmonogramu, a nie na żądanie, należy dodać odpowiadający mu plik .timer.

Tworzenie jednostki usługi systemd oraz jej timera zawiera pełny opis formatu plików oraz składni harmonogramu.

Gdzie trafia wyjście i dlaczego znika

Kolejność przekierowań ma znaczenie. > file 2>&1 kieruje standardowe wyjście do pliku, a następnie kieruje standardowy błąd w to samo miejsce. 2>&1 > file wykonuje to w odwrotnej kolejności: standardowy błąd nadal trafia do terminala, a terminal jest elementem, który zaraz zniknie. Bash obsługuje również &> file dla obu strumieni jednocześnie.

Drugim zaskoczeniem jest buforowanie. Gdy standardowe wyjście jest terminalem, biblioteka C czyści bufor przy każdej linii. Gdy standardowe wyjście jest plikiem, przełącza się na bufor blokowy o rozmiarze kilku kilobajtów, więc tail -f ~/import.log nie pokazuje nic przez minuty, a zadanie wygląda na zawieszone. Wymuś buforowanie liniowe za pomocą stdbuf -oL ./import.sh > ~/import.log 2>&1 lub użyj przełącznika programu, takiego jak python3 -u lub grep --line-buffered.

Unikaj tego wzorca:

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

nohup chroni tylko import.sh i nic więcej. tee jest oddzielnym procesem w tym samym potoku i nadal kończy działanie przy sygnale hangup. import.sh zapisuje wtedy do potoku bez czytelnika, więc otrzymuje SIGPIPE i zatrzymuje się. Umieść cały potok wewnątrz setsid bash -c '...' lub zapisuj bezpośrednio do pliku i uruchom tail -f na nim po ponownym połączeniu.

Jeszcze jeden szczegół dotyczący konkretnie rsync. --info=progress2 zapisuje strumień znaków powrotu karetki, który wygląda poprawnie w terminalu, ale staje się jedną ogromną linią w pliku dziennika lub w journal. W przypadku uruchomienia bez nadzoru, zrezygnuj z tego i użyj zamiast tego --stats.

Dlaczego zadanie działające w powłoce kończy się niepowodzeniem w systemd lub cron

Interaktywna powłoka odczytuje /etc/profile, ~/.profile oraz ~/.bashrc, dzięki czemu posiada dostęp do PATH, shims menedżerów wersji oraz wyeksportowanych zmiennych. Jednostka systemd nie odczytuje żadnego z tych plików. Cron również ich nie wczytuje: w systemach Debian i Ubuntu zadania crona są uruchamiane z SHELL=/bin/sh oraz PATH=/usr/bin:/bin.

Objawem w systemd jest zgłaszanie przez systemctl status błędu (code=exited, status=203/EXEC), co oznacza, że systemd nie mógł wykonać pliku, ponieważ ścieżka była błędna lub plik nie posiadał uprawnień do wykonywania. W przypadku crona zazwyczaj występuje command not found, dostarczane pocztą lokalną lub niedostarczane nigdzie, jeśli system pocztowy nie jest zainstalowany.

Przed poświęceniem godziny na domysły, sprawdź rzeczywiste środowisko:

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

To polecenie wyświetli dokładne środowisko, w którym uruchomione zostanie zadanie. Następnie uzupełnij braki. Używaj ścieżek bezwzględnych dla wszystkich własnych plików, ponieważ systemd rozwiązuje nazwę rsync w oparciu o stałą listę ścieżek systemowych, a nie w oparciu o PATH powłoki. Przekazuj wymagane zmienne za pomocą -p Environment="KEY=value" w wierszu poleceń lub za pomocą EnvironmentFile=/etc/default/myjob w pliku jednostki. Jeśli zadanie faktycznie wymaga środowiska logowania, uruchom je jako /bin/bash -lc 'my-command' i zaakceptuj fakt, że zadanie będzie teraz zależne od Twoich plików konfiguracyjnych (dotfiles).

Co nadal przerywa działanie zadania w tle

  • Restart systemu. Nic w tmux nie przetrwa restartu, ponieważ serwer jest zwykłym procesem, a sesje stanowią jego stan w pamięci operacyjnej. Aktualizacje jądra wymuszają restarty, dlatego zadanie, którego nie można tanio zrestartować, powinno znajdować się w jednostce, którą można systemctl enable.
  • Mechanizm OOM (Out-of-Memory) killer. dmesg -T | grep -i 'killed process' pokazuje jego działanie, w tym nazwę procesu, który został wybrany do zakończenia. Duży import danych na małym VPS jest częstym celem.
  • Czyszczenie przez logind. Jeśli /etc/systemd/logind.conf ma ustawione KillUserProcesses=yes, pozostałe procesy są kończone po zakończeniu ostatniej sesji użytkownika, co obejmuje również serwer tmux. Sprawdź bieżące ustawienie za pomocą loginctl show --property=KillUserProcesses i wyłącz to zachowanie dla swojego użytkownika za pomocą loginctl enable-linger "$USER".
  • Brak miejsca na dysku. Zadanie zatrzymuje się, ponieważ przekierowany plik dziennika zapełnił system plików, a nie z powodu wylogowania. Uruchom df -h, zanim przypiszesz winę sygnałowi zakończenia.

Uruchamianie zadania przez SSH bez utrzymywania połączenia

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

systemd-run kończy działanie natychmiast po uruchomieniu jednostki, dzięki czemu polecenie ssh również zwraca sterowanie, a zadanie traci połączenie z sesją, która je wywołała. Jest to czysta metoda.

Wersja nohup wymaga większej uwagi:

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

Bez przekierowań polecenie to wydaje się zawieszać. sshd utrzymuje kanał otwarty, dopóki jakikolwiek proces korzysta ze standardowego wyjścia lub standardowego błędu zdalnego polecenia, a zadanie uruchomione w tle dziedziczy oba te strumienie. Samo nohup nie rozwiązuje problemu, ponieważ nohup przekierowuje wyjście tylko wtedy, gdy jest ono terminalem, a w tym przypadku jest to potok (pipe) prowadzący do klienta. Dodanie < /dev/null zamyka również wejście. ssh -n wykonuje to samo zadanie od strony klienta.

FAQ

Dlaczego moje polecenie kończy działanie po zerwaniu połączenia SSH?

Pseudo-terminal (pty), z którego korzystała sesja, zostaje zniszczony, a jądro wysyła SIGHUP do grupy procesów pierwszego planu na tym terminalu. Domyślną akcją dla SIGHUP jest zakończenie procesu. Zadania w tle również kończą działanie, ponieważ bash wysyła SIGHUP do każdego zadania w swojej tablicy przed zamknięciem. Polecenie ignorujące SIGHUP, na przykład uruchomione za pomocą nohup, lub takie, które nigdy nie współdzieliło sesji, jak jednostka systemd, pozostaje nienaruszone.

Co jest lepsze do sześciogodzinnego rsync: tmux czy systemd-run?

systemd-run. Sesja tmux zależy od procesu serwera, który został uruchomiony przez użytkownika, więc kończy się przy następnym restarcie i jest niewidoczna dla każdego, kto nie wie, że należy użyć tmux ls. Uruchomienie sudo systemd-run --unit=bigsync --collect /usr/bin/rsync -aH --stats /srv/data/ /mnt/backup/ zapewnia systemctl status bigsync dla stanu oraz journalctl -u bigsync dla wyjścia, co pozwala kolejnemu administratorowi na wgląd bez dodatkowych instrukcji. Używaj tmux do pracy, w której musisz obserwować ekran i wprowadzać dane.

Jak zobaczyć wyjście zadania, dla którego zapomniałem przekierować strumień?

Zazwyczaj jest to niemożliwe, ponieważ wyjście trafiło do terminala, który już nie istnieje. Gdy proces nadal działa, można sprawdzić jego otwarte pliki za pomocą sudo ls -l /proc/<pid>/fd lub monitorować wywołania systemowe przez sudo strace -p <pid>, ale tekst, który został już wypisany, przepadł. reptyr <pid> pozwala przenieść proces na nowy terminal, a kernel.yama.ptrace_scope = 1 w systemie Ubuntu oznacza, że wymagane jest sudo dla procesu, który nie jest procesem potomnym użytkownika. Nawyk, który pozwala uniknąć takich sytuacji, to przekierowanie wyjścia do pliku na początku pracy i użycie tail -f dla tego pliku.

Czy odłączona sesja tmux przetrwa restart?

Nie. Serwer tmux jest zwykłym procesem, a sesje są jego stanem w pamięci operacyjnej, więc restart kończy oba te elementy. Serwer kończy również działanie, gdy /etc/systemd/logind.conf ustawia KillUserProcesses=yes, a użytkownik wylogowuje się z ostatniej sesji, czemu zapobiega loginctl enable-linger "$USER". W przypadku zadań, które muszą wznowić działanie automatycznie po restarcie, należy utworzyć jednostkę systemd i użyć systemctl enable.

Dlaczego mój skrypt działa w powłoce, ale kończy się błędem jako jednostka systemd?

Jednostka nie odczytuje /etc/profile ani ~/.bashrc, więc nie posiada ani Twoich dodatków w PATH, ani wyeksportowanych zmiennych. systemctl status wskazujące na (code=exited, status=203/EXEC) oznacza, że systemd nie mógł w ogóle wykonać pliku, dlatego należy użyć ścieżki bezwzględnej i sprawdzić bit wykonywalności. Uruchom sudo systemd-run --collect --wait --unit=envtest /usr/bin/env, odczytaj wynik za pomocą journalctl -u envtest, a otrzymasz dokładne środowisko, w którym działa zadanie. Brakujące elementy należy uzupełnić za pomocą Environment= lub EnvironmentFile=.

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