初步說明

初步說明

我有一個在 tmux 中的 bash 內部運行的 dotnet 程序,它偶爾會失敗並出現非零錯誤代碼。我正在嘗試使用 systemd 服務檔案以程式設計方式在 tmux 內啟動我的 dotnet 程式。

這是服務文件:

[Unit] 
Description=dotnet application

[Service] 
Type=forking 
ExecStart=/home/alpine_sour/scripts/rofdl
Restart=always 
User=root

[Install]
WantedBy=multi-user.target

這是 rofdl shell 腳本:

#!/bin/bash 
/usr/bin/tmux kill-session -t "rof" 2> /dev/null || true 
/usr/bin/tmux new -s "rof" -d "cd /home/alpine_sour/rofdl && dotnet run"

現在,當我啟動服務時,systemd 選擇主 PID 作為 tmux 伺服器,我認為這是因為它是第一個執行的命令。因此,當 tmux 視窗中的程式退出並顯示任何錯誤代碼且不再有視窗時,tmux 伺服器會退出並顯示成功錯誤代碼,導致 systemd 無法重新啟動。即使我要 Restart=always,tmux 伺服器也只會在我的程式失敗且沒有其他視窗時重新啟動。

  Process: 24980 ExecStart=/home/alpine_sour/scripts/rofdl (code=exited, status=0/SUCCESS)
 Main PID: 24984 (tmux: server)
           ├─24984 /usr/bin/tmux new -s rofdl -d cd /home/alpine_sour/rofdl && dotnet run -- start
           ├─24985 sh -c cd /home/alpine_sour/rofdl && dotnet run -- start
           ├─24987 dotnet run -- start
           └─25026 dotnet exec /home/alpine_sour/rofdl/bin/Debug/netcoreapp2.1/rofdl.dll start

所以我想知道如何讓 systemd 追蹤進程分支的最低層級而不是更高層級的 tmux 伺服器。我需要一種方法來告訴 systemd 追蹤 tmux 伺服器的子進程而不是伺服器本身並相應地重新啟動。

答案1

初步說明

  • 這個答案是基於 Debian 9 中的實驗。
  • 我假設您的服務是系統服務(在/etc/systemd/system)。
  • 您在問題正文末尾附近發布的內容看起來像摘抄systemctl status …。它沒有提及 cgroup。這個答案假設對照組都參與其中。我認為systemd需要它們,所以它們必須是。
  • 命令本身可能會循環運行,直到成功:

    cd /home/alpine_sour/rofdl && while ! dotnet run; do :; done
    

    但我知道你想要一個systemd解決方案。


問題

首先請閱讀如何tmux運作。了解哪個進程是誰的孩子將會非常有幫助。

哪些進程屬於服務

在您原來的情況下,在 cgroup 中的所有進程退出後,該服務將被視為不活動(並準備好重新啟動,如果適用)。

您的腳本嘗試終止舊tmux會話,而不是舊tmux伺服器。然後tmux new(相當於tmux new-session)啟動伺服器或使用舊伺服器。

  • 如果它使用舊的,那麼伺服器和您的命令 ( dotnet …) 都不會是該腳本的後代。這些進程不屬於與該服務關聯的 cgroup。腳本退出後,systemd將認為該服務處於非活動狀態。

  • 如果它啟動一個新tmux伺服器,那麼該伺服器和命令將被指派給與該服務關聯的 cgroup。然後我們的命令可能會終止,但如果伺服器內還有其他會話/視窗(稍後建立),伺服器可能會保留並systemd認為服務處於活動狀態。

如果只有一個主進程,則主進程退出後整個 cgroup 都會被殺死。主Type=simple進程是由 指定的進程ExecStart=。您Type=forking需要使用PIDFile=並透過這種方式傳遞 PID 來指定主進程。當您停止服務時,systemd會殺死屬於該服務的所有進程。因此,僅在 cgroup 中包含特定於該服務的進程非常重要。在您的情況下,您可能想要排除tmux伺服器,即使它是從服務內部啟動的。

