标签 Envoy 下的文章

前言

在之前的文章中,我们花了大量的篇幅,从记录后端pod真实ip开始说起,然后引入envoy,再解决了各种各样的需求:配置自动重载、流量劫持、sidecar自动注入,到envoy的各种能力:熔断、流控、分流、透明代理、可观测性等等,已经可以支撑起一个完整的服务治理框架了

而今天介绍的istio,正是前面提到的这些所有功能的集大成者,从本文开始,我们将详细介绍istio,并且与之前手搓的功能做一个详细的对比,为大家以后选择服务治理的某个功能提供参考

istio架构

           ┌──────────────┐
           │   istiod     │   ← 控制面
           │ (Pilot+CA)   │
           └──────┬───────┘
                  │ xDS (gRPC / TLS)
                  │
┌────────────┐    │    ┌────────────┐
│  Envoy     │◄───┼───►│   Envoy    │  ← 数据面
│ (Sidecar)  │         │ (Sidecar)  │
└─────▲──────┘         └─────▲──────┘
      │ iptables             │
      │                      │
   App Pod                App Pod
  • 数据面就是之前一直在研究的envoy,包括4/7代理、熔断、限流、可观测性等等,envoy就是执行由控制面下发的配置
  • 控制面istiod主要的职责:将配置下发到每一个envoy去。由于istio中配置以crd的形式成为了k8s的资源,所以要不断的监听k8s apiserver,将资源的变化翻译成envoy看得懂的配置,并且下发到envoy去

至于其余istio的资源,我们后面详细介绍

istio安装

不说废话,先把istio安装上去再说

首先准备好k8s集群,其次下载istio(这一步有可能需要上网)

curl -L https://istio.io/downloadIstio | sh -
cd istio-*
sudo ln -s $PWD/istioctl /usr/local/bin/istioctl

验证兼容性

istioctl x precheck

开始安装

istioctl install --set profile=default -y

由于镜像仓库没法直接使用,所以需要一些特殊的方法,具体可以看这篇文章: 快速拉取docker镜像

需要的镜像有:

docker.io/istio/pilot:1.28.2
docker.io/istio/proxyv2:1.28.2

安装完成:

▶ kubectl -n istio-system get pod
NAME                                    READY   STATUS    RESTARTS   AGE
istio-ingressgateway-865c448856-qs8s2   1/1     Running   0          8s
istiod-86c75775bb-j7qbg                 1/1     Running   0          12s

安装完成,要从哪儿开始呢?

istio的自动注入

kubectl label namespace default istio-injection=enabled

同之前envoy一样,给namespace打上标签之后,重启服务即可

kubectl rollout restart deploy nginx-test

重启之后sidecar已经注入进去了,我们来观察一下istio注入到底做了什么事情

先describe看看events

Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  8s    default-scheduler  Successfully assigned default/nginx-test-6f855b9bb9-9phsv to wilson
  Normal  Pulled     8s    kubelet            Container image "docker.io/istio/proxyv2:1.28.2" already present on machine
  Normal  Created    8s    kubelet            Created container: istio-init
  Normal  Started    8s    kubelet            Started container istio-init
  Normal  Pulled     8s    kubelet            Container image "docker.io/istio/proxyv2:1.28.2" already present on machine
  Normal  Created    8s    kubelet            Created container: istio-proxy
  Normal  Started    8s    kubelet            Started container istio-proxy
  Normal  Pulled     6s    kubelet            Container image "registry.cn-beijing.aliyuncs.com/wilsonchai/nginx:latest" already present on machine
  Normal  Created    6s    kubelet            Created container: nginx-test
  Normal  Started    5s    kubelet            Started container nginx-test

1个initContainer,1个业务container和1个sidecar

其中initContainer:

