前阵子,我将日常容器工具换成了 Podman,主要是不想再逐项判断 Docker Desktop 的订阅条款是否适用于当前项目。

迁移过程基本顺利,直到我尝试将一个 Web 服务发布到宿主机的 80 端口。在 Linux 的 Rootless Podman 环境中,使用 pasta 发布低位端口会因为权限不足而失败;将宿主机端口换成 8080 后,容器便能正常启动。

我不想为此放宽宿主机的非特权端口范围,也不打算换回原来的容器工具,于是写了一个简单的 TCP/UDP 端口转发程序:soulteary/portmap

写在前面

Docker Desktop 的免费使用范围与组织规模、收入和使用场景有关,而个人实验、团队协作和公司项目又经常出现在同一台开发机上。为了减少这类判断,我将日常使用的容器工具统一换成了 Podman。

无论是 macOS 上的 Podman Desktop,还是 Linux 上的 Rootless Podman,常用的镜像、容器和端口映射命令都与 Docker 比较接近。不过,在 Linux 上执行下面的命令时,问题出现了:

podman run --rm -d \
  -p 80:80 \
  docker.io/library/nginx:alpine

Rootless Podman 使用 pasta 发布端口时,会返回类似下面的错误:

Listen failed for HOST TCP port */80: Permission denied
Couldn't listen on requested TCP ports

将宿主机端口改成 8080 后,容器便能正常启动:

podman run --rm -d \
  -p 8080:80 \
  docker.io/library/nginx:alpine

常规 Docker 环境通常由具备相应权限的守护进程或辅助程序完成端口发布。Rootless Podman 不依赖高权限守护进程,因此宿主机对低位端口的权限限制会直接表现出来。

portmap 的处理方式很简单:让 Rootless Podman 继续发布高位端口,再由宿主机上的一个独立进程提供标准端口入口。它默认使用 Go 标准库完成 TCP/UDP 转发,不依赖 socat,必要时也可以切换到 socat 模式。

我已经将它与 macOS 上的 Podman Desktop 配合使用了接近两个月。项目于上个月开源,目前没有遇到明显问题。不过,这只是个人开发环境下的使用结果,不能替代公网环境中的长期运行和压力测试。

本文基于 2026 年 8 月 8 日的提交 26f768cfeat: add stress tool)编写。

适用场景与限制

我最初的需求很明确:让 Rootless Podman 中的 Web、SSH 和 DNS 服务继续使用 804432253 这些标准端口,同时不放宽宿主机默认网络命名空间中的非特权端口范围。

它也适合服务入口不方便修改的场景。例如,旧客户端写死了端口,或者服务正在从宿主机迁移到 Rootless 容器,迁移期间需要保持外部地址不变。HomeLab、内部工具和少量边缘节点也是比较合适的使用场景:端口不多、流量可控,运行一个独立进程通常比维护一组防火墙规则更直接。

反过来,如果服务本来就能使用 8080,就没有必要再增加一层转发。需要处理 TLS、域名路由或负载均衡时,我会直接使用 Caddy、Nginx、HAProxy 或 Traefik;需要转发大量端口,或者面对高流量公网入口时,nftables 和专门的代理程序通常更合适。

当前实现也不会把原始客户端地址传递给目标服务。目标服务看到的是 portmap 发起的连接,因此依赖真实源 IP 完成审计、访问控制或限流的服务,不适合直接使用这种方式。

我现在主要用它给几个 Podman 服务提供固定入口。在 Linux 上长期运行时交给 systemd 管理;在 macOS 上则与 Podman Desktop 配合使用。配置稳定以后,平时基本感觉不到它的存在。

小工具没有必要覆盖所有场景。只有一两个端口需要处理时,我会继续使用这种方式;端口数量和流量规模再大一些,就换成防火墙规则或成熟网关。

macOS 与 Podman Desktop

portmap 最初用于解决 Linux Rootless Podman 的低位端口问题,但我目前使用最多的环境其实是 macOS 与 Podman Desktop。

Podman 在 macOS 上运行于虚拟机中,容器网络与 Linux 宿主机上的 Rootless 网络并不完全相同。在这里,portmap 主要负责为虚拟机中的容器服务提供固定的宿主机入口,或者将 Podman Desktop、本地开发服务和远程设备的端口整理到同一套配置中。

