直接回答:把 Go 程序做成系统服务就是写一个 .service 单元文件放到 /etc/systemd/system/,里面用 ExecStart 指向二进制,再 systemctl daemon-reload 和 enable 一下;这样能开机自启、崩溃自动重启。

在**《让reviewdog支持gitlab-push-commit,守住代码质量下限》**[1]一文中,gitlab-runner2通过自身提供的install命令将自己安装为了一个系统服务(如下面步骤):

1
2
3
4
5
6
# Create a GitLab CI user
sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash

# Install and run as service
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
sudo gitlab-runner start

在主流新版linux上(其他os或linux上的旧版守护服务管理器如sysvinit、upstart等,我们暂不care),系统服务就是由systemd[3]管理的daemon process(守护进程)。

systemd是什么?linux主机上电后,os内核被加载并启动,os内核完成初始化以后,由内核第一个启动的程序是init程序,其PID(进程ID)为1,它为系统里所有进程的“祖先”,systemd便是主流新版linux中的那个init程序,它负责在主机启动后拉起所有安装为系统服务的程序。

这些被systemd拉起的服务程序以守护进程(daemon process)的形式运行,那什么又是守护进程呢?《UNIX环境高级编程3rd(Advanced Programming in the UNIX Environment)》[4]一书中是这样定义的:

1
2
3
Daemons are processes that live for a long time. They are often started when the system is bootstrapped and terminate only when the system is shut down. Because they don’t have a controlling terminal, we say that they run in the background. UNIX systems have numerous daemons that perform day-to-day activities.

守护进程是长期存在的进程。它们通常在系统启动时被启动,并在系统关闭时才终止。因为它们没有控制终端,我们说它们是在后台运行的。UNIX系统有许多执行日常活动的守护进程。

该书还提供了一个用户层应用程序将自己变为守护进程的标准步骤(编码规则(coding rules)),并给出了一个C语言[5]示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include "apue.h"
#include <syslog.h>
#include <fcntl.h>
#include <sys/resource.h>

void
daemonize(const char *cmd)
{
int i, fd0, fd1, fd2;
pid_t pid;
struct rlimit rl;
struct sigaction sa;

/*
* Clear file creation mask.
*/
umask(0);
/*
* Get maximum number of file descriptors.
*/
if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
err_quit("%s: can’t get file limit", cmd);
/*
* Become a session leader to lose controlling TTY.
*/
if ((pid = fork()) < 0)
err_quit("%s: can’t fork", cmd);
else if (pid != 0) /* parent */
exit(0);
setsid();

/*
* Ensure future opens won’t allocate controlling TTYs.
*/
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);

sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
err_quit("%s: can’t ignore SIGHUP", cmd);
if ((pid = fork()) < 0)
err_quit("%s: can’t fork", cmd);
else if (pid != 0) /* parent */
exit(0);
/*
* Change the current working directory to the root so
* we won’t prevent file systems from being unmounted.
*/
if (chdir("/") < 0)
err_quit("%s: can’t change directory to /", cmd);
/*
* Close all open file descriptors.
*/
if (rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;
for (i = 0; i < rl.rlim_max; i++)
close(i);
/*
* Attach file descriptors 0, 1, and 2 to /dev/null.
*/
fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);
/*
* Initialize the log file.
*/
openlog(cmd, LOG_CONS, LOG_DAEMON);
if (fd0 != 0 || fd1 != 1 || fd2 != 2) {
syslog(LOG_ERR, "unexpected file descriptors %d %d %d",
fd0, fd1, fd2);
exit(1);
}
}

那么,Go应用程序是否可以参考上面的转换步骤将自己转换为一个守护进程呢?很遗憾!Go团队说很难做到[6]。Go社区倒是有很多第三方的方案,比如像go-daemon[7]这样的第三方实现,不过我并没有验证过这些方案,不保证完全ok。

Go团队推荐通过像systemd这样的init system来实现Go程序的守护进程转换[8]。gitlab-runner就是将自己安装为system服务,并由systemd对其进行管理的。

