香港服务器运行 RHEL 9 时,如何用 Podman 代替 Docker,构建更安全的容器运行时

凌晨两点,机房的冷风像刀子一样直往袖口里钻。告警短信把我从值班折叠椅上弹了起来——前端 Nginx 容器疑似被提权脚本利用,两个边缘节点 CPU 飙到 100%。那一晚我站在香港葵涌机房的过道里,看着机柜上方的蓝灯一闪一闪,心里打了个决定:这批运行 RHEL 9 的节点,全面从 Docker 迁到 Podman,把“以 root 运行的守护进程 + 默认过宽的权限”这一隐患一次性解决干净。
三天后,迁移上线。后来再看到类似的扫描流量,我只是抬手抹了抹汗:rootless + SELinux + systemd/Quadlet 的组合,让风险在最外层就被掐灭了。
我的环境与现场约束
机房与硬件(真实可落地)
| 项目 | 参数 |
|---|---|
| 机房 | 香港葵涌(运营商三线 BGP) |
| 服务器 | 2× Intel Xeon Silver 4314;128 GB ECC;2× NVMe Samsung PM9A3 1.92 TB(RAID1/ZFS mirror,给容器单独划分 /data);2×10 GbE |
| 操作系统 | RHEL 9.3/9.4 Minimal(SELinux=Enforcing,firewalld 默认启用) |
| 容器栈 | podman 4.x、buildah、skopeo、netavark/aardvark-dns、crun、container-selinux |
| 主要工作负载 | Nginx(边缘静态缓存/反向代理)、Go 微服务(orders-api)、PostgreSQL、Prometheus |
| 注册表 | 内部 Harbor(proxy cache docker.io/quay.io/ghcr),镜像签名开启 |
| 时区/本地化 | TZ=Asia/Hong_Kong,系统 locale en_US.UTF-8 |
这次我们要解决的“痛点”
- 去守护进程:Podman 无需常驻 root daemon,降低单点失败面与被控面。
- rootless:普通用户起容器(user namespace),把“容器内 UID=0 == 主机 root”这层错觉打断。
- SELinux/seccomp/cgroups v2:在 RHEL 9 上“原生”把控能力边界。
- systemd/Quadlet:把容器当一等公民服务管理,明确依赖、健康检查、自动更新与回滚。
- 镜像供应链安全:签名校验、只信任内网 Harbor 的受控来源。
部署前的“地基”加固(别跳过)
1)存储与文件系统(OverlayFS 的老坑)
OverlayFS 在 XFS 上要求 ftype=1。不满足会遇到奇怪的写放大/白名单失败。
# 检查容器数据盘(示例 /dev/nvme0n1p3)
xfs_info /dev/nvme0n1p3 | grep ftype
# 若不是 ftype=1,务必重建分区/重新 mkfs.xfs -n ftype=1
我把容器根设在独立 NVMe 阵列:
# /etc/containers/storage.conf(节选)
[storage]
driver = "overlay"
graphroot = "/data/containers"
runroot = "/run/containers"
[storage.options.overlay]
mountopt = "nodev,metacopy=on"
ignore_chown_errors = "true"
# rootless 用户会自动走 fuse-overlayfs
2)基础软件安装
sudo dnf install -y podman buildah skopeo \
container-selinux containers-common \
netavark aardvark-dns crun jq iperf3 \
podman-plugins
podman --version
crun --version
3)引擎配置(用 systemd、journald、crun)
# /etc/containers/containers.conf(节选)
[engine]
cgroup_manager = "systemd"
events_logger = "journald"
runtime = "crun" # RHEL 9 上优选 crun,启动快、占用小
init_path = "/usr/libexec/podman/catatonit"
4)用户命名空间(rootless 的基石)
创建运行账户 ops,分配子 UID/GID:
sudo useradd -m -s /bin/bash ops
sudo usermod --add-subuids 100000-165536 --add-subgids 100000-165536 ops
# 允许用户会话在无登录时保活(方便 systemd --user)
sudo loginctl enable-linger ops
5)网络与防火墙(netavark)
# 基础转发与 NAT
sudo firewall-cmd --permanent --add-masquerade
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
# 创建业务网段(netavark 会生成配置)
sudo podman network create --subnet 10.89.0.0/24 prod-net
podman network inspect prod-net
提示:rootless 网络默认走 slirp4netns,吞吐一般;高并发服务建议 rootful 或 rootless 使用 pasta 模式:
# rootless 高性能网络(需要安装 pasta 包)
sudo dnf install -y pasta
podman run --network=pasta ...
6)镜像源与内网 Harbor(加速 + 受控)
# /etc/containers/registries.conf.d/001-mirrors.conf(简化示例)
unqualified-search-registries = ["harbor.infra.hk", "docker.io"]
[[registry]]
prefix = "docker.io"
location = "registry-1.docker.io"
mirror = [{ location = "harbor.infra.hk/proxy-docker" }]
[[registry]]
prefix = "quay.io"
location = "quay.io"
mirror = [{ location = "harbor.infra.hk/proxy-quay" }]
启用镜像签名策略(只信任内网源/已签名):
// /etc/containers/policy.json(示例)
{
"default": [{"type":"reject"}],
"transports": {
"docker": {
"harbor.infra.hk": [{"type":"insecureAcceptAnything"}]
}
}
}
真正在生产里,我会把 harbor.infra.hk 改成 要求签名(cosign/simple signing),并给 ops 组发放公钥。
从 Docker 迁移到 Podman:三条路线我都走过
路线 A:最少改动,先用 podman-docker 兜底
sudo dnf install -y podman-docker
# 这会提供 /usr/bin/docker -> podman 的兼容层
docker ps # 实际执行的是 podman
适合“快速止血”,但我最终还是把编排切到 Quadlet 或 Kube YAML,从根上拥抱 Podman 的最佳实践。
路线 B:继续用 Compose(过渡)
# 方案1:podman-compose(Python 工具)
pip3 install podman-compose
podman-compose -f docker-compose.yml up -d
# 方案2:Docker Compose 客户端连 Podman socket
systemctl --user enable --now podman.socket
export DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock
docker compose up -d
路线 C:转成 Kubernetes YAML,podman play kube
我用 kompose 把 docker-compose 转成 Kube YAML,再用 Podman 起:
kompose convert -f docker-compose.yml -o out-kube/
podman play kube out-kube/orders-api-pod.yaml
实操:我在 RHEL 9 上把“orders-api + Nginx”跑成生产
1)目录与 SELinux 标签
sudo mkdir -p /data/orders-api/{config,logs,db}
# 给持久卷打容器文件上下文,否则会遇到 AVC 拒绝
sudo semanage fcontext -a -t container_file_t "/data/orders-api(/.*)?"
sudo restorecon -Rv /data/orders-api
2)用 Quadlet 写成“系统级服务”(强烈推荐)
Quadlet 是 Podman 官方把 systemd 单元文件“抽象”出来的写法,读起来跟 systemd 很像,但字段直接映射 podman run 选项。
文件:/etc/containers/systemd/orders-api.container
[Unit]
Description=Orders API (Prod)
Wants=network-online.target
After=network-online.target
[Container]
Image=harbor.infra.hk/prod/orders-api:1.18.3
ContainerName=orders-api
# rootless 运行则移除 User=/Group=
User=10001
Group=10001
Network=prod-net
PublishPort=443:8443/tcp
Volume=/data/orders-api/config:/app/config:Z,ro
Volume=/data/orders-api/logs:/var/log/app:Z
ReadOnly=true
Tmpfs=/run
Tmpfs=/tmp
NoNewPrivileges=true
# 精确收敛能力
DropCapabilities=ALL
AddCapabilities=CAP_NET_BIND_SERVICE
SeccompProfile=/usr/share/containers/seccomp.json
# 资源与限制
Memory=1G
MemorySwap=0
PidsLimit=512
CPUQuota=150%
# 健康检查
HealthCmd=curl -fsS https://127.0.0.1:8443/healthz || exit 1
HealthInterval=30s
HealthRetries=3
HealthTimeout=3s
HealthStartPeriod=20s
# 自动更新(配合受控 registry)
AutoUpdate=registry
# 环境
Environment=TZ=Asia/Hong_Kong
EnvironmentFile=/etc/orders-api.env
[Install]
WantedBy=multi-user.target
加载并启动:
sudo systemctl daemon-reload
sudo systemctl enable --now orders-api.service
systemctl status orders-api
journalctl -u orders-api -f
3)Nginx 反向代理(同样用 Quadlet)
/etc/containers/systemd/edge-nginx.container
[Unit]
Description=Edge Nginx (TLS Termination)
After=network-online.target
Wants=orders-api.service
[Container]
Image=harbor.infra.hk/edge/nginx:1.25-alpine
ContainerName=edge-nginx
Network=prod-net
PublishPort=80:80/tcp
PublishPort=443:443/tcp
Volume=/data/nginx/conf:/etc/nginx/conf.d:Z,ro
Volume=/data/nginx/certs:/etc/nginx/certs:Z,ro
ReadOnly=true
NoNewPrivileges=true
DropCapabilities=ALL
AddCapabilities=CAP_NET_BIND_SERVICE
HealthCmd=nginx -t || exit 1
HealthInterval=60s
RestartPolicy=always
[Install]
WantedBy=multi-user.target
4)Kube YAML 玩法(podman play kube)
有时我更愿意保持接近 K8s 的语义,方便将来上 K8s:
# orders-api-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: orders-api
spec:
containers:
- name: orders-api
image: harbor.infra.hk/prod/orders-api:1.18.3
ports:
- containerPort: 8443
volumeMounts:
- name: config
mountPath: /app/config
readOnly: true
- name: logs
mountPath: /var/log/app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
volumes:
- name: config
hostPath:
path: /data/orders-api/config
type: Directory
- name: logs
hostPath:
path: /data/orders-api/logs
type: Directory
部署:
podman play kube orders-api-pod.yaml --network prod-net
供应链安全与镜像治理(我踩过的点)
镜像签名与策略
Harbor 上对 prod/* 强制签名(cosign),CI/CD 阶段签署。
主机 /etc/containers/policy.json 对 harbor.infra.hk/prod 设为 必须签名;对 proxy cache 仅允许拉取但不用于生产。
只给容器“最小能力”
我把高危能力一律剔除:--cap-drop=ALL,按需 仅 加 NET_BIND_SERVICE、个别诊断容器临时给 SYS_PTRACE。
文件系统一律 --read-only --tmpfs /run --tmpfs /tmp,需要写入的路径 单独挂卷 + :Z。
SELinux:遇阻别关掉,定位并“打标签”
症状:容器内写日志报 Permission denied,podman logs 没错误。
定位:
sudo ausearch -m avc -ts recent | audit2why
# 看到 hostPath 没有 container_file_t
sudo semanage fcontext -a -t container_file_t "/data/orders-api/logs(/.*)?"
sudo restorecon -Rv /data/orders-api/logs
经验:不要下意识 setenforce 0,那只是把门拆了。
运行维护:日志、指标、更新与回滚
日志与监控
日志进 journald:journalctl -u orders-api -f。
进程/资源:podman ps --format '{{.Names}}\t{{.Status}}'、podman stats。
事件流:podman events --since 1h。
Prometheus:我更偏好从业务侧暴露 /metrics,宿主机用 node_exporter;容器级别指标可配合 cgroups v2 导出器。
自动更新(受控的“拉流”升级)
在镜像里打标签:
LABEL io.containers.autoupdate=registry
定时器:
systemctl enable --now podman-auto-update.timer
systemctl status podman-auto-update.timer
# 手动试跑
sudo podman auto-update --dry-run
回滚:
镜像层面:podman image ls 找到上一个 digest,podman run 指向旧 digest。
Quadlet:把 Image= 切回上一个 tag/digest,systemctl daemon-reload && systemctl restart xxx。
| 项目 | 配置 | 结果(我的节点实测,仅供参考) |
|---|---|---|
| orders-api 启动时间 | crun vs runc | crun 平均 180ms,runc 300ms 左右 |
| rootless 吞吐 | slirp4netns(host->容器) | ~3–4 Gbps(单流),延迟略高 |
| rootless 吞吐 | pasta(host stack) | ~8–9 Gbps(单流),接近宿主 |
| rootful 吞吐 | bridge(netavark) | 9–10 Gbps |
| 镜像拉取延迟 | 直连 docker.io(HK->US) | P50 ~ 1.6s/P95 ~ 4.8s |
| 镜像拉取延迟 | Harbor proxy(本地) | P50 ~ 120ms/P95 ~ 300ms |
| Nginx QPS | 1 台边缘节点(10k keepalive) | Podman 与 Docker 无显著差异(±1%) |
经验:网络是体感差异最大的地方;rootless 默认网络适配场景更多的是“应用侧出网、低端口转发”,极限吞吐要么走 pasta,要么 rootful。
我踩过的坑和现场解法(都是汗出来的)
OverlayFS + XFS ftype=0
症状:随机 I/O 报错、容器拉起即崩。
解法:重建为 ftype=1(前文)。
SELinux 拒绝写卷
症状:容器内目录可读不可写。
解法:挂载加 :Z,或 semanage fcontext+restorecon。
rootless 端口映射失败(<1024)
症状:映射 80/443 报权限问题。
解法:rootless 改高位端口(如 8443)再由前端 Nginx 做 TLS 终止,或用 pasta。
firewalld 与 netavark 规则冲突
症状:容器可出不可入。
解法:开启 --add-masquerade,确保业务端口显式放行;不要混用 legacy iptables。
镜像拉取被限速
症状:高峰期大量 429。
解法:Harbor 做 proxy cache,CI/CD 全量走内网 registry;生产环境 禁止直接拉外网镜像。
Compose 到 Podman 语义差异
症状:healthcheck/restartPolicy 行为与预期不同。
解法:用 Quadlet 或 Kube YAML + podman play kube,systemd 层定义更明确。
重启后 rootless 服务没起
症状:systemctl --user 服务不自启动。
解法:loginctl enable-linger <user>,并把 Quadlet 放在 ~/.config/containers/systemd/。
安全基线清单(我现在每台 RHEL 9 主机都做)
- SELinux:Enforcing(永不关闭),装 container-selinux
- /etc/containers/containers.conf:cgroup_manager=systemd、runtime=crun、events_logger=journald
- /etc/containers/storage.conf:容器根在独立 NVMe;XFS ftype=1
- 用户:rootless 运行,配置 subuid/subgid;必要时 pasta
- 网络:firewall-cmd 放行端口 + masquerade;自定义 podman network
- Quadlet:所有长期服务写成 .container,带健康检查和资源限制
- 能力最小化:DropCapabilities=ALL + 精确添加
- 文件系统:ReadOnly=true + Tmpfs=/run,/tmp + 必要卷 :Z
- 供应链:只信任 Harbor,镜像签名校验,禁止直拉外网
- 自动更新:io.containers.autoupdate=registry + 定时器;留好回滚路径
- 审计:podman events、journalctl、ausearch -m avc 做日常巡检
“为什么是 Podman”——我自己的答案
更小的攻击面:没有 root 守护进程,rootless 把“容器逃逸==拿到宿主”的风险降了一个维度。
更贴合 RHEL 9 的安全内核:SELinux/cgroups v2/systemd 天然配合,策略更细,落地更顺。
运维友好:Quadlet 把容器纳入 systemd,统一了“服务”的管理范式;排障时 journalctl 一条龙。
平滑迁移:先 podman-docker 兜底,后面再逐步切 Quadlet/Kube YAML;不需要一次性“手术”。
RHEL 9 + Podman 运维实战总结
第三周的周五夜里,我又值了个夜班。走到机房天台,维港的风吹得人清醒。手机里弹出一条告警:有批扫描脚本刚被挡在 Nginx 前。点进去一看,容器健康检查一切正常,SELinux 日志里记录了几次被拒绝的可疑访问,自动更新也把一个小版本悄悄替换掉了。
我突然意识到,这套 RHEL 9 + Podman 的堆叠,不只是把 Docker 换了个“命令名字”,而是把组织的“默认安全态”往前推了一大步。
那晚回到办公室,我在交班记录上写了句关于“整面墙”的话:不是每一块砖都能挡住风,但把砖摆对了,风就进不来。
附:关键命令速查
# 查看宿主/容器信息
podman info
# 网络
podman network create --subnet 10.89.0.0/24 prod-net
podman network inspect prod-net
# 生成 systemd(从临时运行的容器)
podman run --name tmp -d harbor.infra.hk/prod/orders-api:1.18.3
podman generate systemd --new --name tmp > /etc/systemd/system/orders-api.service
# 镜像移动(skopeo)
skopeo copy docker://docker.io/library/nginx:1.25 \
docker://harbor.infra.hk/proxy-docker/library/nginx:1.25
# SELinux 审计
ausearch -m avc -ts recent | audit2why
# 自动更新
systemctl enable --now podman-auto-update.timer
podman auto-update --dry-run
如果你正打算在香港节点的 RHEL 9 上把 Docker 换成 Podman,希望这篇“带汗味”的实操记能少让你走几步弯路。有什么更狠的安全招数或遇到奇怪报错,直接甩给我,我们把坑填得更平。