SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Cách giữ lệnh chạy sau khi ngắt kết nối SSH

Khi SSH ngắt, kernel gửi SIGHUP khiến tiến trình bị dừng. Bạn hãy dùng nohup, disown, tmux hoặc systemd-run để duy trì lệnh. Bài viết hướng dẫn chọn công cụ phù hợp cho từng tác vụ.

Tại sao lệnh của bạn bị ngắt khi SSH mất kết nối

Để một lệnh tiếp tục chạy sau khi SSH ngắt, lệnh đó phải nằm ở nơi mà tín hiệu ngắt (hangup signal) không thể chạm tới. Mỗi phương pháp dưới đây là một cách sắp xếp khác nhau để đạt được điều đó, vì vậy hãy bắt đầu với cơ chế hoạt động.

Phiên đăng nhập của bạn chạy trên một pty (pseudo-terminal), một thiết bị đầu cuối ảo mà sshd tạo ra trên máy chủ cho phiên làm việc của bạn. Đây là terminal điều khiển shell của bạn và mọi lệnh bạn khởi chạy từ shell đó. Nếu bạn muốn biết chi tiết lộ trình này, những gì SSH thiết lập khi bạn đăng nhập sẽ giải thích rõ. Khi kết nối TCP bị ngắt, sshd đóng đầu kết nối của nó và pty bị hủy. Kernel coi đó là sự kiện terminal bị ngắt, nên nó gửi SIGHUP đến nhóm tiến trình chạy ở foreground của terminal đó và đến session leader, chính là shell của bạn. Hành động mặc định của SIGHUP là kết thúc tiến trình. Lệnh của bạn nằm trong nhóm tiến trình foreground, nên lệnh của bạn bị ngắt.

Các tiến trình chạy nền (background jobs) cũng không an toàn. Một job khởi chạy với & nằm trong nhóm tiến trình riêng của nó, nên kernel không gửi tín hiệu trực tiếp cho nó. Nhưng Bash thì có. Khi nhận được SIGHUP, một bash tương tác sẽ gửi lại SIGHUP cho mọi job trong bảng của nó trước khi thoát. Từ phía bạn, kết quả trông giống hệt nhau: job đã biến mất và file log dừng lại giữa chừng.

Có một sự bất đối xứng ở đây gây nhầm lẫn cho người dùng. Gõ exit không làm ngắt các job chạy nền của bạn, vì bash chỉ làm điều đó khi tùy chọn huponexit được bật, và mặc định nó đang tắt. Một kết nối bị rớt lại làm ngắt chúng. Job vẫn tồn tại khi bạn đóng terminal một cách bình thường vẫn có thể bị ngắt khi wifi bị rớt.

Hai hệ quả xảy ra, và chúng là toàn bộ vấn đề. Một tiến trình bỏ qua SIGHUP, hoặc không có terminal điều khiển, sẽ không bị ngắt. Và một tiến trình có đầu ra tiêu chuẩn (standard output) vẫn trỏ vào pty đã bị hủy sẽ không có nơi để ghi: thao tác ghi thất bại với EIO (lỗi nhập/xuất), và hầu hết các chương trình sẽ thoát tại thời điểm đó. Bạn phải giải quyết cả hai nửa vấn đề này. Nhiều công thức chỉ giải quyết nửa đầu, đó là lý do tại sao mọi người báo cáo rằng "nohup không hoạt động".

Nếu kết nối của bạn bị rớt nhiều lần trong ngày, hãy khắc phục cả điều đó. ServerAliveInterval 60 trong ~/.ssh/config ngăn phiên làm việc nhàn rỗi bị loại bỏ bởi timeout của NAT (network address translation) trên đường truyền. Một phiên làm việc không bao giờ mở được là một lỗi khác với các nguyên nhân khác, đó là lúc sự khác biệt giữa connection refused và connection timed out trở nên quan trọng.

Phương pháp nào giúp lệnh tiếp tục chạy sau khi ngắt kết nối SSH?

Bốn phương án, sắp xếp theo mức độ quan trọng của tác vụ.

  • nohup hoặc setsid: dành cho tác vụ chạy một lần, bạn khởi chạy ngay và đọc log sau đó. Bạn phải tự chuyển hướng đầu ra (output).
  • disown: dành cho tác vụ bạn đã lỡ chạy mà quên bảo vệ. Nó giúp giải cứu tiến trình. Nó không thể hiển thị lại đầu ra cho bạn.
  • tmux hoặc screen: dành cho công việc bạn cần theo dõi, tạm dừng và quay lại trong nhiều ngày.
  • systemd-run hoặc một unit file thực thụ: dành cho bất cứ thứ gì cần tồn tại lâu hơn phiên đăng nhập của bạn, ví dụ như một rsync kéo dài sáu giờ hoặc một tác vụ import database qua đêm.

