Nginx gRPC 代理与长连接实践:grpc_pass、HTTP/2 上游与流式传输

系统讲解 Nginx 作为 gRPC 网关的完整配置,覆盖 grpc_pass 与 HTTP/2 上游、trailers 与双向流式、超时与重试、健康检查与连接复用,并对比 gRPC 代理与普通 HTTP 反向代理的关键差异。

gRPC 以 HTTP/2 为传输底座,用 Protobuf 定义接口,天然支持多路复用与双向流式,已经成为微服务内部通信的事实标准。但 gRPC 的协议特性与普通 HTTP/1.1 差异很大:它依赖 HTTP/2 的帧与流、依赖 trailers 传递状态、依赖长连接维持多路复用。这意味着传统的 proxy_pass 配置往往无法直接代理 gRPC 流量,必须使用 Nginx 专门的 grpc_pass 指令族。本文从协议原理出发,逐层拆解 Nginx 代理 gRPC 的配置要点与生产陷阱。

一句话总结: gRPC 代理的核心是让 Nginx 用 HTTP/2 与上游通信,并正确透传 trailers、超时与流控语义,而不是简单地把字节流转发过去。

1. gRPC 与 HTTP/2 上游的基本原理

一句话总结: gRPC 把请求映射为 HTTP/2 的流,状态码放在 trailers 而非 header,因此代理层必须理解帧与流的边界。

gRPC 的报文结构是:请求体是一段 5 字节前缀加 Protobuf 负载,前缀里的 1 字节表示是否压缩、4 字节表示消息长度;响应体同样如此。一次 RPC 对应 HTTP/2 的一条流,content-type 固定为 application/grpc。关键差异在于状态码不在 header 里:gRPC 的最终状态放在 HTTP trailers 的 grpc-status 与 grpc-message 字段中,HTTP 层的 200 只表示「连接可用」,真正的业务错误码藏在 trailer。

# 观察一次 gRPC 调用在 HTTP/2 层的真实形态
grpcurl -plaintext -v localhost:50051 helloworld.Greeter/SayHello
# 输出中可以看到 response headers、response trailers 两段
# trailers 里包含 grpc-status: 0 表示成功

因为状态在 trailers,代理层必须原样透传 trailer 帧。Nginx 的 grpc_pass 就是为此设计的:它会自动把上游的 trailer 转发给客户端,而 proxy_pass 会把 trailer 丢弃,导致客户端永远拿不到 grpc-status,只能靠超时判断失败。这就是为什么 gRPC 流量不能直接复用 HTTP 代理配置。

另一个原理差异是多路复用。HTTP/1.1 的一条 TCP 连接同一时刻只能承载一个请求,因此 proxy_pass 时代上游连接池按「并发请求数」估算即可。HTTP/2 的一条连接可以并发承载成百上千条流,gRPC 上游连接池的规模需求骤降,但单条连接的流量与内存压力上升。理解这一点,才能正确设置上游连接数与并发流上限。

2. grpc_pass 与 HTTP/2 上游配置

一句话总结: 用 grpc_pass 替代 proxy_pass,客户端侧开启 HTTP/2,上游侧用 grpcs 走 TLS,并显式声明 grpc 的 Host 头。

最小可用的 gRPC 代理配置如下,它监听 443 端口并接受 HTTP/2 客户端,转发到明文 gRPC 上游:

server {
    listen 443 ssl;
    http2 on;
    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;

    location /helloworld.Greeter/ {
        # 关键:用 grpc_pass 而不是 proxy_pass
        grpc_pass grpc://grpc_backend;
        # gRPC 要求 Host 头与上游一致,否则部分框架会拒绝
        grpc_set_header Host $host;
        grpc_set_header X-Real-IP $remote_addr;
    }
}

upstream grpc_backend {
    server 10.0.0.11:50051;
    server 10.0.0.12:50051;
    # gRPC 长连接必须开启 keepalive,否则每条 RPC 都要重建 HTTP/2 连接
    keepalive 32;
}

grpc_pass 的协议前缀有两个:grpc:// 表示明文 HTTP/2(h2c),grpcs:// 表示带 TLS 的 HTTP/2。上游启用 TLS 时还要配合 grpc_ssl_certificate 与 grpc_ssl_trusted_certificate 完成双向校验:

location /secure.Greeter/ {
    grpc_pass grpcs://secure_backend;
    # 上游校验客户端证书时,Nginx 需要出示自己的证书
    grpc_ssl_certificate     /etc/nginx/certs/client.crt;
    grpc_ssl_certificate_key /etc/nginx/certs/client.key;
    grpc_ssl_trusted_certificate /etc/nginx/certs/ca.crt;
    grpc_ssl_verify on;
}

2.1 客户端侧 HTTP/2 的开启条件

一句话总结: Nginx 只有在 TLS + ALPN 协商到 h2 时才接受 gRPC,明文端口需要显式声明 http2 才能接收 h2c。

浏览器与 gRPC 客户端都要求 HTTP/2,而 Nginx 的 HTTP/2 在 1.25 之前必须由 listen ... ssl 隐式开启,1.25 之后改为显式 http2 on;。明文 gRPC(不带 TLS)在服务网格内部很常见,此时必须在 listen 50051; 之后显式写 http2 on;,否则 Nginx 会按 HTTP/1.1 解析帧而直接报错:

server {
    listen 50051;
    http2 on;          # 明文端口必须显式开启,否则 h2c 请求被拒绝
    location / {
        grpc_pass grpc://127.0.0.1:50052;
    }
}

如果客户端用 HTTP/1.1 发起请求(例如 curl 不带 --http2-prior-knowledge),Nginx 会返回 426 或直接解析失败。生产上建议在网关入口做一次探测:命中 content-type: application/grpc 但协议不是 HTTP/2 的请求直接返回 400,避免这类请求进入上游造成难以定位的协议错误。

3. trailers 与流式传输

一句话总结: 单向流、服务端流、双向流都靠 HTTP/2 的 DATA 帧承载,代理层不能缓冲整个响应体,否则流式体验会被破坏。

gRPC 支持四种调用模式:一元(Unary)、服务端流(Server Streaming)、客户端流(Client Streaming)、双向流(Bidirectional Streaming)。后三种都依赖 HTTP/2 的数据帧持续推送。代理层如果开启了响应缓冲,就会把流式响应攒成完整报文再下发,实时性完全丧失:

location /stream.Events/ {
    grpc_pass grpc://grpc_backend;

    # 关闭缓冲:让数据帧一到达就转发给客户端
    grpc_buffering off;
    # 上游响应头一到就下发给客户端,不等响应体
    grpc_read_timeout 3600s;
    grpc_send_timeout 3600s;

    # 大消息场景放宽单帧限制
    grpc_buffer_size 16k;
}

grpc_buffering off 是流式场景的关键。Nginx 默认会缓冲上游响应以提升吞吐,但对长连接推送来说,缓冲意味着延迟累积。同时必须把 grpc_read_timeout 与 grpc_send_timeout 从默认的 60s 放大:双向流可能空闲数分钟才推送下一条消息,60 秒的超时会把正常空闲连接直接掐断。

trailers 的透传还需要注意 grpc_set_header 与 TE: trailers 头。部分 gRPC 实现要求客户端声明 TE: trailers,Nginx 在转发时会自动补上,但如果前面还有一层代理(比如 ingress)把该头过滤掉了,就会出现 grpc-status 丢失的现象。排查时先确认链路上的每一跳都保留 TE 与 content-type 头。

# 抓包确认 trailer 帧是否真的透传到了客户端
sudo tcpdump -i any -A 'tcp port 50051' | grep -A2 grpc-status

4. 超时、重试与流控

一句话总结: gRPC 的超时要按调用类型分别设置,重试只对幂等的一元调用安全,流控则要靠 grpc_buffer_size 与并发流上限配合。

超时是 gRPC 代理最容易踩坑的地方。一元调用通常几百毫秒完成,而双向流可能持续数小时,两者显然不能用同一组超时。推荐按 location 区分:

# 一元调用:短超时快速失败
location /api.Unary/ {
    grpc_pass grpc://grpc_backend;
    grpc_connect_timeout 3s;
    grpc_read_timeout    10s;
    grpc_send_timeout    10s;
}

# 长连接流式:长超时或干脆不设
location /api.Stream/ {
    grpc_pass grpc://grpc_backend;
    grpc_connect_timeout 3s;
    grpc_read_timeout    3600s;
    grpc_send_timeout    3600s;
}

grpc_next_upstream 控制重试行为。默认只对连接失败与超时重试,且 gRPC 的重试语义比 HTTP 更危险:一个已经发送了部分消息的流被重试,会导致上游收到重复消息。因此只有一元、幂等的调用才适合开启更激进的重试:

location /api.ReadOnly/ {
    grpc_pass grpc://grpc_backend;
    # 连接错误与超时可换一台上游重试
    grpc_next_upstream error timeout http_502 http_503;
    grpc_next_upstream_tries 2;
    grpc_next_upstream_timeout 5s;
}

流控方面,grpc_buffer_size 决定 Nginx 读取上游响应时的缓冲区大小。缓冲区越小,内存占用越低,但大消息会被拆成更多次读写;缓冲区越大,单连接内存越高。对于传输大 payload(比如文件分片)的 gRPC 接口,建议把缓冲区调到 16k~64k 并做压测验证。同时 http2_max_concurrent_streams 限制了客户端单连接能并发多少条流,网关层通常需要放宽到 128 以上,避免客户端因流被拒而不断重连。

5. 健康检查与连接复用

一句话总结: gRPC 上游的健康检查要用 HTTP/2 探测而非 TCP 探测,keepalive 池必须开启并定期清理空闲连接。

gRPC 上游「端口开着」不等于「服务可用」——HTTP/2 连接建立后,如果后端业务线程池耗尽,新流会被拒绝。因此健康检查应该用 gRPC 官方的健康检查协议,而不是简单的 TCP 探测。Nginx 开源版只支持被动健康检查,用 grpc_next_upstream 把失败的上游临时摘除:

upstream grpc_backend {
    server 10.0.0.11:50051 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:50051 max_fails=3 fail_timeout=10s;
    keepalive 64;
    keepalive_timeout 60s;
    keepalive_requests 1000;
}

max_fails=3 fail_timeout=10s 表示连续失败 3 次后摘除 10 秒。这个组合对 gRPC 要格外小心:gRPC 的失败往往表现为 trailer 里的非零 grpc-status,而 Nginx 的被动检查只统计传输层失败与 HTTP 5xx,业务层的 grpc-status: 14(UNAVAILABLE)并不会触发摘除。因此应用层的健康状态需要额外的主动探测,可以用 Nginx Plus 的 health_check,或者用 sidecar 定期调用健康检查接口并动态更新 upstream。

连接复用是 gRPC 代理的性能关键。HTTP/2 的多路复用让上游连接数需求很低,但代价是空闲连接会被中间设备(NAT、防火墙)悄悄回收。keepalive_timeout 60s 让 Nginx 在 60 秒空闲后主动关闭连接,比等待对端 RST 更可控:

# 观察上游连接复用情况:ESTAB 数量应远小于 QPS
ss -tn state established '( dport = :50051 )' | wc -l

如果发现每次请求都新建连接(TIME_WAIT 大量堆积),通常是因为 keepalive 指令漏写、或者 location 里用了 grpc_pass 直接指向 IP 而没有经过 upstream 块。keepalive 只在 upstream 块内生效,直接写 grpc_pass grpc://10.0.0.11:50051 会绕过连接池。

6. 与普通 HTTP 代理的关键差异

一句话总结: gRPC 代理在指令族、状态码位置、连接语义、缓冲策略四个方面都与 HTTP 代理不同,混用会导致静默失败。

把差异整理成一张对照表,能避免大部分配置错误:

维度HTTP 代理(proxy_pass)gRPC 代理(grpc_pass)
上游协议HTTP/1.1 或 HTTP/2强制 HTTP/2(h2c 或 h2)
状态码位置响应头HTTP trailers(grpc-status)
超时指令proxy_read_timeoutgrpc_read_timeout
请求头设置proxy_set_headergrpc_set_header
重试指令proxy_next_upstreamgrpc_next_upstream
缓冲指令proxy_bufferinggrpc_buffering
上游连接每请求一条或 keepalive 池依赖 HTTP/2 多路复用

最容易犯的错误是「指令混用」:在 grpc_pass 的 location 里写 proxy_read_timeout,Nginx 不会报错但该指令完全不生效,超时仍然是默认 60 秒。类似地,proxy_set_header Host $host 对 gRPC 也无效,必须写 grpc_set_header。这类错误在测试环境往往表现正常(因为响应快、没触发超时),上线后遇到慢调用才暴露。

另一个差异是错误码映射。HTTP 代理下,上游 502 会被 proxy_next_upstream 捕获;而 gRPC 上游崩溃时,客户端看到的是 HTTP/2 的 RST_STREAM 或 grpc-status: 14,Nginx 的日志里记录的是 upstream prematurely closed connection 之类的信息。排查 gRPC 故障时,必须同时看 Nginx 的 error_log 与客户端侧的 grpc-status,两边对齐才能定位是网络层还是应用层的问题。

# 为 gRPC 单独准备日志格式,记录 grpc 特有字段
log_format grpc '$remote_addr "$request" $status '
                'upstream=$upstream_addr '
                'rt=$request_time urt=$upstream_response_time';

server {
    listen 50051;
    http2 on;
    access_log /var/log/nginx/grpc.log grpc;
    location / { grpc_pass grpc://grpc_backend; }
}

7. 生产实践与可观测性

一句话总结: gRPC 网关的生产化需要补齐指标采集、协议感知的日志与灰度路由,才能把「能跑通」提升到「能运营」。

gRPC 的可观测性与 HTTP 有明显差别:一次 RPC 的耗时由 HTTP 层耗时与业务耗时共同构成,而业务耗时体现在 trailer 的 grpc-status 上。Nginx 的 $upstream_response_time 只统计到响应体结束,对于流式调用它记录的是「最后一条消息到达」的时间,未必等于业务处理完成时间。因此长连接场景要结合客户端的 span 一起分析。

指标采集方面,除了通用的连接数、QPS、P99,gRPC 网关还应关注:活跃流数量、grpc-status 非零比例、上游连接复用率、trailer 透传失败次数。这些指标可以通过 stub_status 拿到连接维度数据,再配合 OpenResty 的 lua-resty 在日志阶段解析 trailer:

# OpenResty 场景:在 log_by_lua 中记录 grpc-status
log_by_lua_block {
    local status = ngx.var.upstream_http_grpc_status
    if status and status ~= "0" then
        ngx.log(ngx.WARN, "grpc failed status=", status)
    end
}

灰度发布是 gRPC 网关的另一个高频需求。由于 gRPC 的请求路径是 包名.服务名/方法名,可以直接用 location 前缀做服务级路由,再用 split_clients 做流量切分:

# 按 5% 流量切到新版本
split_clients "${remote_addr}${request_uri}" $grpc_version {
    5%     "v2";
    *      "v1";
}

upstream grpc_v1 { server 10.0.1.11:50051; keepalive 32; }
upstream grpc_v2 { server 10.0.2.11:50051; keepalive 32; }

map $grpc_version $grpc_upstream {
    v1  grpc_v1;
    v2  grpc_v2;
}

server {
    listen 50051;
    http2 on;
    location / {
        grpc_pass grpc://$grpc_upstream;
    }
}

灰度期间要特别关注 trailers 的完整性:新旧版本的 gRPC 框架版本可能不同,个别老版本在 trailers 的编码上存在兼容问题,表现为客户端偶尔收不到 grpc-status 而超时。上线前用 grpcurl 对新旧两个 upstream 各跑一轮用例,确认 trailer 行为一致。

gRPC 网关的容量规划要跳出「按 QPS 估算」的思维。HTTP/2 多路复用意味着单条连接承载的并发流数才是关键指标,http2_max_concurrent_streams、上游 keepalive 数量与业务线程池三者要匹配:流上限设得比线程池大,多余的流只会排队;设得比连接数承载力小,则会浪费复用能力。压测时用 ghz 这类 gRPC 专用压测工具,按并发流数而非并发连接数施压,才能测出真实瓶颈。

8. 总结

环节要点
协议原理gRPC 基于 HTTP/2 流,状态码在 trailers 而非 header
基本配置grpc_pass 替代 proxy_pass,客户端开 HTTP/2,上游用 grpc/grpcs
流式传输grpc_buffering off,超时按调用类型分别设置
超时重试grpc_next_upstream 只对幂等一元调用开启
健康检查端口探测不够,需应用层主动探测
连接复用upstream keepalive 必须显式开启,否则 TIME_WAIT 堆积
与 HTTP 差异指令族、状态码位置、缓冲策略全面不同,不可混用
生产运营采集 grpc-status 指标,灰度路由按服务方法切分

Nginx 代理 gRPC 的难点不在语法,而在协议语义的透传:trailers 必须原样转发、流式不能缓冲、超时要按调用类型区分、连接池要靠 keepalive 维持。把这四点做对,Nginx 就能成为一个稳定、低延迟的 gRPC 网关,同时保留限流、鉴权、灰度等接入层能力。掌握 gRPC 代理之后,接入层的下一道防线是应用层攻击检测,接下来看看如何在 Nginx 中集成 WAF 与 ModSecurity。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期