Init Containers:
  istio-init:
    Container ID:  containerd://2bf56cd37703d82a2a43e94e8c8d683ed66b0afe22bf7148a597d67b89a727a8
    Image:         docker.io/istio/proxyv2:1.28.2
    Image ID:      docker.m.daocloud.io/istio/proxyv2@sha256:39065152d6bd3e7fbf6bb04be43c7a8bbd16b5c7181c84e3d78fa164a945ae7f
    Port:          <none>
    Host Port:     <none>
    Args:
      istio-iptables
      -p
      15001
      -z
      15006
      -u
      1337
      -m
      REDIRECT
      -i
      *
      -x

      -b
      *
      -d
      15090,15021,15020
      --log_output_level=default:info
...

和之前envoy中劫持流量的做法一样,istio依然是使用iptables将端口流量导入到代理之中处理

尝试访问一下:

▶ curl 10.22.12.178:30785/test
i am backend in backend-6d76f54494-g6srz

成功,再次查看istio-proxy日志。空的?为了调试方便,将其打开并且输出至控制台

kubectl -n istio-system edit cm istio

apiVersion: v1
data:
  mesh: |-
    accessLogFile: /dev/stdout
  ...

至此,istio的第一个功能探索完毕,自动注入sidecar container并且完成了流量劫持

Upgrade Required 426 的问题

当前的架构是左图,现在要前进到右图

watermarked-istio_1.png

其实就是在backend注入istio-proxy,直接重启就好

▶ kubectl get pod -owide
NAME                          READY   STATUS        RESTARTS   AGE     IP            NODE     NOMINATED NODE   READINESS GATES
backend-5d4d7b598c-f7852      2/2     Running       0          13s     10.244.0.49   wilson   <none>           <none>
nginx-test-6f855b9bb9-9phsv   2/2     Running       0          58m     10.244.0.48   wilson   <none>           <none>

注入完成,测试一下

▶ curl 10.22.12.178:30785/test
Upgrade Required
▶ kubectl logs -f -l app=nginx-test -c istio-proxy
[2026-01-26T07:54:42.977Z] "GET /test HTTP/1.1" 426 - upstream=10.244.0.48:80 duration=6ms route=default
[2026-01-26T07:54:42.978Z] "- - -" 0 - upstream=10.105.148.194:10000 duration=9ms route=-

在nginx注入istio-proxy,backend没有注入的时候并没有报错。而一旦nginx与backend都注入的时候就会出现Upgrade Required (426)错误,Nginx Sidecar 发现目标(Backend)是一个纯文本服务,它会回退到“透明代理”模式,简单地把 Nginx 发出的流量透传出去

Nginx Sidecar 发现目标也有 Sidecar,它会尝试建立一个高度优化的、基于 mTLS 的隧道(关于mTLS后面会详细介绍)。如果此时 Nginx 发出的请求头(比如缺少 Host 字段,或者使用了 HTTP/1.0)不符合 Envoy 对这种隧道
协议的预期,Envoy 可能会向 Nginx 发送一个特殊的响应,或者 Nginx 在尝试通过这种隧道通信时,因为某些 Header 冲突(如 Connection: close)自发产生了 426 错误

想要解决这个问题有两种方法

改造nginx中加入标记

        location /test {
            proxy_http_version 1.1; # 必须添加这一行
            proxy_set_header Host $host; # 这一行也是必须的
            proxy_pass http://backend_ups;
        }

Nginx 的 proxy_pass 默认使用 HTTP/1.0。在 Istio 环境中,HTTP/1.0 不支持长连接(Keep-Alive)以及一些现代的协议协商,这与 Istio Sidecar(Envoy)默认的 L7 代理行为冲突,Istio 需要 HTTP/1.1 来支持复杂连接管理问题

改造backend service

如果nginx改造有难度,那也可以尝试改造backend-service

apiVersion: v1
kind: Service
metadata:
  name: backend-service
  namespace: default
spec:
  ports:
  - name: tcp-80 # 原为 http-80 改为 tcp-80
    port: 10000
    protocol: TCP
    targetPort: 10000
  selector:
    app: backend