Quy tắc cần ghi nhớ: nếu việc quên mất tác vụ đó gây ra vấn đề, thì tác vụ đó thuộc về systemd, không phải tmux. Cửa sổ tmux là thứ mà con người phải tự ghi nhớ. Một unit có tên, trạng thái, log và chính sách khởi động lại mà người kế nhiệm có thể tìm thấy mà không cần ai hướng dẫn.

nohup và setsid: chạy lệnh và thoát phiên làm việc

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

nohup thiết lập disposition của SIGHUP thành ignore rồi chạy lệnh của bạn, nhờ đó tín hiệu hangup từ kernel gửi đến sẽ không có tác dụng. Bạn phải tự thực hiện việc chuyển hướng (redirect) đầu ra. Nếu bạn để standard output trỏ vào terminal, nohup sẽ tự động chuyển hướng nó vào nohup.out trong thư mục hiện tại, hoặc dùng $HOME/nohup.out nếu không ghi được file, đồng thời in ra thông báo:

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

File này rất dễ bị bỏ quên, vì vậy hãy tự đặt tên cho nó. $! lưu PID (process identifier) của tiến trình chạy nền gần nhất, việc lưu lại PID giúp bạn kiểm tra tiến trình sau khi đăng nhập lại.

setsid giải quyết vấn đề tương tự theo hướng khác. Nó chạy lệnh trong một session mới mà không có controlling terminal, do đó không có terminal nào có thể gửi tín hiệu hangup cho tiến trình.

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

Hãy sử dụng --fork. Nếu không có nó, setsid sẽ gọi setsid() tại chỗ bất cứ khi nào tiến trình không phải là process group leader (điều thường xảy ra trong shell script), khiến script của bạn bị treo tại đó. Với --fork, hành vi của lệnh sẽ đồng nhất dù bạn chạy trong script hay tại dấu nhắc lệnh.

Kiểm tra kết quả thực tế:

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

Cột TTY hiển thị ? nghĩa là tiến trình không có controlling terminal, nên không gì có thể làm nó bị hangup. Trong nohup, cột TTY vẫn hiển thị giá trị như pts/0 khi bạn còn kết nối, và chuyển thành ? sau khi pty bị hủy. Cả hai kết quả đều cho thấy tiến trình vẫn hoạt động bình thường. Công việc đã được duy trì.

disown: giải cứu một tiến trình bạn đã khởi chạy

Bạn đã khởi chạy một tiến trình mất hai giờ ở foreground và sau đó mới nhớ ra vấn đề này. Đừng kill nó rồi chạy lại từ đầu.

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

Ctrl-Z tạm dừng tiến trình, bg tiếp tục chạy nó ở background, và jobs -l in ra số thứ tự job bên cạnh PID của nó. disown -h %1 đánh dấu job đó để bash không gửi tín hiệu SIGHUP cho nó. Lệnh disown %1 đơn thuần sẽ loại bỏ hoàn toàn job đó khỏi bảng quản lý của bash, điều này có tác dụng tương tự đối với tín hiệu hangup, nhưng sau đó jobs sẽ không còn liệt kê nó nữa.

Điều mà disown không thể làm được là di chuyển đầu ra (output). Tiến trình vẫn giữ pty làm đầu ra tiêu chuẩn, và khi pty biến mất, lần ghi tiếp theo sẽ trả về lỗi EIO. Vì vậy, disown chỉ lưu được các tiến trình chạy ngầm (quiet job) một cách đáng tin cậy, ví dụ như quá trình biên dịch ghi kết quả vào file, và thường làm mất các tiến trình xuất nhiều dữ liệu ra màn hình. Tiến trình đó hoặc là vẫn sống nhưng không có nơi để in kết quả, hoặc sẽ chết ngay tại dòng output tiếp theo.

Có một công cụ cứu hộ cho các file descriptor. reptyr di chuyển một tiến trình đang chạy vào terminal hiện tại của bạn: cài đặt nó bằng sudo apt install -y reptyr, sau đó chạy reptyr <pid> từ bên trong một cửa sổ tmux. Nó hoạt động thông qua ptrace, và Ubuntu cung cấp kernel.yama.ptrace_scope = 1, vốn chỉ cho phép trace các tiến trình con của chính bạn, vì vậy một tiến trình bạn kế thừa cần có sudo reptyr <pid>. Hãy coi đây là công cụ khẩn cấp. Đừng xây dựng quy trình làm việc dựa trên nó.

