借助 Podman 构建、运行和管理容器

2023-04-18, 星期二, 12:05

DevOpsContainer培训

本文在 2023 年 4 月进行了一次重写,因为基础设施供应商整了个大活,导致某个项目的生产环境提前切换到正处于评估中的 Podman 环境,属于是被迫学习了。

一点历史和基础知识

Docker 解决了关于运行容器的许多问题:

  • 定义容器镜像格式
  • 怎样构建镜像(Dockerfile / docker build
  • 怎样管理镜像(docker images *
  • 怎样分发镜像(docker push / pull
  • 怎样运行容器(docker run
  • 怎样管理容器实例(docker ps

之后 Docker 联合 Google,CoreOS 等组织成立了一个标准化组织 Open Container Initiative (OCI),定义了怎样“运行”一个容器,然后把这部分功能剥离出来成为 runc。然而除此之外的标准是模糊不清的,因此导致了各种各样的 High-Level Container Runtime 的出现。

Podman 是 Red Hat 推出的容器管理工具,在 RHEL 7.6 及之后的版本中成为默认的 High-Level Container Runtime,其工具集中还包含了其他一些程序:

  • Podman:用于直接管理 pod 和容器镜像(runstopstartpsattach 和exec ,等等)
  • Buildah:用于构建、推送和签名容器镜像
  • Skopeo:用于复制、检查、删除和签名镜像

Podman 工具集的优势在于:

  • 支持以 rootless 模式运行
  • 与 systemd 集成度较好
  • 从 Kubernetes 吸收了 Pod 的概念

搞咩容器化啊?

有人说,「我 nohup java -jar app.jar >/dev/null 2>&1 & 好好的,搞咩容器化啊?」。

首先,这是宏大计划的一部分。让我来讲几个没品笑话:

  • 某员工重装电脑忘了备份自己的 Maven 本地缓存,然后引用的依赖过于久远以至于中央仓库都没了,制品打不出来了;
  • 前端有个项目某个依赖只识别 Node < 12.*,高了就报错;
  • 修复 Bug 上线,发现落后了几个版本,因为上一个发布者代码没提交;
  • 没人记得有个老项目怎么打包了;

所以我们要弄个 CI 进来,比如 Jenkins,把打包搞成一个可以描述,可以控制的过程。打包的结果可以是标准制品,也可以是容器镜像。

然后到了部署阶段,还要翻找资料看看项目依赖什么运行环境,比如哪个版本的 JRE 或者 Node 环境。

在我还在学写 Java 的时候,有几个同事(反正里面没我)用的 Oracle JDK,在代码里调用了 sun.misc.base64decoder package,等到部署到服务器上时,用的 OpenJDK,哦豁。相比之下 JDK 17 和 JDK 8 之争都没什么震撼效果了(大概 javax.*jakarta.* 还能算一次)。

如果使用容器技术,就不用关心服务器上应当安装什么运行时,甚至启动参数也可以做在镜像里,部署的时候只要确保容器启动了就行。也不用编写 pm2 或者 nohup 指令,让系统自带的管理工具去控制就好。

需要从 Docker 迁移吗?

没有必要将现有的 Docker 环境切换到 Podman。 等你发现 Docker / Podman 不够用的时候就需要上 Kubernetes 了,而那玩意用的 Containerd,操作介面的逻辑都换了(有人做了个 nerdctl,那是另一码事了,用了你就是真·天选·NERD)。唯一的好处是提前熟悉了 Pod 的概念(而我在本文里就没打算重点提)。

对新分配的机器,如果使用的是 RH 系(或与之兼容的)发行版,或者你对安全需求的强迫症发作了,可以考虑分配一个 App Runner 用户,使用 systemd 和 Podman 来部署和管理服务。

对于好不容易学会了 Docker 的用户,可以安装 podman-docker 软件包,每次运行 docker 命令时实际上是在执行 podman 命令。

Podman 还支持 Docker Socket API,因此 podman-docker 软件包还在 /var/run/docker.sock 和 /var/run/podman/podman.sock 之间建立了一个链接。因此您可以继续使用 docker-py 和 docker-compose 工具运行 Docker API 命令,而无需 Docker 守护进程。

虽然构建镜像的工作交给了 Buildah,podman-build 命令实际上是对 Buildah 的调用,与 docker build 命令一样,可以通过 Containerfile 或 Dockerfile 构建 Docker 兼容的容器镜像。

学习途径

Podman 的文档和老牌的 manual 一样得当字典看。如果你需要一个 Quick Start 的话,建议看对应版本的 RHEL 的系统管理员手册中关于 Podman 部分的阐述。Red Hat 还写了许多详细的博文,这个就只能通过搜索引擎了。

rootless

虽然容器引擎(如 Docker)可让您以普通(非 root)用户身份运行 Docker 命令,但执行这些请求的 Docker 守护进程还是以 root 用户身份运行。因此,普通用户可以通过其容器发出可能会损害系统的请求。通过设置 rootless 容器用户,系统管理员可以防止常规用户所做的潜在的损坏容器的活动,同时仍然允许这些用户在其自己的帐户下安全地运行大多数容器功能。

创建专门的用户用于运行容器

useradd --comment "user for running applications" joe
passwd joe
  • 用户会自动配置为能够使用 rootless Podman;
  • useradd 命令会在 /etc/subuid 和 /etc/subgid 文件中自动设置可访问用户和组 ID 的范围;
  • 如果您手动更改 /etc/subuid 或 /etc/subgid,则必须运行 podman system migrate 命令,以允许应用新的更改;
  • 切换用户时不要使用 su 或 su - 命令,因为这些命令不会设置正确的环境变量,应当使用 SSH 登录;

在容器内部使用非 root 用户

尽管 Podman 是使用 rootless 模式运行的,容器内部仍会以 root 用户身份运作,可以使用 --userns=keep-id 改变这个行为:

podman run --userns=keep-id ... dorian-image:0.0.1-SNAPSHOT

通过 idwhoami 命令可以查看效果。

缺点

  • 没有 CAP_NET_BIND_SERVICE 的进程无法绑定 1024 以内的端口。
  • 不能在容器内使用 ping 命令(设置 sysctl 可解)

数据持久化

容器的创建和销毁是正常的行为,但这并不意味着程序运行过程中产生的状态和数据无法保留。

Bind mounts 是早期使用的持久化机制,使用 bind mount 时,宿主机的文件 / 目录被挂载到容器中。

如果你需要控制被持久化的文件(例如挂载配置文件)或者需要由容器外的进程访问文件,应该使用 Bind mount 模式。

现代化的容器应用更推荐使用卷(volume):

  • 卷可以完全由 Docker 管理,隔离宿主机架构和操作系统的影响
  • 卷更容易备份和迁移(podman volume export
  • 容器之间可以安全地共享卷
  • 卷不会影响容器的体积
  • 卷可以运行在其他设备甚至云上,提供加密等高级功能
  • 卷的管理和生命周期可以与容器脱离

如果容器应用会产生一些不需要持久化的数据,可以考虑 tmpfs mount,避免向容器存储层写入大量数据。

-v--volume--mount 能做的事情差不多,不过 --mount 参数接受键值对组输入,可读性更佳:

  • type: bind|volume|tmpfs:持久化方案
  • source|src:如果是 bind mount 模式,指向宿主机文件 / 目录;如果是 volume 模式,指向具名卷;使用匿名卷时可省略
  • destination|dst|target:容器内的目录

SELinux

挂载文件系统时可能会遇到 Permission Denied 问题。

许多国内云服务商提供的 Linux 发行版(比如 AnolisOS 和 OpenCloud OS)默认都关闭了 SELinux,也可以 手动关闭

如果自己机器的发行版想要运行,可以通过 --security-opt label=disable 关闭,也可以在挂载选项中添加 :z 为目录添加 container_t 上下文,使用 Z 则表示限定到当前容器。

容器的网络与容器间通信

域名与域名解析

podman-run 命令中使用 --add-host=host:ip 可以添加自定义 host,通过 --dns=ipaddr 可以覆盖容器内的 /etc/resolv.conf

要在容器内部访问宿主机,使用 host.containers.internal 作为 hostname(对应 Docker 的 host.docker.internal)。

暴露端口到宿主机

低段位的端口在 rootless 模式下不可用

-P 让 Podman 决定映射到哪个端口,前提是 Dockerfile 中 EXPOSE 了该端口

Pod

同一 pod 中的所有容器共享 IP 地址、MAC 地址和端口映射,可以使用 localhost:port 在同 pod 内的的容器间通信。

使用 systemd 管理容器

Podman 的设计目的不是为了启动整个 Linux 系统,或管理诸如启动顺序、依赖关系检查和失败服务的恢复等服务。这是像 systemd 这样成熟的初始化系统的任务。

使用 systemd 单元文件可以:

  • 设置容器或 Pod 作为 systemd 服务启动;
  • 定义容器化服务运行的顺序,并检查依赖项(例如确保另一个服务正在运行、文件可用或已挂载资源);
  • 使用 systemctl 命令控制状态;

Podman 4.4+ 应当使用 Quadlet 创建 systemd 单元文件,相关内容可参考本站的 Quadlet 让 systemd 管理容器更容易,关于 auto-update 和 rollback 则可参考 Podman 的 auto-update 和 rollback

要在系统启动时启用服务,无论用户是否登录,将 systemd 单元文件复制到 /etc/systemd/system 目录中。

# systemctl enable <service>

要在用户登录时启动服务并在用户注销时停止该服务,将单元文件复制到 $HOME/.config/systemd/user 目录中。

$ systemctl --user enable <service>

要允许用户在系统启动并保留日志时启动服务,输入:

# loginctl enable-linger <username>

先按照期望的方法启动容器:

podman run --rm \
    --security-opt label=disable \
    --name=mysql57 \
    --secret mysql57-root-password,type=env,target=MYSQL_ROOT_PASSWORD \
    --volume=vol_mysql57_data:/data \
    --publish 3306:3306 \
    docker.io/library/mysql:5.7.42

使用 podman-generate-systemd 命令在当前目录下生成名为 container-mysql57.service 的 unit 文件,--new 标志表示 Podman 将在服务启动时创建容器,在服务关闭时删除容器。

podman generate systemd --new --files --name mysql57

根据自身需求编辑 unit 文件,然后复制到 $HOME/.config/systemd/user/

# container-mysql57.service
# autogenerated by Podman 4.2.0
# Tue Apr 18 11:13:04 CST 2023

[Unit]
Description=Podman container-mysql57.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=%t/containers

[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=on-failure
TimeoutStopSec=70
ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStart=/usr/bin/podman run \
	--cidfile=%t/%n.ctr-id \
	--cgroups=no-conmon \
	--rm \
	--sdnotify=conmon \
	--replace \
	--detach \
    --security-opt label=disable \
	--name=mysql57 \
	--secret mysql57-root-password,type=env,target=MYSQL_ROOT_PASSWORD \
    --volume=vol_mysql57_data:/data \
	--publish 3306:3306 \
	docker.io/library/mysql:5.7.42
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id
Type=notify
NotifyAccess=all

[Install]
WantedBy=default.target
  • --conmon-pidfile 选项指向存储主机上运行的 conmon 进程的进程 ID 的路径。conmon 进程以与容器相同的退出状态终止,允许 systemd 报告正确的服务状态,并在需要时重启容器。
  • --cidfile 选项指向存储容器 ID 的路径。
  • %t 是运行时间目录根目录的路径,例如 /run/user/$UserID
  • %n 是该服务的全名。

非 root 用户安装和设置服务的启动使用:

systemctl --user daemon-reload
systemctl --user enable --now container-mysql57.service

当服务以非正常状态退出时或机器重启时,systemd 会试着再启动服务。可以通过 systemctljournalctl 查看日志:

systemctl --user status container-mysql57.service
journalctl --user -xeu container-mysql57.service

修改 unit 文件后需要 reload daemonrestart service

用 auto update 和 latest tag 整活

如果使用 latest tag 来标识镜像的版本号,则可以为 podman-run 命令添加 --label io.containers.autoupdate=registry 参数,然后使用 podman-auto-update 检查 registry 侧是否存在新版本的镜像并更新,更新失败时 podman-auto-update 也提供了简单的方法用于回滚。

podman auto-update --dry-run
UNIT                                CONTAINER                        IMAGE                        POLICY      UPDATED
container-external-redmine.service  29f326fee3e5 (external-redmine)  docker.io/bitnami/redmine:5  registry    pending

也许可以手动拉取新版本的镜像,把 latest tag 赋予该镜像,使用 --label io.containers.autoupdate=local 参数配合 podman-auto-update 更新。

使用 latest tag 标记镜像可以方便发布版本的流程,但是做不好就会给外部追踪工作造成困难。

用 secret 管理秘密

为了不在代码中明文存储生产数据库的连接配置,我们可以使用这些方法:

  • application.properties 中配置密文,然后通过自定义的 ProcessorConfigurer 在运行时解析;
  • 通过 Nacos 等程序在线上分发配置;
  • 使用云服务商的 Vault 服务;
  • 指示 Spring 在运行时读取环境变量,这种方法最为简单直白;
spring:
  datasource:
    username: root
    password: ${DB_PASSWORD}

把密码明文写在一个文本文件中,再用 podman-secret-create 创建对应的 secret。

$ podman secret create dorian-db-password <file-that-store-plain-secret>
$ podman secret inspect dorian-db-password
[
    {
        "ID": "2e9e5bd09d07629c9eb0c597c",
        "CreatedAt": "2023-04-17T17:33:02.704272653+08:00",
        "UpdatedAt": "2023-04-17T17:33:02.704272653+08:00",
        "Spec": {
            "Name": "dorian-db-password",
            "Driver": {
                "Name": "file",
                "Options": {
                    "path": "/home/yufan/.local/share/containers/storage/secrets/filedriver"
                }
            }
        }
    }
]

dorian-db-password 在执行时解密并赋给环境变量 DB_PASSWORD

podman run --userns=keep-id \
-p 8888:8080 \
-e DB_HOST=not-a.site \
-v ./logs:/app/logs:Z \
--secret dorian-db-password,type=env,target=DB_PASSWORD \
dorian-image:0.0.1-SNAPSHOT

secret 不会进入 podman commit 创建的镜像,也无法被 podman export 输出。不过由于 podman-secret 是一个比较新的功能,文档也仅说明了默认模式下 --driver=file,强调在宿主机中保存的 secret 是未经加密的。有一些资料例如 Podman Secrets Documentation: Available drivers? #18387 提到可以使用 --driver=pass 以利用 GPG 加密信息。不过本段的主要目的是避免秘密通过源代码管理程序或制品库泄露,如何在目标服务器被攻破后保护秘密就是另一个话题了。

容器编排

Kubernetes 是大规模容器编排的事实标准,而 Nomad 是稍小一些的另一种选择,不过对于我们通常的单节点项目而言,使用成本远大于收益。由于我倾向于使用 systemd 管理服务的方案,还没试过配合 docker-compose 会整出什么花样来。

当然你也可以用 podman play kube kube-style.yaml 命令使用一个 Kubernetes 风格的编排配置在本地系统中启动 pod 和容器,不过先想想这个命令为什么要叫 play

Maintenance

大版本更新后适配 Podman 配置的变更:

podman system migrate

如果遇到问题,可以 reset:

podman system reset

清理未使用的镜像、容器、网络:

podman system prune