题外话:其实,自从有了容器技术(比如:docker)后,daemon service(守护进程服务)的需求似乎减少了。因为使用-d选项运行容器,应用本身就运行于后台,使用--restart=always/on-failure选项,容器引擎(比如docker engine)会帮我们管理service,并在service宕掉后重启service。

那么,我们如何像gitlab-runner那样将自己安装为一个systemd service呢?我们继续向下看。

注意:这里只是将Go应用安装成一个systemd service,并不是自己将自己转换为守护进程,安装为systemd service本身是可行的,也是安全的。

翻看gitlab-runner源码,你会发现gitlab-runner将自己安装为系统服务全依仗于github.com/kardianos/service这个Go包,这个包是Go标准库database包维护者之一Daniel Theophanes开源的系统服务操作包,该包屏蔽了os层的差异,为开发人员提供了相对简单的Service操作接口,包括下面这些控制动作:

1
2
// github.com/kardianos/service/blob/master/service.go
var ControlAction = [5]string{"start", "stop", "restart", "install", "uninstall"}

好了,下面我们就用一个例子myapp[9]来介绍一下如何利用kardianos/service包让你的Go应用具备将自己安装为system service的能力。

myapp是一个http server,它在某个端口上提供服务,当收到请求时,返回"Welcome"字样的应答:

1
2
3
4
5
6
7
8
9
10
11
12
// https://github.com/bigwhite/experiments/blob/master/system-service/main.go

func run(config string) error {
... ...

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[%s]: receive a request from: %s\n", c.Server.Addr, r.RemoteAddr)
w.Write([]byte("Welcome"))
})
fmt.Printf("listen on %s\n", c.Server.Addr)
return http.ListenAndServe(c.Server.Addr, nil)
}

现在我们要为myapp增加一些能力,让它支持将自己安装为systemd service,并可以通过subcommand启动(start)、停止(stop)和卸载(uninstall)systemd service。

我们首先通过os包和flag包为该程序增加subcommand和其参数的解析能力。我们不使用第三方命令行参数解析包,只是用标准库的flag包。由于myapp支持subcommand,我们需要为每个带命令行参数的subcommand单独申请一个FlagSet实例,如下面代码中的installCommand和runCommand。每个subcommand的命令行参数也要绑定到各自subcommand对应的FlagSet实例上,比如下面代码init函数体中的内容。

另外由于使用了subcommand,默认的flag.Usage不再能满足我们的要求了,我们需要自己实现一个usage函数并赋值给flag.Usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// https://github.com/bigwhite/experiments/blob/master/system-service/main.go

var (
installCommand = flag.NewFlagSet("install", flag.ExitOnError)
runCommand = flag.NewFlagSet("run", flag.ExitOnError)
user string
workingdir string
config string
)

const (
defaultConfig = "/etc/myapp/config.ini"
)

func usage() {
s := `
USAGE:
myapp command [command options]

COMMANDS:
install install service
uninstall uninstall service
start start service
stop stop service
run run service

OPTIONS:
-config string
config file of the service (default "/etc/myapp/config.ini")
-user string
user account to run the service
-workingdir string
working directory of the service`

fmt.Println(s)
}

func init() {
installCommand.StringVar(&user, "user", "", "user account to run the service")
installCommand.StringVar(&workingdir, "workingdir", "", "working directory of the service")
installCommand.StringVar(&config, "config", "/etc/myapp/config.ini", "config file of the service")
runCommand.StringVar(&config, "config", defaultConfig, "config file of the service")
flag.Usage = usage
}

func main() {
var err error
n := len(os.Args)
if n <= 1 {
fmt.Printf("invalid args\n")
flag.Usage()
return
}

subCmd := os.Args[1] // the second arg

// get Config
c, err := getServiceConfig(subCmd)
if err != nil {
fmt.Printf("get service config error: %s\n", err)
return
}
... ...
}

这些都完成后,我们在getServiceConfig函数中获取即将安装为systemd service的本服务的元配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// https://github.com/bigwhite/experiments/blob/master/system-service/config.go

