SSD Nodes Learn 🎉 VPS $5.50/月〜
ガイド Matt Connor著者 Matt Connor

SSH切断後もコマンドを実行し続ける方法

SSH切断時にコマンドが終了する原因はSIGHUPとptyです。nohup、disown、tmux、systemd-runの違いと、標準出力でEIOが起きる落とし穴を解説します。

SSH 接続が切断されるとコマンドが終了する理由

SSH 接続が切断された後もコマンドを実行し続けるには、ハングアップシグナルが届かない場所でコマンドを実行する必要があります。以下の各方法は、その状態を別の形で構成するものです。まず仕組みを理解してください。

ログインシェルは pty(疑似端末)上で実行されます。pty は、セッション用に sshd がサーバー上で作成する仮想端末デバイスです。これはシェルと、そのシェルから起動するすべてのコマンドの制御端末です。この経路の詳細については、ログイン時に SSH が構成するものを参照してください。TCP 接続が切断されると、sshd は自身の接続端を閉じ、pty は破棄されます。カーネルはこれを端末のハングアップとして扱い、その端末のフォアグラウンドプロセスグループとセッションリーダーであるシェルに SIGHUP を送信します。SIGHUP の既定の動作はプロセスを終了することです。コマンドはフォアグラウンドプロセスグループに属していたため、終了します。

バックグラウンドジョブも安全とは限りません。& で起動したジョブは独自のプロセスグループに属するため、カーネルはそのジョブに直接シグナルを送りません。代わりに Bash が処理します。対話型 Bash は SIGHUP を受信すると、終了する前にジョブテーブル内の各ジョブへ SIGHUP を再送します。利用者から見ると結果は同じです。ジョブは消え、ログファイルは行の途中で止まります。

ここには、混乱しやすい非対称性があります。exit を入力しても、バックグラウンドジョブはハングアップしません。Bash がその処理を行うのは huponexit オプションが設定されている場合だけで、既定では無効だからです。一方、接続が切断されるとジョブはハングアップします。端末を正常に閉じたときには生き残ったジョブが、Wi-Fi の切断時には終了することがあります。

ここから導かれる重要な点は2つだけです。SIGHUP を無視するプロセス、または制御端末を持たないプロセスは、ハングアップしません。また、標準出力が破棄された pty を指したままのプロセスには、書き込み先がありません。書き込みは EIO(入出力エラー)で失敗し、多くのプログラムはその時点で終了します。両方の問題を解決する必要があります。多くの手順は最初の問題しか解決しないため、「nohup が機能しなかった」と報告されます。

接続が1日に何度も切断される場合は、その問題も解決してください。~/.ssh/configServerAliveInterval 60 は、経路上の NAT(ネットワークアドレス変換)のタイムアウトによってアイドル状態のセッションが破棄されることを防ぎます。セッションをまったく開始できない場合は別の障害であり、原因も異なります。その場合は、connection refused と connection timed out の違いが重要になります。

SSH 接続が切断された後もコマンドを実行し続ける方法

方法は4つあります。ジョブの重要度が高い順に説明します。

  • nohup または setsid: 今すぐ開始し、後でログを確認する単発の処理です。出力は自分でリダイレクトします。
  • disown: すでに開始したものの、保護し忘れたジョブです。プロセスを救済します。ただし、出力を取り戻すことはできません。
  • tmux または screen: 数日間にわたって監視、中断、再開する必要がある作業です。
  • systemd-run または実際の unit ファイル: ログイン後も実行し続ける必要がある処理です。6時間の rsync や、夜間のデータベースインポートなどが該当します。

覚えておくべき原則は、ジョブを忘れると問題になる場合、そのジョブは tmux ではなく systemd で管理することです。tmux のウィンドウは、人が覚えておく必要があります。一方、unit には名前、状態、ログ、再起動ポリシーがあり、次の担当者も説明を受けずに確認できます。

nohup と setsid: 起動してそのまま離れる

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

nohupSIGHUP の処理を無視する設定にしてからコマンドを実行します。そのため、カーネルから 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() を呼び出します。シェルスクリプト内では通常この条件に該当するため、スクリプトはそこでブロックされます。--fork を付けると、スクリプト内でもプロンプト上でも動作が同じになります。

実際に作成された状態を確認します。

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