Istio 只有在识别到流量是 HTTP 时才会进行深度的协议检查和转换。如果你把这个服务声明为 TCP,Istio 就会将其视为原始字节流进行透传,不再关心它是 HTTP/1.0 还是 1.1。优点就是彻底解决 426 问题,无需改 Nginx。
缺点则是你会失去 Istio 针对该服务的 HTTP 监控指标(如请求数、4xx/5xx 统计)、分布式追踪以及基于路径的路由功能

http 1.0 与 http 1.1

这里再简单介绍一下两个协议版本的区别

  • 连接管理(最显著的区别)

    • HTTP 1.0:短连接 (Short-lived),默认情况下,客户端每发起一个请求,都要与服务器建立一次 TCP 三次握手。请求结束并收到响应后,TCP 连接立即关闭。如果页面有 10 张图片,浏览器就要建立 10 次 TCP 连接。这带来了极高的延迟和资源开销。
    • HTTP 1.1:持久连接 (Persistent Connection / Keep-Alive)。默认开启 Connection: keep-alive。一个 TCP 连接可以被多个请求复用。只有在明确声明 Connection: close 或连接超时后才会关闭。
    • 在 Istio 中: Envoy 极度依赖持久连接来维持高性能的 Sidecar 间隧道。HTTP 1.0 的频繁断开会让 Envoy 感到“压力山大”,甚至认为这是一种非标准的协议行为。
  • Host Header

    • HTTP 1.0:人们认为一个 IP 对应一个网站,所以请求头里不需要带域名信息。
    • HTTP 1.1:随着虚拟主机(一个 IP 跑多个网站)的流行,HTTP 1.1 规定请求头必须包含 Host 字段。
    • 在 K8s/Istio 中: Istio 的路由决策、Service 的匹配完全依赖 Host 头。这也是为什么 Nginx 使用 HTTP 1.0 转发时,如果不手动补全 Host 头,后端往往会返回 404 或协议错误。

以上是istio必须要求HTTP 1.1最主要的两个因素,当然还有其他非常重要的区别

特性HTTP 1.0HTTP 1.1
连接模型默认短连接,每次请求新开 TCP默认持久连接 (Keep-Alive),复用 TCP
Host 头部可选 (导致无法支持虚拟主机)必须 (支持一 IP 多域名)
流水线 (Pipelining)不支持支持 (但在实际应用中受限)
断点续传不支持支持 (通过 Range 头部)
缓存控制简单 (Expires)复杂且强大 (Cache-Control, ETag)
默认协议版本许多旧软件(如 Nginx proxy)的默认值现代 Web 应用的基石标准

小结

本章内容算是一个开胃小菜,成功安装了istio,并且解决了一个非常常见的426问题,至于怎么把之前在envoy的那些最佳实践搬迁到istio,那就是后面的内容了,敬请期待

后记

如果整个namespace都已经有了注入标签istio-injection=enabled,但是某个deployment不想让istio注入

kubectl patch deployment nginx -p '{"spec":{"template":{"metadata":{"annotations":{"sidecar.istio.io/inject":"false"}}}}}'

联系我

  • 联系我,做深入的交流


至此,本文结束
在下才疏学浅,有撒汤漏水的,请各位不吝赐教...

前言

本节详细聊一下基于envoy的可观测性

日志

首先是日志,配置日志的方式也很简单

static_resources:
  listeners:
    - name: ingress_listener
      address:
        socket_address:
          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
                ...
                access_log:
                - name: envoy.access_loggers.stdout
                  typed_config:
                    "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
                    log_format:
                      text_format: "[%START_TIME%] \"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%\" %RESPONSE_CODE% %BYTES_SENT% %DURATION% %REQ(X-REQUEST-ID)% \"%REQ(USER-AGENT)%\" \"%REQ(X-FORWARDED-FOR)%\" %UPSTREAM_HOST% %UPSTREAM_CLUSTER% %RESPONSE_FLAGS%\n"
  • 该配置是将日志输出在控制台,也可以直接输出为文件,然后通过工具采集走path: /var/log/envoy/access.log
  • 也可以直接将日志输出至kafka,并且按比例采集、只采集4xx、5xx等都可以配置,这里就不在赘述了

