0%

张量分析推导 loss 对 X\mathbf{X} 的导数

利用张量分析书写矩阵乘法

在一个标准的神经网络线性层中,输出和输入的关系如下

T=XW+b(1)\tag{1} \mathbf{T} = \mathbf{X} \mathbf{W} + \mathbf{b}

其中

  • T\mathbf{T} 是一个 b×hb \times h 的矩阵,这里 bb 一般是 batch_sizehh 是隐藏层大小
  • X\mathbf{X} 是一个 b×mb \times m 的矩阵 ,这里 mm 一般是 emb_size * block_size
  • W\mathbf{W} 是一个 m×hm \times h 的矩阵
  • b\mathbf{b}pytorch 中一般不是一个矩阵,是一个长度为 hh 的向量,
    通过使用了 pytorch 的扩展第一列扩展到所有列

我们可以使用张量分量的形式来表示

T=(xikwjk+bj)gigj(2)\tag{2} \mathbf{T} = (x_{ik} w^{k}_{\cdot j} + b_j)\mathbf{g}^i\mathbf{g}^j

用张量推导导数

在深度学习中,上述说的 T\mathbf{T} 其实会是 lossloss 的一个函数,然后 T\mathbf{T} 又是 X\mathbf{X} 的函数,写出来是下述的样子

loss=l(T(X))loss = l(\mathbf{T}(\mathbf{X}))

上述 ll 可以看做是一个二阶张量 T\mathbf{T} 的标量函数,T\mathbf{T} 是二阶张量 X\mathbf{X} 的二阶张量函数,而张量函数的导数存在以下链式求导规则

lX=lT:TX\frac{\partial l}{\partial \mathbf{X}} = \frac{\partial l}{\partial \mathbf{T}} \vcentcolon \frac{\partial \mathbf{T}}{\partial \mathbf{X}}

将上式按照张量分量展开如下

lX=ltijgigj:tklxmngkglgmgn=ltijtklxmnδikδjlgmgn=ltijtijxmngmgn(4)\tag{4} \begin{aligned} \frac{\partial l}{\partial \mathbf{X}} &= \frac{\partial l}{\partial t_{ij}}\mathbf{g}_i\mathbf{g}_j \vcentcolon \frac{\partial t_{kl}}{\partial x_{mn}}\mathbf{g}^k\mathbf{g}^l\mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{ij}} \frac{\partial t_{kl}}{\partial x_{mn}} \delta_i^{\cdot k} \delta_j^{\cdot l} \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{ij}} \frac{\partial t_{ij}}{\partial x_{mn}} \mathbf{g}_m\mathbf{g}_n \\ \end{aligned}

于是我们得到了基于张量分量的链式法则。在公式 (4)(4) 中,其实 ltij\frac{\partial l}{\partial t_{ij}} 是一个已知字段,在上一次 backward 中已经计算出来了,现在我们的目标是计算 lossloss 各相对于 W\mathbf{W}X\mathbf{X}b\mathbf{b} 的导数,将该链式法则带入 (2)(2)

lX=ltij(xikwjk+bj)xmngmgn=ltijδimδknwjkgmgn=ltmjwjngmgn=ltmj(wjn)gmgn=lTW(5)\tag{5} \begin{aligned} \frac{\partial l}{\partial \mathbf{X}} &= \frac{\partial l}{\partial t_{ij}} \frac{\partial (x_{ik} w^{k}_{\cdot j} + b_j)}{\partial x_{mn}} \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{ij}} \delta_i^{\cdot m} \delta_k^{\cdot n} w^k_{\cdot j} \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{mj}} w^n_{\cdot j} \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{mj}} (w_j^{\cdot n})^\top \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial \mathbf{T}} \mathbf{W}^\top \end{aligned}

上式中 (wki)(w_{ki})^\top 表示矩阵的转置。因此我们将上式改为矩阵形式就可以得到

lX=lTW(6)\tag{6} \frac{\partial l}{\partial \mathbf{X}} = \frac{\partial l}{\partial \mathbf{T}}\mathbf{W}^\top

由此,我们用很简单规整的数学推导推出了 lossloss 相对于 X\mathbf{X} 的导数(或者称为梯度)

同样的思路,我们可以推导出 W\mathbf{W} 对应的导数

lW=ltij(xikwjk+bj)wmngmgn=ltijxikδkmδjngmgn=ltinximgmgn=(xim)ltingmgn=XlT(7)\tag{7} \begin{aligned} \frac{\partial l}{\partial \mathbf{W}} &= \frac{\partial l}{\partial t_{ij}} \frac{\partial (x_{ik} w^{k}_{\cdot j} + b_j)}{\partial w_{mn}} \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{ij}} x_{ik} \delta^{km} \delta_{j}^{\cdot n} \mathbf{g}_m\mathbf{g}_n \\ &= \frac{\partial l}{\partial t_{in}} x_i^{\cdot m} \mathbf{g}_m\mathbf{g}_n \\ &= (x^m_{\cdot i})^\top \frac{\partial l}{\partial t_{in}} \mathbf{g}_m\mathbf{g}_n \\ &= \mathbf{X}^\top \frac{\partial l}{\partial \mathbf{T}} \end{aligned}