func getServiceConfig(subCmd string) (*service.Config, error) {
c := service.Config{
Name: "myApp",
DisplayName: "Go Daemon Service Demo",
Description: "This is a Go daemon service demo",
Executable: "/usr/local/bin/myapp",
Dependencies: []string{"After=network.target syslog.target"},
WorkingDirectory: "",
Option: service.KeyValue{
"Restart": "always", // Restart=always
},
}

switch subCmd {
case "install":
installCommand.Parse(os.Args[2:])
if user == "" {
fmt.Printf("error: user should be provided when install service\n")
return nil, errors.New("invalid user")
}
if workingdir == "" {
fmt.Printf("error: workingdir should be provided when install service\n")
return nil, errors.New("invalid workingdir")
}
c.UserName = user
c.WorkingDirectory = workingdir

// arguments
// ExecStart=/usr/local/bin/myapp "run" "-config" "/etc/myapp/config.ini"
c.Arguments = append(c.Arguments, "run", "-config", config)
case "run":
runCommand.Parse(os.Args[2:]) // parse config
}

return &c, nil
}

这里要注意的是service.Config中的Option和Arguments,前者用于在systemd service unit配置文件中放置任意的键值对(比如这里的Restart=always),而Arguments则会被组成为ExecStart键的值,该值会在start service时传入使用。

