使用 systemd 监听服务端口

2021-11-01 ⏳5.3分钟(2.1千字)

这是博客系统开源计划的第一篇文章。网站的 http 服务使用 Go 语言开发。因为要监听标准的 80 和 443 端口,所以 http 服务一直是以 root 账号运行的。但使用 root 运行风险很大。一旦代码有漏洞被坏人攻破,就会被拿到根用户权限,后果不堪设想。所以像 nginx 这样的 http 服务都支持设置工作进程的运行账号。也就是说,主进程使用 root 账号启动,完成端口监听绑定动作,然后再使用非特权账号启动工作进程对外提供服务。我的博客是用 Go 语言开发,实现类似的机制不太容易。于是就选用 systemd 提供的 socket activation 功能来实现。本来 http 服务也需要使用 systemd 管理,所以就不需要再自己实现端口监听功能了。今天把相关的知识整理出来,分享给大家。

关于 systemd 的 socket activation 功能的详细介绍,大家可以参考 Pid Eins 的文章,Pid Eins 是 systemd 的核心开发者。简单来详 socket activation 就是 systemd 可以代理服务进程来监听某端口,并把对应的 fd 通过环境变量的形式传给服务进程。

那 socket activation 要解决什么问题呢?答案是系统启动速度问题。unix 下的传统的系统服务都是顺序启动的,一个接一个。如果开机启动的服务有很多,整个过程会很慢长。后来像 ubuntu 之类的 linux 发行版引入了并行启动的概念。对于没有依赖的服务(比如蓝牙和http服务就互不依赖),可以一并发启动,加快系统启动速度。

那相互依赖的服务能不能并行启动呢?比如说有三个服务:syslog、dbus和bluetooth。dbus 依赖 syslog,bluetooth 依赖 dbus。怎么才能实现并行启动呢?这就需要弄明白依赖的本质!

syslog 服务启动后会监听一个 unix 套接字文件,用于接收日志数据。dbus 在启动和运行的时候需要输出日志,所以得等到 syslog 启动之后才能正常运行。同样的道理,dbus 作为消息总线,也是在监听 unix 套接字文件。bluetooth 启动的时候需要收发消息,必须在 dbus 之后启动。

但 dbus 是如何跟 syslog 通信的呢?使用套接字!bluetooth 跟 dbus 呢?也是套接字。所以说,这里依赖的本质是套接字的依赖。为了能实现并行启动,systemd 可以替服务监听套接字,哪怕是服务还没有启动。也就是说,systemd 可以替 syslog 和 dbus 监听套接字,然后并行启动这三个服务。在 syslog 启动之前,如果有其他服务要发送日志数据,systemd 可以先把数据缓存起来,等 syslog 启动后再转发给它。对于 dbus 也是同理。这样 bluetooh 就可以跟 syslog 和 dbus 同时启动。对 bluetooth 而言,syslog 和 dbus 的套接字已经准备好了。

所以说,systemd 可以最大程度的实现并行启动,缩减系统启动时间。这种技术对于服务器来说好像用处不大,但对PC来说可以大大改善用户体验。(所以 systemd 的争议比较大,有些发行版坚持不用它)

因为 systemd 是一号进程,所以可以监听任何端口。systemd 在启动服务的时候又可以指定运行帐号,所以说是完美契合我的需求。那应该如何实现所谓的 socket activation 呢?

首先我们需要改造 http 服务代码,来支持从 systemd 接收监听端口。

systemd 可以监听多个端口。绑定之后会启动 http 服务进程,并且给服务进程注入三个环境变量:

从 systemd 继承套接字的代码如下:

if os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()) {
  if os.Getenv("LISTEN_FDS") != "3" {
    panic("LISTEN_FDS should be 3")
  }
  names := strings.Split(os.Getenv("LISTEN_FDNAMES"), ":")
  for i, name := range names {
    switch name {
    case "http":
      f1 := os.NewFile(uintptr(i+3), "http port")
      ln80, err = net.FileListener(f1)
    case "https":
      f2 := os.NewFile(uintptr(i+3), "https port")
      ln443, err = net.FileListener(f2)
    case "quic":
      f3 := os.NewFile(uintptr(i+3), "quic port")
      lnUDP, err = net.FilePacketConn(f3)
    }
  }
}

