週五下午五點半,SSH 進正式機,敲下 nohup java -jar app.jar &,看著 log 開始滾,關掉視窗,下班。

星期一早上收到客服訊息說系統從週末就連不上。SSH 進去 ps -ef | grep java,什麼都沒有。

這件事我踩過,同事也踩過,但我們兩個踩的還不是同一種。他的版本更氣人:他沒加 nohup,只打了 java -jar app.jar &,關掉視窗,服務活得好好的。同一份佈署筆記,兩台一模一樣的機器,兩個相反的結果。

先怪 Java,怪錯了

那時候第一個念頭是 JVM 有沒有裝什麼 shutdown hook 在亂關。翻了一輪 Spring Boot 的 graceful shutdown 設定,server.shutdown=graceful 沒開,spring.lifecycle.timeout-per-shutdown-phase 是預設值。程式碼裡也沒有人自己註冊 Runtime.getRuntime().addShutdownHook

第二個念頭是 OOM Killer。dmesg | grep -i kill 乾乾淨淨,機器有 16G,這支服務吃不到 2G。

兩個假設都不成立之後,我才意識到一件事:進程死掉的時間點,跟我關掉終端機的時間點對得上。那就不是 Java 的問題。Java 從頭到尾只是一個乖乖收訊號的進程,決定要不要送那個訊號給它的,是外面那層 shell。

訊號是誰送的,送給誰

拆開來看其實只有三個角色。

終端機(或 SSH session)是房東。你的 shell 是二房東。java -jar 開出來的那個進程是房客。房東要收房的時候,只會通知二房東一聲 SIGHUP,也就是 hang up,「線斷了」。房東不會挨家挨戶敲門。

二房東收到這聲通知之後會做一件很盡責的事:在自己搬走之前,照著租客名冊一個一個轉告。

& 這個符號做的事,只是把房客登記進名冊而已。它讓進程在背景跑,但那份名冊還在,門牌還掛著。它沒有解掉任何一件跟 SIGHUP 有關的事。

所以房客要活下來,只有兩條路:從名冊上被劃掉,或者自己戴耳塞裝作沒聽到。後面會看到,這剛好對應兩個不同的指令。

我第一次測,測錯了東西

這件事不用查文件,跑一行就知道。我在自己的 Mac 上跑了兩個 shell 的原廠預設值:

1
2
3
4
5
6
7
# zsh 的 HUP 選項(-f 代表不讀任何設定檔,看的是原廠值)
zsh -f -c '[[ -o HUP ]] && echo "HUP = ON" || echo "HUP = OFF"'
# 輸出:HUP = ON

# bash 的 huponexit(--norc 同理)
bash --norc -c 'shopt huponexit'
# 輸出:huponexit off

zsh 5.9 原廠是 ON,bash 3.2.57 原廠是 off。看到這兩行,我以為答案出來了:zsh 會殺,bash 不會。

於是我去驗。讓 shell 開一個背景 job、自己 exit,一秒後檢查 PID 還在不在:

1
2
bash --norc -c 'sleep 30 & echo $! > b.pid; exit'   # → PID 1501 仍存活
zsh -f -c 'sleep 40 & echo $! > z.pid; exit' # → PID 16798 仍存活

HUP=ON 的那個也活著。加 disown(17651)、加 nohup(18489)都活著。我猜是非互動 shell 不做 job 管理那一套,於是加 -i 再用 script 包一層 pty 模擬真正的互動終端機,同樣四種組合重跑:36031、36934、37728、38657。八組全活,沒有一組死掉。

測不出來不是因為沒有差別,是因為我測的那條路根本不殺人。

兩條路,只有一條會殺人

man bash 把這件事寫得很清楚,我一開始沒讀到:

1
2
3
4
5
The shell exits by default upon receipt of a SIGHUP.  Before exiting,
an interactive shell resends the SIGHUP to all jobs, running or
stopped. Stopped jobs are sent SIGCONT to ensure that they receive the
SIGHUP. To prevent the shell from sending the signal to a particular
job, it should be removed from the jobs table with the disown builtin

第一句跟第二句講的是同一條路徑:當 shell 自己收到 SIGHUP,互動 shell 會在離開之前,把這個訊號原封不動轉發給所有 job。這條路沒有開關可以關,不歸 huponexit 管。