接下来,我们便利用service包基于加载的Config创建操作服务的实例(srv),然后将它和subCommand一并传入runServiceControl实现对systemd service的控制(如下面代码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// https://github.com/bigwhite/experiments/blob/master/system-service/main.go
func main() {

// ... ...
c, err := getServiceConfig(subCmd)
if err != nil {
fmt.Printf("get service config error: %s\n", err)
return
}

prg := &NullService{}
srv, err := service.New(prg, c)
if err != nil {
fmt.Printf("new service error: %s\n", err)
return
}

err = runServiceControl(srv, subCmd)
if err != nil {
fmt.Printf("%s operation error: %s\n", subCmd, err)
return
}

fmt.Printf("%s operation ok\n", subCmd)
return
}

func runServiceControl(srv service.Service, subCmd string) error {
switch subCmd {
case "run":
return run(config)
default:
return service.Control(srv, subCmd)
}
}

好了,代码已经完成!现在让我们来验证一下myapp的能力。

我们先来完成编译和二进制程序的安装:

1
2
3
4
5
6
7
8
$make
go build -o myapp main.go config.go

$sudo make install
cp ./myapp /usr/local/bin
$sudo make install-cfg
mkdir -p /etc/myapp
cp ./config.ini /etc/myapp

接下来,我们就来将myapp安装为systemd的服务:

1
2
3
4
5
6
7
$sudo ./myapp install -user tonybai -workingdir /home/tonybai
install operation ok

$sudo systemctl status myApp
● myApp.service - This is a Go daemon service demo
Loaded: loaded (/etc/systemd/system/myApp.service; enabled; vendor preset: enabled)
Active: inactive (dead)

我们看到安装后,myApp已经成为了myApp.service,并处于inactive状态,其systemd unit文件/etc/systemd/system/myApp.service内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$sudo cat /etc/systemd/system/myApp.service
[Unit]
Description=This is a Go daemon service demo
ConditionFileIsExecutable=/usr/local/bin/myapp

After=network.target syslog.target

[Service]
StartLimitInterval=5
StartLimitBurst=10
ExecStart=/usr/local/bin/myapp "run" "-config" "/etc/myapp/config.ini"

WorkingDirectory=/home/tonybai
User=tonybai

Restart=always

RestartSec=120
EnvironmentFile=-/etc/sysconfig/myApp

[Install]
WantedBy=multi-user.target

接下来,我们来启动一下该服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$sudo ./myapp start
start operation ok

$sudo systemctl status myApp
● myApp.service - This is a Go daemon service demo
Loaded: loaded (/etc/systemd/system/myApp.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2022-09-09 23:30:01 CST; 5s ago
Main PID: 623859 (myapp)
Tasks: 6 (limit: 12651)
Memory: 1.3M
CGroup: /system.slice/myApp.service
└─623859 /usr/local/bin/myapp run -config /etc/myapp/config.ini

Sep 09 23:30:01 tonybai systemd[1]: Started This is a Go daemon service demo.
Sep 09 23:30:01 tonybai myapp[623859]: listen on :65432

我们看到myApp服务成功启动,并在65432这个端口上监听!

我们利用curl向这个端口发送一个请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$curl localhost:65432
Welcome

$sudo systemctl status myApp
● myApp.service - This is a Go daemon service demo
Loaded: loaded (/etc/systemd/system/myApp.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2022-09-09 23:30:01 CST; 1min 27s ago
Main PID: 623859 (myapp)
Tasks: 6 (limit: 12651)
Memory: 1.4M
CGroup: /system.slice/myApp.service
└─623859 /usr/local/bin/myapp run -config /etc/myapp/config.ini

Sep 09 23:30:01 tonybai systemd[1]: Started This is a Go daemon service demo.
Sep 09 23:30:01 tonybai myapp[623859]: listen on :65432
Sep 09 23:31:24 tonybai myapp[623859]: [:65432]: receive a request from: 127.0.0.1:10348

我们看到myApp服务运行正常并返回预期应答结果。

现在我们利用stop subcommand停掉该服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
$sudo systemctl status myApp
● myApp.service - This is a Go daemon service demo
Loaded: loaded (/etc/systemd/system/myApp.service; enabled; vendor preset: enabled)
Active: inactive (dead) since Fri 2022-09-09 23:33:03 CST; 3s ago
Process: 623859 ExecStart=/usr/local/bin/myapp run -config /etc/myapp/config.ini (code=killed, signal=TERM)
Main PID: 623859 (code=killed, signal=TERM)

Sep 09 23:30:01 tonybai systemd[1]: Started This is a Go daemon service demo.
Sep 09 23:30:01 tonybai myapp[623859]: listen on :65432
Sep 09 23:31:24 tonybai myapp[623859]: [:65432]: receive a request from: 127.0.0.1:10348
Sep 09 23:33:03 tonybai systemd[1]: Stopping This is a Go daemon service demo...
Sep 09 23:33:03 tonybai systemd[1]: myApp.service: Succeeded.
Sep 09 23:33:03 tonybai systemd[1]: Stopped This is a Go daemon service demo.

修改配置/etc/myapp/config.ini(将监听端口从65432改为65431),然后再重启该服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$sudo cat /etc/myapp/config.ini
[server]
addr=":65431"

$sudo ./myapp start
start operation ok

$sudo systemctl status myApp
● myApp.service - This is a Go daemon service demo
Loaded: loaded (/etc/systemd/system/myApp.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2022-09-09 23:34:38 CST; 3s ago
Main PID: 624046 (myapp)
Tasks: 6 (limit: 12651)
Memory: 1.4M
CGroup: /system.slice/myApp.service
└─624046 /usr/local/bin/myapp run -config /etc/myapp/config.ini

Sep 09 23:34:38 tonybai systemd[1]: Started This is a Go daemon service demo.
Sep 09 23:34:38 tonybai myapp[624046]: listen on :65431

从systemd的状态日志中我们看到myApp服务启动成功,并改为监听65431端口,我们访问一下该端口:

1
2
3
4
5
$curl localhost:65431
Welcome

$curl localhost:65432
curl: (7) Failed to connect to localhost port 65432: Connection refused

从上述结果可以看出,我们的配置更新和重启都是成功的!

我们亦可以使用myapp的uninstall功能从systemd中卸载该服务:

1
2
3
4
$sudo ./myapp uninstall
uninstall operation ok
$sudo systemctl status myApp
Unit myApp.service could not be found.

好了,到这里我们看到:在文章开始处提出的给Go应用增加将自己安装为systemd service的能力的目标已经顺利实现了。

最后小结一下:service包让我们的程序有了将自己安装为system service的能力。它也可以让你开发出将其他程序安装为一个system service的能力,不过这个作业就留给大家了:)。大家如有问题,欢迎在评论区留言。

这篇笔记整理自我自己的实践记录,如果做法有出入,或者你踩过别的坑,欢迎到留言板一起聊聊。

站内搜索

没有找到内容!