Envoy 实践
Email:gaulzhw@gmail.com
本文介绍Envoy的一些基本概念以及实践操作,以期通过本文的介绍让读者可以了解到Envoy的原理,帮助读者理解Istio的Data Panel层实现。
写作本文的初衷是源于近期项目中需要做的微服务平台,平台需要针对微服务做控制。在技术选型的过程中比较了Envoy、Istio的实现,最终决定以Envoy来完成特定的业务需求。在使用Envoy的过程中,由于文档资料较少,实践中遇到了一些困难,故将实践中的一些理解和过程记录下来,方便大家查阅,减少弯路。
1. Service Mesh
关于Service Mesh不是本篇文章的重点,但是理解Service Mesh的概念、优势、发展,对理解本文有很大的帮助,此处罗列下Service Mesh的几篇文章,希望读者花一些时间先阅读一下文章的内容,对Service Mesh有个了解和认识。
虽然目前的Service Mesh已经进入了以Istio、Conduit为代表的第二代,由Data Panel、Control Panel两部分组成。但是以Istio为例,它也没有自己去实现Data Panel,而是在现有的Data Panel实现上做了Control Panel来达成目标。
所以说要掌握Istio,或者说要理解Service Mesh,首先需要掌握Data Panel的实现,而Envoy就是其中的一种实现方案。关于Envoy是什么,可以做什么,有什么优点,可以到Envoy的官网上查看详细信息,本文注重于Envoy的一些实践操作,重点关心怎么利用Envoy实现一些需求。
2. Envoy术语
要深入理解Envoy,首先需要先了解一下Envoy中的一些术语。
-
Host:能够进行网络通信的实体(如服务器上的应用程序)。
-
Downstream:下游主机连接到Envoy,发送请求并接收响应。
-
Upstream:上游主机接收来自Envoy连接和请求并返回响应。
-
Listener:可以被下游客户端连接的命名网络(如端口、unix套接字)。
-
Cluster:Envoy连接到的一组逻辑上相似的上游主机。
-
Mesh:以提供一致的网络拓扑的一组主机。
-
Runtime configuration:与Envoy一起部署的外置实时配置系统。
3. Envoy的启动
官方提供了Envoy的Docker镜像,本文中使用的镜像名是envoyproxy/envoy-alpine。
镜像中已经将Envoy安装到/usr/local/bin目录下,可以先看看envoy进程的help信息。
# /usr/local/bin/envoy --help
USAGE:
/usr/local/bin/envoy [--disable-hot-restart] [--max-obj-name-len
<uint64_t>] [--max-stats <uint64_t>] [--mode
<string>] [--parent-shutdown-time-s <uint32_t>]
[--drain-time-s <uint32_t>]
[--file-flush-interval-msec <uint32_t>]
[--service-zone <string>] [--service-node
<string>] [--service-cluster <string>]
[--hot-restart-version] [--restart-epoch
<uint32_t>] [--log-path <string>] [--log-format
<string>] [-l <string>]
[--local-address-ip-version <string>]
[--admin-address-path <string>] [--v2-config-only]
[--config-yaml <string>] [-c <string>]
[--concurrency <uint32_t>] [--base-id <uint32_t>]
[--] [--version] [-h]
envoy进程启动的时候需要指定一些参数,其中最重要的是--config-yaml
参数,用于指定envoy进程启动的时候需要读取的配置文件地址。Docker中配置文件默认是放在/etc/envoy
目录下,配置文件的文件名是envoy.yaml
。
所以在启动容器的时候需要将自定义的envoy.yaml配置文件挂载到指定目录下替换掉默认的配置文件。
/usr/local/bin/envoy -c <path to config>.{json,yaml,pb,pb_text} --v2-config-only
tip:envoy默认的日志级别是info,对于开发阶段需要进行调试的话,调整日志级别到debug是非常有用的,可以在启动参数中添加-l debug
来将日志级别进行切换。
4. Envoy的启动配置
在介绍Envoy的配置文件之前,先介绍一下Envoy的API。Envoy提供了两个版本的API,v1和v2版本API。现阶段v1版本已经不建议使用了,通常都是使用v2的API。
v2的API提供了两种方式的访问,一种是HTTP Rest的方式访问,还有一种GRPC的访问方式。关于GRPC的介绍可以参考官方文档,在后面的文章中只实现了GRPC的API。
Envoy的启动配置文件分为两种方式:静态配置和动态配置。
-
静态配置是将所有信息都放在配置文件中,启动的时候直接加载。
-
动态配置需要提供一个Envoy的服务端,用于动态生成Envoy需要的服务发现接口,这里叫XDS,通过发现服务来动态的调整配置信息,Istio就是实现了v2的API。
4.1 静态配置
以一个最简化的静态配置来做示例,体验一下envoy。
下面是envoy.yaml配置文件:
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 127.0.0.1, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: some_service }
http_filters:
- name: envoy.router
clusters:
- name: some_service
connect_timeout: 0.25s
type: STATIC
lb_policy: ROUND_ROBIN
hosts: [{ socket_address: { address: 127.0.0.1, port_value: 80 }}]
在此基础上启动两个容器,envoyproxy容器和nginx容器,nginx容器共享envoyproxy容器的网络,以此来模拟sidecar。
docker run -d -p 10000:10000 -v `pwd`/envoy.yaml:/etc/envoy/envoy.yaml --name envoyproxy envoyproxy/envoy-alpine
docker run -d --network=container:envoyproxy --name nginx nginx</pre>
根据配置文件的规则,envoy监听在10000端口,同时该端口也在宿主机的10000端口上暴露出来。当有请求到达监听上后,envoy会对所有请求路由到some_service这个cluster上,而该cluster的upstream指向本地的80端口,也就是nginx服务上。
static.png4.2 动态配置
动态配置可以实现全动态,即实现LDS(Listener Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、EDS(Endpoint Discovery Service),以及ADS(Aggregated Discovery Service)。
ADS不是一个实际意义上的XDS,它提供了一个汇聚的功能,以实现需要多个同步XDS访问的时候可以在一个stream中完成的作用。
下面的图通过在静态配置的基础上,比较直观的表示出各个发现服务所提供的信息。
xds.png由此,典型的动态配置文件如下:
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 127.0.0.1, port_value: 9901 }
dynamic_resources:
cds_config:
ads: {}
lds_config:
ads: {}
ads_config:
api_type: GRPC
cluster_names: [xds_cluster]
static_resources:
clusters:
- name: xds_cluster
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
http2_protocol_options: {}
hosts: [{ socket_address: { address: envoy-server, port_value: 50051 }}]
tip:动态配置和静态配置最大的区别在于,启动的时候一定要指定cluster和id,这两个参数表示该Envoy进程属于哪个cluster,id要求在相同的cluster下唯一,以表示不同的指向发现服务的连接信息。这两个参数可以在envoy的启动命令中添加--service-cluster
和--service-node
,也可以在envoy.yaml配置文件中指定node.cluster
和node.id
。
5. 深入实验
接下来的实验主要以动态配置的方式来实现一个简单的需求,首先描述一下需求场景:
有两个微服务,一个是envoy-web,一个envoy-server。
-
envoy-web相当于下图中的front-envoy作为对外访问的入口。
-
envoy-server相当于下图中的service_1和service_2,是内部的一个微服务,部署2个实例。
envoy-server有3个API,分别是/envoy-server/hello、/envoy-server/hi、/envoy-server/self,目的是测试envoy对于流入envoy-server的流量控制,对外只允许访问/envoy-server/hello和/envoy-server/hi两个API,/envoy-server/self不对外暴露服务。
envoy-web也有3个API,分别是/envoy-web/hello、/envoy-web/hi、/envoy-web/self,目的是测试envoy对于流出envoy-web的流量控制,出口流量只允许/envoy-web/hello和/envoy-web/self两个访问出去。
最终的实验:外部只能访问envoy-web暴露的接口
-
当访问/envoy-web/hello接口时返回envoy-server的/hello接口的数据,表示envoy-web作为客户端访问envoy-server返回服务响应的结果。
-
当访问/envoy-web/hi接口时,envoy-web的envoy拦截住出口流量,限制envoy-web向envoy-server发送请求,对于前端用户返回mock数据。
-
当访问/envoy-web/self接口时,envoy-web出口流量可以到达envoy-server容器,但是envoy-server在入口流量处控制住了此次请求,拒绝访问envoy-server服务,对于前端用户返回mock数据。
5.1 静态配置
首先以静态配置的方式先实现功能。
5.1.1 编写服务代码
服务代码分为envoy-web和envoy-server两个服务,采用SpringBoot的方式,下面记录一些重要的代码片段。
- envoy-server
@RestController
public class HelloRest {
private static final Logger LOGGER = LoggerFactory.getLogger(HelloRest.class);
@GetMapping("/envoy-server/hello")
public String hello() {
LOGGER.info("get request from remote, send response, say hello");
return "hello";
}
@GetMapping("/envoy-server/hi")
public String hi() {
LOGGER.info("get request from remote, send response, say hi");
return "hi";
}
@GetMapping("/envoy-server/self")
public String self() {
LOGGER.info("get request from remote, send response, say self");
return "self";
}
}
- envoy-web
@RestController
public class HelloController {
private static final Logger LOGGER = LoggerFactory.getLogger(HelloController.class);
@Autowired
private RestTemplate template;
@GetMapping("/envoy-web/local")
public String sayLocal() {
LOGGER.info("get request, send response");
return "local";
}
@GetMapping("/envoy-web/hello")
public String sayHello() {
String url = "http://127.0.0.1:10000/envoy-server/hello";
LOGGER.info("get request, send rest template to {}", url);
return getRemote(url, "mock value for hello");
}
@GetMapping("/envoy-web/hi")
public String sayHi() {
String url = "http://127.0.0.1:10000/envoy-server/hi";
LOGGER.info("get request, send rest template to {}", url);
return getRemote(url, "mock value for hi");
}
@GetMapping("/envoy-web/self")
public String saySelf() {
String url = "http://127.0.0.1:10000/envoy-server/self";
LOGGER.info("get request, send rest template to {}", url);
return getRemote(url, "mock value for self");
}
private String getRemote(String url, String mock) {
try {
ResponseEntity<String> response = template.getForEntity(url, String.class);
return response.getBody();
} catch (Exception e) {
LOGGER.error("error happens: {}", e);
return mock;
}
}
}
tip:为简化起见,代码只是介绍对出入流量的控制,直接在envoy-web上访问了本地的envoy端口进行转发流量,实际代码中可以用服务名:服务端口号
访问,而此时为了使得envoy仍然可以拦截入和出的流量,可以配置iptables(Istio的实现中也是使用了iptables)。
5.1.2 编写配置文件
针对不同的服务,也配置了两份envoy.yaml配置文件。
- envoy-server
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9900 }
static_resources:
listeners:
- name: listener_ingress
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/envoy-server/hello" }
route: { cluster: cluster_server }
- match: { prefix: "/envoy-server/hi" }
route: { cluster: cluster_server }
http_filters:
- name: envoy.router
clusters:
- name: cluster_server
connect_timeout: 0.5s
type: STATIC
lb_policy: ROUND_ROBIN
hosts:
- { socket_address: { address: 127.0.0.1, port_value: 8081 }}
- envoy-web
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9900 }
static_resources:
listeners:
- name: listener_ingress
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/envoy-web/" }
route: { cluster: cluster_ingress }
- match: { prefix: "/envoy-server/hello" }
route: { cluster: cluster_egress }
- match: { prefix: "/envoy-server/self" }
route: { cluster: cluster_egress }
http_filters:
- name: envoy.router
clusters:
- name: cluster_ingress
connect_timeout: 0.5s
type: STATIC
lb_policy: ROUND_ROBIN
hosts:
- { socket_address: { address: 127.0.0.1, port_value: 8080 }}
- name: cluster_egress
connect_timeout: 0.5s
type: STATIC
lb_policy: ROUND_ROBIN
hosts:
- { socket_address: { address: 172.17.0.2, port_value: 10000 }}
- { socket_address: { address: 172.17.0.3, port_value: 10000 }}
5.1.3 启动测试
#envoy-server1
docker run -d -v `pwd`/envoy-server.yaml:/etc/envoy/envoy.yaml --name envoyproxy-server1 envoyproxy/envoy-alpine /usr/local/bin/envoy --service-cluster envoy-server --service-node 1 -c /etc/envoy/envoy.yaml --v2-config-only
docker run -d --network=container:envoyproxy-server1 --name envoy-server1 envoy-server:1.1
#envoy-server2
docker run -d -v `pwd`/envoy-server.yaml:/etc/envoy/envoy.yaml --name envoyproxy-server2 envoyproxy/envoy-alpine /usr/local/bin/envoy --service-cluster envoy-server --service-node 2 -c /etc/envoy/envoy.yaml --v2-config-only
docker run -d --network=container:envoyproxy-server2 --name envoy-server2 envoy-server:1.1
#envoy-web
docker run -d -p 10000:10000 -v `pwd`/envoy-web.yaml:/etc/envoy/envoy.yaml --name envoyproxy-web envoyproxy/envoy-alpine /usr/local/bin/envoy --service-cluster envoy-web --service-node 1 -c /etc/envoy/envoy.yaml --v2-config-only
docker run -d --network=container:envoyproxy-web --name envoy-web envoy-web:1.1
当容器部署完毕之后,可以直接访问以下3个url,其中hi和self的访问返回的是mock数据,虽然同为mock数据,但是这两个url其实是不相同的,一个是在envoy出口流量处做的控制,一个是在envoy入口流量处做的控制,其中的细节可以再去品味品味。
example.png5.2 动态配置
动态配置需要实现发现服务,通过GRPC的方式获取相应。
动态的配置文件在前面的内容中已经有过介绍,最重要的是需要提供一个发现服务,对外提供XDS服务,下面以其中的一个LDS作为介绍,其他XDS实现类似。
- 服务端:既然作为服务,就需要对外提供接口服务。
public class GrpcService {
private Server server;
private static final int PORT = 50051;
private void start() throws IOException {
server = ServerBuilder.forPort(PORT)
.addService(new LdsService())
.addService(new CdsService())
.addService(new RdsService())
.addService(new EdsService())
.addService(new AdsService())
.build()
.start();
System.err.println("Server started, listening on " + PORT);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.err.println("*** shutting down gRPC server since JVM is shutting down");
GrpcService.this.stop();
System.err.println("*** server shut down");
}));
}
private void stop() {
if (server != null) {
server.shutdown();
}
}
private void blockUntilShutdown() throws InterruptedException {
if (server != null) {
server.awaitTermination();
}
}
public static void main(String[] args) throws IOException, InterruptedException {
final GrpcService server = new GrpcService();
server.start();
server.blockUntilShutdown();
}
}
- XDS:通过GRPC生成服务端的stub文件,实现LdsServer继承自ListenerDiscoveryServiceGrpc.ListenerDiscoveryServiceImplBase,需要实现streamListeners方法。
public class LdsService extends ListenerDiscoveryServiceGrpc.ListenerDiscoveryServiceImplBase {
private static final Logger LOGGER = LogManager.getLogger();
@Override
public StreamObserver<Discovery.DiscoveryRequest> streamListeners(StreamObserver<Discovery.DiscoveryResponse> responseObserver) {
return new StreamObserver<Discovery.DiscoveryRequest>() {
@Override
public void onNext(Discovery.DiscoveryRequest request) {
XdsHelper.getInstance().buildAndSendResult(request, responseObserver);
}
@Override
public void onError(Throwable throwable) {
LOGGER.warn("Error happens", throwable);
}
@Override
public void onCompleted() {
LOGGER.info("LdsService completed");
}
};
}
}
6. 总结
至此,基本介绍完Envoy使用的一些常见问题,在实现的时候也会有其他一些细节需要注意。
比如,envoy作为一个服务之间网络请求的代理,如何拦截全部的入和出流量?
Istio给了一个很好的解决方案,就是通过iptables。它会使用一个特定的uid(默认1337)用户运行envoy进程,iptables对于1337用户的流量不做拦截。下面就是参考Istio的iptables.sh做的一个实现:
uname=envoy
uid=1337
iptalbes -t nat -F
iptables -t nat -I PREROUTING -p tcp -j REDIRECT --to-ports 10000
iptables -t nat -N ENVOY_OUTPUT
iptables -t nat -A OUTPUT -p tcp -j ENVOY_OUTPUT
iptables -t nat -A ENVOY_OUTPUT -p tcp -d 127.0.0.1/32 -j RETURN
iptables -t nat -A ENVOY_OUTPUT -m owner --uid-owner ${uid} -j RETURN
iptables -t nat -A ENVOY_OUTPUT -p tcp -j REDIRECT --to-ports 10000
更多的实现细节则需要再研究挖掘了,同时也欢迎一起讨论。