b\mathbf{b} 对应的导数

lb=ltij(wikxjk+bj)bmgm=ltijδjmgm=ltimgm=iltimgm(8)\tag{8} \begin{aligned} \frac{\partial l}{\partial \mathbf{b}} &= \frac{\partial l}{\partial t_{ij}} \frac{\partial (w_{ik} x^{k}_{\cdot j} + b_j)}{\partial b_{m}} \mathbf{g}_m \\ &= \frac{\partial l}{\partial t_{ij}} \delta_j^{\cdot m} \mathbf{g}_m \\ &= \frac{\partial l}{\partial t_{im}} \mathbf{g}_m \\ &= \sum_i \frac{\partial l}{\partial t_{im}} \mathbf{g}_m \\ \end{aligned}

这里比较特殊,最终得到的结果其实是 lT\frac{\partial l}{\partial \mathbf{T}} 按照行求和得到了一个长度为 hh 的向量。

将上述的公式表示为 pytorch 代码即为

1
2
3
dX = dT @ W.T
dW = X.T @ dT
db = dT.sum(0)

在公司内网开发的时候,有时候会无法连接外网,公司提供了一个 http proxy,例如 http://my.proxy:1087 来访问外网。因此我们需要通过这个代理来访问 github 等仓库。

访问 github 有两种方式,一种是 https 的,另一种是 ssh 的。当我们使用 https 来访问 github 的时候,直接在命令行使用 export http_proxy=http://my.proxy:1087 https_proxy=http://my.proxy:1087 就可以直接使用 ggit clone https://github.com/weixiao-huang/weixiao-huang.github.io.git 来克隆相应的仓库。

但是当我们要使用 ssh 来 git clone 的时候,会发现无法使用。我们需要更改一下 ssh 的配置,方式如下:

更改 ~/.ssh/config 添加下面的代码

1
2
3
Host  github.com
User git
ProxyCommand socat - PROXY:my.proxy:%h:%p,proxyport=1087

通过上述方式即可以通过代理连接上 github,使用 git clone git@github.com:weixiao-huang/weixiao-huang.github.io.git

gloo 是一款基于 envoy 的云原生的API网关,它能够非常方便的和 K8S 进行集成,通过监听相关的 CRD,基于 envoy 的 xDS 接口对 envoy 配置进行 hot reload。同时也能很方便的集成 knative。另外它的文档也比较完善,设计比较简单,易于上手。

关于如何上手并使用 gloo,相信网上也能搜到一些中文资料,当然最好的方式当然是直接去 gloo 官方文档 查看。本文主要深入 gloo 的源码,探究 gloo 是如何实现对 envoy 的配置下发的。

从最基本意义上讲,gloo 是转化引擎,Envoy xDS 服务器为 Envoy 提供高级配置(包括 gloo 的自定义 Envoy 过滤器)。gloo 遵循基于事件的体系结构,监视各种配置源以进行更新,并立即通过 v2 版本的 gRPC 接口更新 Envoy 配置。

本文基于 gloo 1.14.0 对 gloo 源码进行分析。

核心代码结构

gloo 的核心概念

整体上,gloo 的核心概念如下图所示:

可以看到,主要核心控制器有 3 个:EmitterEventLoopSyncer。其中 Emitter 实现了 Snapshots() 函数,这个函数会返回一个 Snapshot channel,然后在主循环 EventLoop 中会执行一个 Run() 函数去监听 Snapshot channel,一旦接收到新的 Snapshot,就会把这个 Snapshot 发送给 Syncer 实现的 Sync() 函数去处理,而这个 Sync() 函数就是最主要的将 gloo 配置同步到 envoy 的核心函数。

在上面我们可以频繁提到一个 Snapshot 的概念,这个就是 gloo 的最核心的数据结构。Snapshot 是一堆资源的集合,包括 gloo 的各种 CR 以及 K8S 的 Services 等资源,下面是 ApiSnapshot 的一个定义,可以参考源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type ApiSnapshot struct {
Artifacts gloo_solo_io.ArtifactList
Endpoints gloo_solo_io.EndpointList
Proxies gloo_solo_io.ProxyList
UpstreamGroups gloo_solo_io.UpstreamGroupList
Secrets gloo_solo_io.SecretList
Upstreams gloo_solo_io.UpstreamList
AuthConfigs enterprise_gloo_solo_io.AuthConfigList
Ratelimitconfigs github_com_solo_io_gloo_projects_gloo_pkg_api_external_solo_ratelimit.RateLimitConfigList
VirtualServices gateway_solo_io.VirtualServiceList
RouteTables gateway_solo_io.RouteTableList
Gateways gateway_solo_io.GatewayList
VirtualHostOptions gateway_solo_io.VirtualHostOptionList
RouteOptions gateway_solo_io.RouteOptionList
HttpGateways gateway_solo_io.MatchableHttpGatewayList
GraphqlApis graphql_gloo_solo_io.GraphQLApiList
}