admin管理页面

envoy有默认的admin页面,方便查看统计信息、打开某些功能的开关等

admin:
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901

打开9901页面:

watermarked-envoy_ob_1.png

可以查看相关的统计信息、也可以打开某些开关,功能还是很丰富的

merics接入prometheus

打开了admin之后,就默认提供了相关的prometheus stats http://10.105.148.194:9901/stats/prometheus

这时只需在k8s集群外弄一个prometheus,并且采集该envoy即可

prometheus.yml

global:
  scrape_interval: 5s
  evaluation_interval: 5s

rule_files:
  - /etc/prometheus/*.rules

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
    - targets: ['localhost:9090']

  - job_name: "envoy"
    metrics_path: /stats/prometheus
    static_configs:
    - targets: ["10.105.148.194:9901"]
docker run -d --name prometheus \
  -p 9090:9090 \
  -v ./prometheus.yml:/etc/prometheus/prometheus.yml \
  -v /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime \
  registry.cn-beijing.aliyuncs.com/wilsonchai/prometheus:v3.5.0

traces接入jaeger

jaeger的安装可以参考这里: opentelemetry全链路初探--埋点与jaeger

jaeger启动之后,改造一下envoy的配置,这里要特别注意,不同版本的配置不一样,我这里envoy的版本是:v1.32

static_resources:
  listeners:
    - name: ingress_listener
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                ...

                tracing:

                  provider:
                    name: envoy.tracers.opentelemetry
                    typed_config:
                      "@type": type.googleapis.com/envoy.config.trace.v3.OpenTelemetryConfig
                      service_name: envoy-proxy
                      grpc_service:
                        envoy_grpc:
                          cluster_name: jaeger_otlp_collector
                ...

  clusters:
    ...
    - name: jaeger_otlp_collector
      type: LOGICAL_DNS
      connect_timeout: 5s
      lb_policy: ROUND_ROBIN
      http2_protocol_options: {}

      load_assignment:
        cluster_name: jaeger_otlp_collector
        endpoints:
        - lb_endpoints:
          - endpoint:
              address:
                socket_address:
                  address: 10.22.12.178
                  port_value: 4317
    ...

修改完成之后重启下envoy

jaeger成功接收到了来自envoy的trace

watermarked-envoy_ob_2.png
watermarked-envoy_ob_3.png

由于只在envoy配置了trace,没有和后端服务联动,所有只显示了envoy这一段的trace信息,如果要联动后端,可以参考这个系列的文章: 全链路监控配置

小结

至此,logs、metrics、traces三大可观测的指标建设完成,envoy可观测性的建设也结束了

联系我

  • 联系我,做深入的交流

 title=


至此,本文结束
在下才疏学浅,有撒汤漏水的,请各位不吝赐教...

今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

为什么要用ETCD

先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

另外,Easegress最核心的设计主要有三个:

  • 一是无第三方依赖的自己选主组集群的能力
  • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
  • 三是内置一个Data Store用于集群控制和数据共享。

对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

背景问题

好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

用户报告的问题是——

在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

// DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
// to catch-up after compacting the raft storage entries.
// We expect the follower has a millisecond level latency with the leader.
// The max throughput is around 10K. Keep a 5K entries is enough for helping
// follower to catch up.
DefaultSnapshotCatchUpEntries uint64 = 5000

另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

另外还有下面几项也会导致etcd的内存会增加

  1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
  2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
  3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

(很明显,etcd这么做就是为了一个高性能的考虑)

Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

总结

要用好 etcd,有如下的实践

  • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
  • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
  • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
  • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

最后,欢迎大家关注我们的开源软件! https://github.com/megaease/