通常先由 Podman 将容器端口发布到 macOS 宿主机上的高位端口,再由 portmap 转发到最终使用的入口。它不替代 Podman Desktop 的网络层,只负责最后一段明确的端口转发。

快速开始

先使用 Rootless Podman 将 Nginx 容器的 80 端口发布到宿主机的 8080

podman run -d \
  --name rootless-web \
  -p 127.0.0.1:8080:80 \
  docker.io/library/nginx:alpine

使用 curl 确认服务正常:

curl -I http://127.0.0.1:8080

接着,使用 portmap 将宿主机的 80 端口转发到 8080

sudo portmap \
  -listen-host 127.0.0.1 \
  -listen-port 80 \
  -target 127.0.0.1:8080

现在可以直接访问标准 HTTP 端口:

curl -I http://127.0.0.1

此时的访问路径如下:

curl
  → 127.0.0.1:80
  → portmap
  → 127.0.0.1:8080
  → Rootless Podman
  → Nginx:80

这个例子中,只有宿主机上的 portmap 需要监听低位端口。Podman、容器和 Nginx 仍然以原来的方式运行。

这样可以把低位端口监听集中到一个功能单一的宿主机进程中。不过,通过 sudo 启动时,portmap 仍然拥有完整的 root 权限。临时测试时这么做比较方便;长期运行时,后文介绍的 systemd capability 方案更合适。

Rootless Podman 为什么不能监听低位端口

Linux 使用 net.ipv4.ip_unprivileged_port_start 定义非特权端口范围的起点:

cat /proc/sys/net/ipv4/ip_unprivileged_port_start

许多系统的默认结果是:

1024

按照 Linux 内核文档,低于这个值的端口属于特权端口,普通用户需要 root 权限或 CAP_NET_BIND_SERVICE 才能监听。

在下面这条命令中,两个 80 的含义并不相同:

podman run -p 80:80 ...

前一个是宿主机端口,后一个是容器端口。容器里的 Nginx 可以正常监听自己的 80,真正失败的是 Rootless 网络组件在宿主机上创建 :80 监听套接字的过程。

因此,给容器里的进程增加下面的 capability,并不能解决宿主机端口发布失败的问题:

podman run \
  --cap-add NET_BIND_SERVICE \
  -p 80:80 \
  ...

这里的 NET_BIND_SERVICE 影响的是容器中的进程,不是宿主机上的 pasta。同样,改成 --network host 也不会让启动容器的普通用户自动获得低位端口权限。

Podman 官方故障排查文档也记录了这个问题,并给出了修改 ip_unprivileged_port_start 的处理方式。

修改系统参数也可以解决

如果希望普通用户能够监听 80 及以上的端口,可以直接修改系统参数:

sudo sysctl net.ipv4.ip_unprivileged_port_start=80

需要永久生效时,可以将它写入 /etc/sysctl.d/

sudo tee /etc/sysctl.d/90-unprivileged-ports.conf >/dev/null <<'EOF'
net.ipv4.ip_unprivileged_port_start=80
EOF

sudo sysctl --system

这个参数表示“第一个非特权端口”。设置为 80 后,在对应网络命名空间中,普通用户将能够监听 8065535 之间的端口;设置为 0,则相当于取消特权端口区间。

在个人开发机或用途单一的测试机上,这种方式通常没有什么问题,而且最省事。但在多人共用的开发服务器、HomeLab 主机,或者同时运行许多服务的机器上,我更希望将权限授予一个明确的程序,而不是放宽所有普通用户能够监听的端口范围。

所以我的处理方式是让 Rootless Podman 继续发布高位端口,只将低位端口交给一个功能单一的宿主机程序。权限需求并没有消失,但它被收敛到了一个更明确的边界中。

socat、防火墙规则与 portmap

最初遇到这个问题时,我使用的是一条 socat 命令:

sudo socat \
  TCP-LISTEN:80,fork,reuseaddr \
  TCP:127.0.0.1:8080

如果机器上已经安装了 socat,并且只需要临时转发一次,这条命令已经足够。

但随着使用次数增加,也开始需要在不同设备上重复部署,我更希望它能够以单个二进制分发,不依赖系统是否已经安装 socat,并为 TCP 和 UDP 提供一致的配置方式。同时,我还希望看到连接数量、上下行字节和连接持续时间。

另一个办法是使用 nftablesiptables 配置 REDIRECT 或 DNAT。内核转发的性能更好,也能够保留更多网络语义。不过,这种方式会进入整台机器的防火墙配置,需要考虑规则持久化、发行版差异,以及与现有防火墙管理工具之间的关系。