上面的 ApiSnapshot 里储存了很多东西,包括 gloo 自己的 CRD,例如 UpstreamListVirtualServiceListRouteTableList 等,也有 K8S 自己的资源(当然 gloo 在这里稍微做了一层转换),例如 EndpointListSecretList 等。

Snapshot 的作用如下:

  • gloo 会通过一些 informer 接收到 K8S 中相应的 CRD 信息,并把这些信息汇总到 Snapshot
  • gloo 会在 Snapshots() 函数中一秒轮训一次,周期性的把 Snapshot 送入 EmitterSnapshots() 接口返回的 Snapshot Channel 中

核心 Translate 流程

下图是一个总体的服务入口和 Translate 流程

gloo 如何把 K8S 的资源汇总转换到 Snapshot 中

在这里我们主要查看一下 Emitter 的逻辑,下面是一个 apiEmitter 的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type apiEmitter struct {
forceEmit <-chan struct{}
artifact gloo_solo_io.ArtifactClient
endpoint gloo_solo_io.EndpointClient
proxy gloo_solo_io.ProxyClient
upstreamGroup gloo_solo_io.UpstreamGroupClient
secret gloo_solo_io.SecretClient
upstream gloo_solo_io.UpstreamClient
authConfig enterprise_gloo_solo_io.AuthConfigClient
rateLimitConfig github_com_solo_io_gloo_projects_gloo_pkg_api_external_solo_ratelimit.RateLimitConfigClient
virtualService gateway_solo_io.VirtualServiceClient
routeTable gateway_solo_io.RouteTableClient
gateway gateway_solo_io.GatewayClient
virtualHostOption gateway_solo_io.VirtualHostOptionClient
routeOption gateway_solo_io.RouteOptionClient
matchableHttpGateway gateway_solo_io.MatchableHttpGatewayClient
graphQLApi graphql_gloo_solo_io.GraphQLApiClient
}

可以看到 Emitter 中存了很多 Client,需要注意的是这些 Client 并不是 K8S 的 client-go,而是 gloo 自己封装的 client。

EmitterSnapshots() 方法会做以下事情

  1. 初始化各 Client 并 watch 其中数据变化
  2. 一旦 Watch 到数据,对数据进行部分处理,然后塞入临时的 currentSnapshot 结构体中
  3. 每一秒一次,把 currentSnapshot 送入 Snapshot channel

下面针对这 3 个过程从源码进行解析

1. 初始化各 Client 并 watch 其中数据变化

第一步,apiEmitterRegister() 函数会调用各个 client 的 Register 接口,这些 Register 接口会对各个 client 对应的 K8S informer 进行初始化。这些 Register 会最终调用到 ResourceClient 这个接口的 Register(),这部分代码已经不在 gloo 里了,是在 gloo 依赖的 solo-kit 中。然后这个 Register() 最重要的是实现方式在 kube/resource_client.go,最终会调用到 kube/resource_client_factory.go 中。这个函数里的逻辑就很清晰了,里面针对需要监听的 namespace 初始化了对应资源的 sharedInformer 并加入缓存中。

第二步,会调用各个 client 的 List 接口,对 Register() 中注册的 informer 进行 Start,等待数据 sync 到本地 cache 之后顺便 List 一份返回。这里是一个 artifact 资源的 List 调用的地方,往里看也会调用到 solo-kit 的 kube/resource_client.gokubesecret/resource_client.goList 代码。

第三部,会调用各个 client 的 Watch 接口,监听该资源的情况,一旦有变化会反馈到 Watch 接口返回的 channel 中。例如这里是一个 artifact 资源的 Watch 调用的地方,最终也会调用到 solo-kit,此处不在赘述。

2. Watch 到数据,对数据进行部分处理

上面的 Watch 接口会返回一个 artifactNamespacesChan,在下面会监听这个 channel,一旦有数据过来,就会汇总发布到 artifactChan,然后另一个 goroutine 也在监听 artifactChan,一旦有数据产生,就会放入 currentSnapshot

gloo 通过这种方式,把所有需要的资源逗整合到了 Snapshot 结构体中。

3. 把 currentSnapshot 送入 Snapshot channel

每一秒一次,将上面 List 以及 Watch 得到的结果都放入 currentSnapshot,最后currentSnapshot 深拷贝一份放入 Snapshot channel 中

通过上面的三步,就实现了最核心的 K8S 资源到 Snapshot 的转换,最后会将这个 Snapshot 资源传入 Sync() 函数中,实现最核心的 K8S 配置到 envoy 的 xDS 转换流程。

xDS 流程

