我在 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 …
。它没有提到 cgroups。这个答案假设对照组参与其中。我认为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
s 到dotnet
同时将tmux
服务器保留为父级。在任何情况下dotnet
都有一个不是 的父级systemd
。
您可以dotnet
像这样将其设为孤儿:nohup dotnet … &
,然后让该 shell 退出。您还需要存储 PID,PIDFile=
在单元配置文件中使用,这样服务就知道要监视哪个进程。然后它可能会起作用。
需要明确的是:在我的测试中, whonohup 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时可能会出现问题。
解决方案和示例
reptyr
到tmux
此示例在 中运行脚本tty2
。脚本准备tmux
并exec
s 到dotnet
。最后, 中的 shelltmux
尝试窃取现在的 tty 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 run
不使用reptyr
,不使用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
解释:
主脚本会终止独占
tmux
服务器(如果有)并重新启动它。服务器启动后,脚本退出。服务仍然存在,因为 cgroup 中至少还剩下一个进程,即所述服务器。服务器生成一个 shell 来处理“内部”脚本。脚本从
'
after开始,到before-d
结束。它全部被引号括起来,但引号从单引号变为双引号,然后又变回单引号几次。这是因为和需要由处理主脚本的 shell 扩展,其他变量(例如)必须等到在“内部”shell 中( 内)才可以扩展。以下资源可能会有所帮助:'
||
$tmux
$service
$status
tmux
参数扩展(变量扩展)和引号内的引号。里面的shell
tmux
准备忽略HUP
信号。shell 在服务所需的 pidfile 中注册其 PID。
然后它运行
dotnet
并存储其退出状态(严格地说,如果cd
失败那么它将是的退出状态cd
)。shell 终止
tmux
服务器。我们kill "$PPID"
也可以使用以下命令来执行此操作(参见这),但如果有人杀死了服务器,而另一个进程获得了它的 PID,我们就会杀死一个错误的进程。寻址tmux
更安全。因为trap
shell 仍然存在。然后 shell 循环直到其 PPID 与之前不同。我们不能依赖于与
$ppid
的比较$PPID
,因为后者不是动态的;我们从 中检索当前 PPIDps
。现在 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
解释:
如果
tmux new-session
创建服务器(因为没有),我们希望从一开始就将其放在另一个 cgroup 中,以防止当其他东西开始使用服务器并且我们尚未更改其 cgroup 并systemd
决定出于某种原因终止服务时出现竞争条件。我尝试运行tmux new-session
但cgexec
失败了;因此另一种方法:一个更改其自己的 cgroup 的子shell(通过写入/sys/fs/cgroup/systemd/system.slice/tasks
)然后exec
更改为tmux new-session
。内部的 shell
tmux
通过启用remain-on-exit
会话选项来启动。退出后,窗格仍然存在,另一个进程(本例中为主脚本)可以从服务器检索其退出状态tmux
。同时,主脚本会检索其他 shell 运行的窗格的唯一 ID。如果有人加入会话或创建新窗格并使用它们,主脚本仍然能够找到正确的窗格。
内部的 shell
tmux
通过将其 PID 写入来在与服务关联的 cgroup 中注册它/sys/fs/cgroup/systemd/system.slice/rofdl.service/tasks
。里面的 shell
tmux
运行dotnet …
。dotnet
终止后,shell 退出。从 检索到的退出状态dotnet
由 shell 报告给tmux
服务器。由于
remain-on-exit on
,在“内”壳退出后,窗格仍然处于死状态。与此同时,主 shell 循环直到窗格死掉。然后它向
tmux
服务器查询相关的退出状态并将其报告为自己的状态。这种方式systemd
从 获得退出状态dotnet
。
笔记:
还有引号中的引号。
而不是
dotnet run
它可以是exec dotnet run
。最后一种形式很好:dotnet
替换内壳,因此只有一个进程而不是两个。问题是当被dotnet
它无法处理的信号杀死时。事实证明,#{pane_dead_status}
如果窗格中的进程被信号强制杀死,则会报告一个空字符串。在dotnet
和之间维护一个外壳tmux
可以防止这种情况:外壳会转换信息(参见这个问题) 并返回一个数字。有些 shell(实现?)会使用隐式 来运行最后一条命令
exec
,这是我们不想要的。这就是我使用exit "$?"
after 的原因dotnet …
。但是如果 shell 本身被强制杀死,empty 的问题
#{pane_dead_status}
又会出现。最后的办法是status="${status:-255}"
将 empty 状态转换为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
并循环直到文件重新出现。“内部”shelldotnet
在准备就绪时将退出状态写入文件。
笔记:
- 如果内壳被杀死怎么办?如果无法创建文件怎么办?主脚本无法退出循环的情况相对容易出现。
- 写入临时文件然后重命名是一种很好的做法。如果我们这样做
echo "$?" > "$statf"
,文件将被创建为空,然后写入。这可能会导致主脚本读取空字符串作为状态的情况。通常,接收方可能会获得不完整的数据:读取到 EOF,而发送方正在写入中间,文件即将增长。重命名可使具有正确内容的正确文件立即出现。
最后说明
答案2
也许您可以RestartForceExitStatus=
在服务文件中使用
获取退出状态定义列表,当主服务进程返回时,将强制自动重启服务,无论使用 Restart= 配置的重启设置如何。参数格式类似于 RestartPreventExitStatus=。
https://www.freedesktop.org/software/systemd/man/systemd.service.html