如何在K8s中使用Envoy作为负载均衡器
本文字数:3200字
阅读时间:10分钟
在当今高度分布式的世界中,单主机架构正日益被多个、更小的、互联服务架构所取代(无论好坏),代理和负载均衡技术似乎有了复兴。除了之前的一些经典技术之外,近年来还出现了几种新的代理技术,这些技术以各种技术实现,通过不同的功能推广自己,例如,与指定云插件的轻松集成(“云原生”),具有高性能和低内存占用,或动态配置的特点。
可以说是最流行的两种“经典”代理技术是NGINX(C)和HAProxy(C),其中包括一些新生的力量是Zuul(Java),Linkerd(Rust),Traefik(Go),Caddy(Go)和Envoy(C ++)。
所有这些技术都具有不同的feature sets,并且针对某些特定方案或宿主环境(例如,Linkerd经过微调以便在K8s中使用)。
在这篇文章中,我不打算对这些进行比较,而只关注一个特定的场景:如何使用Envoy作为K8s中运行的服务的负载均衡器。
Envoy是一个“高性能C++分布式代理”,最初在Lyft实现,但从那时起就被广泛应用。它性能高,资源占用少,支持“网络层”API管理的动态配置,并提供一些高级功能,如各种负载平衡算法,速率限制,熔断和镜像。
出于多种原因,我选择Envoy作为负载均衡器代理。
除了能够使用网络层API动态控制外,它还支持简单,基于YAML的配置的硬编码,这对我的目的很方便,并且易于上手。
它内置了对其调用的服务发现技术的支持,该技术叫做STRICT_DNS。其建立在查询DNS记录的基础上,并期望看到具有IP地址的A记录,用于upstream集群的每个节点。这使得K8s的 headless service变得简单易用。
它支持各种负载平衡算法,其中包括“最少请求”。
在开始使用Envoy之前,我通过service类型的对象访问K8s中的服务LoadBalancer,这是从K8s外部访问服务的一种非常典型的方式。负载均衡器服务的确切的工作方式取决于宿主环境——如果它一开始就支持它。我使用的是Google K8s引擎,其中每个负载均衡器服务都映射到TCP级别的Google Cloud负载均衡器,该负载均衡器仅支持循环负载均衡算法。
就我而言,这是一个问题,因为我的服务具有以下特点。
这些请求长期运行,响应时间各不相同,从100毫秒到秒不等。
请求的处理是CPU密集型,实际上一个请求的处理使用了一个CPU内核的100%。
并行处理许多请求会降低响应时间。(这是由于该服务的内部工作原理,它并不能有效地运行多个并行请求。)
由于上述特性,循环负载均衡算法并不适合,因为通常——偶然—多个的请求最终会在同一个节点上运行,这使得平均响应时间比集群在更均匀地分配负载的情况下能够实现的响应时间差得多。
在本文的其余部分,我将描述在K8s中运行的服务前部署Envoy作为负载均衡器所需的步骤。
为应用程序创建Headless Service
在K8s中有一种称为Headless Service的特定服务,恰好与Envoy的STRICT_DNS
服务discovery 模式一起使用非常方便。
Headless Service不向底层pod提供单个IP和负载平衡,但它只有DNS配置,它为我们提供A记录,其中包含与标签选择器匹配的所有pods的IP地址。
此服务类型旨在用于我们希望实现负载平衡以及维护与upstream pods 的连接的场景,这正是我们可以使用Envoy执行的操作。
我们可以通过将.spec.clusterIP
字段设置为"None"
来创建Headless Service。因此,假设我们的应用程序pods具有app
标签值为myapp
,我们可以使用以下yaml创建Headless Service。
kind: Service
metadata:
name: myapp
spec:
clusterIP: None
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
selector:
app: myapp
(Service
的名称不必与应用程序名称或app标签一样,但这是一个很好的约定。)
现在,如果我们检查K8s集群内的服务的DNS记录,我们将看到具有IP地址的单独A记录。如果我们有3个pods,我们会看到类似于此的DNS摘要。
$ nslookup myappServer: 10.40.0.10
Address: 10.40.0.10#53
Non-authoritative answer:
Name: myapp.namespace.svc.cluster.local
Address: 10.36.224.5
Name: myapp.namespace.svc.cluster.local
Address: 10.38.187.17
Name: myapp.namespace.svc.cluster.local
Address: 10.38.1.8
Envoy的STRICT_DNS
的服务发现工作原理是维护DNS服务器返回的所有A记录的IP地址,并每两秒钟刷新组IP地址。
创建Envoy镜像
在不提供动态API形式的网络层的情况下使用Envoy的最简单方法是将硬编码配置添加到静态yaml文件中。
以下是对域名myapp给出的IP地址进行负载均衡的基本配置。
admin:access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 80 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { host_rewrite: myapp, cluster: myapp_cluster, timeout: 60s }
http_filters:
- name: envoy.router
clusters:
- name: myapp_cluster
connect_timeout: 0.25s
type: STRICT_DNS
dns_lookup_family: V4_ONLY
lb_policy: LEAST_REQUEST
hosts: [{ socket_address: { address: myapp, port_value: 80 }}]
请注意以下部分。
type: STRICT_DNS
:这里我们指定服务发现类型。把它设置为STRICT_DNS
很重要,因为这与我们设置的headless service配合得很好。lb_policy: LEAST_REQUEST
:我们可以选择不同的负载均衡算法,可能ROUND_ROBIN
和LEAST_REQUEST
是最常见的。(请记住,LEAST_REQUEST
不检查所有的upstream节点,但它只从2个随机选项中选择。)hosts: [{ socket_address: { address: myapp, port_value: 80 }}]
:在这一部分中,我们在address字段指定了Envoy必须从哪个域名获取到的A记录。您可以在文档中找到更多有关各种配置参数的信息。
现在我们必须将以下Dockerfile放在envoy.yaml旁边的yaml配置文件。
FROM envoyproxy/envoy:latestCOPY envoy.yaml /etc/envoy.yaml
CMD /usr/local/bin/envoy -c /etc/envoy.yaml
最后一步是构建镜像,并将其推送到某个地方(如Docker hub或云插件的容器注册表),以便能够在K8s中使用它。假设我想将其推送到我的个人Docker hub帐户,我可以使用以下命令执行此操作。
$ docker build -t markvincze/myapp-envoy:1 .$ docker push markvincze/myapp-envoy:1
可选:使用Envoy镜像可参数化
如果我们希望能够使用环境变量自定义Envoy配置的某些部分,而不需要重建Docker镜像,那么我们可以在yaml配置中进行一些env var替换。假设我们希望能够自定义要代理的headless service 的名称,以及负载均衡器算法,那么我们必须通过以下方式修改yaml配置。
admin:access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 80 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { host_rewrite: myapp, cluster: myapp_cluster, timeout: 60s }
http_filters:
- name: envoy.router
clusters:
- name: myapp_cluster
connect_timeout: 0.25s
type: STRICT_DNS
dns_lookup_family: V4_ONLY
lb_policy: ${ENVOY_LB_ALG}
hosts: [{ socket_address: { address: ${SERVICE_NAME}, port_value: 80 }}]
然后实现一个小的shell脚本(docker-entrypoint.sh
),并在其中进行env var替换
set -e
echo "Generating envoy.yaml config file..."
cat /tmpl/envoy.yaml.tmpl | envsubst \$ENVOY_LB_ALG,\$SERVICE_NAME > /etc/envoy.yaml
echo "Starting Envoy..."
/usr/local/bin/envoy -c /etc/envoy.yaml
并更改我们的Dockerfile以运行此脚本,而不是直接启动Envoy。
FROM envoyproxy/envoy:latestCOPY envoy.yaml /tmpl/envoy.yaml.tmpl
COPY docker-entrypoint.sh /
RUN chmod 500 /docker-entrypoint.sh
RUN apt-get update && \
apt-get install gettext -y
ENTRYPOINT ["/docker-entrypoint.sh"]
请记住,如果使用此方法,则必须在K8s deployment中指定这些env vars,否则它们将为空。
创建Envoy的Deployment
最后,我们必须为Envoy本身创建一个deployment。
apiVersion: v1kind: Service
metadata:
name: myapp-envoy
labels:
app: myapp-envoy
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
selector:
app: myapp-envoy
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: myapp-envoy
labels:
app: myapp-envoy
spec:
selector:
matchLabels:
app: myapp-envoy
template:
metadata:
labels:
app: myapp-envoy
spec:
containers:
- name: myapp-envoy
image: mydockerhub/mycustomenvoy
imagePullPolicy: Always
env:
- name: "ENVOY_LB_ALG"
value: "LEAST_REQUEST"
- name: "SERVICE_NAME"
value: "myapp"
ports:
- name: http
containerPort: 80
- name: envoy-admin
containerPort: 9901
(如果我们使Envoy Docker镜像可参数化,我们只需要env变量。)
应用此yaml后,Envoy代理应该可以运行,您可以通过将请求发送到Envoy服务的主端口来访问底层服务。
在此示例中,我仅添加了ClusterIP类型的服务,但如果要从集群外部访问代理,还可以使用LoadBalancer服务或Ingress对象。
下图说明了整个设置的体系结构。
该镜像仅显示一个Envoy pod,但如果需要,您可以将其扩展为更多实例。当然,您可以使用Horizontal Pod Autoscaler根据需要自动创建更多。(所有实例都是自主的并且彼此独立。)
实际上,与底层服务相比,您可能需要更少的代理实例。在我们使用Envoy的当前生产应用程序中,我们在约400个upstream pods上每秒提供约1000个请求,但我们只有3个Envoy运行实例,CPU负载约为10%。
Troubleshooting and Monitoring
在Envoy配置文件中,您可以看到一个admin部分:配置Envoy管理端点。这可用于检查有关代理的各种诊断信息。
(如果默认情况下没有发布管理端口9901的服务,那么仍然可以通过端口转发到带有kubectl
的pod来访问。假设已调用其中一个叫做myapp-envoy-656c8d5fff-mwff8
的Envoy pods,那么您可以启动端口转发命令kubectl port-forward myapp-envoy-656c8d5fff-mwff8 9901
。然后你可以访问该页面http://localhost:9901
。)
一些有用的节点:
/config_dump
:打印代理的完整配置,这有助于验证pod上是否有正确的配置。/clusters
:显示在Envoy发现的所有upstream 节点,以及为每个节点处理的请求数。这对于检查负载平衡算法是否正常工作非常有用。
监控的一种方法是使用Prometheus从代理 pods中获取统计数据。Envoy内置了对此的支持,Prometheus统计数据发布在该路线的管理端口上/stats/prometheus
。
您可以从此存储库下载这些可视化指标的Grafana仪表板,它将为您提供以下一组图表。
关于负载均衡算法的一个词
负载平衡算法可以对集群的整体性能产生重大影响。使用最少请求的算法对于需要均匀分散负载的服务是有益的,例如当服务是CPU密集型并且容易超载时。另一方面,最少的请求存在这样的问题:如果其中一个节点由于某种原因而失败,并且失败响应时间很快,那么负载平衡器将发送很多不成比例的请求到故障节点 - 这对round robin不会有问题。
我用虚拟API做了一些基准测试,并比较了round robin和最少请求LB算法。事实证明,最小的请求可以显著提高总体性能。
我对API进行了大约40分钟的输入流量不断增加的基准测试。在整个基准测试中,我收集了以下指标:
正在执行的请求数(“正在运行的请求”),由服务器细分
正在执行的请求数,平均每台服务器
传入请求速率(每5分钟增加一次)
错误率(通常没有,但是当速度开始变慢时,这开始显示一些超时)
服务器上记录的响应时间百分位数(0.50,0.90和0.99)
统计数据看起来像ROUND_ROBIN
。
结果LEAST_REQUEST
如下:
您可以在结果上看到LEAST_REQUEST
可以使节点之间的流量分布更加平滑,从而降低高负载时的平均响应时间。
确切的改进将取决于实际的API,因此我强烈建议您对自己的服务进行基准测试以做出决定。
(之前我提到过,在使用时LEAST_REQUEST,Envoy在选择发送某个请求的节点时不会查看集群中的每个节点,而只是查看2个随机的选项,并将请求发送给具有较少待处理的请求的节点。我很好奇,实际上做一个完整的O(n)扫描并在所有节点中选择请求最少的节点是否会带来进一步的改进,使我在实现这个功能fork of Envoy,并做了同样的基准测试。事实证明,这的确可以进一步提高负载的均匀性和资源利用率,从而减少高负载下的响应时间。这个问题。)已经引起了广泛关注。
摘要
我希望这篇介绍对于开始在K8s中使用Envoy的用户很有帮助。顺便说一下,这不是在K8s上实现最少请求负载平衡的唯一方法。有各种 ingress controllers (其中一个是Ambassador,它建立在 Envoy之上),可以做同样的事情。
如果您有其他的建议,欢迎提出反馈!
◆ ◆ ◆ ◆ ◆
点击阅读原文查看英文原文~