❝本文转自掘金 LEE 的博客,原文:https://juejin.cn/post/7195842444302123069,版权归原作者所有。
事件背景
某天早上刚到公司工位上,正准备开会。被一个业务组项目负责人抓住了,然后着急的说到:“老李啊,跟你说。昨天晚上准备要上线的 Ingressroutes 监控分析模块不能上线了,现在导致我们这边的数据清洗模块,根据计划今天下午应该能对接与接收数据的。 现在怎么办?”,我突然一愣,怎么回事?然后找昨天晚上负责的运维和研发一打听,才知道是因为在 Online Kubernetes 上部署的 Pod 如果需要调用 RBAC 中 token,都不予发放密文,然后申请流程被卡住了。这才导致了小伙伴拿到不到对应的 Access Token 导致 Pod 中的 Informer 没有办法抓取资源导致应用上线失败。
本想这个是一个小问题,没一会我去开会的时候,我就被紧急叫到另外一个会议室。刚进门就有人喊到:“老李,你来了,正好正好,xxxxxx”,果不其然还是那个事情。经过一段时间的故事发展后,就出现了一个需求,落在我们这边。
技术组的小伙伴想:有没有办法让发布的应用 Pod 在通过 Access Token 访问 ApiServer 的时候,不让申请者接触到 Access Token 的内容。
心智负担
虽然 Access token 是访问 ApiServer 一个凭证,在已有的 Kubernetes RBAC 管理系统上就可以完成申请和使用。但是随着时间推移,以及日常使用中,Access token 已经被人滥用,而且在公司内部企微聊天群内,各种 Access token 满天飞。我想这个也是安全组小伙伴忍无可忍的原因吧,实际上 Access token 已经失去管理的意义。
总结眼前这个事情,问题主要如下:
如果这个 Token 泄露,将给使用这个 Token 的应用带来很多安全风险。 Access token 这样的明文分发是接触式,安全组的小伙伴非常反对,希望我们能够提出一种无接触的方式。 Access token 还有一套发放管理系统,以及其他的系统的 Token 文件导入到处。系统过于繁杂,需要有人员管理和维护,以及数据存储等等问题。 每年公司技术安全评审会,Access token 的问题都是非常头痛,大量需要改造和提升的地方。
隐含的神经压力,以及使用流程上面临的很多挑战,都让人焦虑不已。如何解决这个问题?,我想好的办法是:在应用创建和维护的时候提供一个入口,让使用者自己关联应用到已经创建的 Access token,不在走申请 Access token,导出,然后在发布工具中导入。直接通过平台内部关联,直接使用。
既然这里说到是心智负担,但是真正负担在哪里?实际上面已经提到了心智负担的核心内容:就是如何让使用者真正的无接触,将应用与已经创建的 Access token 关联。
有想法的小伙伴会说:“不就是后端服务打通下?有什么好说的?嘶嘶嘶。”,我想说,既然老李出马,就不会这么简单,一定有比这个更优雅的方案,请各位客官耐心往下看。
前置知识
经过一段时间的调研和方案讨论,我们实际明确知道这样做可以减少 Access Token 的浪费,以及提高 Access Token 的安全性,同时也可以简化日常 Access Token 申请与使用的流程复杂度(因为是无接触式的,必然导致安全审核方式以及发放方式比传统的接触式要少很多)。
在动之前还是要准备些知识,还要做好方案设计,这样才能做到:测底从底层解决问题,而不是单纯的从前端 web 换到了后端接口
分享下我理解的一个 Access Token 如何与一个 Deployment 优雅关联的。
有的小伙伴看到这个图觉得有点眼熟,估计马上就想到了 Deployment 与 Configmaps、Secret 这类资源的 VolumeMounts 方式嘛?No!! No!! No!! 都说了“更优雅的方案”,是更有意思的方式。
不卖关子了,官方文档:Opt out of API credential automounting[1]
关键词:serviceAccountName
RBAC
要无接触式的使用 Access Token 之前,还需要了解下 RBAC 的一些概念。
官方文档: Using RBAC Authorization[2]
如果需要了解更多的中文相关内容,小伙伴可以自行 baidu 下,很多相关内容。而这里主要是说 SA、Role、Binding 3 者之间的关系,并用大白话定义他们。
RBAC 用大白话解释:
我是谁 (Who am i) : 对应 ServiceAccount,表示了当前这个 Token 对应的身份是什么? 我能干嘛 (What can i do): 对应 ClusterRole/Role,对资源的权限控制,表示这个规则在 Kubernetes 中对指定资源拥有什么样权限或者控制策略。 我在哪里 (Where am i): 对应 ClusterRoleBinding/RoleBinding,将 Role 与 ServiceAccount 进行绑定,告诉 Token 在什么地方或者资源上生效。
后在创建 RBAC 对应的 namespace 中产生一个 secret 的资源,而这个资源里面就是对应的 Access Token。
Pod 的 ServiceAccount
在 Kubernetes 运行环境中,我们随便 describe 一个 Pod 的信息,都会发现在 Mounts 字段中有一个 secrets/kubernetes.io/serviceaccount ,这个 ServiceAccount 是 Kubernetes 默认给 Pod 挂载的,方便 Pod 内部应用访问 Apiserver,但是这个 ServiceAccount 的权限太小了,导致什么事情都做不了。
Containers:
application:
Container ID: docker://9e9c92065671dacd0b996e4e26bd6713f5f6d0f9e3d06fbce9c8f00b0b981ea0
Mounts:
/var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-2znnm (rw)
既然要无接触式的 Access Token 与应用关联,是不是通过手动替换这个 secrets/kubernetes.io/serviceaccount 就可以实现想要的效果呢?可以,Kubernetes 官方也建议这么使用。
如何关联
创建了 RBAC 资源,如何将这个 Access Token 与一个 Deployment 资源关联在一起?是不是还要把 Access Token 中 token 字段内容贴到 Deployment 内容中呢?不需要。看上面 serviceAccountName 的官方文档,文中有说到。
举个例子:
apiVersion: v1
kind: Deployment
metadata:
name: my-app
spec:
serviceAccountName: my-rbac ## 这里将创建好的 rbac 的 SA 账号名称与 Deployment 关联,完全不需要输入任何 Token
啊!就这?? 我说了一大段,后就这么一行?唉,我说过了:更优雅的方案,就是这么点单,就说优不优雅。
解决思路
当然有了前面的思路和“优雅”方案,是不是 Pod 内的应用程序不要修改呢?需要的。如果内部代码不修改的,下面底层做了再多的事情,还是没有效果。
那么我们需要怎么做才能让开发的代码使用 Pod 内部挂载好的 Access Token 呢?说到这里,我们不得不看看 client-go 的代码。
k8s.io/client-go@v0.26.1/kubernetes/clientset.go
// NewForConfig creates a new Clientset for the given config.
// If config's RateLimiter is not set and QPS and Burst are acceptable,
// NewForConfig will generate a rate-limiter in configShallowCopy.
// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient),
// where httpClient was generated with rest.HTTPClientFor(c).
func NewForConfig(c *rest.Config) (*Clientset, error) {
configShallowCopy := *c
if configShallowCopy.UserAgent == "" {
configShallowCopy.UserAgent = rest.DefaultKubernetesUserAgent()
}
// share the transport between all clients
httpClient, err := rest.HTTPClientFor(&configShallowCopy)
if err != nil {
return nil, err
}
return NewForConfigAndClient(&configShallowCopy, httpClient)
}
上面的代码就是我们创建一个 Kubernetes 客户端需要调用的函数,这个函数就一个入参:c *rest.Config。通过 rest.Config 来配置 Apiserver 和 Access Token 等信息。
我们继续往下追 rest.Config 看看源代码中是怎么定义的。
k8s.io/client-go@v0.26.1/rest/config.go
// Config holds the common attributes that can be passed to a Kubernetes client on
// initialization.
type Config struct {
// Host must be a host string, a host:port pair, or a URL to the base of the apiserver.
// If a URL is given then the (optional) Path of that URL represents a prefix that must
// be appended to all request URIs used to access the apiserver. This allows a frontend
// proxy to easily relocate all of the apiserver endpoints.
Host string
...
// Server requires Bearer authentication. This client will not attempt to use
// refresh tokens for an OAuth2 flow.
// TODO: demonstrate an OAuth2 compatible client.
BearerToken string `datapolicy:"token"`
...
}
其中 Host 和 BearerToken 这两个 String 就是定义 ApiServer 地址和 Access Token 的。马上就有小伙伴会问:“我们配置好的 ServiceAccount 怎么与这两个值关联在一起?”。
不着急,在回答这个问题之前,我们要知道一个新的名词定义:InCluster
InCluster 表示在集群内部,也就是说让 client-go 在创建 Config 的时候使用 InCluster 模式。我们继续看 InCluster 实现 InClusterConfig 代码是什么样的。
k8s.io/client-go@v0.26.1/rest/config.go
// InClusterConfig returns a config object which uses the service account
// kubernetes gives to pods. It's intended for clients that expect to be
// running inside a pod running on kubernetes. It will return ErrNotInCluster
// if called from a process not running in a kubernetes environment.
func InClusterConfig() (*Config, error) {
const (
tokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
rootCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
if len(host) == || len(port) == {
return nil, ErrNotInCluster
}
token, err := os.ReadFile(tokenFile)
if err != nil {
return nil, err
}
tlsClientConfig := TLSClientConfig{}
if _, err := certutil.NewPool(rootCAFile); err != nil {
klog.Errorf("Expected to load root CA config from %s, but got err: %v", rootCAFile, err)
} else {
tlsClientConfig.CAFile = rootCAFile
}
return &Config{
// TODO: switch to using cluster DNS.
Host: "https://" + net.JoinHostPort(host, port),
TLSClientConfig: tlsClientConfig,
BearerToken: string(token),
BearerTokenFile: tokenFile,
}, nil
}
看到代码中的 tokenFile 和 rootCAFile 中定义位置了吧,就是我们通过 serviceAccountName 将自定义的 ServiceAccount 挂载到 Deployment 中,后在 Pod 运行时,Access Token 挂载的位置。同时代码也会通过 host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") 获得 Apiserver 的 Ip 和 Port,后拼成字符串传递给 Host。
那我们要使用 InCluster 创建一个 Kubernetes 客户端怎么写代码呢?
举个例子:
package main
import (
"context"
"fmt"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func main() {
// creates the in-cluster config
config, err := rest.InClusterConfig()
if err != nil {
fmt.Println(err)
}
// creates the clientset
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
fmt.Println(err)
}
for {
pods, err := clientset.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{})
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("There are %d pods in the cluster\n", len(pods.Items))
}
time.Sleep(10 * time.Second)
}
}
是不是很简单,没有那么复杂。将代码编译打包成 Docker Image,然后在 Kubernetes 上部署下,查看日志就能看到结果了。
Console 输出:
## kubectl logs k8s-pod-test-699bd54dfd-g7qv8
There are 26 pods in the cluster
There are 26 pods in the cluster
写在后
当这个技术方案后被落地,并于内部系统完成融合,解决了“无接触式”的 Access Token 分发,而且整个过程没有太多的影响。当然这个也只是众多方案的中的一种解决方案,因为我们这边应用后端开发语言比较纯粹,而且底层调用这块都有一个项目组在维护 SDK,而这部分代码终合并到了 SDK 中,对整个研发日常开发代码没有任何影响。
经过一段时间方案试行,各方反馈都比较正面。
研发:没有繁琐的 Access Token 申请过程,与应用各种绑定也变得非常方便了。 运维:Access Token 自从“无接触式”后,很少有人来找,基本没有 Access Token 的问题。 安全:现在没有人在公司企微里面到处传 Access Token ,之前的失控得到很好的控制。
后还是比较欣慰的,一个小小使用流程上问题,后引发一套工具体系的大改革,真的是:“表层的问题,都是内在矛盾积累后的爆发”。
引用链接
Opt out of API credential automounting: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#opt-out-of-api-credential-automounting
[2]Using RBAC Authorization: https://kubernetes.io/docs/reference/access-authn-authz/rbac/