回想起来,博主第一次接触到Envoy,其实是在微软的示例项目 eShopOnContainers,在这个示例项目中,微软通过它来为Ordering APICatalog APIBasket API 等多个服务提供网关的功能。当时,博主并没有对它做深入的探索。此刻再度回想起来,大概是因为那个时候更迷恋领域驱动设计(DDD)的理念。直到最近这段时间,博主需要在一个项目中用到Envoy,终于决定花点时间来学习一下相关内容。所以,接下来这几篇博客,大体上会以记录我学习Envoy的历程为主。考虑到Envoy的配置项特别多,在写作过程中难免会出现纰漏,希望大家谅解。如对具体的配置项存在疑问,请以官方最新的 文档 为准,本文所用的示例代码已经上传至 Github,大家作为参考即可。对于今天这篇博客,我们来聊聊 ASP.NET Core 搭载 Envoy 实现微服务的反向代理 这个话题,或许你曾经接触过 Nginx 或者 Ocelot,这次我们不妨来尝试一点新的东西,譬如,通过Docker-Compose来实现服务编排,如果对我说的这些东西感兴趣的话,请跟随我的脚步,一起来探索这广阔无垠的技术世界吧!

走近 Envoy

Envoy 官网对Envoy的定义是:

Envoy 是一个开源边缘和服务代理,专为原生云应用设计。

而更进一步的定义是:

Envoy 是专为大型现代服务导向架构设计的 L7 代理和通讯总线。

这两个定义依然让你感到云里雾里?没关系,请看下面这张图:

Envoy架构图
Envoy架构图

注:图片来源

相信从这张图中,大家多少能看到反向代理的身影,即下游客户端发起请求,Envoy对请求进行侦听(Listeners),并按照路由转发请求到指定的集群(Clusters)。接下来,每一个集群可以配置多个终结点,Envoy按照指定的负载均衡算法来筛选终结点,而这些终结点则指向了具体的上游服务。例如,我们熟悉的 Nginx ,使用listen关键字来指定侦听的端口,使用location关键字来指定路由,使用proxy_pass关键字来指定上游服务的地址。同样地,Ocelot 使用了类似的上下游(Upstream/Downstream)的概念,唯一的不同是,它的上下游的概念与这里是完全相反的。

你可能会说,这个Envoy看起来“平平无奇”嘛,简直就像是“平平无奇”的古天乐一般。事实上,Envoy强大的地方在于:

  • 非侵入式的架构: 独立进程、对应用透明的Sidecar模式
Envoy 的 Sidecar 模式
Envoy 的 Sidecar 模式
  • L3/L4/L7 架构:Envoy同时支持 OSI 七层模型中的第三层(网络层, IP 协议)、第四层(传输层,TCP / UDP 协议)、第七层(应用层,HTTP 协议)
  • 顶级 HTTP/2 支持: 视 HTTP/2 为一等公民,且可以在 HTTP/2HTTP/1.1间相互转换
  • gRPC 支持:Envoy 支持 HTTP/2,自然支持使用 HTTP/2 作为底层多路复用协议的 gRPC
  • 服务发现和动态配置:与 Nginx 等代理的热加载不同,Envoy 可以通过 API 接口动态更新配置,无需重启代理。
  • 特殊协议支持:Envoy 支持对特殊协议在 L7 进行嗅探和统计,包括:MongoDBDynamoDB 等。
  • 可观测性:Envoy 内置 stats 模块,可以集成诸如 prometheus/statsd 等监控方案。还可以集成分布式追踪系统,对请求进行追踪。

Envoy配置文件

Envoy通过配置文件来实现各种各样的功能,其完整的配置结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"node": "{...}",
"static_resources": "{...}",
"dynamic_resources": "{...}",
"cluster_manager": "{...}",
"hds_config": "{...}",
"flags_path": "...",
"stats_sinks": [],
"stats_config": "{...}",
"stats_flush_interval": "{...}",
"watchdog": "{...}",
"tracing": "{...}",
"runtime": "{...}",
"layered_runtime": "{...}",
"admin": "{...}",
"overload_manager": "{...}",
"enable_dispatcher_stats": "...",
"header_prefix": "...",
"stats_server_version_override": "{...}",
"use_tcp_for_dns_lookups": "..."
}