如果只是给一两个 Rootless 服务补上标准端口,我更喜欢一个启动后生效、停止后消失的普通进程。

至于 Nginx、Caddy、HAProxy 和 Traefik,它们更适合处理 HTTP、TLS、域名路由和负载均衡。如果只需要转发 SSH、DNS 或其他普通 TCP/UDP 服务,portmap 会更直接。

构建和使用 portmap

项目当前使用 Go 1.26,可以直接从源码构建:

git clone https://github.com/soulteary/portmap.git
cd portmap

go build -trimpath -o portmap .
sudo install -m 0755 portmap /usr/local/bin/portmap

查看帮助信息:

portmap -help

项目也发布了容器镜像。不过在这个场景中,我更习惯直接运行二进制,少经过一层宿主机网络。

监听地址与暴露范围

前面的 Podman 命令使用了:

-p 127.0.0.1:8080:80

这里的 127.0.0.1 很重要。

高位端口只是 portmap 的内部转发目标,没有必要直接暴露给局域网或公网。如果写成:

-p 8080:80

Podman 通常会监听所有宿主机地址。即使认真保护了 80 端口,外部用户仍然可能绕过 portmap,直接访问 8080

portmap 的监听地址也应该明确指定。本地开发时可以使用:

sudo portmap \
  -listen-host 127.0.0.1 \
  -listen-port 80 \
  -target 127.0.0.1:8080

需要为局域网提供服务时,最好指定对应网卡的地址:

sudo portmap \
  -listen-host 192.168.1.20 \
  -listen-port 80 \
  -target 127.0.0.1:8080

确实需要监听全部 IPv4 地址时,再使用 0.0.0.0

程序能够监听端口,并不代表 firewalldnftables 或云安全组已经放行。对外提供服务时,这些入口仍然需要分别检查。

使用配置文件

命令变长以后,不方便记忆和重复使用,可以将参数放进 YAML 文件:

# /etc/portmap/http.yaml
listen_port: 80
listen_host: "127.0.0.1"
target: "127.0.0.1:8080"
proto: tcp
idle_timeout: 5m

启动时指定配置文件:

sudo portmap -config /etc/portmap/http.yaml

命令行中显式设置的参数优先级高于配置文件,因此也可以在使用同一份配置时临时覆盖某个选项。

使用 systemd 运行 portmap

临时测试时直接使用 sudo 最方便。但长期运行时,我不希望转发程序一直持有完整的 root 权限。

一种简单方式是给二进制添加 CAP_NET_BIND_SERVICE

sudo setcap cap_net_bind_service=+ep /usr/local/bin/portmap
getcap /usr/local/bin/portmap

之后,普通用户也可以使用这个二进制监听低位端口。

这种方式足够直接,但 capability 会附着在文件上。升级或替换二进制后,需要重新执行 setcap

我更倾向于让 systemd 在启动服务时授予所需的 capability。可以创建一个模板服务:

# /etc/systemd/system/portmap@.service
[Unit]
Description=Portmap forwarding service for %i
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/local/bin/portmap -config /etc/portmap/%i.yaml
Restart=on-failure
RestartSec=2s

AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

启动前面的 HTTP 配置:

sudo systemctl daemon-reload
sudo systemctl enable --now portmap@http.service

查看状态和日志:

sudo systemctl status portmap@http.service
sudo journalctl -u portmap@http.service -f

DynamicUser 会为服务分配临时用户,AmbientCapabilitiesCapabilityBoundingSet 则将 capability 限制在绑定低位端口所需的 CAP_NET_BIND_SERVICE

二进制仍然是普通文件,升级时不需要重新执行 setcap。如果还需要转发 443,增加一份 /etc/portmap/https.yaml,再启动 portmap@https.service 即可。

实现上的几个细节

TCP 转发

默认的 go 模式基于 Go 标准库实现。

程序使用 net.ListenConfig 创建监听器。每当有新连接进入,便连接 target 指定的地址,再通过两个 io.Copy 分别转发上下行数据:

客户端 → 目标
客户端 ← 目标

数据复制结束后,程序会尝试执行 TCP half-close,让对端能够正常收到 EOF,而不是立即关闭两个方向的连接。

dial-timeoutmax-connsidle-timeout 分别用于限制目标连接等待时间、并发连接数量和空闲时间。目标服务暂时不可用时,只会影响当前连接,不会终止监听进程。