gloo 的 Translate 最终做的工作是把 ApiSnapshot 转化为下面的 EnvoySnapshot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Snapshot is an internally consistent snapshot of xDS resources.
// Consistently is important for the convergence as different resource types
// from the snapshot may be delivered to the proxy in arbitrary order.
type EnvoySnapshot struct {
// Endpoints are items in the EDS V3 response payload.
Endpoints cache.Resources

// Clusters are items in the CDS response payload.
Clusters cache.Resources

// Routes are items in the RDS response payload.
Routes cache.Resources

// Listeners are items in the LDS response payload.
Listeners cache.Resources
}

Translate 转化完成之后,会调用 SetSnapshot 方法EnvoySnapshot 放到本地缓存中。一旦这个缓存被 Set,就会触发 xDS 服务和 envoy 的数据同步。

gloo 在初始化的时候会初始化一个 xdsServer,这个 xdsServer实现下面的 interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Server is a collection of handlers for streaming discovery requests.
type Server interface {
// StreamEnvoyV3 is the streaming method for Evnoy V3 XDS
StreamEnvoyV3(
stream StreamEnvoyV3,
defaultTypeURL string,
) error
// StreamSolo is the streaming method for Solo discovery
StreamSolo(
stream StreamSolo,
defaultTypeURL string,
) error
// Fetch is the universal fetch method.
FetchEnvoyV3(
context.Context,
*envoy_service_discovery_v3.DiscoveryRequest,
) (*envoy_service_discovery_v3.DiscoveryResponse, error)
FetchSolo(
context.Context,
*sk_discovery.DiscoveryRequest,
) (*sk_discovery.DiscoveryResponse, error)
}

然后会需要利用这个 xdsServer 生成一个 EnvoyServerV3,这个才是真正和 Envoy 做交互的,这个 Server 需要实现 Envoy xDS 相关接口

1
2
3
4
5
6
7
8
// Server is a collection of handlers for streaming discovery requests.
type EnvoyServerV3 interface {
envoy_service_endpoint_v3.EndpointDiscoveryServiceServer
envoy_service_cluster_v3.ClusterDiscoveryServiceServer
envoy_service_route_v3.RouteDiscoveryServiceServer
envoy_service_listener_v3.ListenerDiscoveryServiceServer
envoy_service_discovery_v3.AggregatedDiscoveryServiceServer
}

如果仔细查看这些接口的实现的话,会发现基本都是使用上面的 StramEnvoyV3 来实现的,这里就不列举了。

总结

以上就是 gloo 的一些源码的主要流程了,总体来说架构和思路还算清晰,但是代码可能有些复杂,我们在阅读 gloo 代码的时候需要注意的是,*.sk.go 的代码都是生成的代码,所以不用过于在乎这部分代码的写法,大致知道逻辑即可。

在标准的 CentOS 7 系统中,标配的是 3.10 版本的 Linux kernel,在这个内核下搭建 Kubernetes 一版来说还是比较稳定的,但是当集群跑着跑着,创建的 Pod 多于一定数量的时候,就会发现新 Pod 再也创建不起来了,报错

1
Error response from daemon: OCI runtime create failed: container_linux.go:348: starting container process caused "process_linux.go:279: applying cgroup configuration for process caused \"mkdir /sys/fs/cgroup/memory/kubepods/burstable/pod7d54e3ab-2639-11ea-b794-801844e495bc/cba62d4976e90bfdc2fb7cf20a7c37dfbf4b2273742b78f27b6d9b5d15b284ce: cannot allocate memory\"": unknown

主要的关键字是 cannot allocate memory,当然这个问题网上有诸多解答,搜索这个问题其实也挺费劲,但是最终发现一个帖子写得不错,比较有参考意义,在此贴上链接: https://cloud.tencent.com/developer/article/2141496 。这篇文章详细的说明了这个问题发生的原因,也给出了解决方案。在这里总结一下解决方案:

  • 方案一:升级内核到 4.x 之后能解决
  • 方案二:更改机器 grub 配置禁用 kmem 并重启机器
  • 方案三:更改 kubelet 代码并重新编译

我们团队经过尝试,发现方案一和方案二均能解决问题,但是方案三不一定能解决,因此在这里我会着重介绍下方案二。

方案二在上面的文章里也说了一些,这里可以再做一些补充。

首先执行

1
vim /etc/default/grub

GRUB_CMDLINE_LINUX 的配置

1
GRUB_CMDLINE_LINUX="crashkernel=auto spectre_v2=retpoline intel_iommu=on iommu=pt rootflags=prjquota"

末尾加上 cgroup.memory=nokmem,例如

1
GRUB_CMDLINE_LINUX="crashkernel=auto spectre_v2=retpoline intel_iommu=on iommu=pt rootflags=prjquota cgroup.memory=nokmem"

然后下面需要注意,根据机器启动方式不一样,需要生成的 grub 配置可能不一样。

先试用 ls /sys/firmware/efi 查看 /sys/firmware/efi 是否存在,如果存在的话,那么该机器是通过 UEFI 启动,则执行

1
2
cp /boot/efi/EFI/centos/grub.cfg /boot/efi/EFI/centos/grub.cfg.bak
grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg

如果上述目录不存在的话,则执行