有一些工具/方法可以在 cgroup 之間移動進程。或者您可以運行tmux特定於該服務的單獨伺服器。

如何systemd知道要使用哪種退出狀態

Restart=on-failure設定對主進程退出狀態的依賴。建議Type=forking使用它,PIDFile=以便systemd知道要使用什麼退出狀態。

systemd但可能無法檢索退出狀態。

誰檢索退出狀態

子進程退出後,其父進程可以檢索退出狀態(比較殭屍行程)。

無論tmux伺服器是舊的還是新的,您的命令都不會成為子命令,systemd除非它成為孤兒,核心將其父級設定為 PID 1 (或其他一些)而新手父母是正確的systemd

您提供的命令tmux new使tmux伺服器運行 shell,然後 shell 要么運行dotnet並等待其退出,要么exec在將伺服器dotnet保持tmux為父級的情況下退出。無論如何,dotnet都有一個不是的父母systemd

你可以dotnet這樣孤立:nohup dotnet … &,然後讓所謂的 shell 退出。您還需要儲存 PID,PIDFile=在單元設定檔中使用,以便服務知道要監視哪個進程。那麼它可能會起作用。

需要明確的是:在我的測試中,nohup sleep 300 &被成功採用,systemd然後可以檢索其退出狀態(在我處理 cgroups 之後)。

但既然你想tmux先使用,我猜你的指令會與終端機互動。所以nohup在這裡不是正確的工具。在保持進程與終端連接的同時孤立進程可能會很棘手。您想要孤立它,但不能讓 shelltmux簡單地退出,因為這會殺死它的窗格(或使其處於死亡狀態)。

注意Type=forking依賴採用systemd。主服務進程應該分叉並退出。然後systemd收養牠的孩子。但此類守護程序不應與任何終端互動。

tmux另一種方法是讓伺服器內的 shellexec進行dotnet.退出後,tmux伺服器(作為父級)知道其退出狀態。在某些情況下,我們可以從另一個腳本查詢伺服器並檢索退出狀態。

或者由 觸發的 shelltmux new可以將狀態儲存在檔案中,以便可以由另一個腳本擷取。

因為您運行的內容肯定ExecStart=是 的子級,所以這是「另一個腳本」的最佳候選者。systemd它應該等到它可以檢索退出狀態,然後將其用作自己的退出狀態,以便systemd獲取它。請注意,服務應該是Type=simple在這種情況下。

或者,您可以從dotnet …之外開始tmux,然後reptyr從伺服器內部tmux。這種方式dotnet可以從一開始就是一個孩子systemd,當你試圖竊取它的tty時可能會出現問題。


解決方案和範例

reptyrtmux

此範例運行tty2.腳本準備好tmuxexec發送至dotnet。最後,其中的一個 shelltmux試圖竊取 tty 的 now 內容dotnet

服務文件:

[Unit]
Description=dotnet application
[email protected]

[Service]
Type=simple
ExecStart=/home/alpine_sour/scripts/rofdl
Restart=on-failure
User=root
StandardInput=tty
TTYPath=/dev/tty2
TTYReset=yes
TTYVHangup=yes

[Install]
WantedBy=multi-user.target

/home/alpine_sour/scripts/rofdl:

#!/bin/sh
tmux="/usr/bin/tmux"

"$tmux" kill-session -t "rof" 2> /dev/null
"$tmux" new-session -s "rof" -d "sleep 5; exec /usr/bin/reptyr $$" || exit 1

cd /home/alpine_sour/rofdl && exec dotnet run

筆記:

  • 我的測試而htop不是dotnet run揭示了競爭條件(htop更改其終端的設置,reptyr可能會幹擾;因此sleep 5作為一個糟糕的解決方法)和滑鼠支援問題。
  • 可以從tmux與服務關聯的 cgroup 中刪除伺服器。您可能想這樣做。看下面的方法,/sys/fs/cgroup/systemd/程式碼裡有。