tmux: công việc bạn cần theo dõi và quay lại

tmux (terminal multiplexer) giải quyết vấn đề ở một khía cạnh khác. Thay vì bảo vệ tiến trình của bạn khỏi pty, nó cung cấp cho tiến trình một pty không thuộc về phiên SSH của bạn. Máy chủ tmux chạy bên ngoài phiên đó và sở hữu terminal của mọi thứ bên trong nó. Kết nối SSH của bạn chỉ là một trình xem được gắn vào đó. Ngắt kết nối và máy chủ sẽ không nhận ra điều đó.

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

Bắt đầu công việc trong cửa sổ đó, sau đó nhấn Ctrl-b theo sau là d để detach. Đăng nhập lại sau và tiếp tục công việc:

tmux ls
tmux attach -t import

tmux ls sẽ in ra một dòng bắt đầu bằng import: 1 windows. Nếu nó in ra no server running on /tmp/tmux-1000/default, nghĩa là không có phiên nào để attach, vì nó chưa bao giờ được tạo hoặc có thứ gì đó đã kill máy chủ.

screen thực hiện công việc tương tự với tổ hợp phím khác. screen -S import tạo một phiên, và Ctrl-a sau đó d sẽ detach khỏi phiên đó. screen -ls liệt kê các phiên hiện có và screen -r import đưa bạn quay lại một phiên. Công cụ nào cũng tốt cả. Phím detach là phần mà mọi người hay quên.

Multiplexer cũng là nơi phù hợp cho các công việc tương tác cần tồn tại sau khi mất tín hiệu, đó là lý do tại sao chạy Claude Code trên VPS bên trong tmux là thiết lập tiêu chuẩn, và đây cũng là điều giúp cho việc điều khiển phiên làm việc trên máy chủ từ điện thoại trở nên khả thi trên mạng di động vốn thường xuyên kết nối lại sau mỗi vài phút.

systemd-run: giao việc cho PID 1

Đối với một tác vụ không được phép phụ thuộc vào bạn, hãy giao nó cho hệ thống init.

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

Lệnh này tạo ra một transient service unit có tên là bigsync.service. Nó có cgroup riêng, không có terminal điều khiển và không liên quan đến phiên đăng nhập của bạn. Lệnh trả về kết quả ngay lập tức và in ra Running as unit: bigsync.service. Theo dõi nó bằng một trong hai cách sau:

systemctl status bigsync
journalctl -u bigsync -f

--collect yêu cầu systemd xóa unit ngay khi nó kết thúc, kể cả khi thất bại. Nếu thiếu tham số này, một transient unit bị lỗi sẽ vẫn được load và tên của nó vẫn bị chiếm dụng, khiến lần chạy tiếp theo thất bại với thông báo unit đã tồn tại. Đầu ra được ghi vào journal với dấu thời gian trên mỗi dòng. Các mục trong journal chỉ tồn tại sau khi reboot nếu thư mục /var/log/journal tồn tại, vì vậy hãy chạy sudo mkdir -p /var/log/journal và khởi động lại systemd-journald nếu bạn muốn điều đó.

Với tư cách là người dùng bình thường, việc gọi systemd-run mà không có sudo sẽ yêu cầu polkit cấp quyền và in ra ==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===. Hãy dùng sudo cho các system unit.

Bạn cũng có thể chạy tác vụ dưới trình quản lý người dùng của chính mình:

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

Cách này có một cái bẫy. Trình quản lý người dùng của bạn, user@1000.service, thường sẽ dừng lại khi phiên làm việc cuối cùng của bạn kết thúc và nó sẽ kéo theo mọi user unit xuống cùng. Hãy bật tính năng lingering một lần:

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

Lệnh thứ hai sẽ in ra Linger=yes. Khi bật lingering, trình quản lý người dùng của bạn sẽ khởi động cùng hệ thống và tiếp tục chạy bất kể bạn có đăng nhập hay không. Nếu không có nó, systemd-run --user sẽ không mang lại lợi ích gì so với nohup.

systemd-run --scope là một thứ khác. Nó chạy lệnh ở foreground, gắn liền với terminal của bạn, vì vậy nó không giúp ích gì trong trường hợp này.

Đối với bất kỳ tác vụ nào bạn sẽ chạy nhiều lần, hãy viết unit ra file thay vì gõ lệnh transient mỗi lần.