1
2
cp /boot/grub2/grub.cfg /boot/grub2/grub.cfg.bak
grub2-mkconfig -o /boot/grub2/grub.cfg

执行完成之后,可以用重启机器。

机器重启成功之后,可以使用 cat /proc/cmdline 检查配置是否生效,如果里面有 cgroup.memory=nokmem 相关的配置项,则说明修改成功。

在我们的项目中,我们采用了 gloo 作为我们最前端的网关,gloo 是基于 envoy 的云原生网关, gloo 实现了一套控制器,自定义了 VirtualService, RouteTable, Upstream 等 CRD,利用 envoy 的 xDS ,将 K8S 的 CRD 转化为了 envoy 自身的配置实现反向代理。

gloo 将 envoy 的反向代理通过 K8S 部署成为一个 gateway-proxy 的 Deployment,所有网关流量可以打到这个 Deployment 对应的 Pod 或者 Service 上即能实现反向代理。

但是需要注意的是,envoy 并没有实现完善的 Gracefully Stop 功能,在我们的项目中,利用 envoy 的 2222 端口代理了我们的一个 ssh 反向代理服务,用户会通过 envoy 的 2222 端口脸上 upstream 的 ssh 服务。但是如果不做优雅退出的话,一旦我们做 gateway-proxy 这个 Deployment 的滚动更新,就会导致所有用户的 ssh 连接断连,造成非常不好的体验,因此实现一个 Gracefully Stop 是迫在眉睫的事情。

初步调研看来, gloo 有 PR 实现了一个简单的优雅退出的逻辑,也写了 相关文档。但仔细看了 PR 里的细节才发现,gloo 的这个功能比较鸡肋,可能会造成所有的 conn 都退出之后,还在等 sleep 的诡异景象,使得 gateway-proxy pod 迟迟处于 Terminating 状态,不是一个合理的解决方案。

经过调研,我们发现 istio 自己实现了一个优雅退出,通过暴露一个 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 配置。这个给了我们新思路,仔细查看它的代码,详细逻辑是 activeProxyConnections 这个函数

1
2
3
4
5
6
7
8
9
10
11
func (a *Agent) activeProxyConnections() (int, error) {
activeConnectionsURL := fmt.Sprintf("http://%s:%d/stats?usedonly&filter=downstream_cx_active$", a.localhost, a.adminPort)
stats, err := http.DoHTTPGet(activeConnectionsURL)
if err != nil {
return -1, fmt.Errorf("unable to get listener stats from Envoy : %v", err)
}
if stats.Len() == 0 {
return -1, nil
}
// 后面的逻辑省略
}

可以看到可以采用 envoy 的 /stats 接口了解到 downstream 的连接数,通过对监控指标的 filter 即可查询到连接数,如果连接数一直不清零,就一直等待即可。

最终我们通过给 gateway-proxy Pod 书写如下的 preStop 终于实现了优雅退出

1
2
3
4
5
6
7
8
9
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- for i in $(seq 1 8640); do if [[ $(wget -O- "127.0.0.1:19000/stats?usedonly&filter=^listener\.\[__\]_2222\.downstream_cx_active$"
| awk 'BEGIN{sum=0}{sum+=$2;}END{print sum;}') -ne 0 ]]; then sleep
10; else break; fi done

稍微解释一下上面的 command

  • for 循环的作用:我们采用了一个 for 循环,循环 8640 次,并且利用 sleep 命令实现 10s 检测一次,因此总共 86400s,即优雅退出 timeout 大概在 1d 以上。
  • wget 的作用:在容器中并没有直接照抄 istio 的 filter,而是自己写了个 filter=^listener\.\[__\]_2222\.downstream_cx_active$",这是因为 istio 检查了所有 downstream,但是我们没有这个必要,我们主要只需要检查 2222 端口的 downstream 即可,因此我们写了一个正则表达式,这样可以提高 wget 返回的速度。
  • 这里的 usedonly 是必须的,如果不填的话在集群规模大的情况下,此接口会需要返回特别多的信息,特别耗时间,利用 usedonly 能够极大的减小请求时间。
  • awk 的作用:wget 返回的结果类似这样:listener.[__]_2222.downstream_cx_active: 52,不排除会出现多行或者一行都没有,因此我们用 awk 获取了空格后面的数字,例如这里的 52,然后如果有多行,会将多行的数字相加并输出,如果一行都没有就输出 0,我们利用 awk 的结果判断是否是 0 即可知道连接数是否是 0,可谓精妙。

除了上面的配置之外,Pod 的 spec 层面需要配置 terminationGracePeriodSeconds: 86400 固定 1 天时间优雅退出,和上面的 86400s 需要匹配即可完美实现 envoy 监听的 2222 端口的优雅退出。

在书写 Dockerfile 的时候,会存在一个 VOLUME 的关键字,例如

1
2
FROM busybox
VOLUME /mnt/data

我们如果将这个 Dockerfile 打成一个镜像

1
docker build -t test-image .

并将这个镜像在 Kubernetes 中创建一个 Pod 的话,当我们 exec 进入该 Pod,执行