沒有tmux

無論如何使用上述解決方案/dev/tty2。如果您tmux只需要提供一個控制終端,請考慮cd /home/alpine_sour/rofdl && exec dotnet runwithout reptyr, without tmux。即使沒有腳本:

ExecStart=/bin/sh -c 'cd /home/alpine_sour/rofdl && exec dotnet run' rofdl

這是最簡單的。

獨立tmux伺服器

tmux允許您為每個使用者運行多個伺服器。您需要-L-S(請參閱man 1 tmux)來指定一個套接字,然後堅持使用它。這樣你的服務就可以運行一個專屬tmux伺服器。優點:

  • 預設情況下,伺服器和您在其中運行的所有內容都tmux屬於服務的 cgroup。
  • 該服務可以破壞tmux伺服器,而不會有其他任何人(或任何東西)丟失會話的風險。其他人不應該使用此伺服器,除非他們想監視該服務/與該服務互動。如果有人將其用於其他用途,那是他們的問題。

自由終止伺服器的能力tmux允許您孤立運行在tmux.考慮以下範例。

服務文件:

[Unit]
Description=dotnet application

[Service]
Type=forking
ExecStart=/home/alpine_sour/scripts/rofdl
Restart=on-failure
User=root
PIDFile=/var/run/rofdl.service.pid

[Install]
WantedBy=multi-user.target

/home/alpine_sour/scripts/rofdl:

#!/bin/sh
tmux="/usr/bin/tmux"
service="rofdl.service"

"$tmux" -L "$service" kill-server 2> /dev/null
"$tmux" -L "$service" new-session -s "rof" -d '
      trap "" HUP
      ppid="$PPID"
      echo "$$" > '" '/var/run/$service.pid' "'
      cd /home/alpine_sour/rofdl && dotnet run
      status="$?"
   '" '$tmux' -L '$service' kill-server 2> /dev/null "'
      while [ "$ppid" -eq "$(ps -o ppid= -p "$$")" ]; do sleep 2; done
      exit "$status"
  ' || exit 1

解釋:

  1. 主腳本殺死獨佔tmux伺服器(如果有)並重新啟動它。伺服器啟動後,腳本退出。該服務仍然存在,因為 cgroup 中至少還剩下一個進程,即上述伺服器。

  2. 伺服器產生一個 shell 來處理「內部」腳本。腳本從'after開始,在before-d結束。全部都被引用了,但是引用從單引號變為雙引號,然後又返回了幾次。這是因為並且需要透過處理主腳本的 shell 進行擴展,其他變數(例如)必須在「內部」shell 內部進行擴展。以下資源可能會有所幫助:'||$tmux$service$statustmux參數擴展(變數擴展)和引號內的引號

  3. 內部的外殼tmux準備忽略HUP訊號。

  4. shell 在服務期望的 pidfile 中註冊其 PID。

  5. 然後它運行dotnet並儲存其退出狀態(嚴格來說,如果cd失敗那麼它將是 的退出狀態cd)。

  6. shell 殺死tmux伺服器。我們kill "$PPID"也可以這樣做(參見),但是如果有人殺死了伺服器並且另一個進程獲得了它的 PID,我們就會殺死一個錯誤的進程。尋址tmux更安全。因為trap外殼得以倖存。

  7. 然後 shell 會循環直到它的 PPID 與之前不同。我們不能依賴比較$ppid$PPID因為後者不是動態的;我們從 中檢索目前的 PPID ps

  8. 現在 shell 知道它有一個新的父級,它應該是systemd.現在才systemd能夠從 shell 檢索退出狀態。 shell 以dotnet先前擷取的確切退出狀態退出。systemd儘管事實上dotnet從來不是它的子項,但這種方式可以獲得退出狀態。

