有台 Ubuntu 伺服器上的 SQL Server,我想確認它是不是正常。網路上查到的做法幾乎都是 systemctl status mssql-server,但這台的 SQL Server 是用 Docker 跑的,主機上根本沒有這個服務,照查只會得到一行:
Unit mssql-server.service could not be found.
我第一個懷疑是 Docker 沒啟動。結果 Docker 好好的,容器也都在跑。接著用 DataGrip 連進去,資料庫卻一直顯示「正在復原」。我以為是剛開機的正常現象,等了 40 多分鐘都沒好,最後才查到是 Linux 核心卡住,SQL Server 送出去的讀取請求根本沒有送到磁碟。
這篇把整個過程整理成固定的檢查順序:Docker、容器、SQL Server 日誌、資料庫狀態、對外連線。前一關沒過,後面查了也沒意義。後半段記錄「復原一直沒完成」時,怎麼判斷該繼續等還是該動手。
文中的主機位址、容器名稱、主機連接埠、資料庫名稱與路徑都是示例,終端機輸出也已匿名化,操作時請換成自己環境的值:
| 項目 | 示例值 |
|---|---|
| 主機位址 | 192.0.2.10 |
| 容器名稱 | example_mssql |
| 映像檔 | mcr.microsoft.com/mssql/server:2019-latest |
| 連接埠對應 | 主機 14330 → 容器 1433 |
| 資料庫名稱 | example_app_db、example_report_db |
| 主機上的資料目錄 | /path/to/mssql-data |
先確認 Docker 有沒有在跑
sudo systemctl status docker
看到 active (running) 再往下。如果是 inactive 或 failed,先啟動,順便設定開機自動啟動:
sudo systemctl start docker
sudo systemctl enable docker
enable 不能省。少了它,下次主機重開,Docker 和底下所有容器都不會自己起來。
docker ps 要加 -a
sudo docker ps -a
不加 -a 的話,只會列出執行中的容器。SQL Server 容器如果停了,清單裡根本不會出現,很容易誤以為這台沒裝。
預設輸出的欄位很多,一行塞不下,可以只挑需要的欄位:
sudo docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
以下是節錄的輸出,已經匿名化,容器名稱和連接埠都換成示例值:
NAMES IMAGE STATUS PORTS
example_redis redis:7 Up 6 minutes 0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp
example_mssql mcr.microsoft.com/mssql/server:2019-latest Up 6 minutes 0.0.0.0:14330->1433/tcp, [::]:14330->1433/tcp
example_postgres postgres:16 Up 6 minutes 0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp
IMAGE 是 mcr.microsoft.com/mssql/server 的那一列就是 SQL Server。STATUS 的看法:
| 狀態 | 意思 |
|---|---|
Up 6 minutes |
正在執行,後面是這次啟動了多久 |
Exited (0) |
正常停止,通常是有人下過 docker stop |
Exited (1) 或其他非 0 值 |
啟動失敗或異常結束,要看日誌 |
Restarting |
一直啟動失敗,又被重啟策略拉起來,一樣要看日誌 |
當時所有容器都是 Up 6 minutes,表示它們幾乎同時啟動,這台剛重開過。這個線索後面會用到。
容器停了的話,直接啟動:
sudo docker start example_mssql
用 docker compose 建立的容器,到 docker-compose.yml 所在的目錄執行:
sudo docker compose ps -a
sudo docker compose up -d
14330 跟 1433 不是同一個埠
PORTS 欄位的 0.0.0.0:14330->1433/tcp 很容易看錯。箭頭左邊是主機的連接埠,右邊是容器裡的連接埠。SQL Server 在容器裡照樣監聽 1433,但從主機外面要連 14330。
只想看這個容器的對應,可以用:
sudo docker port example_mssql
1433/tcp -> 0.0.0.0:14330
1433/tcp -> [::]:14330
所以 DataGrip、SSMS 或應用程式的連線字串,伺服器都要寫成 192.0.2.10,14330。SQL Server 用逗號分隔主機和連接埠,不是冒號。沿用預設的 1433 會直接連不上。
容器 Up 了,SQL Server 不一定好了
Up 只代表容器裡的程序開始跑了,SQL Server 本身可能還在初始化。要看日誌:
sudo docker logs --tail 50 example_mssql
出現這一行,SQL Server 才開始接受連線:
SQL Server is now ready for client connections. This is an informational message; no user action is required.
想持續看的話加 -f,按 Ctrl+C 離開:
sudo docker logs -f example_mssql
容器起不來的時候,原因幾乎都寫在日誌裡,常見的有四種:
| 日誌裡的訊息 | 原因 | 處理方式 |
|---|---|---|
The SQL Server End-User License Agreement (EULA) must be accepted |
沒有接受授權條款 | 建立容器時加上環境變數 ACCEPT_EULA=Y |
Password validation failed |
SA 密碼不符合複雜度規則 | MSSQL_SA_PASSWORD 至少 8 碼,大寫、小寫、數字、符號四類中要有三類 |
This program requires a machine with at least 2000 megabytes of memory |
記憶體不足 | SQL Server 至少要 2 GB,用 free -h 看剩多少 |
Access is denied |
掛載目錄的權限不對 | 見下方說明 |
權限的問題是因為 2019 之後的映像檔不再用 root 執行,而是用 UID 10001 的 mssql 帳號。把主機目錄掛到 /var/opt/mssql 時,擁有者要改成這個 UID:
sudo chown -R 10001:0 /path/to/mssql-data
授權和密碼是建立容器時傳入的環境變數,已經建好的容器改不了,只能刪掉重建。刪之前一定要確認資料放在掛載的磁碟區或主機目錄,不然資料庫會跟著容器一起刪掉:
sudo docker inspect example_mssql --format '{{json .Mounts}}'
輸出裡要看得到 Destination 是 /var/opt/mssql 的項目。
資料庫顯示正在復原
Docker、容器、日誌都沒問題,我用 DataGrip 連進去查詢,卻一直收到這個錯誤(資料庫名稱已換成示例):
Database 'example_app_db' is being recovered. Waiting until recovery is finished.
這本身不代表資料庫壞了。SQL Server 每次啟動都會對每個資料庫做一次復原:把已經提交、但還沒寫進資料檔的交易重新套用,並取消執行到一半的交易。做完之前,資料庫的狀態是 RECOVERING,所有查詢都會收到這個錯誤。主機剛重開,遇到復原很正常,資料庫越大要等越久。
狀態可以直接查:
SELECT name, state_desc FROM sys.databases;
變成 ONLINE 就能用了。在那之前不要急著重啟容器,重啟不會比較快,復原反而要從頭再跑一次。
問題是,要等多久才算不正常?我一開始也只是等,等到第 40 分鐘才覺得不對。
等多久算不正常:看日誌有沒有進度
正常的復原是有進度的。每個資料庫會依序出現開始、重新套用交易、完成的紀錄,時間比較長的還會定時印出百分比。用這個指令只看復原相關的紀錄:
sudo docker logs example_mssql 2>&1 | grep -E "Starting up database|Parallel redo|Recovery of database|Recovery completed"
我這台的輸出節錄如下,已去除時間並換成示例名稱:
Starting up database 'example_report_db'.
Starting up database 'example_app_db'.
Parallel redo is started for database 'example_report_db' with worker pool size [6].
Parallel redo is started for database 'example_app_db' with worker pool size [6].
Parallel redo is started 表示開始重新套用交易,正常情況下接著會出現 Parallel redo is shutdown,代表這個階段做完了。同一台主機上的另一個大資料庫很快就出現了 shutdown,這兩個小資料庫卻停在 started 之後,什麼都沒有了。
取而代之的是每隔幾分鐘出現一次的這行(檔案識別值與位移已省略):
SQL Server has encountered 20 occurrence(s) of I/O requests taking longer than 15 seconds to complete on file [/var/opt/mssql/data/example_app_db_log.ldf] in database id 5. The OS file handle is <省略>. The offset of the latest long I/O is: <省略>. The duration of the long I/O is: 2721229 ms.
重點在最後的 duration。2,721,229 ms 大約是 45 分鐘,而且這個數字每次出現都在變大,位移卻一直是同一個。意思是:有一筆讀取從 SQL Server 啟動的那一刻送出去,到現在都還沒回來。復原要讀交易記錄檔才能往下走,這筆讀取不回來,復原就永遠停在原地。同樣的訊息也出現在系統資料庫 model 的記錄檔上。
日誌裡還有大量這種錯誤,每 20 秒一次:
Error: 1222, Severity: 16, State: 24.
Lock request time out period exceeded.
spid 後面帶 s 的是 SQL Server 自己的背景工作。它們要的鎖被卡住的復原占著,拿不到就逾時。這是結果不是原因,不用另外處理。
看到這兩種訊息,就不用再等了,要去查 I/O 為什麼不回來。
I/O 卡了 45 分鐘,磁碟卻很閒
直覺會先懷疑磁碟壞了或滿了。我查了一輪,結果剛好相反:
df -h /path/to/mssql-data # 空間只用了 44%
df -i /path/to/mssql-data # inode 用了 2%
vmstat 1 5 # wa 是 0,b 是 0
vmstat 的 wa 是 CPU 花在等 I/O 的比例,b 是卡在 I/O 的程序數,兩個都是 0。再用 /proc/diskstats 取樣 5 秒,NVMe SSD 的寫入延遲 0.6 ms,進行中的 I/O 是 0,忙碌率 0%。磁碟閒得很。
真正的線索在「不可中斷等待」的程序。這種程序的狀態是 D,通常是在等磁碟,但 WCHAN 欄位會告訴你它卡在哪個核心函式:
ps -eo pid,stat,etime,wchan:32,comm | awk 'NR == 1 || $2 ~ /^D/'
PID STAT ELAPSED WCHAN COMMAND
93 DN 43:59 flush_work.isra.0 khugepaged
3108 Ds 38:36 synchronize_rcu_expedited systemd-timedat
3657 Ds 31:24 exp_funnel_lock (fwupdmgr)
4501 Dl 16:10 exp_funnel_lock runc
如果是在等磁碟,WCHAN 會是 io_schedule、blk_ 或 nvme_ 開頭的函式。這裡一個都沒有,全部卡在 synchronize_rcu_expedited、exp_funnel_lock 這類 RCU 相關的函式。RCU 是核心內部用來協調同時存取的機制。它停住的話,所有需要它的工作都會跟著停住,I/O 的完成處理也可能包含在內。
khugepaged 的 ELAPSED 跟開機時間一樣長,表示從開機就卡住了。回頭翻 dmesg,開機後約 10 秒有一段 Call Trace,之後就再也沒有恢復。
所以 SQL Server 回報的「I/O 很慢」,實際上是請求停在核心裡,根本沒有送到磁碟,也不會逾時失敗。這種狀態不會自己恢復。
我的診斷腳本也被卡住了
為了一次收集這些資訊,我準備了一支診斷腳本,裡面用 docker exec 進容器找 sqlcmd 查資料庫狀態。第一次跑完,腳本回報「容器內找不到 sqlcmd」。
這是誤判。上面清單裡最後那個 runc,開始卡住的時間正好是我跑腳本的那一刻。docker exec 要靠 runc 在容器裡建立新程序,而 runc 也需要 RCU,於是跟著卡住。腳本只等了 10 秒就放棄,拿到空的結果,就當成找不到。
這裡學到兩件事。第一,核心卡住的時候,docker exec、docker stop 這類需要 runc 的指令都可能卡住,而且卡住的 runc 殺不掉,每多跑一次就多一個。第二,狀態 D 的程序連 kill -9 都不理,timeout 也沒用。腳本裡的 df、ls 要是剛好碰到卡住的檔案系統,整支腳本就停在那裡。
後來腳本改成把每個指令丟到背景執行,超過時間就放棄等待,繼續下一項:
# run <秒數> <指令字串>:超過時間就不等了,記下來繼續下一項
run() {
local t=$1 cmd=$2 i pid
bash -c "$cmd" >"$TMP/last" 2>&1 &
pid=$!
for ((i = 0; i < t * 10; i++)); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null
cat "$TMP/last"
warn "指令超過 ${t} 秒沒有回應,可能卡在 I/O:$cmd"
return 124
fi
wait "$pid"
cat "$TMP/last"
}
run 10 "df -h /path/to/mssql-data"
背景的指令如果真的卡在 D 狀態,kill -9 一樣殺不掉,但主程式不用等它,可以繼續收集其他資訊。「這個指令沒有回應」本身也是一條線索。
docker exec 那一段則改成在輸出最後多印一個 END。沒收到 END 就是 exec 沒有回應,跟「真的找不到 sqlcmd」分開判斷。
磁碟取樣沒有用 iostat,因為很多主機沒裝 sysstat,改成直接讀 /proc/diskstats 兩次相減:
# /proc/diskstats 欄位:3 名稱、4 讀取次數、7 讀取毫秒、8 寫入次數、11 寫入毫秒、12 進行中、13 忙碌毫秒
snap() { awk '$3 !~ /^(loop|ram|sr|zram)/ {print $3, $4, $7, $8, $11, $12, $13}' /proc/diskstats; }
snap >"$TMP/d1"; sleep 5; snap >"$TMP/d2"
awk -v s=5 '
NR == FNR { r[$1] = $2; rms[$1] = $3; w[$1] = $4; wms[$1] = $5; busy[$1] = $7; next }
($1 in r) {
dr = $2 - r[$1]; dw = $4 - w[$1]
ra = dr > 0 ? ($3 - rms[$1]) / dr : 0
wa = dw > 0 ? ($5 - wms[$1]) / dw : 0
flag = ($6 > 0 && dr == 0 && dw == 0) ? "卡住" : ""
printf "%-12s 讀取延遲 %.1f ms,寫入延遲 %.1f ms,進行中 %d,忙碌率 %.0f%% %s\n",
$1, ra, wa, $6, ($7 - busy[$1]) / (s * 10), flag
}' "$TMP/d1" "$TMP/d2"
總延遲毫秒數除以完成次數就是平均延遲。「有進行中的 I/O,5 秒內卻一筆都沒完成」會標成卡住,那才是磁碟本身卡住的樣子。這次的結果是全部正常,反過來證明問題不在磁碟。
只能重開主機
核心卡在這種狀態,重啟容器沒有用,只能重開主機。我的做法是:
# 1. 先把核心日誌存下來,重開就看不到了
sudo dmesg -T > ~/dmesg-before-reboot.txt
# 2. 其他容器先正常停掉,免得它們也要做復原;docker stop 需要 runc,加 timeout 以免卡住
sudo timeout 180 docker stop -t 60 example_postgres example_redis
# 3. 重開
sudo reboot
SQL Server 的容器不用特別處理。卡住的 I/O 在核心裡,docker stop 也等不到它結束。那兩筆讀寫從頭到尾沒有寫到磁碟,重開的效果跟斷電差不多,SQL Server 會用交易記錄檔處理。
有程序卡在 D 狀態時,關機流程可能停住。如果 5 分鐘後還沒重開,可以用核心的緊急功能,依序同步寫入、把檔案系統改成唯讀、重開:
echo 1 | sudo tee /proc/sys/kernel/sysrq
echo s | sudo tee /proc/sysrq-trigger
echo u | sudo tee /proc/sysrq-trigger
echo b | sudo tee /proc/sysrq-trigger
重開之後,兩個資料庫的復原紀錄變成這樣(已去除時間並換成示例名稱):
Starting up database 'example_report_db'.
Starting up database 'example_app_db'.
Parallel redo is started for database 'example_report_db' with worker pool size [6].
Parallel redo is started for database 'example_app_db' with worker pool size [6].
Parallel redo is shutdown for database 'example_report_db' with worker pool size [6].
Parallel redo is shutdown for database 'example_app_db' with worker pool size [6].
從 started 到 shutdown 只花了 0.13 秒。之前卡了 45 分鐘的復原,I/O 正常的時候一眨眼就做完了。
重開後還要確認三件事:沒有程序卡在 D 狀態、日誌沒有新的長時間 I/O、每個資料庫都是 ONLINE。經歷過這種異常關機,也建議對受影響的資料庫跑一次一致性檢查:
DBCC CHECKDB('example_app_db') WITH NO_INFOMSGS;
沒有輸出就代表沒有發現問題。
原因還沒查到
核心為什麼會在開機時卡住,目前還不知道。能確定的是:
這台用同一版核心,上一次開機正常跑了兩天,關機也是正常關機,所以不是「換了新核心就壞」。
磁碟、空間、記憶體都沒有異常,
dmesg裡也沒有 NVMe 錯誤或重設的紀錄。問題從開機後約 10 秒那段
Call Trace開始。
要往下查,就要看那段 Call Trace 前面印了什麼。重開前存下的 dmesg 可以這樣找:
grep -B 30 -A 40 'Call Trace' ~/dmesg-before-reboot.txt | head -120
沒存到的話,journald 可能還保留上一次開機的核心紀錄:
sudo journalctl -k -b -1 --no-pager | grep -B 30 -A 40 'Call Trace' | head -120
如果之後又發生,可以在 GRUB 選單的「Advanced options for Ubuntu」改用上一版核心開機,比對是不是核心本身的問題。
用 sqlcmd 從容器裡面連一次
平常要確認 SQL Server 和帳密,從容器內部連線最直接,可以先排除網路和連接埠的問題:
sudo docker exec -it example_mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -C -Q "SELECT name, state_desc FROM sys.databases"
這段有四個地方要注意:
我刻意沒加
-P。sqlcmd 會提示你輸入密碼,密碼就不會留在 shell 的指令紀錄裡。較新的 2019 和 2022 映像檔改用
mssql-tools18。如果出現找不到檔案,表示是較舊的映像檔,改用/opt/mssql-tools/bin/sqlcmd,並拿掉-C。mssql-tools18預設會加密連線,容器用的是自簽憑證,不加-C會出現certificate verify failed。docker exec沒有任何回應時,先照前面的方法看有沒有程序卡在D狀態,不要一直重試。
連進去之後,也可以順便查 SQL Server 的啟動時間,跟 docker ps 的 Up 時間對照:
SELECT sqlserver_start_time FROM sys.dm_os_sys_info;
容器裡連得上,外面連不上
問題就在網路這一段。從另一台機器測試主機的 14330:
nc -zv 192.0.2.10 14330
出現 succeeded 或 open,表示連接埠是通的,要回頭檢查連線字串的連接埠和帳密。連不通的話,就要檢查主機所在網路的防火牆,或雲端主機的安全群組。
Docker 對外開放的連接埠是直接寫進 iptables,不會經過 ufw 的規則。所以 ufw status 裡看不到 14330,不代表外面連不到。
讓容器跟著主機一起起來
主機重開後容器沒有自己起來,多半是沒設定重啟策略。先查目前的設定:
sudo docker inspect example_mssql --format '{{.HostConfig.RestartPolicy.Name}}'
如果是 no,改成 unless-stopped:
sudo docker update --restart unless-stopped example_mssql
unless-stopped 會在 Docker 啟動時自動把容器拉起來,但手動 docker stop 過的不會。用 compose 的話,在 service 底下加上 restart: unless-stopped。我這台重開後所有容器都自己起來了,表示這項本來就有設定。
忘記 SA 密碼的時候
SA 密碼是建立容器時用環境變數傳進去的,會用明文存在容器的設定裡:
sudo docker inspect example_mssql --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass
要注意,這只是建立容器當時的密碼。如果之後在 SQL Server 裡改過 SA 密碼,這裡查到的會是舊的。
反過來說,這也代表任何能下 docker 指令的帳號,都查得到 SA 密碼。docker 群組的成員要管好,應用程式也不應該直接用 SA 連線。
常見狀況對照
| 現象 | 原因 | 解法 |
|---|---|---|
systemctl status mssql-server 找不到服務 |
SQL Server 跑在 Docker 容器裡 | 改用 docker ps -a 查 |
docker ps 看不到 SQL Server 容器 |
容器已停止,預設不列出 | 加上 -a |
狀態是 Exited (1) 或 Restarting |
SQL Server 啟動失敗 | 用 docker logs 看錯誤訊息 |
查詢時回應 Database '...' is being recovered |
啟動後的復原還沒完成 | 查日誌的復原紀錄有沒有進度 |
復原停在 Parallel redo is started,日誌出現 taking longer than 15 seconds |
有 I/O 一直沒有完成 | 查磁碟延遲與狀態 D 的程序 |
| I/O 回報很慢,磁碟延遲卻很低,程序卡在 RCU 相關函式 | 核心卡住,I/O 沒有送到磁碟 | 存下 dmesg 後重開主機 |
docker exec 沒有回應 |
runc 被核心問題卡住 | 不要重試,先查狀態 D 的程序 |
日誌大量出現 Error: 1222 |
背景工作拿不到被復原占著的鎖 | 處理復原的問題即可 |
資料庫停在 SUSPECT 或 RECOVERY_PENDING |
資料檔讀不到或磁碟空間不夠 | 看日誌的錯誤訊息,用 df -h 確認磁碟 |
sqlcmd 回應 certificate verify failed |
mssql-tools18 預設加密連線 | 加上 -C |
| 應用程式連 1433 連不上 | 主機對外的連接埠不是 1433 | 用 docker port 查,改連線字串 |
回頭看
這次卡最久的地方,是把「資料庫正在復原」當成只要等就會好。復原有沒有在動,日誌其實看得出來。停在 Parallel redo is started 之後沒有下文,又出現越來越長的 I/O 時間,就該去查 I/O 了。
另一個教訓是 WCHAN。同樣是卡在 D 狀態,卡在 io_schedule 和卡在 synchronize_rcu_expedited 是完全不同的問題。前者去查磁碟,後者查磁碟只會浪費時間。