TTY 列の ? が示すのは、そのプロセスに制御端末がないことです。そのため、プロセスを終了させる端末もありません。nohup では、接続中は TTY 列に pts/0 のような値が表示され、pty が破棄されると ? になります。どちらの結果も正常です。ジョブは存続しています。

disown: すでに開始したジョブを救済する

フォアグラウンドで 2 時間かかるジョブを開始した後、この問題を思い出したとします。ジョブを停止して、最初からやり直してはいけません。

# 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 の管理対象から完全に削除します。これも hangup に対しては同じ効果がありますが、その後は jobs で一覧表示できなくなります。

disown でできないのは、出力先の移動です。プロセスは標準出力として pty を保持したままです。pty がなくなると、次の書き込みで EIO が返されます。そのため、disown はファイルに書き込むコンパイルなど、出力の少ないジョブは確実に救済できますが、出力の多いジョブでは失敗することがよくあります。ジョブは、出力先がないまま存続するか、次に出力する時点で終了します。

ファイルディスクリプターを救済するツールもあります。reptyr は実行中のプロセスを現在の端末に移動します。sudo apt install -y reptyr でインストールし、tmux のウィンドウ内から reptyr <pid> を実行します。これは ptrace を使用して動作します。Ubuntu には kernel.yama.ptrace_scope = 1 が付属しており、自分が所有する子プロセスだけを追跡できます。そのため、引き継いだプロセスには sudo reptyr <pid> が必要です。これは緊急時のツールとして扱ってください。通常の運用をこれに依存してはいけません。

tmux: 監視して後で戻る必要がある作業

tmux(ターミナルマルチプレクサ)は、別の場所でこの問題を解決します。プロセスを pty から保護するのではなく、SSH セッションに属さない pty をプロセスに提供します。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-ad の順に操作するとデタッチできます。screen -ls で存在するセッションを一覧表示し、screen -r import でいずれかを再接続します。ここではどちらのツールを使っても問題ありません。忘れやすいのはデタッチ用のキー操作です。

マルチプレクサは、接続が切れても維持する必要がある対話的な作業の実行場所としても適しています。そのため、VPS 上の tmux 内で Claude Code を実行する構成が一般的です。また、数分ごとに再接続するモバイルネットワークでも、スマートフォンからサーバーセッションを操作する構成を実用的にします。

systemd-run: ジョブを PID 1 に渡す

自分にまったく依存させたくないジョブは、init システムに渡します。

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

これにより、bigsync.service という名前の一時的な service unit が作成されます。専用の cgroup が割り当てられ、制御端末はなく、ログインセッションとの関係もありません。コマンドはすぐに戻り、Running as unit: bigsync.service を出力します。次のいずれかで監視します。

systemctl status bigsync
journalctl -u bigsync -f

--collect を指定すると、失敗した場合でも、完了後に systemd が unit を削除します。指定しない場合、失敗した一時 unit は読み込まれたままになり、その名前も使用中のままです。そのため、次回の実行は unit がすでに存在するというメッセージで失敗します。出力は、各行にタイムスタンプを付けて journal に記録されます。journal のエントリが再起動後も残るのは、/var/log/journal が存在する場合だけです。永続化する場合は、sudo mkdir -p /var/log/journal を実行して systemd-journald を再起動します。

一般ユーザーが sudo なしで systemd-run を呼び出すと、polkit に認証を求め、==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units === を出力します。system unit には sudo を使用します。

自分の user manager でジョブを実行することもできます。

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

ここには注意点があります。ユーザーごとの manager である user@1000.service は、通常、最後のセッションが終了すると停止します。その際、すべての user unit も停止します。lingering は一度だけ有効にします。

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

2 つ目のコマンドは Linger=yes を出力するはずです。lingering を有効にすると、user manager はブート時に起動し、ログインしているかどうかに関係なく実行を続けます。これを有効にしない場合、systemd-run --usernohup に対する利点がありません。

systemd-run --scope は別の機能です。コマンドをフォアグラウンドで実行し、端末に接続します。そのため、ここでの目的には適しません。

複数回実行するジョブは、一時的な unit を毎回入力するのではなく、unit として保存します。