1
2
3
/ # df -Th /mnt/data/
Filesystem Type Size Used Available Use% Mounted on
/dev/sda1 xfs 199.9G 53.8G 146.1G 27% /local-rootfs

会发现 /mnt/data 被挂载了一个宿主机的临时目录,这种情况其实不是 Kubernetes 能控制的,其实是底层的容器运行时控制的,但这样的话就会导致平台无法控制用户镜像中的 VOLUME,可能会导致用户往对应 VOLUME 里疯狂写入数据造成宿主机磁盘写满而存在安全风险。鉴于现在 Kubernetes 官方推荐使用 containerd,所以我们来说一下针对 containerd 的场景该问题该如何解决。

为了解决该问题,我们需要在 containerd 的 /etc/containerd/config.toml 中使用 ignore_image_defined_volumes = true 去避免该问题,参考 文档 ,里面有段注释说明了该选项的功能

1
2
3
# ignore_image_defined_volumes ignores volumes defined by the image. Useful for better resource
# isolation, security and early detection of issues in the mount configuration when using
# ReadOnlyRootFilesystem since containers won't silently mount a temporary volume.

具体实现的代码可以在 这里 找到。

背景

之前项目中用的 go 版本是 1.15.8,期望能升级到 1.17.4。主要驱动是我们用了 bazel,然后想在 M1 Mac 上用 bazel 编译 go 项目,发现需要升级 rules_go 到最新版本,并且 Go 在 1.16 之前官方不提供编译好的 darwin-arm64 的二进制包。因此尝试把项目升级到了 1.17

问题

升级之后,发现一些奇怪的问题,后面我构造了一个详细的最小复现例子。根据这个例子的 README.md 执行,我们发现 go 1.15 和 go 1.16 有了完全不同的两个行为。最终发现是 syscall.Setgroups 这个函数在 1.16、1.17 的行为和 1.15 的不一致,因此我给 go 提了一个 issue。目前已被解决,经过测试,在 go 1.18 中已经不存在该问题。

一、起因

在帮助客户做线上操作的时候,需要安装 寒武纪卡官方的 K8S device-plugin
按照 daemonset yaml 部署 device-plugin 之后,
发现 yaml 里写的 cambricon-k8s-device-plugin:v1.1.3 根本 pull 不下来

1
2
$ docker pull cambricon-k8s-device-plugin:v1.1.3
Error response from daemon: pull access denied for cambricon-k8s-device-plugin, repository does not exist or may require 'docker login': denied: requested access to the resource is denied

后面发现这个镜像需要自己打。但是其实之前有离职的同学曾经打过一个镜像,放在了一台寒武纪机器的本地 containerd image 中,因此我们没必要再打一次镜像了,最好就直接复用这个镜像,把这个镜像 push 到一个内网 registry 上供所有机器使用。

操作了一番之后发现 containerd 这个 ctr 操作和 docker 还是非常不一样的,一开始想尝试要不直接安装一个 nerdctl 得了,但是感觉这么小的一个需求没必要搞那么麻烦,因此就开始探究 ctr export 镜像并 push 的操作。

二、操作

ctr 是 containerd 内置的一个查看镜像的命令,一般安装了 containerd 的话一定会安装 ctr。用 ctr 做镜像操作的话,需要用 ctr image 命令,或者方便一些,直接用 ctr i。如果查看镜像列表,就使用 ctr i ls。但其实我们执行了一下发现

1
2
$ ctr i ls
REF TYPE DIGEST SIZE PLATFORMS LABELS

并没有得到什么结果。这里主要是因为 ctr 里面有一个 namespace 的概念,用 ctr --help 可以看到这个参数

1
2
3
4
$ ctr --help
...
--namespace value, -n value namespace to use with commands (default: "default") [$CONTAINERD_NAMESPACE]
...

我们的镜像是给 k8s 使用的,k8s 会使用 k8s.io 这个 namespace 下的镜像,因此我们需要加一个 -n k8s.io

1
2
$ ctr -n k8s.io i ls -q | grep cambricon-k8s-device-plugin:v1.1.3
172.1.1.34:5000/library/cambricon-k8s-device-plugin:v1.1.3

这里使用 -q 参数只打印了镜像名。然后我们找到了存在在这台寒武纪机器上的这个 172.1.1.34:5000/library/cambricon-k8s-device-plugin:v1.1.3 镜像。现在我们的目的是把这个镜像 tag 成 docker-registry.i.demo/library/cambricon-k8s-device-plugin:v1.1.3,其中 docker-registry.i.demo 姑且当做是内网 docker-registry 的域名地址。

经过查阅资料发现,ctr后续版本合并了 tag 命令,然而一个不幸的消息是我们的 ctr 版本是 v1.2.6,似乎并没有集成 tag 命令,于是只能另辟蹊径。

后来发现,我们可以尝试先把镜像 export 出来成为 tar 包,然后再 import 回去,ctr 在 import 镜像的时候支持重新写个名字。这样看来这么绕一下看起来是可行的:

