docker-proxy的高CPU使用率

本地使用 docker 来搭建 tracing-benchmark 的测试环境,在测试过程中观察到默认的 bridge 网络下 docker-proxy 进程的 CPU 使用率甚至会高于应用容器本身。
一方面不确定 docker-proxy 的高负载对应用容器的性能测试结果会有多大影响,另一方面则是觉得单纯的 TCP 端口转发功能不应该有这么高的 CPU 使用率。
因此计划模拟并测试 docker-proxy 在高网络负载下的具体表现,以及相关替代方案的实际优化效果。

问题模拟

环境准备

系统环境始终保持不变:

原测试环境的容器内运行 golang 编写的 web 应用,对外以 http 接口的形式提供服务,通过 --publish 8080:8080 接收宿主机 8080 端口的入站流量。
模拟环境将 web 应用简化为使用 golang 标准库实现的最小服务,并保持相同构建流程打包容器镜像。其中 golang 服务代码如下:

package main

import "net/http"

func pingHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("pong"))
}

func main() {
	http.HandleFunc("/ping", pingHandler)
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

Dockerfile 中保持相同的基础镜像和多阶段构建逻辑:

# syntax=docker.io/docker/dockerfile:1.10
FROM docker.io/library/golang:1.23-alpine AS build

WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build/ \
    --mount=type=cache,target=/go/pkg/mod/ \
    CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' \
    -o main main.go

FROM gcr.io/distroless/static-debian12:latest
COPY --from=build /app/main /main
ENTRYPOINT ["/main"]

执行构建命令 DOCKER_BUILDKIT=1 docker build --progress=plain --tag pong:v1 . 后即可得到本地镜像 pong:v1

测试复现

为了避免本地资源抢占及资源耗尽导致的测试结果不稳定,可以通过启动参数来限制应用容器能够使用的最大 CPU 和内存,大小核架构的 CPU 最好也设置应用容器始终运行在指定核心上。
测试过程中遇到了 ab 命令本身的性能瓶颈,于是换到了另一个常用的性能测试工具 wrk,两者的效果对比如下:

docker run ab -c1 ab -c2 ab -c4 wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8
--cpus=1 74% 38% 9324 100% 46% 13457 100% 42% 13003 100% 28% 26815 100% 44% 40840 100% 48% 40787 100% 45% 33100 100% 44% 27124
--cpus=2 74% 38% 9290 130% 58% 17044 150% 60% 18940 118% 33% 31009 150% 65% 60099 200% 95% 81361 200% 90% 66332 200% 88% 54463
--cpus=3 74% 38% 9210 130% 58% 17087 149% 60% 18925 119% 33% 31096 149% 65% 60441 273% 132% 117629 300% 136% 100612 300% 135% 82927
--cpus=4 74% 38% 9263 130% 58% 17037 150% 60% 18974 117% 33% 31068 150% 65% 60507 275% 131% 116223 400% 181% 137259 400% 177% 108210

测试过程中还发现 golang 程序在容器内获取到的 NumCPU 是宿主机的 CPU 总核数,对应的 GOMAXPROCS 默认值过大会造成频繁的上下文切换,对 CPU 密集型任务会有严重影响。
可以使用 Uber 的 automaxprocs 在启动时按照规则自动计算 GOMAXPROCS,也可以通过环境变量手动指定一个合适的值,该值对测试结果的影响如下:

docker run wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8
--cpus=2 -e GOMAXPROCS=1 60% 39% 37983 79% 78% 75533 80% 78% 80021 79% 78% 80577 79% 78% 80126
--cpus=2 -e GOMAXPROCS=2 102% 36% 34434 107% 70% 66081 160% 145% 128777 160% 159% 140328 160% 159% 141503
--cpus=2 -e GOMAXPROCS=3 113% 33% 31078 137% 67% 62294 190% 139% 123172 200% 168% 140045 200% 184% 150832
--cpus=2 -e GOMAXPROCS=4 114% 33% 31508 143% 67% 61602 200% 124% 107115 200% 146% 121266 200% 164% 130300
--cpus=2 -e GOMAXPROCS=16 116% 33% 31116 150% 66% 60348 200% 97% 82150 200% 91% 67595 200% 90% 55395

因此模拟测试最终使用 --publish 8080:8080 加上 --cpus=2 -e GOMAXPROCS=3 来启动应用容器,然后使用 wrk 来执行不同并发程度下的性能测试。
测试结果如下,可以观察到除了 docker-proxy 端口转发会随着并发程度的增加而占用越来越多的 CPU,应用容器的最大吞吐量相比 --net=host 也降低了约三分之一。

metrics wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8 wrk -c10 -t10
应用容器 CPU 使用率 67% 96% 133% 167% 189% 200%
端口转发 CPU 使用率 41% 67% 153% 227% 297% 363%
测试工具 CPU 使用率 20% 38% 68% 104% 140% 163%
平均每秒完成的请求数 18559 37077 60250 81457 96570 104760

原因分析

源码实现

GitHub 上早期的 docker/docker 已经迁移到了 moby/moby,在仓库中可以很容易地搜索到 docker-proxy 所在的源码目录 cmd/docker-proxy
从功能入口的 main.go 可以看到 docker-proxy 一共支持 tcp/udp/sctp 三种协议,默认的 --publish 使用 tcp 协议,对应源码在 tcp_proxy.go,其核心实现为:

backend, err := net.DialTCP("tcp", nil, proxy.backendAddr)
if err != nil {
  log.Printf("Can't forward traffic to backend tcp/%v: %s\n", proxy.backendAddr, err)
  client.Close()
  return
}

var wg sync.WaitGroup
broker := func(to, from *net.TCPConn) {
	io.Copy(to, from)
	from.CloseRead()
	to.CloseWrite()
	wg.Done()
}

wg.Add(2)
go broker(client, backend)
go broker(backend, client)

即对于接收到的每一条 client 连接,都会先建立一条到转发目标的 backend 连接,然后再创建两个 goroutine 调用 io.Copy() 实现双向拷贝。
看起来是很常规的流量转发实现,golang 标准库的 io.Copy() 在 linux 环境下也会自动使用 syscall.Splice() 进行优化,因此这部分代码没有特别明显的优化空间。

功能替代

根据 ps aux 进程列表中的 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 8080 可知,使用任意方式将请求流量转发到容器 IP 对应端口即可实现 docker-proxy 等价功能。服务端常见的的反向代理软件有 Nginx 和 HAProxy 等,本地使用 nginx:1.27.2 和 haproxy:3.0.6 进行测试。
使用的 nginx 配置文件和测试结果如下,可以观察到高网络负载下相比 docker-proxy 节省了约一半的 CPU 使用率,且应用容器的最大吞吐量有少量恢复。

daemon off;
pid /tmp/nginx.pid;
worker_processes 4;
events {
  worker_connections 1024;
}

stream {
  server {
    listen 8080;
    proxy_pass 172.17.0.2:8080;
  }
}
metrics wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8 wrk -c10 -t10
应用容器 CPU 使用率 67% 102% 141% 160% 195% 200%
反向代理 CPU 使用率 32% 32% 32% 48% 48% 65% 31% 63% 56% 31% 57% 57% 42%
测试工具 CPU 使用率 20% 39% 77% 94% 145% 160%
平均每秒完成的请求数 18437 35893 67098 84093 116993 126640

使用的 haproxy 配置文件和测试结果如下,可以观察到高网络负载下相比 docker-proxy 节省了一部分 CPU 使用率,且应用容器的最大吞吐量有少量恢复。

global
    maxconn 4096
    log stderr local0 alert
    pidfile /tmp/haproxy.pid
    nbthread 4

defaults
    mode tcp
    timeout client 10s
    timeout connect 10s
    timeout server 10s

frontend main
    bind :8080
    default_backend container

backend container
    server app 172.17.0.2:8080
metrics wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8 wrk -c10 -t10
应用容器 CPU 使用率 64% 99% 141% 170% 196% 200%
反向代理 CPU 使用率 38% 77% 151% 188% 223% 223%
测试工具 CPU 使用率 19% 37% 73% 108% 142% 157%
平均每秒完成的请求数 17955 34459 63186 88561 111106 120964

解决方案

直接使用容器 IP

docker-proxy 的核心逻辑是先监听宿主机端口,再将请求流量转发到容器 IP 的对应端口,因此在执行性能测试时可以直接使用容器 IP 作为目标地址,测试结果如下:

metrics wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8 wrk -c10 -t10
应用容器 CPU 使用率 107% 132% 183% 200% 200% 200%
测试工具 CPU 使用率 34% 69% 142% 185% 199% 198%
平均每秒完成的请求数 30166 60585 119059 142879 156343 156780

禁用 userland-proxy

默认配置下 docker 使用 docker-proxyiptables 共同处理流量转发,但在 /etc/docker/daemon.json 配置文件中也提供了 userland-proxy 配置项,将该项设置为 false 后可以禁用 docker-proxy,只通过 iptables 转发流量,测试结果如下:

metrics wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8 wrk -c10 -t10
应用容器 CPU 使用率 101% 128% 176% 200% 200% 200%
测试工具 CPU 使用率 35% 70% 146% 200% 217% 218%
平均每秒完成的请求数 28558 56730 110939 141769 154384 153135

对比总结

将以上结果汇总到一起,忽略应用容器和测试工具的 CPU 使用率,仅保留流量转发工具的 CPU 使用率和应用容器的最大吞吐量,结果对比如下:

metrics wrk -c1 -t1 wrk -c2 -t2 wrk -c4 -t4 wrk -c6 -t6 wrk -c8 -t8 wrk -c10 -t10
host 网络,默认设置 GOMAXPROCS 00% 31009 00% 60099 00% 81361 00% 66332 00% 54463 00% 53571
host 网络,手动设置 GOMAXPROCS 00% 31078 00% 62294 00% 123172 00% 140045 00% 150832 00% 150762
端口映射,通过 docker-proxy 访问 127 41% 18559 67% 37077 153% 60250 227% 81457 297% 96570 363% 104760
端口映射,nginx 转发请求到容器 IP 32% 18437 64% 35893 96% 67098 96% 84093 150% 116993 156% 126640
端口映射,haproxy 转发请求到容器 IP 38% 17955 77% 34459 151% 63186 188% 88561 223% 111106 223% 120964
端口映射,直接使用容器 IP 访问 00% 30166 00% 60585 00% 119059 00% 142879 00% 156343 00% 156780
端口映射,手动禁用 userland-proxy 00% 28558 00% 56730 00% 110939 00% 141769 00% 154384 00% 153135

拓展


附: