初步说明

初步说明

我在 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并等待它退出,要么execs 到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时可能会出现问题。


解决方案和示例

reptyrtmux

此示例在 中运行脚本tty2。脚本准备tmuxexecs 到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

解释:

  1. 主脚本会终止独占tmux服务器(如果有)并重新启动它。服务器启动后,脚本退出。服务仍然存在,因为 cgroup 中至少还剩下一个进程,即所述服务器。

  2. 服务器生成一个 shell 来处理“内部”脚本。脚本从'after开始,到before-d结束。它全部被引号括起来,但引号从单引号变为双引号,然后又变回单引号几次。这是因为和需要由处理主脚本的 shell 扩展,其他变量(例如)必须等到在“内部”shell 中( 内)才可以扩展。以下资源可能会有所帮助:'||$tmux$service$statustmux参数扩展(变量扩展)和引号内的引号

  3. 里面的shelltmux准备忽略HUP信号。

  4. shell 在服务所需的 pidfile 中注册其 PID。

  5. 然后它运行dotnet并存储其退出状态(严格地说,如果cd失败那么它将是的退出状态cd)。

  6. shell 终止tmux服务器。我们kill "$PPID"也可以使用以下命令来执行此操作(参见),但如果有人杀死了服务器,而另一个进程获得了它的 PID,我们就会杀死一个错误的进程。寻址tmux更安全。因为trapshell 仍然存在。

  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失败了;因此另一种方法:一个更改其自己的 cgroup 的子shell(通过写入/sys/fs/cgroup/systemd/system.slice/tasks)然后exec更改为tmux new-session

  2. 内部的 shelltmux通过启用remain-on-exit会话选项来启动。退出后,窗格仍然存在,另一个进程(本例中为主脚本)可以从服务器检索其退出状态tmux

  3. 同时,主脚本会检索其他 shell 运行的窗格的唯一 ID。如果有人加入会话或创建新窗格并使用它们,主脚本仍然能够找到正确的窗格。

  4. 内部的 shelltmux通过将其 PID 写入来在与服务关联的 cgroup 中注册它/sys/fs/cgroup/systemd/system.slice/rofdl.service/tasks

  5. 里面的 shelltmux运行dotnet …dotnet终止后,shell 退出。从 检索到的退出状态dotnet由 shell 报告给tmux服务器。

  6. 由于remain-on-exit on,在“内”壳退出后,窗格仍然处于死状态。

  7. 与此同时,主 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,而发送方正在写入中间,文件即将增长。重命名可使具有正确内容的正确文件立即出现。

最后说明

  • 如果您不能没有它tmux,那么使用单独tmux服务器的解决方案似乎是最可靠的。
  • 这就是文档Restart=

    在此上下文中,干净退出意味着退出代码为0,或信号SIGHUPSIGINTSIGTERM或之一SIGPIPE,以及 […]

    shell 中的注释$?只是一个数字。再说一遍:此链接。如果您的dotnet退出是因为信号,并且重新启动取决于(不)干净的退出,则systemd直接从中检索退出代码的解决方案可能与从中间 shell 中检索退出状态dotnet的解决方案的行为不同。研究,它可能会有用。systemdSuccessExitStatus=

答案2

也许您可以RestartForceExitStatus=在服务文件中使用

获取退出状态定义列表,当主服务进程返回时,将强制自动重启服务,无论使用 Restart= 配置的重启设置如何。参数格式类似于 RestartPreventExitStatus=。

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

相关内容