1
2
3
4
5
$ ctr -n k8s.io i export cambricon-k8s-device-plugin-v1.1.3.tar 172.1.1.34:5000/library/cambricon-k8s-device-plugin:v1.1.3
$ ctr -n k8s.io i import cambricon-k8s-device-plugin-v1.1.3.tar --base-name docker-registry.i.demo/library/cambricon-k8s-device-plugin
unpacking docker-registry.i.demo/library/cambricon-k8s-device-plugin:v1.1.3 (sha256:4b67b0f9c5c7fee7ed80409b2df4f68d4fa5dd170af88e7281fab7c88cbbd62a)...done
$ ctr -n k8s.io i ls -q | grep docker-registry.i.demo
docker-registry.i.demo/library/cambricon-k8s-device-plugin:v1.1.3

可以看到非常成功,我们成功做了一个 tag 操作。虽然看起来比较蠢,似乎下载一个新版的 ctr 就可以实现。但是如果客户环境不通互联网的话,这类 hack 操作还是比较好的救命稻草。

之后我们就可以使用 ctr i push 把镜像 push 上去了

1
$ ctr -n k8s.io i push docker-registry.i.demo/library/cambricon-k8s-device-plugin:v1.1.3

大功告成

一、起因

在开发机器上跑 bazel 构建命令的时候会报错

1
missing input file '//projects/aaa/bbb:public/docs/home/个人中心.md'

但是实际上是存在这个文件的。执行 ls 对应目录发现下面的报错

1
2
$ ls xxx/
_sidebar.md ????????????.md ????????????.md ????????????.md ????????????.md ????????????.md ????????????.md

比较奇怪,问了其他同学说没有这个问题。

二、解决

自己搜索了半天,终于找到一个靠谱的知乎问题:用 macOS 自带的 SSH 登陆 Linux 后出现中文乱码,如何解决?

按照提示 sudo vim /etc/ssh/ssh_config 之后,注释掉 SendEnv LANG LC_* 即可解决。

需要注意的是,该问题每次 MacOS 系统升级之后都需要手动配置一下,似乎每次系统升级都会把 SendEnv LANG LC_* 加回去。

问题背景

gloo 是一个开源的基于 envoy 的云原生 api 网关,其用 K8S 的 CRD 对于网关路由表等进行了一些封装,使得能通过云原生的方式配置 api 网关,提供灵活的 gateway 网络管控。

我们在项目中使用 gloo 的过程中,将其作为最上层的网关,对接了一个 kubernetes apiserver 的 upstream,在对接过程中,遇到了 这个问题,主要是我们发现 envoy 的 ext_authz 模块不支持把 append response header,只支持 set response header,然而 kube-apiserver 有一个 –requestheader-group-headers 的概念,通过这个 Header 能读到用户的 Groups,但是如果采用 envoy 的 ext_authz 模块的话,就会导致最终只读到一个 Group,而不能拿到用户所有的 Groups。

解决方案

1. PR

我给 envoy 官方提了一个 PR,目前已经合并,这个 PR 合并之后,我又给 gloo 项目提了一个 PR,这个 PR 已经被合并了,gloo 1.10 版本终于支持了相关的功能。

2. 低版本打 patch

不幸的是,我们的线上环境还在用 gloo 1.5.5 版本,还没有相关的功能,因此需要手动给 1.5.5 版本打一个小 patch。

为了更方便的打 patch 和使用,我们就没有采取 PR 中的方式来做,而是直接没有考虑兼容性,直接给 Set header 的部分加了一个 append 功能,牺牲了兼容性,但是改起来更加方便了,具体的改动步骤如下

2.1 循线追踪

问题发生在 gateway-proxy 相关的 pod 中,因此我们需要改的是 gateway-proxy 的 image。即 solo-io/gloo-envoy-wrapper 这个镜像。而 solo-io/gloo-envoy-wrapper 的 base 镜像是 solo-io/envoy-gloo,这个镜像的主要源头在 envoy-gloo 这个项目中,下面简单说一下如何查询并做代码改动:

  1. 查看 https://github.com/solo-io/gloo/blob/v1.5.5/Makefile#L32 可以看出,envoy-gloo 镜像版本为 quay.io/solo-io/envoy-gloo:1.16.0-rc4
  2. 查看 https://github.com/solo-io/envoy-gloo/blob/v1.16.0-rc4/bazel/repository_locations.bzl#L4 可以看出 envoy 版本用了 https://github.com/yuval-k/envoy 的 8f4d759cb53493159bcba921d6109eace48cb6da commit
  3. 查看 https://github.com/yuval-k/envoy/blob/8f4d759cb53493159bcba921d6109eace48cb6da/source/extensions/filters/http/ext_authz/ext_authz.cc#L164 更改 setCopy 为 addCopy

2.2 更改 envoy 代码