tmux從公共伺服器檢索退出狀態

您最初的方法使用通用(預設)tmux伺服器,它僅操作名為 的會話rof。一般來說,其他會話可能存在或出現,因此服務永遠不應該殺死整個伺服器。有幾個方面。我們應該:

  • 防止systemd殺死tmux伺服器,即使伺服器是從服務內部啟動的;
  • 考慮systemd處理dotnet服務的一部分,即使它是從服務內部啟動的,而tmux不是從服務內部啟動的;
  • 以某種方式檢索退出狀態dotnet

服務文件:

[Unit]
Description=dotnet application

[Service]
Type=simple
ExecStart=/home/alpine_sour/scripts/rofdl
Restart=on-failure
User=root

[Install]
WantedBy=multi-user.target

請注意,現在是這樣Type=simple,因為主腳本是我們可以從中檢索退出狀態的唯一有保證的子腳本。該腳本需要找出 的退出狀態dotnet …並將其報告為自己的退出狀態。

/home/alpine_sour/scripts/rofdl:

#!/bin/sh
tmux="/usr/bin/tmux"
service="rofdl.service"
slice="/sys/fs/cgroup/systemd/system.slice"

"$tmux" kill-session -t "rof" 2> /dev/null
( sh -c 'echo "$PPID"' > "$slice/tasks"
  exec "$tmux" new-session -s "rof" -d "
      '$tmux' set-option -t 'rof' remain-on-exit on "'
      echo "$$" > '" '$slice/$service/tasks' "'
      cd /home/alpine_sour/rofdl && dotnet run
      exit "$?"
    ' || exit 1
)

pane="$("$tmux" display-message -p -t "rof" "#{pane_id}")"

while sleep 2; do
  [ "$("$tmux" display-message -p -t "$pane" "#{pane_dead}")" -eq 0 ] || {
    status="$("$tmux" display-message -p -t "$pane" "#{pane_dead_status}")"
    status="${status:-255}"
    exit "$status"
  }
done

解釋:

  1. 如果tmux new-session建立一個伺服器(因為沒有),我們希望它從一開始就位於另一個 cgroup 中,以防止當其他東西開始使用該伺服器並且我們尚未更改其 cgroup 並systemd決定出於任何原因終止該服務時出現競爭情況。我嘗試跑步tmux new-sessioncgexec但失敗了;因此,另一種方法是:一個子 shell 更改自己的 cgroup(透過寫入/sys/fs/cgroup/systemd/system.slice/tasks),然後execs 到tmux new-session

  2. 內部的 shelltmux透過啟用remain-on-exit會話選項來啟動。退出後,該窗格仍然存在,另一個進程(在我們的例子中是主腳本)可以從伺服器檢索其退出狀態tmux

  3. 同時,主腳本會擷取另一個 shell 執行所在窗格的唯一 ID。

  4. 內部的 shelltmux透過將其 PID 寫入與服務關聯的 cgroup 中來註冊它的 PID /sys/fs/cgroup/systemd/system.slice/rofdl.service/tasks

  5. 裡面的外殼在tmux運作dotnet …。終止後dotnet,shell 退出。檢索到的退出狀態dotnet由 shell 回報給tmux伺服器。

  6. 由於remain-on-exit on,在「內」外殼退出後,窗格仍處於死亡狀態。

  7. 同時,主 shell 會循環,直到窗格停止運作。然後它向tmux伺服器查詢相關的退出狀態並將其報告為自己的退出狀態。這種方式systemd可以從 取得退出狀態dotnet

