如何让 systemd 正确管理具有后台进程的脚本?

如何让 systemd 正确管理具有后台进程的脚本?

我有一个 shell 脚本,它在后台运行三个程序,在前台运行几个程序,然后运行trap​​和wait,并且我已经设置了一个单元文件,以便systemd可以启动它并在失败时重新启动它。

然而,我发现,如果一个进程终止,它不会杀死该脚本中的所有内容并重新启动它。对于此应用程序,如果其中任何一个死亡,则必须重新启动它们。

我看到两条合理的路径:

  1. 配置单元文件并可能更改脚本,以便检测到异常并杀死所有异常,然后重新运行脚本。我不知道该怎么做。
  2. 将三个后台进程中的每一个进程配置为具有单独文件的自己的单元.service。但我不知道如何编写.service文件来杀死并重新启动其中任何一个失败的文件。我知道我可以安排它们的依赖关系,以便它们按顺序启动,但我不知道如何让它在 #2 死亡时杀死 #1,反之亦然。

我不想写一个管理器或让程序弄清楚并自行终止 - 这就是目的systemd- 我希望我只是错过了正确的咒语。

.服务文件:

[Unit]
Description=Foobar Interface
After=network.target

[Service]
Type=simple
WorkingDirectory=/home/user/scripts
ExecStart=/home/user/scripts/myscript.sh
Restart=always

[Install]
WantedBy=multi-user.target

重击脚本:

#!/usr/bin/env bash

tty_port=/dev/ttyUSB0

#Clean up any old running processes
pkill -f "cat ${tty_port}"
pkill transport
pkill backgroundprogram

#Configure the target
source /home/user/somescript.sh
foregroundprogram

#Set up the serial port
stty -F $tty_port 115200 

#Read from the port in the background
cat $tty_port &
tty_pid=$!

#Wait for tty device to waken
sleep 15

#Send commands to tty device
echo "command1" > $tty_port
sleep 1
echo "command2" > $tty_port
sleep 1

#Start up the transport
/home/user/transport &>> /dev/null &
transport_pid=$!

#Wait a bit for the transport to start
sleep 1

#Start up the main process
/home/user/backgroundprogram &
background_pid=$!

#Wait a bit for it to start
sleep 1

#Finally, start the tty device
echo "command3" > $tty_port

trap "kill ${background_pid} ${tty_pid} ${transport_pid}; exit 1" INT
wait

这一切都有效,写入日志等,但是当三个进程中的任何一个失败时,它都会继续运行,不会杀死并重新启动所有进程。

答案1

然而,我发现,如果一个进程终止,它不会杀死该脚本中的所有内容并重新启动它。对于此应用程序,如果其中任何一个死亡,则必须重新启动它们。

systemd 正在监视您的 shell 脚本,而不是它的子脚本。你不会systemd 响应子进程的退出,因为这会导致重新启动每次运行命令时。考虑一下,如果我有一个运行的 shell 脚本...

date

我刚刚生成了一个子进程,它运行,然后退出。我不希望这触发我的流程主管采取任何行动。

如果您希望 systemd 监视子进程,请为每个进程创建一个单独的单元文件:

  1. 一台用于配置和读取串口的单元
  2. 一个为/home/user/transport
  3. 一个为/home/user/backgroundprogram

您可以使用 systemd 依赖项来确保服务的正确启动顺序(并确保如果您停止一个服务,它们都会停止),并且您可以使用指令从文件EnvironmentFile加载配置(例如)。$tty_port

您可能会将一些设置命令(“向 tty 设备发送命令...”)放入一行ExecStartPre,或者它们可能会获得自己的Type=oneshot服务。

答案2

如果您可以将主脚本拆分为单独的服务,您可以像这样轻松解决它:

在下面的示例中,我有三个重生服务:s1、s2 和 s3,并通过目标 s.target 将它们作为一个组进行控制。

注意:
如果将三个服务配置为Requires如果您按照s.target 中的全部该组中的参与进程将重新启动。
或者,如果您按照 s.target 中的方式配置它们Wants,那么如果其中一个崩溃并重生,则仅重新启动该单独的进程。




对于每个服务,创建一个服务文件s1、s2、s3:

/etc/systemd/system/s1.service :

[Unit]
Description=my worker s1
After=network.target
Before=foobar.service
PartOf=s.target

[Service]
Type=simple
ExecStart=/usr/local/bin/s1.sh
Restart=always

(注意:如果您的服务相同,您可以创建 [电子邮件受保护]文件而不是多个文件。在手册中查找使用 @ 和 %i 的服务实例。)


现在创建需要 s1、s2 和 s3 服务的主目标(组)文件:

/etc/systemd/system/s.target :

[Unit]
Description=main s service
Requires=s1.service s2.service s3.service
# or
# Wants=s1.service s2.service s3.service