这里我们主要对侦听器(Listeners)、集群(Clusters) 和 管理(Admin)这三个常用的部分来进行说明。其中,(Listeners)、集群(Clusters) 均位于 static_resources 节点下,而 管理(Admin) 则有一个单独的admin节点。

侦听器(Listeners)

侦听器,顾名思义就是侦听一个或者多个端口:

1
2
3
4
5
6
static_resources:
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 9090

在这里,它表示的是侦听9090这个端口,这里的listeners是一个数组,所以,你可以同时侦听多个端口。

过滤器(Filters)

我们知道,单单侦听一个或者多个端口,是无法完成一个HTTP请求的,因为它还不具备处理HTTP请求的能力。

Envoy Filters 架构图
Envoy Filters 架构图

Envoy 中,这一工作由一个或者多个过滤器组成的过滤器链(Filter Chains) 来完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static_resources:
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 9090
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
codec_type: AUTO
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains:
- "*"
routes:
- match:
prefix: "/api/w"
route:
auto_host_rewrite: true
prefix_rewrite: /Weather
cluster: weatherservice
- match:
prefix: "/api/c"
route:
auto_host_rewrite: true
prefix_rewrite: /City
cluster: cityservice
http_filters:
- name: envoy.filters.http.router

在这段配置中,Http Connection Manager表示的是位于 L3(网络层)/L4(传输层) 的过滤器,这个过滤器连接的下一个过滤器是envoy.filters.http.router,表示的是 L7(应用层) 的关于路由的过滤器。个人感觉,这和我们通常所说的中间件相当接近。注意到,我们在 L3/L4 这个层级上做了什么事情呢?其实应该是 TCP/IP 层面上请求转发,这里定义的路由规则如下:

  • 当外部调用者访问/api/w时,请求会被转发到WeatherService
  • 当外部调用者访问/api/c时,请求会被转发到CityService

集群(Clusters)

在过滤器部分,我们定义了路由,那么,请求的最终去向是哪里呢?这里 Envoy 将其称之为 集群::

1
2
3
4
5
6
7
static_resources:
clusters:
# City Service
- name: cityservice
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: ROUND_ROBIN

集群本身,其实只是一个名字,并没有实际的意义,真正工作的其实是指向上游服务的终结点,我们可以为每一个集群指定一个负载均衡策略,让它决定选择哪一个终结点来提供服务。

负载均衡(Load Assignment)

目前,Envoy支持以下负载均衡算法:

  • ROUND_ROBIN:轮询
  • LEAST_REQUEST:最少请求
  • RING_HASH:哈希环
  • RANDOM:随机
  • MAGLEV:磁悬浮
  • CLUSTER_PROVIDED

下面的示例展示了如何为某个集群配置负载均衡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static_resources:
clusters:
# Weather Service
- name: weatherservice
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: LEAST_REQUEST
load_assignment:
cluster_name: weatherservice
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: weatherservice1
port_value: 80
- endpoint:
address:
socket_address:
address: weatherservice2
port_value: 80

其中,weatherservice1weatherservice2是同一个服务的两个容器实例,当我们使用Docker-Compose进行构建的时候,不需要显式地去指定每一个服务的IP地址,只要对应docker-compose.yaml文件中的服务名称即可。

管理(Admin)

管理(Admin)这块儿相对简单一点,主要用来指定Envoy的的管理接口的端口号、访问日志存储路径等等:

1
2
3
4
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9091 }

服务编排

好了,在正确配置Envoy以后,我们来考虑如何对这些服务进行编排,在本文的 例子 中,我们有两个后端服务,WeatherServiceCityService,它们本质上是两个ASP.NET Core应用,我们希望通过Envoy来实现反向代理功能。这样做的好处是,后端服务的架构不会直接暴露给外部使用者。所以,你会注意到,在微服务架构的设计中,网关经常扮演着重要的角色。那么,如何对服务进行编排呢?这里我们使用Docker-Compose来完成这个工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: '3'
services:
envoygateway:
build: Envoy/
ports:
- "9090:9090"
- "9091:9091"
volumes:
- ./Envoy/envoy.yaml:/etc/envoy/envoy.yaml
cityservice:
build: CityService/
ports:
- "8081:80"
environment:
ASPNETCORE_URLS: "http://+"
ASPNETCORE_ENVIRONMENT: "Development"
weatherservice:
build: WeatherService/
ports:
- "8082:80"
environment:
ASPNETCORE_URLS: "http://+"
ASPNETCORE_ENVIRONMENT: "Development"