huponexit 管的是另一條,你自己在鍵盤上打 exit 的那條。它預設 off,所以 job 活著。

而我前面那八組,測的全是第二條。

關掉終端機視窗、SSH 被網路砍斷,走的是第一條。所以我重測一次,這次不打 exit,直接對 shell 送 SIGHUP:

1
2
3
# 用 script 開一個 pty 互動 bash,在裡面起背景 job,記下 shell 與 job 的 PID
kill -HUP <shell 的 PID>
# → job 8292 已死 (DEAD)

死了。同一個做法,背景那支改用 nohup 起,PID 11923 仍存活;改成起完補一句 disown,PID 15249 仍存活。

這才是週五下午那件事的解釋。我做的不是打 exit,是把視窗叉掉。終端機消失,shell 收到 SIGHUP,然後它很盡責地把這個訊號轉發給我三分鐘前才起的那支 Java。

順帶一提,我日常用的那個 zsh,HUP 測出來是 OFF,跟原廠的 ON 相反。同一個 zsh 5.9,只因為讀了我的設定檔跟外掛,選項就被改掉了,我 grep .zshrc.zprofile 還找不到是誰動的手。

所以「& 到底會不會活」這個問題,答案取決於你是怎麼離開那個終端機的、你用哪個 shell、還有那台機器上某個你可能沒讀過的設定檔。佈署腳本建立在這種東西上面,等於在賭。

五種做法,各自解掉哪一步

把常見的幾種寫法放在一起看,差別立刻清楚。

java -jar app.jar 什麼都沒做,前景跑,關掉就死。這個沒爭議。

java -jar app.jar & 只是丟到背景。名冊還在,所以你打 exit 走人它活著,你把視窗叉掉它就跟著陪葬。上一段那個 8292 就是它。

Ctrl+Z 之後 bgexit,跟上一種是同一件事換個姿勢。Ctrl+Z 送的是 SIGTSTP 把它停住,bg 讓它在背景繼續。job 從頭到尾都在名冊上,一步都沒少。網路上很多筆記把這招列成獨立的「第三種方式」,但它跟 & 的結果完全一樣。

disown 是第一個真的有動作的。java -jar app.jar & 之後補一句 disown,等於把這個房客從名冊上劃掉。轉發的前提是它在名冊上,劃掉之後 shell 找不到它,兩條路徑都不會轉。這不是我的推論,man bash 上面那段最後一句就是這樣寫的:要讓 shell 不要送訊號給某個 job,就把它從 jobs table 移除。

它的缺點不在生死,在事後補救這件事:你得記得敲第二句,而且它只解掉訊號,那支 process 的 stdout 還是接在即將消失的那個終端機上。

nohup java -jar app.jar & 換個角度解。它不去動名冊,而是在進程啟動前就把 SIGHUP 設成忽略。訊號照送,房客裝作沒聽到。這是四種裡面最不依賴 shell 行為的一種,也是為什麼各家筆記都推它。

setsid java -jar app.jar 是最徹底的一種:直接開一個新的 session,讓進程變成自己的 session leader,沒有 controlling terminal。房客搬出這棟樓了,房東收房跟它無關。要注意這是 Linux 的 util-linux 提供的指令,macOS 上沒有,我在自己機器上 command -v setsid 是空的。

nohup 的那個檔案,跟你想的不一樣

nohup 有個被講錯很久的細節。很多筆記寫「nohup 會在當前目錄產生 nohup.log」,這句話兩個地方都不對。

檔名是 nohup.out,不是 nohup.log。而且它不是每次都會產生。macOS 的 man page 寫得很清楚:

1
2
3
terminal, the standard output is appended to the file nohup.out in the
current directory. If standard error is a terminal, it is directed to
the same place as the standard output.

關鍵是開頭那個條件句:只有當 stdout 是終端機的時候,nohup 才會接管並倒進 nohup.out。如果你已經自己重導向過,nohup 就不插手。我實測跑了一次不重導向的 nohup,因為那個環境的 stdout 不是 tty,目錄下什麼檔案都沒生出來,輸出直接走原本的管道。

這個細節在佈署腳本裡會咬人。你在互動式終端機測的時候看到 nohup.out 長出來,於是寫了一段 logrotate 去輪替它;等腳本被 cron 或 CI 呼叫的時候 stdout 不是 tty,nohup.out 根本不存在,你的輪替設定空轉,而真正的輸出跑到你沒預期的地方去了。

