前阵子,我将日常容器工具换成了 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 日的提交 26f768c(feat: add stress tool)编写。
适用场景与限制
我最初的需求很明确:让 Rootless Podman 中的 Web、SSH 和 DNS 服务继续使用 80、443、22、53 这些标准端口,同时不放宽宿主机默认网络命名空间中的非特权端口范围。
它也适合服务入口不方便修改的场景。例如,旧客户端写死了端口,或者服务正在从宿主机迁移到 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 后,在对应网络命名空间中,普通用户将能够监听 80 到 65535 之间的端口;设置为 0,则相当于取消特权端口区间。
在个人开发机或用途单一的测试机上,这种方式通常没有什么问题,而且最省事。但在多人共用的开发服务器、HomeLab 主机,或者同时运行许多服务的机器上,我更希望将权限授予一个明确的程序,而不是放宽所有普通用户能够监听的端口范围。
所以我的处理方式是让 Rootless Podman 继续发布高位端口,只将低位端口交给一个功能单一的宿主机程序。权限需求并没有消失,但它被收敛到了一个更明确的边界中。
socat、防火墙规则与 portmap
最初遇到这个问题时,我使用的是一条 socat 命令:
sudo socat \
TCP-LISTEN:80,fork,reuseaddr \
TCP:127.0.0.1:8080
如果机器上已经安装了 socat,并且只需要临时转发一次,这条命令已经足够。
但随着使用次数增加,也开始需要在不同设备上重复部署,我更希望它能够以单个二进制分发,不依赖系统是否已经安装 socat,并为 TCP 和 UDP 提供一致的配置方式。同时,我还希望看到连接数量、上下行字节和连接持续时间。
另一个办法是使用 nftables 或 iptables 配置 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。
程序能够监听端口,并不代表 firewalld、nftables 或云安全组已经放行。对外提供服务时,这些入口仍然需要分别检查。
使用配置文件
命令变长以后,不方便记忆和重复使用,可以将参数放进 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 会为服务分配临时用户,AmbientCapabilities 和 CapabilityBoundingSet 则将 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-timeout、max-conns 和 idle-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