可以注意到,这里需要部署3个服务,其中,Envoy负责监听9090端口,即对外的网关。而两个后端服务,WeatherServiceCityService则被分别部署在80828081端口。这里最重要的是envoy.yaml这个配置文件,我们在上一节编写的配置文件会挂在到容器目录:/etc/envoy/envoy.yamlEnvoy将如何使用这个配置文件呢?事实上,这个Dockerfile是这样编写的:

1
2
3
4
FROM envoyproxy/envoy-alpine:v1.16-latest
COPY ./envoy.yaml /etc/envoy.yaml
RUN chmod go+r /etc/envoy.yaml
CMD ["/usr/local/bin/envoy", "-c", "/etc/envoy.yaml", "--service-cluster", "reverse-proxy"]

除此之外,Envoy通过9091端口提供管理功能,我们可以通过这个端口来获得集群、请求、统计等信息,这一特性我们将会在接下来的文章里用到:

Envoy 管理界面功能一览
Envoy 管理界面功能一览

这里,想分享一个关于Envoy的小技巧,当我们在指定集群的地址时,可以使用docker-compose.yaml中定义的服务的名称,这会比填入一个固定的IP地址要更优雅一点,理由非常简单,我们不希望每次都来维护这个地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Weather Service
- name: weatherservice
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: weatherservice
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
# 建议使用 docker-compose.yaml 文件中对应的服务名称来代替IP地址
address: weatherservice
port_value: 80

接下来,如果每一个服务的Dockerfile都编写正确的话,我们就可以通过docker compose up命令启动这一组服务,通过命令行下打印出来的信息,我们可以确认,基于Envoy的网关服务、两个基于ASP.NET Core的后端服务都已经成功运行起来:

在 Docker-Compose 中成功启动服务
在 Docker-Compose 中成功启动服务

还记得我们的路由规则是是如何定义的吗?

  • 当外部调用者访问/api/w时,请求会被转发到WeatherService
  • 当外部调用者访问/api/c时,请求会被转发到CityService

实际的情况如何呢?我们不妨来验证一下:

通过 Envoy 调用 WeatcherService
通过 Envoy 调用 WeatcherService

可以注意到,不管是浏览器返回的结果,还是容器内部输出的日志,都表明请求确实被转发到对应的服务上面去了,这说明我们设计的网关已经生效。至此,我们实现了基于Envoy的反向代理功能,有没有觉得比Nginx要简单一点?重要的是,基于Docker-Compose的服务编排使用起来真的舒适度爆棚,这意味着你会有更多的属于程序员的贤者时间。前段时间热映的电视剧《觉醒时代》,鲁迅先生写完《狂人日记》后那一滴眼泪令人动容。也许,这种如匠人一般反复雕琢、臻于完美的心境是互通的,即使人类的悲欢并不相通,对美和极致的追求竟然出奇的相似。

本文小结

本文主要介绍了 ASP.NET Core 搭载 Envoy 实现反向代理这一过程。对于 Envoy,有两个重要的定义:第一、Envoy 是一个开源边缘和服务代理,专为原生云应用设计。第二、Envoy 是专为大型现代服务导向架构设计的 L7 代理和通讯总线。相比 NginxOcelot ,Envoy 提供了 L7 级别的代理服务,支持 HTTP/2gRPC,无侵入式的Sidecar模式可以提供与应用进程完全隔离的代理服务。接下来,博主对 Envoy 配置文件的结构及主要的配置项进行了说明,对于常见的 API 网关,我们应该重点关注侦听器(Listeners) 和 集群(Clusters)。最终,我们结合Docker-Compose对服务进行了编排,并由此构建出了一个基本的反向代理的方案。本文的源代码已托管至 Github ,大家可以在此基础上做进一步的探索。好了,以上就是这篇博客的的全部内容啦,欢迎大家就本文中提出的方案、代码等进行讨论,如果大家有任何意见或者建议,欢迎在评论区进行留言,谢谢大家!

参考文档