通过更改 envoy 代码可以形成下面的 patch

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/source/extensions/filters/http/ext_authz/ext_authz.cc b/source/extensions/filters/http/ext_authz/ext_authz.cc
index d6eede1..bb10544 100644
--- a/source/extensions/filters/http/ext_authz/ext_authz.cc
+++ b/source/extensions/filters/http/ext_authz/ext_authz.cc
@@ -179,7 +179,7 @@ void Filter::onComplete(Filters::Common::ExtAuthz::ResponsePtr&& response) {
ENVOY_STREAM_LOG(trace, "ext_authz filter added header(s) to the request:", *callbacks_);
for (const auto& header : response->headers_to_set) {
ENVOY_STREAM_LOG(trace, "'{}':'{}'", *callbacks_, header.first.get(), header.second);
- request_headers_->setCopy(header.first, header.second);
+ request_headers_->addCopy(header.first, header.second);
}
for (const auto& header : response->headers_to_add) {
ENVOY_STREAM_LOG(trace, "'{}':'{}'", *callbacks_, header.first.get(), header.second)

2.3 改动并编译 envoy-gloo 代码

2.3.1 改代码

把上面的 patch 放到 bazel/external/envoy.patch,然后需要更改的代码如下:

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
diff --git a/bazel/repositories.bzl b/bazel/repositories.bzl
index 4128f75..129d716 100644
--- a/bazel/repositories.bzl
+++ b/bazel/repositories.bzl
@@ -28,6 +28,10 @@ def _repository_impl(name, **kwargs):
(location["tag"], name),
)

+ if "patches" in location:
+ kwargs["patches"] = location["patches"]
+ kwargs["patch_args"] = ["-p1"]
+
if "commit" in location:
# Git repository at given commit ID. Add a BUILD file if requested.
if "build_file" in kwargs:
diff --git a/bazel/repository_locations.bzl b/bazel/repository_locations.bzl
index 6a6509b..99a7801 100644
--- a/bazel/repository_locations.bzl
+++ b/bazel/repository_locations.bzl
@@ -3,6 +3,7 @@ REPOSITORY_LOCATIONS = dict(
envoy = dict(
commit = "51faa5a74f8e19d914078a02acd7b19565b583e8",
remote = "https://github.com/yuval-k/envoy",
+ patches = ["//bazel/external:envoy.patch"],
),
inja = dict(
commit = "4c0ee3a46c0bbb279b0849e5a659e52684a37a98"

2.3.2 编译

建议在镜像中做编译。查看 https://github.com/solo-io/envoy-gloo/blob/v1.16.0-rc4/cloudbuild.yaml#L2 可以看到使用的镜像为 envoyproxy/envoy-build-ubuntu:e7ea4e81bbd5028abb9d3a2f2c0afe063d9b62c0-amd64,然后执行

1
2
3
4
git clone https://github.com/solo-io/envoy-gloo && cd envoy-gloo
git checkout v1.16.0-rc4
docker run --rm -it -v $PWD:/src -w /src \
envoyproxy/envoy-build-ubuntu:e7ea4e81bbd5028abb9d3a2f2c0afe063d9b62c0-amd64 ./ci/do_ci.sh bazel.release

即可以开始编译。注意最好在 Linux 下编译,建议内存为 16Gi。

编译完毕之后,参考 https://github.com/solo-io/envoy-gloo/blob/v1.16.0-rc4/cloudbuild.yaml#L12 的命令得知 linux/amd64/build_release_stripped/envoy 是实际使用的二进制包,利用下述命令打镜像

1
2
cp -f linux/amd64/build_release_stripped/envoy ci/envoy.stripped
TAGGED_VERSION=v1.16.0-rc4-patch make docker-release

如果 docker build 太慢,可以考虑把 ci/Dockerfile 改为下述的形式,其中 1.16.0-rc4 根据版本来选择

1
2
FROM quay.io/solo-io/envoy-gloo:1.16.0-rc4
ADD envoy.stripped /usr/local/bin/envoy

可以看到打了 quay.io/solo-io/envoy-gloo:1.16.0-rc4-patch 镜像,注意这里会自动执行 push 操作,其实是 push 不上去的,可以忽略 push 的报错。

2.4 打 gloo-envoy-wrapper 镜像

1
2
3
4
git clone https://github.com/solo-io/gloo && cd gloo
git checkout v1.5.5
ENVOY_GLOO_IMAGE=quay.io/solo-io/envoy-gloo:1.16.0-rc4-patch make gloo-envoy-wrapper-docker
docker tag quay.io/solo-io/gloo-envoy-wrapper:1.5.5 quay.io/solo-io/gloo-envoy-wrapper:1.5.5-patch

就可以得到 quay.io/solo-io/gloo-envoy-wrapper:1.5.5-patch 镜像了,这个镜像就可以用于启动 gateway-proxy 了。

总结

总的来说,本文主要目的没有讲述 gloo 的 PR 相关的工作,着重点讲述了 gloo gateway-proxy 镜像如何制作的,希望通过本文,让大家能了解到 gloo 和 envoy 直接的一些联系,以及 gloo 是如何在 envoy 上做了一些插件和编译操作。