首先我们检查当前的里程ID是不是跟 LISTEN_PID 一致,如果不一样,说明当前进程是服务进程 fork 出来的子进程,就不需要再重复监听了。

然后通过 LISTEN_FDS 检查端口数量。我的程序需要监听 tcp 80/443 和 udp 443 三个端口。

最后就是通过 LISTEN_FDNAMES 指定的名字绑定 fd。systemd 监听的套接字 fd 是从 3 开始的,依次加一,顺序跟 LISTEN_FDS 指定的名字保持一致。也就说,如果 LISTEN_FDNAMES 的值为http:https:quic,那 80 端口对应的 fd 就是 3,443 对应的是4,udp 443 对应的就是5。

在 Go 语言中,可以通过os.NewFile将 fd 转换成对应的文件对象,然后再生成对应的监听对象。

以上就是服务端代码需要配合改造的部分。现在我们说一下 systemd 的配置。

要让 systemd 监听套接字或才端口,需要仓库 .socket 配置文件。结构如下:

[Unit]
Description=lehu http socket

[Socket]
ListenStream=80
FileDescriptorName=http
Service=lehu.service

[Install]
WantedBy=sockets.target

这里分成了三个部分:Unit、Socket和Install。Unit部分写一下配置描述,不重要。Install 部分指定安装位置。这里统一设为 sockets.target。核心是 Socket 部分。其实从配置名称上也能看出意思。ListenStream 表示监听 tcp 端口。如果要监听 udp,则使用 ListenDatagram。FileDescriptorName 表示套接字名字,也就是 LISTEN_FDNAMES 里面的值。Service 表示对应的服务。systemd 会先监听端口,然后启动对应的服务。

用了 .socket 配置,我们来看看服务的配置,也就是 .service 配置:

[Unit]
Description=lehu
After=network.target
Requires=lehu-http.socket lehu-https.socket lehu-quic.socket

[Service]
ExecStart=...
User=nobody
Group=nobody
KillMode=process
Restart=on-failure

[Install]
WantedBy=multi-user.target

结构跟 .socket 一样。Unit 部分中出现了 After 和 Requires 两个配置。After 的意思是当前服务必须在 network.target 启动之后启动。因为是网络服务,如果 network.target 都没启动说明网络栈还没准备好,不能正常启动当前服务。Requires 表示当前服务依赖的其他服务或者监听对象。这里指定说 lehu.service 依赖 lehu-http.socket、lehu-https.socket和lehu-quic.socket三组监听端口。也就说,如果要启动 lehu.service,systemd 会先监听就三个端口。Service 部分通过 ExecStart 设置服务可执行文件路径和运行参数,通过 User 和 Group 设置运行账号。[Install] 部分是把当前服务加到 multi-user.target 这个组里,对应 sysv 3 这个运行级别。

然后把所有的配置都加到 /etc/systemd/system 下,然后执行:

systemctl enable lehu.service lehu-http.socket lehu-https.socket lehu-quic.socket

这个时候 systemd 已经开始监听端口了。因为还没有访问对应的端口,所以 systemd 不会启动 lehu.service。

如果你通过浏览器访问 80 端口,systemd 收到后会自动启动 lehu.service。如果你把所有单元都停掉,只启动 lehu-http.socket,然后再访问 80 端口,systemd 收到请求后会尝试启动 lehu.service。又因为 lehu.service 依赖其他端口监听,所以 systemd 也会一并开始监听。

以上就是本文的主要内容。systemd接管了很多系统启动和管理的工作,不太符合 unix 中小美的哲学。但 systemd 也确实解决了很多关键的问题,所以争议比较大。但大规大,像 debian 和 ubuntu 这样的发行版也已经迁移到 systemd 上了,我们必须得学习相关的知识。而且 systemd 甚至支持通过 cgroup 来隔离服务使用资源,是云原生时代绕不过去的基础设施。本文只是从 socket activation 角度介绍了 systemd 的用法,希望能给大家带来一定的启发。

参考文献: