一次让我通宵的故障:Nginx热加载引发的连接闪断
2019年夏天,凌晨2:14,线上告警群炸了。
我们的微服务网关是Nginx + Lua脚本写的路由逻辑,当时只是改了一条路由规则,执行了 nginx -s reload。结果:2分钟内,所有后端的错误率从0.3%飙到37%,大量请求返回502。原因很简单:Nginx的reload是master进程拉起新worker,然后旧worker优雅退出,但旧的worker连接池里的keepalive连接全部被强制销毁。低峰期可能感知不到,但在凌晨的业务高峰,这个闪断直接打穿了所有服务的连接池。
监控图我看了整整三小时:Nginx的active connections像心电图一样剧烈抖动,后端的Tomcat重启了4次,缓存穿透导致数据库连接数直接打到上限。从那天起,我就在想:为什么我们用的流量入口代理,连个配置热更新都做不干净?
当时团队里刚好有个从网易出来的同事,说他们在用Envoy。他说了一句让我印象很深的话:「Nginx是『静态配置 + 计划内优雅』,Envoy是『动态配置 + 运行时自适应』」。虽然我后来跳槽到了另一家公司,但Envoy这个词一直留在脑子里。
最近半年,我们公司的业务从单体逐渐拆成20多个微服务,Nginx的静态配置开始撑不住了。路由规则没法热更新、服务实例的上下线全靠手动改upstream、金丝雀发布基本靠捞流量。我决定彻底调研Envoy,把服务网格那一套引进来。这篇文章就是我这三个月的踩坑记录。
问题定位:Nginx静态配置在微服务场景下的五个痛点
先把核心问题说透。我们当时的流量架构是:
用户请求
↓ (HTTPS)
Nginx(硬编码upstream:10.0.0.1:8080, 10.0.0.2:8080, 10.0.0.3:8080)
↓ (HTTP/1.1 keepalive)
网关服务(Java Spring Cloud Gateway)
↓ (HTTP/1.1 keepalive)
21个微服务节点(分布在3台物理机的Docker容器里)
这个架构有明显的问题:
| 痛点 | 具体场景 | 造成的后果 |
|---|---|---|
| 1. 配置热更新不彻底 | 修改nginx.conf,执行reload | 连接闪断、请求丢失、上游重置连接 |
| 2. upstream维护靠人肉 | 服务扩缩容要改conf,再reload | 平均每周要reload 3次,每次都有小抖动 |
| 3. 无主动健康检查 | Nginx被动检查是等请求失败才踢掉节点 | 失败请求直接损在用户身上 |
| 4. 灰度/金丝雀能力弱 | 按权重轮询,改权重必须reload | 运维节奏跟不上业务迭代 |
| 5. 可观察性基本靠后端日志 | Nginx的access log字段有限,连接级指标要靠telegraf硬采集 | 排障要跨多个系统找日志,效率低 |
这五个痛点的根源在于:Nginx的进程模型和配置模型都是为「静态代理」设计的,它不感知服务注册和发现。Nginx的上游服务器列表是启动时或reload时从配置文件加载的,没有任何机制去对接Consul、etcd或Kubernetes API。虽然OpenResty可以用lua-resty-http去拉注册中心,但本质上是绕弯子。
方案对比:Envoy vs Nginx集群 vs APISIX
我不打算直接说「Envoy最好」,先做一轮并行验证。当时考虑的候选方案有四个:
方案A:Nginx集群 + Consul Template
保留Nginx,用Consul Template监听Consul里的服务变化,自动生成上游配置并reload。最大优势是复用现有架构,团队不需要学新东西。
验证结论(2023年,环境:腾讯云CVM 4核8G,CentOS 7.9):Consul Template从检测到服务变化到reload完成,平均耗时850ms。期间Nginx处于「配置已生成但尚未reload」的中间态,如果此时有请求进来,仍会打到已下线的节点。而且高频reload导致的连接闪断问题依然存在,只是从人肉操作变成了自动化操作,故障频率从每周3次变成每天7-8次——因为自动reload太频繁了。不推荐。
方案B:APISIX(OpenResty + etcd)
APISIX是国人主导的API网关项目,控制面用etcd存配置,数据面基于OpenResty。它确实解决了动态配置问题,但它是「API网关」思维,不是「服务网格」思维。它提供的是路由、限流、鉴权、日志等网关能力,但服务网格里关键的mTLS、重试、熔断、流量镜像、分布式追踪这些能力,APISIX是缺失的。如果你只需要API网关,APISIX是很优秀的方案,但这次我们明确要的是服务网格能力,所以APISIX先放一边。
方案C:Envoy + 静态配置文件
先不上控制面,用纯静态配置把Envoy跑起来,验证它能否替代Nginx作为流量入口。这是最小验证单元,能快速看到Envoy的连接池管理、健康检查、超时重试这些内置能力。
方案D:Envoy + Istio控制面
完整服务网格方案,用Istio的Pilot作为xDS服务器,Envoy作为数据面代理,自动对接Kubernetes服务发现。功能最全,但部署成本最高,Istio本身要吃掉不少资源,控制面故障会影响数据面配置推送。
我当时选了C和D并行验证。本文写的重点是用方案C把Envoy跑通、并理解它的核心模型,然后自然过渡到完整的服务网格。
先说结论性对比:
| 维度 | Nginx | Envoy |
|---|---|---|
| 配置模型 | 静态文件 + reload | 静态/动态双模式,xDS协议支持运行时全量更新 |
| 热更新机制 | master重启worker,断开keepalive连接 | 监听器/路由/集群全部支持热替换,连接不断 |
| 服务发现 | 无内置,需Consul Template等外部工具 | 内置EDS(Endpoint Discovery Service),直接对接注册中心 |
| 负载均衡算法 | 轮询、加权轮询、ip_hash | 轮询、随机、最小请求数、一致性哈希、环形哈希等7种 |
| 主动健康检查 | 不支持,只能被动max_fails | 内置HTTP/TCP/gRPC主动健康检查,可配置超时和间隔 |
| 超时重试 | proxy_next_upstream可选有限重试 | 每个路由可配重试策略:5xx、connect-failure、retriable-status-codes等 |
| 熔断 | 无原生,需Lua脚本实现 | 内置熔断器:并发连接数、并发请求数、异常比例、异常数量四个维度 |
| 指标可观察性 | 无原生指标端点,需stub_status | 原生暴露Prometheus格式指标,几百个计数器/直方图 |
| 协议支持 | HTTP/1.1、HTTP/2(需编译)、TCP/UDP | HTTP/1.1、HTTP/2、gRPC、TCP/UDP、WebSocket、MongoDB、DynamoDB等 |
| 内存占用 | 每worker约5-10MB(2C/4G环境) | 基础约40-60MB(含连接池和upstream状态) |
抛开功能对比,我最在意的是第一个维度——配置热更新。Envoy的整个设计就是围绕「配置是不可靠的、运行时要能自适应」这个理念来的。Envoy有一个独立的「热重启」机制和「毫秒级配置收敛」能力,它的worker进程不重启,监听器替换是渐进式的。这直接击中了我们Nginx reload闪断的痛点。
环境准备:用Docker Compose把Envoy跑起来
版本说明(2023年12月测试通过):
- 操作系统:macOS 13.6(Apple Silicon)/ 腾讯云CVM CentOS 7.9,双环境验证
- Docker Engine 24.0.7,Docker Compose v2.23.0
- Envoy Docker镜像:envoyproxy/envoy:v1.28.0(注意1.28.0版本较新,如果你用旧教程里的v1.17.0,部分配置字段有差异)
- 后端服务:Nginx Alpine 1.25.3作为两个demo upstream服务
- 压测工具:wrk 4.2.0(CentOS下编译安装) / hey 0.1.4(macOS brew安装)
先准备两个模拟上游服务。我用Nginx alpine的容器做demo,因为不需要写代码就能返回不同响应头:
# 准备目录
mkdir -p ~/envoy-lab && cd ~/envoy-lab
# 创建三个后端服务目录,每个放一个不同的index.html
mkdir -p demo-backend-1 demo-backend-2
# 后端1:
cat <<'EOF' > demo-backend-1/index.html
<h1>Backend 1 (10.0.0.11)</h1>
<p>This is served by backend-1 container</p>
EOF
# 后端2:
cat <<'EOF' > demo-backend-2/index.html
<h1>Backend 2 (10.0.0.12)</h1>
<p>This is served by backend-2 container</p>
EOF
# 给原来的Nginx镜像加一个自定义启动脚本,用于注入不同响应头
cat <<'EOF' > demo-backend-1/start.sh
#!/bin/sh
echo "server { listen 8080; root /usr/share/nginx/html; add_header X-Backend-Node backend-1; }" > /etc/nginx/conf.d/default.conf
nginx -g "daemon off;"
EOF
chmod +x demo-backend-1/start.sh demo-backend-2/start.sh
然后写第一个docker-compose.yaml,先只启动两个后端服务:
# ~/envoy-lab/docker-compose.yaml (第一阶段)
version: '3.8'
services:
backend-1:
image: nginx:1.25.3-alpine
container_name: backend-1
volumes:
- ./demo-backend-1/index.html:/usr/share/nginx/html/index.html:ro
- ./demo-backend-1/start.sh:/start.sh:ro
command: /bin/sh /start.sh
networks:
envoy-lab-net:
ipv4_address: 10.0.0.11
ports:
- "18081:8080"
backend-2:
image: nginx:1.25.3-alpine
container_name: backend-2
volumes:
- ./demo-backend-2/index.html:/usr/share/nginx/html/index.html:ro
- ./demo-backend-2/start.sh:/start.sh:ro
command: /bin/sh /start.sh
networks:
envoy-lab-net:
ipv4_address: 10.0.0.12
ports:
- "18082:8080"
networks:
envoy-lab-net:
driver: bridge
ipam:
config:
- subnet: 10.0.0.0/24
启动后端:
cd ~/envoy-lab
docker compose up -d backend-1 backend-2
# 验证:
curl http://localhost:18081/
curl http://localhost:18082/
第一个Envoy配置:静态文件实现负载均衡
现在写Envoy的静态配置文件。这里要理解Envoy的三个核心概念:Listener(监听器)、Cluster(集群)、Route(路由)。Nginx里没有完全对应的概念,我用中文注释写清楚。
# ~/envoy-lab/envoy.yaml (静态配置)
static_resources:
# Listeners:Envoy监听什么端口、处理什么协议
listeners:
- name: listener_0
address:
# 监听10000端口接收外部HTTP请求
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
# 路由配置:什么请求路径转发到哪个集群
route_config:
name: local_route
virtual_hosts:
- name: backend_services
# 所有域名都匹配
domains: ["*"]
routes:
# 根路径转发给service_httpbin集群
- match:
prefix: "/"
route:
cluster: service_backend
# 超时配置:连接超时0.25秒,最大请求超时30秒
timeout: 30s
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
# Clusters:Envoy可转发的后端服务集合
clusters:
- name: service_backend
connect_timeout: 0.25s
type: STATIC
# 这里写负载均衡策略,原本L7带round_robin,后面实测时改成了LEAST_REQUEST
lb_policy: LEAST_REQUEST
# 是否开启主动健康检查
health_checks:
- timeout: 1s
interval: 3s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: "/"
# 后端实例列表(静态模式下写死,动态模式下由EDS下发)
load_assignment:
cluster_name: service_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 10.0.0.11
port_value: 8080
- endpoint:
address:
socket_address:
address: 10.0.0.12
port_value: 8080
# 访问日志
# 第一个是标准输出,第二个是本地文件
admin:
access_log_path: "/dev/stdout"
address:
socket_address:
address: 0.0.0.0
port_value: 9901
把这个配置放进docker-compose里,加入Envoy服务:
# ~/envoy-lab/docker-compose.yaml (完整版)
version: '3.8'
services:
envoy:
image: envoyproxy/envoy:v1.28.0
container_name: envoy
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml:ro
networks:
envoy-lab-net:
ipv4_address: 10.0.0.10
ports:
# 外部访问Envoy的端口
- "10000:10000"
# Admin控制台(可查看实时指标)
- "9901:9901"
backend-1:
image: nginx:1.25.3-alpine
container_name: backend-1
volumes:
- ./demo-backend-1/index.html:/usr/share/nginx/html/index.html:ro
- ./demo-backend-1/start.sh:/start.sh:ro
command: /bin/sh /start.sh
networks:
envoy-lab-net:
ipv4_address: 10.0.0.11
ports:
- "18081:8080"
backend-2:
image: nginx:1.25.3-alpine
container_name: backend-2
volumes:
- ./demo-backend-2/index.html:/usr/share/nginx/html/index.html:ro
- ./demo-backend-2/start.sh:/start.sh:ro
command: /bin/sh /start.sh
networks:
envoy-lab-net:
ipv4_address: 10.0.0.12
ports:
- "18082:8080"
networks:
envoy-lab-net:
driver: bridge
ipam:
config:
- subnet: 10.0.0.0/24
启动完整环境并验证:
cd ~/envoy-lab
docker compose down && docker compose up -d
# 验证Envoy是否正常启动
curl http://localhost:9901/server_info
# 轮询访问两次,看到不同的X-Backend-Node响应头说明负载均衡生效
curl -s -I http://localhost:10000/ | grep X-Backend-Node
curl -s -I http://localhost:10000/ | grep X-Backend-Node
# 查看Envoy日志确认无配置错误
docker logs envoy
由于我的lb_policy写的是LEAST_REQUEST,且两个后端性能一致,实际看到的是交替返回。如果你用轮询(ROUND_ROBIN),应该严格按1、2、1、2的顺序返回。
进阶:手动下线一个后端,看Envoy主动健康检查
现在把backend-2容器停掉:
docker stop backend-2
# 等5秒(健康检查间隔3秒 + 超时1秒 + unhealthy阈值3),然后访问:
curl -s -I http://localhost:10000/ | grep X-Backend-Node
# 连续访问5次,全部返回backend-1
# 此时再看看backend-1的日志中,Envoy的健康检查请求路径
docker logs backend-1 | grep "GET /" | tail -3
用Nginx做同样的事情:如果Nginx里upstream同时指向backend-1和backend-2,你把backend-2停掉,Nginx会怎么做?Nginx只有在请求失败后(max_fails),才会把该节点标记为不可用。且默认max_fails为1,fail_timeout为10s。也就是说:有请求到backend-2且连接失败,才触发标记。而Envoy是主动每3秒发一次健康检查,后台-2在停止服务后的1.5秒内就会被踢掉。
我们通过wrk做一次严谨的对比压测(测试环境:macOS 13.6 M2 Max、Docker Desktop 4.25,后端容器在Docker内部网络):
# 1. 正常情况下的Envoy吞吐量,使用LEAST_REQUEST策略
wrk -t4 -c200 -d30s http://localhost:10000/
# 结果(Envoy 1.28.0, Docker Desktop 4.25, macOS 13.6):
# Thread Stats Avg Stdev Max +/- Stdev
# Latency 1.24ms 0.63ms 18.36ms 83.51%
# Req/Sec 11.03k 1.21k 14.82k 82.00%
# Latency Distribution
# 50% 1.14ms
# 75% 1.42ms
# 90% 2.02ms
# 99% 3.19ms
# 329952 requests in 30.00s, 46.40MB read
# Requests/sec: 10998.34
# Transfer/sec: 1.55MB
# 2. 停掉backend-2后的Envoy吞吐量,不做任何负载均衡策略调整
docker stop backend-2
wrk -t4 -c200 -d30s http://localhost:10000/
# 结果:
# Thread Stats Avg Stdev Max +/- Stdev
# Latency 1.11ms 0.55ms 17.05ms 84.53%
# Req/Sec 11.42k 1.17k 14.67k 82.50%
# 341028 requests in 30.00s, 48.06MB read
# Requests/sec: 11367.22
# Transfer/sec: 1.60MB
# 3. 对比:用Nginx做同样的事(配置轮询,停掉其中一个)
# 搭建Nginx反代到两个后端,停掉backend-2后,用户请求直接打到backend-2的断连热点:
wrk -t4 -c200 -d30s http://localhost:18080/
# 结果(Nginx 1.25.3, Docker):
# Thread Stats Avg Stdev Max +/- Stdev
# Latency 2.16ms 1.08ms 24.77ms 79.12%
# Req/Sec 8.87k 1.06k 13.30k 76.00%
# 264258 requests in 30.00s, 36.50MB read
# Non-2xx or 3xx responses: 6520 (约2.5%的请求直接失败)
# Requests/sec: 8818.22
# Transfer/sec: 1.26MB
数据中心:Envoy在单后端的极端情况下吞吐 11367 req/s,零失败请求;Nginx在同配置下有 6520 个请求直接返回502(占比2.5%)。虽然Nginx通过max_fails也能在一段时间后将backend-2摘掉,但在这段时间内的失败请求,损失直接反映在用户体验上。Envoy通过主动健康检查在1.5-3秒内将失效节点剔除,且不产生任何失败请求。
这里要特别解释一个细节:为什么Envoy在单后端时吞吐量反而提升了?这不是玄学,因为LEAST_REQUEST策略下,当只有一个健康节点时,Envoy不会浪费请求到不健康节点上,所有连接都打向可用节点,自然吞吐提升。而Nginx轮询在两个后端存在时是公平的,但后端故障时,它的一半流量还在打向故障节点。
Envoy的关键优势:动态配置xDS协议
上面用的全是静态配置,还不足以打动我。Envoy真正炸裂的是它的动态配置能力,通过xDS协议从控制面拉取配置:
# xDS协议族
# LDS - Listener Discovery Service:监听器配置
# RDS - Route Discovery Service:路由配置
# CDS - Cluster Discovery Service:集群配置
# EDS - Endpoint Discovery Service:端点(服务实例)配置
# SDS - Secret Discovery Service:TLS证书配置
这五个服务合在一起,就是「数据面与控制面解耦」的核心。Nginx的conf文件是唯一事实来源,而Envoy的运行状态可以完全由外部控制面动态驱动。
用Istio作为控制面时,Pilot会把Kubernetes中的Service和Pod变化实时转成xDS协议下发给所有Envoy实例。服务扩容不需要改任何配置文件,Envoy自动感知新Pod。这就是服务网格。
# 一个最小的Kubernetes + Istio部署示例(用于说明动态配置形态)
# 实际部署需要istioctl install --set profile=demo
apiVersion: v1
kind: Service
metadata:
name: demo-service
spec:
selector:
app: demo
ports:
- port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-deployment
spec:
replicas: 3
selector:
matchLabels:
app: demo
template:
metadata:
labels:
app: demo
spec:
containers:
- name: demo-container
image: nginx:1.25.3-alpine
ports:
- containerPort: 8080
如果只有Kubernetes原生Service + 普通Deployment,即使Pod IP变了,Service的ClusterIP会做DNAT。但有了Envoy和Istio后,Envoy会直接拿到所有Pod的IP列表,做更精细的负载均衡——这就是为什么服务网格可以提供更高级的流量管理能力。
完整服务网格演示:Envoy + Kubernetes + Istio
这一节我尽量给你一个能直接复制的真实操作流程。用K3s替代完整Kubernetes,因为它资源占用低,适合单机验证。
# 环境:腾讯云CVM 8C16G,Ubuntu 22.04
# 安装K3s(轻量Kubernetes)
curl -sfL https://get.k3s.io | sh -
# 安装Istio(使用1.20.2版本,2023年11月发布)
curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.20.2 TARGET_ARCH=x86_64 sh -
cd istio-1.20.2
export PATH=$PWD/bin:$PATH
# 安装Istio到k3s
istioctl install --set profile=demo -y
# 给命名空间打上自动注入sidecar的标签
kubectl label namespace default istio-injection=enabled
# 部署示例服务
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: product-service
labels:
app: product
spec:
replicas: 2
selector:
matchLabels:
app: product
template:
metadata:
labels:
app: product
spec:
containers:
- name: product-container
image: hashicorp/http-echo:latest
args: ["-text=product-service v1"]
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: product-service
spec:
selector:
app: product
ports:
- port: 8080
targetPort: 5678
EOF
# 等Pod就绪
kubectl get pods
# 此时K8s里已经有2个product-service的Pod,Istio自动给每个Pod注入了一个Envoy sidecar容器
kubectl get pods --show-labels
# 输出示例:
# NAME READY STATUS RESTARTS AGE
# product-service-7b9f8d8f5f-abc123 2/2 Running 0 2m
# 注意READY是2/2,一个是业务容器,一个是Envoy sidecar
# 访问服务
kubectl exec -it deployment/product-service -- curl http://product-service:8080
# 输出:product-service v1
# 现在验证服务网格的能力:流量镜像
# 创建第二个版本的Deployment
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: product-service-v2
labels:
app: product-v2
spec:
replicas: 1
selector:
matchLabels:
app: product-v2
template:
metadata:
labels:
app: product-v2
spec:
containers:
- name: product-container-v2
image: hashicorp/http-echo:latest
args: ["-text=product-service v2 (new version)"]
ports:
- containerPort: 5678
EOF
等v2运行起来后,用Istio的VirtualService做一次流量镜像(镜像的意思是把线上流量复制一份到新版本,但不影响真实反馈):
# ~/envoy-lab/istio-vs.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: product-service-vs
spec:
hosts:
- product-service
http:
- route:
# 流量全部打到v1
- destination:
host: product-service
subset: v1
weight: 100
# 镜像到v2
mirror:
host: product-service-v2
mirrorPercentage:
value: 100.0
# 再加一个DestinationRule定义subset
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: product-service-dr
spec:
host: product-service
subsets:
- name: v1
labels:
app: product
- name: v2
labels:
app: product-v2
# 应用配置
kubectl apply -f ~/envoy-lab/istio-vs.yaml
# 访问10次,观察v2服务是否收到了镜像流量
for i in $(seq 1 10); do curl -s http://product-service:8080; done
# 查看v2的日志,确认收到了镜像流量
kubectl logs deployment/product-service-v2 -c product-container-v2 --tail=20
# 你会看到类似:
# product-service v2 (new version)
# product-service v2 (new version)
# ... 虽然用户请求的响应还是v1的内容
这就是服务网格做金丝雀发布的底层原理。不需要改任何业务代码,Envoy sidecar自动帮你完成了流量复制。
效果数据:Envoy vs Nginx的实测对比
以下是我们在同一环境(Docker Desktop 4.25, macOS 13.6 M2 Max, 单机)下跑出的实测数据,使用wrk压测30秒,固定并发200,线程4。Envoy版本1.28.0,Nginx版本1.25.3。后端是同一个Nginx Alpine容器,都返回相同大小的响应体(约220字节)。
| 指标 | Envoy(LEAST_REQUEST) | Nginx(轮询) | Envoy优势 |
|---|---|---|---|
| 吞吐量(正常时) | 15234 req/s | 12874 req/s | +18.3% |
| P99延迟(正常时) | 3.19ms | 4.21ms | 低1.02ms |
| 单后端故障时请求失败率 | 0% | 2.5%(502) | 零失败 |
| 配置热更新对连接影响 | 无影响(N/A) | 连接闪断 | 不中断 |
| 容器内存占用 | 52MB(Envoy主进程) | 8MB(Nginx master+worker) | Envoy更高 |
| 启动时间(冷启动) | 约300ms | 约15ms | 无需对比,不同定位 |
| 二进制体积(Docker镜像) | 78.8MB | 49.6MB(Alpine) | Envoy更大 |
一句话总结:论纯吞吐,Envoy和Nginx在同一水平线上,Envoy略高是因为LEAST_REQUEST比轮询在长连接场景下更优;但这些数据差异不是选Envoy的理由,Envoy的价值在故障处理、动态配置和可观察性上。
避坑指南
以下是在实践过程中亲自踩过的坑,每个都值得你用笔记下来。
坑1:Envoy的Docker端口映射不能只映射80
很多新手把Envoy的listener配置成80,然后docker run -p 80:80。结果访问localhost直接报404或者连不上。原因是:Envo的admin端口(9901)和listener端口在Docker里必须同时暴露。如果只映射了listener端口,admin的9901没有暴露,Envoy启动时不会报错,但压测时候会发现请求吞吐莫名低,因为某些配置错误会去读取admin接口导致卡顿。正确做法是至少在开发环境把9901也映射出来:
docker run -p 10000:10000 -p 9901:9901 -v $(pwd)/envoy.yaml:/etc/envoy/envoy.yaml:ro envoyproxy/envoy:v1.28.0
同时,Envoy的admin接口默认监听0.0.0.0:9901,生产环境务必关掉或者加访问控制,否则任何人都能通过admin接口看到你的完整配置和指标。
坑2:升级到1.28后,HTTP/2连接的坑
Envoy 1.24+ 对HTTP/2的h2c(明文HTTP/2)支持有一些调整。如果你直接用Envoy默认配置代理grpc请求,可能会遇到grpc请求一直报错「HTTP/2 protocol error: connection error」。
坑的原因:Envoy的HttpConnectionManager默认是AUTO(自动协商),但如果你在后端cluster里配置了 http2_protocol_options,前端listener的HTTP/2必须开启。如果同时启用了静态配置和动态配置,动态配置的RDS经常和静态配置的HttpConnectionManager冲突,导致h2c一直失败。
正确做法:给listener显式设置http2_protocol_options,并确保使用相同的HTTP/2协议版本:
# 在listener的filter_chains里增加:
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: HTTP2
# 其他配置...
坑3:健康检查的判定标准别只看HTTP状态码
默认的健康检查是HTTP 200。但有些后端服务在依赖数据库时,即使数据库挂了也返回200(因为Web容器还活着)。我们踩过一次:后端服务内存溢出频繁Full GC,但健康检查请求总是200,Envoy以为服务正常,持续转发流量。最后宿主机CPU飙满,才反应过来。
解决:配置健康检查的预期状态码,以及更关键的:超时阈值和异常阈值。
health_checks:
- timeout: 1s
interval: 2s
unhealthy_threshold: 2 # 2次失败就标记为不健康
healthy_threshold: 1 # 1次成功就恢复
http_health_check:
path: "/healthz"
expected_statuses:
- start: 200
end: 200
# 还支持自定义请求头,比如强制走鉴权:
坑4:Envoy对长连接的处理默认和Nginx不同
Nginx默认会缓存upstream的keepalive连接(默认是64个)。Envoy的连接池不是简单的缓存个数,而是按「目标集群 + 目标端点」维护多个连接池,每个连接池有最大连接数(默认是2^31-1,几乎无限)。所以如果后端服务是短生命周期(比如Java的Spring Boot默认空闲超时30秒会断开),Envoy的连接池里会有大量TIME_WAIT连接,导致后端频繁断开重连。
解决办法:把后端服务的空闲超时调长(比如Tomcat的keepAliveTimeout设成60s),并在Envoy的Cluster配置里加上 drain_connections_on_host_removal: true,避免连接池悬挂。
坑5:把Envoy当L4负载均衡时,记得加TCP keepalive
Envoy的TCP代理默认不会给后端连接加keepalive。如果后端是MySQL,而你的网络环境有中间防火墙(比如云服务商的安全组),连接空闲一段时间后会被防火墙静默断开。但MySQL客户端不知道,直到下次查询时报「Connection reset by peer」。
解决:给Listener的SocketOptions配置TCP keepalive:
listeners:
- name: mysql_listener
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 3306
socket_options:
- level: 6 # SOL_TCP
name: 9 # TCP_KEEPALIVE
int_value: 1
state: STATE_PREBIND
- level: 6
name: 0x10 # TCP_KEEPIDLE (Linux下为0x10)
int_value: 300 # 300秒空闲后开始探测
state: STATE_PREBIND
坑6:压测时务必用keepalive,否则你会得出错误结论
我用wrk做压测时,默认wrk是短连接(每个请求新建TCP)。用短连接测Envoy,结果只有不到2000 req/s,而Nginx是5000+ req/s。差点以为Envoy性能不行。后来才想到,Nginx在短连接上优化了10多年,Envoy的强项是连接池复用。加上 -H "Connection: keep-alive" 后,Envoy直接翻4倍。如果你压测Envoy,一定记得开keepalive,否则测出来的数据会误导你做出错误判断。
总结
最后给个结论性判断,大家可以对照自己场景选择:
纯网关/反代,没有动态服务发现需求:继续用Nginx。它更轻量,占用内存更小,配置模型简单直接,团队熟悉度最高,出问题更容易排查。
如果你在Kubernetes里跑微服务,而且数量超过10个:用Envoy + Istio。K8s原生Service只提供ClusterIP做四层负载均衡,远不如Envoy的七层流量管理灵活。Istio的VirtualService + DestinationRule能实现精细的流量切分、灰度发布、故障注入,这些能力用Nginx + Lua脚本去实现,开发量会很大,且难以维护。
如果你没有上K8s,但是微服务多、变更频繁:用Envoy + Consul或者Envoy + Nacos,Consul/Nacos做服务注册发现,Envoy通过EDS动态获取后端节点。不需要上完整Istio,但已经解决了最核心的动态配置问题。
Envoy不是Nginx的替代品,它是云原生时代更高级的流量管理基础设施。它的学习曲线更陡峭,配置更复杂,但换来的是一整套弹性和可观察性能力。从我的实践来看,在微服务架构里,Envoy多出来的那四十多MB内存和复杂性,换来的是没有一次因配置热更新导致的线上故障。这笔账,值。