收到 SIGTERM 后,程序会先停止接受新连接,再等待已有连接结束。整个实现主要由监听、目标拨号和双向复制三部分组成,出现问题时也比较容易沿着这条路径排查。

UDP 转发

UDP 没有连接,不能直接照搬 TCP 的处理方式。

portmap 会以客户端地址为键,为每个客户端维护一条到目标地址的 UDP 会话。目标返回数据后,再通过这张会话表将响应发回原客户端。长时间没有数据的会话会被回收;未设置 idle-timeout 时,默认空闲时间为 60 秒。

例如,Rootless 容器中的 DNS 服务发布在宿主机的 5353/udp

podman run -d \
  --name rootless-dns \
  -p 127.0.0.1:5353:53/udp \
  your-dns-image

可以将宿主机的 53/udp 转发到这个端口:

sudo portmap \
  -proto udp \
  -listen-host 127.0.0.1 \
  -listen-port 53 \
  -target 127.0.0.1:5353

UDP 模式下,max-conns 限制的是会话数量。DNS 还可能使用 TCP,因此要提供完整的 DNS 服务,需要分别启动 TCP 和 UDP 两个实例。

广播、组播以及依赖源地址保持不变的协议,不适合这种简单的会话转发模型。

测试方法与结果

只说“我用着没有问题”,参考价值有限。

这次更新中,我给仓库增加了一个 cmd/loadtest 压测工具。它默认在同一个进程中启动 Echo 服务和 portmap 的转发模块:

压测客户端 → portmap → Echo 服务

下面这组测试在我的 Mac 上完成,环境为 darwin/arm64、10 核 M5、Go 1.26.4。

需要说明的是,这组测试验证的是进程内的自包含转发链路,不包括低位端口权限、Podman 网络和真实业务服务,也不是 Linux Rootless Podman 的端到端成绩。

使用的命令如下:

./loadtest \
  -proto tcp \
  -mode throughput \
  -conns 100 \
  -duration 10s
./loadtest \
  -proto udp \
  -mode throughput \
  -conns 50 \
  -duration 10s \
  -payload 512
./loadtest \
  -proto tcp \
  -conns 20 \
  -requests 1000 \
  -max-conns 200 \
  -idle-timeout 5m

压测程序原始报告使用 MB/s,但代码实际按照 1024 × 1024 字节换算,因此下面统一记作 MiB/s。

场景 成功请求 请求速率 有效载荷吞吐 p50 / p95 / p99 错误
TCP,100 条长连接,运行 10 秒 906,266 90,584.63 req/s 88.46 MiB/s 1.06 / 1.721 / 2.297 ms 0
UDP,50 个会话,512 字节负载,运行 10 秒 977,248 97,693.30 req/s 47.70 MiB/s 504 / 888 / 1,200 µs 0
TCP,20 条连接,每条完成 1,000 次请求 20,000 80,630.53 req/s 78.74 MiB/s 239 / 362 / 440 µs 0

三组测试都没有出现拨号、读写或内容校验错误,停止内建服务后,活跃连接计数也都回到了零。

这里的吞吐量只统计成功回显的有效载荷字节,不等于网卡上的双向总流量。这些数据也只用于检查当前实现的基本可靠性和性能,不能直接代表 Podman 网络或公网环境中的实际表现。

如果要验证已经安装并正在运行的二进制,可以使用 -external 指向它的监听地址:

go run ./cmd/loadtest \
  -external 127.0.0.1:13000 \
  -proto tcp \
  -conns 100 \
  -duration 10s

外部模式需要另外准备 Echo 服务,测到的是实际运行的二进制和宿主机网络,而不只是进程内的转发函数。

最后

portmap 解决的是一个很小的问题:Rootless 容器不直接监听低位端口时,如何为它补上一个简单、固定的宿主机入口。

修改 ip_unprivileged_port_start 更省事,socat 更适合一次性操作,nftables 的性能更好,Caddy、Nginx 和 HAProxy 能够处理更复杂的流量。portmap 并不打算替代这些工具。

它只是把我经常重复做的一件事整理成了一个单独的程序:不改变 Rootless Podman 的运行方式,不放宽普通用户能够监听的端口范围,也不为了几个端口引入完整的网关。

目前,这已经足够啦。

本文就先写到这里吧,我们下篇文章再见。

–EOF