筆記:

  • 再次有引號內的引號

  • 取而代之的dotnet runexec dotnet run。最後一種形式很好:dotnet替換了內殼,因此只有一個進程而不是兩個。問題是當dotnet被它無法處理的信號殺死時。事實證明#{pane_dead_status},如果窗格中的進程被訊號強行終止,則會報告空字串。在dotnet和之間維護一個 shelltmux可以防止這種情況:shell 轉換資訊(參見這個問題)並傳回一個數字。

    一些 shell(實作?)使用隱式運行最後一個命令exec,這是我們不想要的。這就是我使用exit "$?"after 的原因dotnet …

    但如果強行殺掉shell本身,又會出現空的問題#{pane_dead_status}。作為最後的手段status="${status:-255}"將空狀態轉換為255(儘管我不確定255在這種情況下是最佳值)。

  • 有一個競爭條件:當主腳本查詢 時tmux#{pane_id}它可能不是右窗格。如果有人在 之後tmux new-session和 之前在會話中附加並進行遊戲tmux display-message,我們可能會得到錯誤的窗格。時間窗口很小,但這仍然沒有我想要的那麼優雅。

    如果tmux new-session能像can那樣印#{pane_id}到控制台tmux display-message -p,應該沒有問題。有了-PF它就可以在會話中顯示它。不支援-p.

  • 您可能需要一些邏輯,以防tmux伺服器被殺死。

透過文件檢索退出狀態

上面的例子可以修改,所以remain-on-exit on不需要,#{pane_id}不需要(避免競爭條件,至少是所描述的)。

上一個範例中的服務文件仍然存在。

/home/alpine_sour/scripts/rofdl:

#!/bin/sh
tmux="/usr/bin/tmux"
service="rofdl.service"
slice="/sys/fs/cgroup/systemd/system.slice"
statf="/var/run/$service.status"

rm "$statf" 2>/dev/null

"$tmux" kill-session -t "rof" 2> /dev/null
( sh -c 'echo "$PPID"' > "$slice/tasks"
  exec "$tmux" new-session -s "rof" -d '
      echo "$$" > '" '$slice/$service/tasks' "'
      cd /home/alpine_sour/rofdl && dotnet run
      echo "$?" > '" '$statf.tmp'
      mv '$statf.tmp' '$statf'
    " || exit 1
)

while sleep 2; do
  status="$(cat "$statf" 2>/dev/null)" && exit "$status"
done

這個機制非常簡單:主 shell 刪除舊的狀態檔案(如果有),觸發tmux並循環,直到檔案重新出現。準備好後,「內部」shell 將退出狀態寫入dotnet檔案。

筆記:

  • 如果內殼被殺死怎麼辦?如果無法建立檔案怎麼辦?相對容易出現主腳本無法退出循環的情況。
  • 寫入臨時檔案然後重命名是一個很好的做法。如果我們這樣做echo "$?" > "$statf",該文件將被創建為空,然後寫入。這可能會導致主腳本讀取空字串作為狀態的情況。一般來說,接收方可能會得到不完整的資料:讀取直到 EOF,而發送方正在寫入中間並且檔案即將增長。重新命名可以立即顯示具有正確內容的正確檔案。

最後的筆記

  • 如果您不能沒有tmux,那麼使用單獨tmux伺服器的解決方案似乎是最可靠的。
  • 這就是文件說的是Restart=

    在這種情況下,乾淨退出意味著退出代碼為0,或訊號SIGHUPSIGINTSIGTERMSIGPIPE、 和 [...]之一

    shell 中的註解$?只是一個數字。再次:這個連結。如果您dotnet因訊號而退出並重新啟動取決於(非)乾淨退出,則systemd直接從中擷取退出程式碼的解決方案的行為可能與從中間 shell 擷取退出狀態dotnet的解決方案不同。systemd研究一下SuccessExitStatus=,也許有用。

答案2

RestartForceExitStatus=也許你可以在服務文件中使用

取得退出狀態定義列表,當主服務程序傳回該列表時,將強制自動服務重新啟動,無論使用 Restart= 配置的重新啟動設定為何。參數格式類似 RestartPreventExitStatus=。

https://www.freedesktop.org/software/systemd/man/systemd.service.html

相關內容