新手最常見的兩個崩潰時刻:
docker run ubuntu —— 容器啟動後立刻就結束了,什麼都沒發生。docker stop,結果卡了整整 10 秒才停,而且正在處理的請求全部斷線。這兩件事看起來毫無關聯,其實是同一個原理的兩面:容器的生命,完全等於它裡面 PID 1 那個 process 的生命。
這一章先把生命週期的狀態機講清楚,然後深入 PID 1 這個「容器裡的一號人物」——
它有什麼特殊責任、為什麼你的 Spring Boot 收不到關機訊號、以及一個只差幾個字元卻決定成敗的寫法差異(CMD java -jar app.jar vs CMD ["java", "-jar", "app.jar"])。
容器不是「開著」或「關著」兩種狀態而已,它有一個完整的生命週期。先建立這張地圖,之後每個指令的作用就有地方掛。
docker create docker start
(映像) ──────────→ [Created] ──────────────→ [Running]
│ ↑
docker pause ─────┤ │ docker unpause
↓ │
[Paused]
│
docker stop / 主程式自己結束
↓
[Exited]
│ docker rm
↓
(消失,可寫層一併刪除)docker ps -a 看得到、docker ps 看不到的那些。docker rm 之後才真正消失,可寫層一併刪掉(ch03 講的資料就是在這一刻沒的)。docker run = docker create + docker start,是一個方便的組合技。docker run 會累積出一堆 Exited 的殭屍容器(用 docker ps -a 就看得到),而不是重複使用同一個。docker start <名字>,不是 docker run。養成加 --name 的習慣(docker run --name myapp ...),之後所有指令都能用這個名字操作,不用複製那串亂碼 ID。臨時測試則加 --rm,結束後自動刪除,不留垃圾。來看這個讓無數新手困惑的現象:
$ docker run ubuntu
$ docker ps
CONTAINER ID IMAGE STATUS
(空的,什麼都沒有)
$ docker ps -a
CONTAINER ID IMAGE STATUS
7f3a9c1b2e4d ubuntu Exited (0) 2 seconds ago ← 已經結束了「我明明啟動了一個 Ubuntu,怎麼馬上就死了?是不是壞了?」
ubuntu 映像的預設指令是 bash。-it),bash 一啟動發現沒有輸入可讀,立刻正常結束。所以這不是壞掉,是它正確地做完了你叫它做的事——執行 bash,然後 bash 結束了。
# 想要進去互動:給它終端機
docker run -it ubuntu bash
# 想要它一直跑:讓 PID 1 是一個不會結束的程式
docker run -d nginx # nginx 本來就會常駐-i(interactive):保持標準輸入開著。-t(tty):分配一個虛擬終端機。兩個要一起用才有互動 shell 的效果。-d(detach):背景執行,指令立刻返回,不佔住你的終端機。systemctl start nginx 讓服務跑到背景——在容器裡這樣做會直接自殺:啟動指令把服務丟到背景後自己結束了,PID 1 一死,容器就跟著死,那個背景服務也一起被清掉。nginx -g 'daemon off;'——明確叫 nginx 不要進背景。同理,Java 應用直接 java -jar app.jar(本來就是前景),千萬不要寫成 nohup java -jar app.jar &。tail -f /dev/null 或 sleep infinity 當作容器的主指令,目的就是「弄一個永遠不會結束的 PID 1,讓容器活著」,方便進去除錯。這是個 hack,理解原理後就知道它為什麼有效。PID 1 在 Linux 裡不是普通的編號,它有兩個特殊責任。搞不清楚這個,你的服務每次部署都會硬生生斷線。
SIGTERM(禮貌的結束請求)時,就算程式沒寫處理邏輯,核心也會用預設行為把它終止。docker stop myapp
① Docker 送 SIGTERM 給容器的 PID 1 →「請你收工」
② 等待寬限期(預設 10 秒)
③ 時間到還沒死 → 送 SIGKILL(強制砍,無法被忽略)如果你的程式收不到 SIGTERM,就會走完整整 10 秒然後被硬殺——正在處理的 HTTP 請求直接斷線、資料庫交易沒有 rollback、連線池沒有正常釋放。
Dockerfile 的 CMD 和 ENTRYPOINT 有兩種寫法:
# ❌ shell form(字串)
CMD java -jar /app/app.jar
# ✅ exec form(JSON 陣列)
CMD ["java", "-jar", "/app/app.jar"]shell form 會被 Docker 自動改寫成 /bin/sh -c "java -jar /app/app.jar",於是容器裡變成:
PID 1: /bin/sh -c java -jar /app/app.jar ← 收到 SIGTERM 的是它
└─ PID 7: java -jar /app/app.jar ← 你的應用在這裡改用 exec form,Java 就直接是 PID 1,SIGTERM 直達 JVM,Spring Boot 的關機流程正常運作:
PID 1: java -jar /app/app.jar ← 訊號直達,優雅關機生效# application.yml
server:
shutdown: graceful # 停止收新請求,等現有請求做完
spring:
lifecycle:
timeout-per-shutdown-phase: 20s搭配 docker stop --time=30(把寬限期拉長到 30 秒),確保應用有足夠時間收尾。兩邊的時間要對得上:Docker 的寬限期必須大於應用的關機逾時,否則還是會被硬殺。
docker run --init,Docker 會塞一個極小的 init 程式(tini)當 PID 1,由它負責轉發訊號與回收殭屍,你的程式當 PID 2。兩個問題一次解決。學過前四章之後,這些參數不再是要背的咒語,而是有原理可推的開關。
docker run -d --name myapp \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=dev \
-v app-data:/data \
-m 512m --cpus 1.5 \
--restart unless-stopped \
myapp:1.0| 參數 | 作用 | 對應原理(哪一章) |
|---|---|---|
-d | 背景執行 | 本章:不佔住終端機 |
-it | 互動式終端機 | 本章:讓 shell 有輸入可讀而不結束 |
--name | 取名字 | 本章+ch07:名字也是網路上的 DNS 名稱 |
--rm | 結束後自動刪除容器 | ch03:可寫層一併清掉,不留垃圾 |
-p 8080:8080 | 連接埠映射 | ch02 network namespace/ch07 詳解 |
-e KEY=VAL | 設定環境變數 | ch12:設定與秘密注入的主要管道 |
-v 名稱:/路徑 | 掛載 volume | ch03 可寫層會消失/ch06 詳解 |
-m / --cpus | 資源上限 | ch02:cgroup |
--restart | 重啟策略 | 本章下一段 |
--init | 塞一個 init 當 PID 1 | 本章:訊號轉發+回收殭屍 |
-u 1000:1000 | 以指定使用者執行 | ch02 安全/ch10 詳解 |
no(預設):死了就死了。on-failure[:次數]:只有非零結束碼才重啟,可限制次數。適合批次任務。unless-stopped:常駐服務的最佳選擇——會一直重啟,但如果是你主動 docker stop 的,就尊重你的決定不再自動拉起(重開機後也不會擅自啟動)。always:無論如何都重啟,連你手動停掉後、Docker 服務重啟時也會把它拉回來。CrashLoopBackOff)。看到容器不斷重啟,第一件事是 docker logs 查真正的死因,而不是加大重試次數。docker logs -f --tail 100 myapp # 跟隨最後 100 行
docker logs --since 10m myapp # 只看最近 10 分鐘docker logs 顯示的是容器 PID 1 的「標準輸出(stdout)與標準錯誤(stderr)」,Docker 把它們寫到宿主機上一個 JSON 檔案裡。這個心智模型直接推導出兩個重要結論:
/var/log/app.log),docker logs 什麼都看不到,而且那個檔案會隨容器一起消失。容器化應用的日誌應該直接印到 stdout,讓外部的日誌系統去收集。(Spring Boot 預設就是印到 console,不要畫蛇添足去設定 file appender。)docker run --log-opt max-size=10m --log-opt max-file=3 ...docker exec -it myapp bash # 開一個 shell 進去
docker exec myapp ls /app # 只執行一個指令就出來
docker exec -u root -it myapp bash # 以 root 身分進去(除錯用)exit 離開時只是結束那個 shell,容器不會停。docker attach:attach 是「接上 PID 1 的終端機」,你在裡面按 Ctrl+C 會直接殺掉主程式、容器結束。幾乎所有情況都該用 exec 而不是 attach。docker debug 或臨時掛一個含工具的容器進同一個 namespace。docker ps -a:列出所有容器(含已結束的)。容器「消失了」時的第一個排查點。docker inspect myapp:傾印該容器的完整設定 JSON——IP、掛載、環境變數、OOMKilled 與 ExitCode(ch02 的 137 就在這裡確認)。docker stats:即時看各容器的 CPU/記憶體用量,記憶體那欄會顯示「已用 / cgroup 上限」。docker cp myapp:/app/heapdump.hprof ./:把容器裡的檔案複製出來(撈 heap dump、log 很好用)。docker top myapp:看容器裡有哪些 process,可以驗證你的程式到底是不是 PID 1。docker stop(送 SIGTERM,等寬限期,再 SIGKILL)= 有禮貌;docker kill(直接 SIGKILL)= 沒禮貌,只在卡死時用;docker rm(刪掉已停止的容器,可寫層一併消失);docker rm -f(kill + rm 一次做完)。容器在 Running 狀態,只代表 PID 1 還活著。但你的 Spring Boot 可能正卡在啟動階段、或資料庫連線池已經爆掉——process 活著,服務其實已經廢了。
# Dockerfile 裡宣告
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1--start-period:啟動寬限期,對 Java 特別重要——Spring Boot 冷啟動動輒 30~60 秒,這段期間檢查失敗不算數,否則容器會在啟動途中被判死刑。docker ps 的 STATUS 欄:(healthy) / (unhealthy)。depends_on 搭配 condition: service_healthy,才能真正做到「等資料庫準備好再啟動應用」(ch11 會詳細拆解這個大坑)。用久了突然發現 C 槽/根目錄滿了,元兇通常是這四類:
docker ps -a 一看嚇一跳。<none> 的舊映像層。docker system df # 先看誰在吃空間(強烈建議先跑這個)
docker system prune # 清停止的容器、無用網路、懸空映像
docker system prune -a # 連「沒有容器在用的映像」都清掉
docker builder prune # 清建置快取docker system prune --volumes 會刪掉沒被使用的 volume——你的資料庫資料很可能就在裡面。手滑一次就是整組開發資料歸零。養成習慣:先 docker system df 看清楚,再決定清什麼;--volumes 這個旗標請當作危險品對待。| 指令 | 做什麼 | 狀態變化 | 注意事項 |
|---|---|---|---|
| docker run | create + start(造新容器並啟動) | 映像 → Running | 每次都是全新容器,不是叫醒舊的 |
| docker start | 啟動既有的容器 | Exited/Created → Running | 要重啟同一個容器用這個,不是 run |
| docker stop | 送 SIGTERM,等寬限期後 SIGKILL | Running → Exited | 預設等 10 秒;--time 可調 |
| docker kill | 直接送 SIGKILL | Running → Exited | 沒有收尾機會,只在卡死時用 |
| docker restart | stop + start | Running → Running | 可寫層保留,資料還在 |
| docker rm | 刪除容器 | Exited → 消失 | 可寫層一併刪除,沒 volume 的資料就沒了 |
| docker exec | 在既有容器裡多開一個 process | 不變 | exit 只結束那個 shell,容器不會停 |
| docker attach | 接上 PID 1 的終端機 | 可能變 Exited | Ctrl+C 會殺掉主程式!幾乎都該改用 exec |
| 面向 | shell form(字串) | exec form(JSON 陣列) |
|---|---|---|
| 寫法 | CMD java -jar app.jar | CMD ["java", "-jar", "app.jar"] |
| 實際執行 | /bin/sh -c "java -jar app.jar" | 直接 exec java |
| 誰是 PID 1 | /bin/sh(你的程式是 PID 7) | 你的程式本身 |
| 收得到 SIGTERM 嗎 | 收不到——sh 忽略且不轉發 | 收得到,直達應用 |
| docker stop 的結果 | 卡 10 秒後被 SIGKILL 硬殺,請求斷線 | 觸發優雅關機,請求做完才退出 |
| 能不能用 $變數、管線 | 可以(有 shell 幫忙展開) | 不行(除非自己寫 sh -c) |
| 建議 | 避免用在主程式 | 預設就用這個 |
| 策略 | 行為 | 適用場景 |
|---|---|---|
| no(預設) | 結束就結束,不重啟 | 一次性任務、本機隨手測試 |
| on-failure[:n] | 只有非零結束碼才重啟,可限次數 | 批次工作、允許失敗重試的任務 |
| unless-stopped | 一直重啟,但尊重你手動停止的決定 | 常駐服務的最佳預設 |
| always | 無論如何都拉起來,連手動停掉的也會在 Docker 重啟時復活 | 絕對不能中斷的基礎服務 |
點擊卡片翻面查看答案,共 11 張。