再実行するジョブ用の永続的な unit
[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 と timer の作成では、ファイル形式とスケジュール構文を詳しく説明しています。

出力先と、出力が消える理由

リダイレクトの順序は重要です。> file 2>&1 は標準出力をファイルに向け、その後、標準エラー出力も同じ場所に向けます。2>&1 > file は逆の動作をします。標準エラー出力は端末に向かい続け、まもなく消える端末が出力先になります。Bash では、両方のストリームを同時に指定する &> file も使用できます。

もう 1 つの注意点はバッファリングです。標準出力が端末の場合、C ライブラリは行ごとにフラッシュします。標準出力がファイルの場合は、数 KB 単位のブロックバッファーに切り替わるため、tail -f ~/import.log は数分間何も表示せず、ジョブが停止したように見えます。stdbuf -oL ./import.sh > ~/import.log 2>&1 で行バッファリングを強制するか、python3 -ugrep --line-buffered のようにプログラム独自のオプションを使用してください。

次のパターンは避けてください。

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

nohup が保護するのは import.sh だけです。同じパイプライン内の別プロセスである tee は、ハングアップすると終了します。その後、import.sh は読み手のいないパイプへ書き込むため、SIGPIPE を受け取って停止します。パイプライン全体を setsid bash -c '...' の中に入れるか、ファイルへ直接書き込み、再接続したときに tail -f を実行してください。

rsync について、もう 1 つ注意点があります。--info=progress2 はキャリッジリターンのストリームを書き込みます。端末では正しく見えますが、ログファイルや journal では非常に長い 1 行になります。無人実行ではこれを外し、代わりに --stats を使用してください。

シェルでは動作するジョブが systemd や cron では失敗する理由

対話型シェルは /etc/profile~/.profile~/.bashrc を読み込むため、PATH、バージョンマネージャーの shim、export された変数を利用できます。systemd の unit はこれらを読み込みません。cron も同様です。Debian と Ubuntu では、cron はジョブを SHELL=/bin/shPATH=/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"、または unit ファイルの EnvironmentFile=/etc/default/myjob で渡します。ジョブでログイン環境が実際に必要な場合は、/bin/bash -lc 'my-command' として実行します。ただし、そのジョブは dotfile に依存することになります。

切り離したジョブを停止させる要因

  • 再起動です。サーバーは通常のプロセスであり、セッションはそのメモリ上の状態にすぎないため、tmux の状態は再起動後も保持されません。カーネル更新には再起動が伴うため、容易に再起動できないジョブは systemctl enable 可能な unit に入れてください。
  • Out-of-memory killer です。dmesg -T | grep -i 'killed process' で、選択されたプロセス名を含めて確認できます。小規模な VPS での大規模なインポートは、頻繁に対象になります。
  • logind によるクリーンアップです。/etc/systemd/logind.confKillUserProcesses=yes を設定している場合、最後のセッションが終了すると残存プロセスが強制終了され、tmux server も対象になります。現在の設定は 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 は unit の起動が完了するとすぐに戻るため、ssh コマンドも戻り、ジョブは起動元のセッションに接続されません。これが適切な方法です。

nohup を使う方法では、さらに注意が必要です。

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

リダイレクトを指定しないと、処理が停止したように見えます。sshd は、リモートコマンドの標準出力または標準エラーを保持しているプロセスが存在する間、チャネルを開いたままにします。バックグラウンドで実行したジョブは、標準出力と標準エラーの両方を継承します。nohup だけでは解決しません。nohup が出力をリダイレクトするのは、その出力が端末の場合だけであり、ここではクライアントへ戻るパイプだからです。< /dev/null を追加すると、入力側も閉じられます。ssh -n はクライアント側で同じ処理を行います。

FAQ

SSH 接続が切れるとコマンドが停止するのはなぜですか?

セッションが使用していた pty(擬似端末)が破棄され、その端末のフォアグラウンドプロセスグループにカーネルが SIGHUP を送信するためです。SIGHUP のデフォルト動作はプロセスの終了です。bash は終了する前にジョブテーブル内のすべてのジョブへ SIGHUP を再送するため、バックグラウンドジョブも終了します。SIGHUP を無視するコマンド(nohup で起動したコマンドなど)や、そもそもセッションを共有していない systemd unit は影響を受けません。

6 時間の 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.confKillUserProcesses=yes を設定して最後のセッションからログアウトするときにも終了します。loginctl enable-linger "$USER" がこれを防ぎます。再起動後も自動的に復帰させる必要がある作業では、systemd unit を作成して systemctl enable してください。

シェルではスクリプトが実行できるのに、systemd unit では失敗するのはなぜですか?

unit は /etc/profile~/.bashrc も読み込まないため、ユーザー固有の PATH の追加設定も、export された変数も利用できません。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