[Install]
WantedBy=multi-user.target

完毕。
像往常一样,你现在必须运行systemctl daemon-reload

现在您可以启动您的服务并systemctl start s.target
启动 s1、s2 和 s3。

您可以通过systemctl stop s.target
停止 s1、s2 和 s3 来停止所有三个服务。

当然,您可以像往常一样启动/停止/重新启动/状态各个服务:
systemctl status s1

如果您终止 s1、s2 或 s3 进程,它将自动重新生成(Restart=always)。
如果您使用Requires,则组中的所有进程都将重新启动。

PS:systemctl enable s.target如果您想在启动时启动服务,请运行。

PS:不幸的是,当使用 systemctl 时,您不能像使用“s1”那样使用简写词“s”来表示“s.target”,而不是键入完整的“s1.service”。当你想管理该组时,你必须输入“s.target”。

答案3

#!/usr/bin/env/python3
# POSIX shell and bash < 4.3 doesn't want to do this.
# https://unix.stackexchange.com/questions/285156/exiting-a-shell-script-if-certain-child-processes-exit
#
# If you haven't written python3 before, be aware the string type
# is Unicode (UTF-8).  Python 3.0 aborts on invalid UTF-8.
# Python 3.1 aims to round-trip invalid UTF-8 using "surrogateescape".
# Python 3.2 may accept non-UTF-8 encoding according to your locale.
# ...
#
# * Functions should be better tested.
#
# * Doesn't bother killing (and waiting for) child processes.
#   Assumes systemd does it for us.
#   Convenient, but I'm not 100% happy about it.
#
# * Otherwise direct translation of nasty script, e.g. use of "sleep".

import sys
import os
import time

tty_port = "/dev/ttyS0"  # or: tty_port = sys.environ["tty_port"]

def die(msg):
    sys.exit(msg)

# Run program in background
def bg(*argv):
    pid = os.fork()
    if pid == 0:
        # Child process: exec or die
        # Either way, we never return from this function.
        try:
            os.execvp(argv[0], argv)
        except Exception as e:
            # By convention, child always uses _exit()
            sys._exit(e)
        assert False
    return pid

def __fg(*argv):
    pid = bg(*argv)
    (_, status) = os.waitpid(pid, 0)
    return status

# Run program, wait for exit, die if the program fails
def fg(*argv):
    status = __fg(*argv)
    if os.WIFEXITED(status):
        code = os.WEXITSTATUS(status)
        if code != 0:
            die("exit status {} from running {}".format(code, argv))
    elif os.WIFSIGNALED(status):
        die("signal {} when running {}"
            .format(os.WTERMSIG(status), argv))
    else:
        assert False, "Unexpected result from waitpid()"

# Use with care.
# "Any  user input that is employed as part of command should be carefully sanitized, to ensure that unexpected shell commands or command options are  not executed."
#
def bg_shell(cmd):
    return bg("/bin/sh", "-c", cmd)

def fg_shell(cmd):
    return fg("/bin/sh", "-c", cmd)


fg("stty", "-F", tty_port, "115200")

tty_pid = bg("cat", tty_port)
print("\"cat {}\" started as pid {}".format(tty_port, tty_pid))

time.sleep(15)

tty_out = open(tty_port, "w")
def tty_print(msg):
    tty_out.write(msg)
    tty_out.flush()

tty_print("command1")
time.sleep(1)

tty_print("command2")
time.sleep(1)

transport_pid = bg_shell("exec /home/user/transport >/dev/null 2>&1")
print("transport started as pid {}".format(transport_pid))
time.sleep(1)

tty_print("command3")
time.sleep(1)

background_pid = bg("/home/user/backgroundprogram")
print("backgroundprogam started as pid {}".format(background_pid))

(pid, status) = os.wait()

# This could be modified to accept exit code 0 as a success,
# and/or accept exit due to SIGTERM as a success.

if os.WIFEXITED(status):
    die("exit status {} from pid {}".format(os.WEXITSTATUS(status)), pid)
elif os.WIFSIGNALED(status):
    die("signal {} when running {}".format(os.WTERMSIG(status), pid))
else:
    assert False, "Unexpected result from wait()"

答案4

bash在该命令的最新版本中,wait有一个选项-n可以等待任何后台进程结束,然后退出。

此外,由于尚不清楚的原因,cat偶尔会在启动和等待之间退出,但直到 才宣布结束wait。所以我jobs在 wait 之前添加了一个命令, whcih 似乎检查是否cat已退出。如果有,wait 只关注剩下的两个进程。如果尚未退出,则当三个进程中的任何一个结束时,等待结束。

所以我的脚本中的最后wait一行被替换为

jobs
wait -n

如果调用 wait 后任何作业结束,则 wait 退出,systemd 终止所有剩余的子进程,并重新启动脚本。

相关内容