所以不要讓 nohup 決定。自己寫死:

1
nohup java -jar app.jar > /var/log/myapp/app.log 2>&1 &

或者你根本不想要那份輸出(Spring Boot 通常已經有自己的 logback 在寫檔了),就丟進黑洞:

1
nohup java -jar app.jar > /dev/null 2>&1 &

2>&1 那段別漏。只導 stdout 不導 stderr,異常堆疊會繼續往舊管道噴,而那個管道在你關掉終端機之後就沒人接了,運氣不好還會讓進程在寫入時卡住。

我看過一次 60G 的 nohup.out。服務跑了八個月,把整台機器的磁碟塞到 98%,最後是資料庫先寫不進去才爆出來。查了半天以為是資料量成長,其實是一支每秒印兩行 DEBUG 的排程。

上面這些,正式環境一個都不該用

講完五種做法,講個掃興的:這些全部都只是在對抗症狀。

nohup ... & 解決的是「我關掉終端機它不要死」。但正式環境真正需要的是另外幾件事:機器重開之後它要自己起來、它自己 crash 之後要有人把它拉起來、log 要有地方去而且會輪替、要有一個標準的方式查它現在活著沒。這四件事,nohup 一件都不管。

Linux 上這件事早就有標準答案了。寫一份 /etc/systemd/system/myapp.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Unit]
Description=My Spring Boot App
After=network.target

[Service]
User=appuser
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

寫完檔案要 systemctl daemon-reload 讓 systemd 重讀,然後 systemctl enable --now myapp 啟用並啟動,systemctl status myapp 查狀態,journalctl -u myapp -f 看 log。SIGHUP 這整個問題從一開始就不存在,因為訊號不是從你的終端機生出來的,這支 process 也不掛在任何終端機底下。

那個 SuccessExitStatus=143 值得單獨講。JVM 收到 SIGTERM 正常關閉時,離開碼是 128 加 15 等於 143。systemd 預設只把 0 當成功,看到 143 會把 unit 判成 failed

要講精確一點:這不會導致「你 stop 它、它自己又爬起來」,因為 systemctl stop 這條路本來就不觸發 Restart=。真正的代價是那個 failed 狀態會一直掛在 systemctl status 上、會被你的監控抓去告警、還會觸發你設的 OnFailure=。每次正常重啟服務都留一筆假的失敗紀錄,久了就沒人看告警了。這一行是在告訴 systemd:143 是我自己關的,算成功。

回到週五下午五點半

現在我 SSH 進正式機,只會敲兩種指令的其中一種。

要是這台已經有 systemd unit,就 systemctl restart myapp 然後 journalctl -u myapp -f 看它起來,看完關視窗走人。

要是這是一台臨時的測試機,或是我在別人的環境上急著驗一個東西,那就 nohup java -jar app.jar > /tmp/app.log 2>&1 &,重導向寫死,然後心裡清楚這只是暫時的。

至於 java -jar app.jar & 這種寫法,我現在看到會停下來問一句:這台機器的 shell 是哪一種二房東?問不出答案就不要用。你的服務不該把生死押在一個沒人記得誰改過的設定值上。

還沒解掉的部分我也說清楚:容器裡這整套邏輯又是另一回事。Docker 或 Kubernetes 中 java -jar 常常直接當 PID 1,而 PID 1 對訊號的處理規則跟一般 process 不同,預設不吃那些沒有自訂 handler 的訊號,這會直接影響 graceful shutdown 收不收得到 SIGTERM。同一個「訊號送給誰、誰會轉發」的問題,換一層又要重問一次。


素材來源:Notion 開發筆記「Java-Jar 檔啟用方式」,原始出處為 java -jar 後台啟動的四種方式(Linux)(該文把輸出檔寫成 nohup.log,本文依 man page 更正為 nohup.out)。

驗證範圍:shell 選項值(HUP / huponexit)、nohup.out 的產生條件、八組 exit 路徑存活測試、三組 kill -HUP 路徑測試(裸 job 8292 死亡、nohup 11923 存活、disown 15249 存活),皆為 macOS 26(zsh 5.9 / bash 3.2.57)本機實跑輸出,man bash 引文出自本機 man page。systemd 段落依 systemd.service(5) 的行為描述撰寫,本機無 systemd,未實跑驗證。容器 PID 1 那段未驗證。