上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-08-20 11:27 阅读量:275


凌晨两点,机房的冷风像刀子一样直往袖口里钻。告警短信把我从值班折叠椅上弹了起来——前端 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.xbuildahskopeonetavark/aardvark-dnscruncontainer-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,希望这篇“带汗味”的实操记能少让你走几步弯路。有什么更狠的安全招数或遇到奇怪报错,直接甩给我,我们把坑填得更平。

目录结构
全文