Một unit cố định cho tác vụ bạn sẽ chạy lại
[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

Lưu nội dung đó thành /etc/systemd/system/nightly-sync.service, chạy sudo systemctl daemon-reload, sau đó khởi động nó bằng sudo systemctl start nightly-sync và đọc lại trạng thái bằng journalctl -u nightly-sync. Thêm một file .timer tương ứng khi tác vụ cần chạy theo lịch trình thay vì chạy theo yêu cầu.

Viết một systemd service unit và timer của nó bao gồm đầy đủ định dạng file và cú pháp lịch trình.

Đầu ra đi đâu và tại sao nó biến mất

Thứ tự của các lệnh chuyển hướng (redirection) rất quan trọng. > file 2>&1 trỏ standard output vào file, sau đó trỏ standard error vào cùng vị trí đó. 2>&1 > file thực hiện ngược lại: standard error vẫn tiếp tục đổ ra terminal, và terminal là thứ sắp biến mất. Bash cũng chấp nhận &> file cho cả hai luồng cùng lúc.

Điều bất ngờ thứ hai là cơ chế đệm (buffering). Khi standard output là terminal, thư viện C sẽ flush sau mỗi dòng. Khi standard output là file, nó chuyển sang chế độ block buffer với dung lượng vài kilobyte, vì vậy tail -f ~/import.log không hiển thị gì trong nhiều phút và công việc trông như đã chết. Hãy ép buộc line buffering bằng stdbuf -oL ./import.sh > ~/import.log 2>&1, hoặc sử dụng tùy chọn riêng của chương trình, ví dụ như python3 -u hoặc grep --line-buffered.

Tránh mẫu cấu trúc này:

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

nohup chỉ bảo vệ import.sh và không gì khác. tee là một tiến trình riêng biệt trong cùng pipeline, và nó vẫn bị ngắt khi có tín hiệu hangup. import.sh sau đó sẽ ghi vào một pipe không có trình đọc, vì vậy nó nhận SIGPIPE và dừng lại. Hãy đặt toàn bộ pipeline vào trong setsid bash -c '...', hoặc ghi trực tiếp vào file và chạy tail -f trên file đó khi bạn kết nối lại.

Một chi tiết nữa dành riêng cho rsync. --info=progress2 ghi một luồng các ký tự xuống dòng (carriage return) trông có vẻ đúng trên terminal nhưng lại trở thành một dòng cực dài trong file log hoặc trong journal. Đối với các tiến trình chạy ngầm không cần giám sát, hãy bỏ nó đi và sử dụng --stats thay thế.

Tại sao một job chạy được trong shell của bạn lại lỗi khi chạy dưới systemd hoặc cron

Shell tương tác của bạn đọc các file /etc/profile, ~/.profile~/.bashrc, nên nó có sẵn PATH, các shim của trình quản lý phiên bản và các biến môi trường đã export. Một unit của systemd không đọc các file này. Cron cũng không đọc chúng: trên Debian và Ubuntu, cron chạy các job với SHELL=/bin/shPATH=/usr/bin:/bin.

Triệu chứng dưới systemd là systemctl status báo lỗi (code=exited, status=203/EXEC), nghĩa là systemd không thể thực thi file, do sai đường dẫn hoặc file chưa được cấp quyền thực thi. Dưới cron, lỗi thường là command not found, được gửi qua mail cục bộ, hoặc không gửi đi đâu cả nếu hệ thống mail chưa được cài đặt.

Hãy kiểm tra môi trường thực tế trước khi mất hàng giờ để đoán mò:

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

Lệnh này in ra chính xác môi trường mà job của bạn sẽ chạy cùng. Sau đó hãy khắc phục khoảng trống đó. Sử dụng đường dẫn tuyệt đối cho mọi thứ của riêng bạn, vì systemd phân giải một lệnh rsync đơn thuần dựa trên danh sách đường dẫn hệ thống cố định chứ không bao giờ dựa trên PATH của shell. Hãy truyền các biến cần thiết bằng -p Environment="KEY=value" trên dòng lệnh, hoặc với EnvironmentFile=/etc/default/myjob trong file unit. Khi một job thực sự cần môi trường đăng nhập của bạn, hãy chạy nó dưới quyền /bin/bash -lc 'my-command' và chấp nhận rằng job đó giờ đây phụ thuộc vào các file cấu hình (dotfiles) của bạn.

Những yếu tố vẫn làm dừng một tiến trình chạy ngầm

  • Khởi động lại máy chủ. Không có gì trong tmux tồn tại sau khi reboot, vì tmux server chỉ là một tiến trình thông thường và các session nằm trong bộ nhớ của nó. Cập nhật kernel yêu cầu reboot, vì vậy một công việc không thể khởi động lại dễ dàng cần được đưa vào một unit mà bạn có thể systemctl enable.
  • Trình diệt tiến trình khi thiếu bộ nhớ (OOM killer). dmesg -T | grep -i 'killed process' sẽ hiển thị thông tin này, bao gồm tên tiến trình bị chọn. Một tác vụ import dữ liệu lớn trên VPS cấu hình thấp thường là mục tiêu.
  • Cơ chế dọn dẹp của logind. Nếu /etc/systemd/logind.conf thiết lập KillUserProcesses=yes, các tiến trình còn sót lại của bạn sẽ bị kill khi session cuối cùng kết thúc, bao gồm cả tmux server. Kiểm tra thiết lập hiện tại bằng loginctl show --property=KillUserProcesses và loại trừ user của bạn bằng loginctl enable-linger "$USER".
  • Đầy ổ cứng. Công việc dừng lại vì file log bạn redirect đã làm đầy filesystem, chứ không phải do bạn thoát ra. Hãy chạy df -h trước khi đổ lỗi cho tín hiệu (signal).

Chạy một tác vụ qua SSH mà không cần duy trì kết nối

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

systemd-run trả về ngay khi unit đã khởi động, vì vậy lệnh ssh cũng trả về và tác vụ không còn kết nối với phiên đã khởi chạy nó. Đây là cách làm sạch sẽ.

Phiên bản nohup cần cẩn thận hơn:

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

Nếu không có các lệnh chuyển hướng (redirection), tiến trình này có vẻ như bị treo. sshd giữ kênh kết nối mở chừng nào còn tiến trình nào đó vẫn sử dụng standard output hoặc standard error của lệnh từ xa, và một tác vụ chạy nền sẽ kế thừa cả hai. Chỉ dùng nohup là không đủ, vì nohup chỉ chuyển hướng output khi output đó là một terminal, còn ở đây nó là một pipe quay về client của bạn. Thêm < /dev/null sẽ đóng cả phía input. ssh -n thực hiện công việc tương tự từ phía client.

FAQ

Why does my command stop when the SSH connection drops?

The pty (pseudo-terminal) your session was using is destroyed, and the kernel sends SIGHUP to the foreground process group on that terminal. The default action for SIGHUP is to end the process. Background jobs go too, because bash resends SIGHUP to every job in its table before it exits. A command that ignores SIGHUP, such as one started with nohup, or one that never shared your session at all, such as a systemd unit, is unaffected.

Is tmux or systemd-run better for a six-hour rsync?

systemd-run. A tmux session depends on a server process that you started, so it ends at the next reboot, and it is invisible to anyone who does not know to run tmux ls. Running sudo systemd-run --unit=bigsync --collect /usr/bin/rsync -aH --stats /srv/data/ /mnt/backup/ gives you systemctl status bigsync for the state and journalctl -u bigsync for the output, both of which the next administrator finds without being told. Use tmux for work where you need to watch the screen and type into it.

How do I see the output of a job I forgot to redirect?

Usually you cannot, because that output went to a terminal which no longer exists. While the process is still running you can inspect its open files with sudo ls -l /proc/<pid>/fd or watch its system calls with sudo strace -p <pid>, but the text already written is gone. reptyr <pid> can move the process onto a fresh terminal, and Ubuntu's kernel.yama.ptrace_scope = 1 means it needs sudo for a process that is not your own child. The habit that avoids all of this is to redirect into a file at the start and tail -f that file.

Does a detached tmux session survive a reboot?

No. The tmux server is an ordinary process and the sessions are its in-memory state, so a reboot ends both. It also dies when /etc/systemd/logind.conf sets KillUserProcesses=yes and you log out of your last session, which loginctl enable-linger "$USER" prevents. For work that has to come back on its own after a reboot, write a systemd unit and systemctl enable it.

Why does my script run in the shell but fail as a systemd unit?

A unit does not read /etc/profile or ~/.bashrc, so it has neither your PATH additions nor your exported variables. systemctl status showing (code=exited, status=203/EXEC) means systemd could not execute the file at all, so use an absolute path and check the executable bit. Run sudo systemd-run --collect --wait --unit=envtest /usr/bin/env, read it back with journalctl -u envtest, and you have the exact environment your job gets. Supply whatever is missing with Environment= or EnvironmentFile=.

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