本文详细解析了 Envoy 官方示例 jaeger-tracing
的构建流程、相关文件及其功能,旨在补充 Envoy 官方文档中对 Jaeger tracing 说明的不足之处。该示例将服务与 Envoy 部署在同一容器中,由 Envoy 负责服务发现、追踪和网络逻辑,初步展现了服务网格的架构理念。这种设计不仅有助于理解分布式追踪的实现,还为深入学习如 Istio 等服务网格技术奠定了基础。
1. 架构概览
此示例通过 docker-compose
启动了一个包含多个容器的微服务环境。其架构并非传统的 Sidecar 模式,而是采用一个入口代理结合内部路由代理的方式。
核心架构如下:
- 一个入口 Envoy 代理: 名为
front-envoy
,是所有外部请求的统一入口,也是追踪的发起者。 - 两个后端服务:
service1
和service2
。这两个服务容器内部都运行着一个 Envoy 代理,但它们的作用是进行服务间的路由并传播追踪上下文,而不是生成追踪数据。 - 一个 Jaeger All-in-One 实例: 用于接收、存储和可视化由
front-envoy
发送过来的追踪数据。
请求与追踪流程:
一个外部请求首先到达 front-envoy
。front-envoy
在此处发起追踪,生成 Span,并添加追踪头,然后将请求路由到 service1
。service1
内部的 Envoy 在收到请求后,会传播追踪头,并将请求路由到 service2
。最终,所有 Span 数据由 front-envoy
统一发送给 Jaeger。
2. 文件作用详解
下面我们来逐一分析每个文件的作用。
docker-compose.yaml
这是整个示例的“总指挥”,定义了四个核心服务:jaeger
, service1
, service2
, 和 front-envoy
。
front-envoy
: 使用envoy.yaml
作为配置,是所有流量的入口代理,并暴露10000
端口。service1
&service2
: 后端服务。它们各自将service1-envoy-jaeger.yaml
和service2-envoy-jaeger.yaml
挂载到容器内,供其内部的 Envoy 进程使用。jaeger
: Jaeger 服务,用于接收和展示追踪数据。
envoy.yaml
(front-envoy 的配置)
这是入口代理 front-envoy
的核心配置文件。
- 关键配置:
tracing
: 这是启用和发起追踪的关键部分。provider
: 指定了追踪服务提供商为zipkin
(兼容 Jaeger)。collector_cluster
:jaeger
,指明了将追踪数据发送到jaeger
服务。
route_config
: 定义了路由规则,将所有收到的请求 (/
) 都路由到service1
集群。clusters
: 定义了上游集群,包括service1
和jaeger
。
service1-envoy-jaeger.yaml
(service1 内部 Envoy 的配置)
此配置文件由 service1
容器内部的 Envoy 加载。
- 作用: 纯粹的路由功能。
- 关键配置:
- 它没有
tracing
配置块,因此它不会发起追踪,只会传播上游 (front-envoy
) 传来的追踪头。 route_config
: 将所有收到的请求路由到service2
集群。
- 它没有
service2-envoy-jaeger.yaml
(service2 内部 Envoy 的配置)
与 service1
的配置类似,由 service2
容器内部的 Envoy 加载。
- 作用: 请求的终点路由。
- 关键配置:
- 同样没有
tracing
配置块。 route_config
: 将请求路由到service_log
(在本示例中可视为终点)。
- 同样没有
verify.sh
用于验证示例是否正常工作的自动化脚本。
- 执行流程:
curl localhost:10000/
: 向front-envoy
发送一个请求。- 等待几秒,让
front-envoy
将追踪数据异步发送到 Jaeger。 - 向 Jaeger API (
http://localhost:16686/api/traces?service=front-proxy
) 查询由front-proxy
服务(front-envoy
在envoy.yaml
中定义的集群名)生成的追踪数据,并验证其完整性。
3. 构建与运行流程
启动所有服务:
docker-compose up --build -d
发送测试请求:
./verify.sh
在 Jaeger UI 中查看追踪:
- 在浏览器中打开 Jaeger UI: http://localhost:16686
- 在左上角的 “Service” 下拉菜单中选择
front-proxy
。 - 点击 “Find Traces” 按钮。
- 点击找到的追踪记录,你将看到一个包含多个 Span 的完整调用链,清晰地展示了从
front-envoy
到service1
再到service2
的完整流程。
4. 容器内部进程分析
下面我们来逐一分析每个容器内部运行的核心进程。
jaeger-tracing-front-envoy-1
- 容器角色: 入口 Envoy 代理。
- 运行进程:
envoy
: 这是该容器的唯一核心进程。docker-entrypoint
脚本启动了 Envoy 代理。它会加载envoy.yaml
配置文件,监听10000
端口,处理所有传入的变量,并负责发起和上报追踪数据。
jaeger-tracing-service1-1
- 容器角色: 后端应用服务
service1
。 - 运行进程:
envoy
(后台进程): 容器内的启动脚本 (/usr/local/bin/start_service.sh
) 首先会在后台启动一个 Envoy 代理进程。这个 Envoy 进程加载的是service1-envoy-jaeger.yaml
配置,负责将流入的请求路由到service2
。python
(前台进程): 脚本接着会启动一个 Python 应用服务器(基于aiohttp
),这个 Python 进程是service1
的业务逻辑本身,它会监听一个内部端口(如8000
),等待并处理由其内部 Envoy 代理转发过来的请求。
jaeger-tracing-service2-1
- 容器角色: 后端应用服务
service2
。 - 运行进程:
envoy
(后台进程): 与service1
完全相同,容器内的启动脚本会先在后台启动一个 Envoy 代理。这个 Envoy 进程加载的是service2-envoy-jaeger.yaml
配置。python
(前台进程): 同样,脚本会启动一个aiohttp
Python 应用服务器作为service2
的业务逻辑。
jaeger-tracing-jaeger-1
- 容器角色: Jaeger 分布式追踪系统。
- 运行进程:
/go/bin/all-in-one
: 这是 Jaeger 官方提供的“一体化”可执行文件。这个单一进程包含了 Jaeger 的所有核心组件,方便在开发和测试环境中快速部署:- Jaeger Agent: 监听并接收 Span 数据(例如,通过 Zipkin 协议的
9411
端口)。 - Jaeger Collector: 从 Agent 接收数据,进行验证、处理和存储。
- Jaeger Query: 提供 API 接口用于查询和检索追踪数据。
- Jaeger UI: 提供一个 Web 界面(在
16686
端口),用于可视化展示追踪数据。
- Jaeger Agent: 监听并接收 Span 数据(例如,通过 Zipkin 协议的
5. 深入探讨:为什么 Service 容器内需要 Envoy?
这是一个非常核心的问题,触及了“服务网格”(Service Mesh)架构的精髓。简单来说,在 service
容器内运行 Envoy 进程是为了将复杂的网络通信逻辑从应用代码中剥离出来。
尽管在这个示例中,front-envoy
处理了追踪的“发起”,但 service1
和 service2
内部的 Envoy 进程仍然扮演着至关重要的角色:
1. 服务发现 (Service Discovery)
- 问题:
service1
的 Python 代码如何知道service2
在哪里?在动态的容器环境中,IP 地址是会变化的,硬编码地址是不行的。 - 解决方案:
service1
的 Python 代码被配置为将所有出站请求都发送给它自己容器内的 Envoy 代理(通常是发往localhost
)。然后,这个内部的 Envoy 代理根据它的配置文件 (service1-envoy-jaeger.yaml
),知道service2
这个服务的逻辑名称,通过 Docker 的内部 DNS 将其解析到正确的容器地址。 - 好处: 应用代码变得极其简单,它不需要关心网络拓扑,只需要知道下一个服务的逻辑名称即可。
2. 追踪上下文的传播 (Trace Context Propagation)
- 问题:
front-envoy
发起了追踪并生成了追踪头(如x-b3-traceid
)。如果service1
直接调用service2
,谁来保证这些追踪头能被正确地传递下去? - 解决方案:
service1
内部的 Envoy 代理在接收到请求时,会自动识别这些追踪头,并在将请求转发给service2
时,确保这些头信息被完整地包含在内。 - 好处: 这保证了分布式追踪的链条不会断裂。开发者无需在 Python 代码中编写任何用于提取和重新注入追踪头的逻辑。Envoy 透明地处理了这一切。
3. 统一的网络控制层 (Unified Network Control)
- 问题: 如果你想为服务间的调用添加更复杂的网络策略,比如重试、超时、熔断、流量加密 (mTLS) 等,应该在哪里实现?
- 解决方案: 这一切都可以在 Envoy 的配置文件中声明式地完成。你不需要在 Python、Java、Go 等每一种语言的服务中都重复实现一遍这些复杂的逻辑。
- 好处:
- 语言无关: 无论你的服务是用什么语言写的,网络行为都由 Envoy 统一控制。
- 简化应用: 应用开发者可以专注于业务逻辑,而不是处理复杂的网络故障场景。
- 集中管理: 运维人员可以通过修改 Envoy 配置来调整整个系统的网络策略,而无需改动或重新部署任何应用代码。
总结
在这个示例中,service
容器内的 Envoy 进程虽然看起来只是做了一个简单的路由,但它实际上是构建了一个微型但功能完备的“服务网格”的基石。它充当了一个智能的、可配置的本地网络代理,将服务本身(Python 应用)与服务之间的复杂通信(网络)彻底解耦。
6. Dockerfile 与 Envoy 配置深度解析
Dockerfile 解析 (基于通用实践)
此示例中的 Dockerfile
位于 ../shared/
目录,体现了镜像复用的思想。
../shared/envoy/Dockerfile
(用于 front-envoy
)
这个 Dockerfile
的作用是构建一个纯粹的 Envoy 代理镜像。
# 使用官方的 Envoy 镜像作为基础
FROM envoyproxy/envoy:v1.23-latest
# (v1.23-latest 是一个示例版本号)
# 将 docker-entrypoint.sh 脚本复制到容器中并赋予执行权限
COPY docker-entrypoint.sh /
RUN chmod +x /docker-entrypoint.sh
# 设置容器启动时执行的命令
ENTRYPOINT ["/docker-entrypoint.sh"]
# 默认启动命令,会被 docker-compose.yaml 中的 command 覆盖
CMD ["/usr/local/bin/envoy", "-c", "/etc/envoy/envoy.yaml"]
- 核心: 它基于官方的
envoyproxy/envoy
镜像,这个镜像已经包含了编译好的 Envoy 二进制文件。 ENTRYPOINT
: 使用一个自定义的docker-entrypoint.sh
脚本作为入口,这通常是为了在启动 Envoy 之前执行一些预处理任务(比如等待其他服务就绪)。
../shared/python/Dockerfile
(用于 service1
和 service2
)
这个 Dockerfile
更复杂,它需要将 Python 应用和 Envoy 代理打包到同一个镜像中。
# 使用一个包含 Python 环境的基础镜像
FROM python:3.9-slim
# 安装 Envoy
# (这里会包含从网络下载并安装 Envoy 二进制文件的步骤)
# 例如:
# RUN apt-get update && apt-get install -y curl
# RUN curl -L https://getenvoy.io/cli | bash -s -- -b /usr/local/bin
# RUN getenvoy fetch envoy:v1.23-latest --path /usr/local/bin/envoy
# 安装 Python 依赖
COPY requirements.txt .
RUN pip install -r requirements.txt
# 复制应用代码和启动脚本
COPY app.py /
COPY start_service.sh /
RUN chmod +x /start_service.sh
# 设置容器启动时执行的命令
CMD ["/start_service.sh"]
- 多阶段构建: 它首先确保了 Python 环境,然后安装了 Envoy。
- 打包: 将 Python 应用 (
app.py
)、依赖 (requirements.txt
) 和启动脚本 (start_service.sh
) 全部复制到镜像中。 CMD
: 容器启动时执行start_service.sh
脚本,该脚本会负责先启动后台的envoy
进程,再启动前台的python
应用进程。
Envoy 配置关键字段解析
envoy.yaml
(front-envoy)
# ...
tracing:
provider:
name: envoy.tracers.zipkin
typed_config:
"@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig
collector_cluster: jaeger
collector_endpoint: "/api/v2/spans"
shared_span_context: false
collector_endpoint_version: HTTP_JSON
# ...
clusters:
- name: service1
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: service1
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: service1
port_value: 8000
tracing
:provider
: 定义了追踪器。envoy.tracers.zipkin
是 Envoy 内置的与 Zipkin 协议兼容的追踪器,Jaeger 正好支持该协议。collector_cluster
: 关键连接。它告诉 Envoy 应该将追踪数据发送到名为jaeger
的上游集群。collector_endpoint
: 指定了 Jaeger Collector 接收数据的具体 API 路径。
clusters
:type: STRICT_DNS
: 表示 Envoy 会使用标准的 DNS 查询来发现这个集群中的后端地址。在 Docker Compose 环境中,服务名(如service1
)会自动注册到 Docker 的内部 DNS 中。address: service1
: Envoy 会去 DNS 查询名为service1
的主机,Docker DNS 会将其解析为jaeger-tracing-service1-1
容器的 IP 地址。
service1-envoy-jaeger.yaml
# ...
route_config:
name: local_route
virtual_hosts:
- name: backend
domains:
- "*"
routes:
- match:
prefix: "/"
route:
cluster: service2
decorator:
operation: checkStock
decorator
:operation: checkStock
: 这是一个非常重要的字段。它为这个路由操作(即调用service2
)赋予了一个业务名称checkStock
。当你在 Jaeger UI 中查看追踪数据时,代表这个步骤的 Span 将会以checkStock
命名,而不是一个模糊的 HTTP URL。这极大地提升了追踪数据的可读性。
7. 端到端追踪流程详解:一个请求的完整生命周期
为了完全理解其工作原理,让我们跟随一个外部请求,看看追踪信息是如何诞生、传递并最终被展示的。
第 1 步:追踪的诞生 (Initiation)
- 外部请求: 客户端(例如
curl
)向http://localhost:10000/
发送一个普通的 HTTP 请求。这个请求不包含任何追踪信息。 front-envoy
的决策: 请求到达front-envoy
。由于其envoy.yaml
中配置了tracing
,Envoy 会检查请求头。- 生成追踪上下文:
front-envoy
发现没有传入的追踪头(如x-b3-traceid
),它判断这是一个新请求的开始。于是,它会:- 生成一个全局唯一的 Trace ID。这个 ID 将贯穿整个请求链路。
- 为自己即将执行的第一个操作(将请求路由到
service1
)生成一个 Span ID。 - 将这些 ID 以及其他信息(统称为“追踪上下文”)打包成标准的 HTTP 头(如
x-b3-traceid
,x-b3-spanid
,x-b3-sampled
等)。
- 注入追踪头:
front-envoy
在将请求转发给service1
之前,会将这些新生成的追踪头注入到请求中。
至此,一个分布式追踪正式诞生。
第 2 步:上下文的传播 (Propagation)
- 到达
service1
: 带有追踪头的请求到达service1
容器,并被其内部的envoy
进程接收。 - 透明代理:
service1
的envoy
检查请求头,发现了追踪上下文。由于它自己的配置 (service1-envoy-jaeger.yaml
) 中没有tracing
块,它不会尝试发起新的追踪或修改追踪 ID。它的职责是传播。 - 转发给应用:
service1
的envoy
将请求(连同所有追踪头)原封不动地转发给在同一容器内监听的python
应用。 - 应用内调用:
service1
的python
应用处理完业务逻辑后,需要调用service2
。它将请求发往自己本地的envoy
。 - 再次传播:
service1
的envoy
再次接收到这个出站请求,并将追踪头继续添加到发往service2
的请求中。这个过程对 Python 应用是完全透明的。 - 到达
service2
: 请求到达service2
,其内部的envoy
同样地、透明地传播追踪头给python
应用。
第 3 步:数据的收集与聚合 (Collection & Aggregation)
- Span 的生成: 在整个请求 - 响应链路中,只有
front-envoy
是活跃的追踪数据生成者。它会在以下几个关键节点生成 Span:- 入口 Span: 接收到外部请求,准备发往
service1
时。 - 出口 Span: 当
service1
调用service2
的请求流经它时。 - 以及所有响应返回时,它会记录每个环节的耗时、HTTP 状态码等信息,并完成对应的 Span。
- 入口 Span: 接收到外部请求,准备发往
- 异步上报:
front-envoy
不会阻塞请求去上报追踪数据。它会将这些完成的 Span 放入一个缓冲区,然后通过一个独立的后台线程,异步地将它们发送到jaeger
集群(由collector_cluster: jaeger
定义)。 - Jaeger Collector: Jaeger 的 Collector 组件在
9411
端口接收这些符合 Zipkin 格式的 Span 数据。 - 聚合: Collector 会根据所有 Span 中相同的 Trace ID 将它们归属到同一次请求链路中。然后,它利用每个 Span 的
Span ID
和Parent Span ID
字段,像拼图一样将它们组织成一个有父子关系的树状结构(Trace)。
第 4 步:可视化展示 (Visualization)
- 查询: 当你在浏览器中打开 Jaeger UI 并查询
front-proxy
服务时,Jaeger Query 组件会从存储中检索出完整的 Trace 数据。 - 渲染: Jaeger UI 将这个树状结构的 Trace 数据渲染成一个直观的火焰图(Flame Graph)或甘特图。
- 你可以看到根 Span 是
front-envoy
的入口操作。 - 它的子 Span 是对
service1
的调用。 service1
的子 Span 是对service2
的调用。- 每个 Span 的长度代表了其耗时,可以轻松发现链路中的性能瓶颈。
- 点击每个 Span,可以看到由 Envoy 附加的详细标签(Tags),如
http.method
,http.status_code
, 以及由decorator.operation
定义的业务操作名checkStock
。
- 你可以看到根 Span 是
通过这个完整的生命周期,Envoy 和 Jaeger 协作,提供了一个对应用代码几乎无侵入的、强大而清晰的分布式追踪解决方案。
8. 总结
本文通过详细解析 Envoy 官方的 Jaeger Tracing 示例,深入探讨了分布式追踪的实现原理和架构设计。我们从架构概览开始,了解了如何通过 docker-compose
启动一个包含多个容器的微服务环境,并通过 Envoy 代理实现追踪上下文的传播。接着,我们分析了各个配置文件的功能,特别是如何通过 Envoy 的路由和追踪配置来实现服务间的追踪信息传递。最后,我们详细描述了一个请求的完整生命周期,从追踪的诞生、上下文的传播,到数据的收集与聚合,直至可视化展示,全面展示了 Envoy 和 Jaeger 在分布式追踪中的强大协作能力。
这种设计不仅有助于理解分布式追踪的实现,还为深入学习如 Istio 等服务网格技术奠定了基础。通过这个示例,读者可以更好地掌握服务网格中的追踪上下文传播与数据可视化,为实际应用中的可观测性打下坚实的基础。
如果你对分布式追踪、服务网格或 Envoy 有任何疑问,欢迎在评论区留言讨论。希望本文能为你的学习和实践提供帮助!