ServiceAccount 的设计

什么是 ServiceAccount

K8s 集群内部的 Pod 通过 ServiceAccount 来访问 API Server,ServiceAccount 标识了一个 Pod 的身份。每一个 Pod 都有且仅绑定一个 ServiceAccount,如无特别指定,Pod 默认会与对应 Namespace 下的默认 ServiceAccount 绑定(每一个 Namespace 会默认有一个名为 default 的自动创建的 ServiceAccount)。集群管理者可自定义创建 ServiceAccount,并在创建 Pod 的时候指定对应 Pod 与其关联,从而给 Pod 绑定一个不同于 default 的身份。

多个不同的 Pod 可以绑定同一个 ServiceAccount,但一个 Pod 只能绑定一个 ServiceAccount

ServiceAccount 可与 RBAC 机制结合,从而通过 RBAC 规则来为 ServiceAccount 下的 Pod 设置对应的权限规则。

ServiceAccount Admission Plugin 的逻辑

K8s 利用 Admission 插件机制来使每一个 Pod 都与一个 ServiceAccount 绑定。ServiceAccount Admission Controller 默认在 API Server 处于开启状态,它将完成以下工作(参考源码: kubernetes/plugin/pkg/admission/serviceaccount/admission.go):

  • 在创建 Pod 的时候检查对应 Pod 是否有绑定 ServiceAccount,如果没有,绑定 default ServiceAccount
  • 获取 Pod 对应的 ServiceAccount,如果找不到,拒绝创建 Pod
  • 获取 ServiceAccount 对应的 Secret 对象并将其挂载到 Pod 里每一个容器的 /var/run/secrets/kubernetes.io/serviceaccount

serviceaccount_controller 的逻辑

每一个 Namespace 在创建的时候都会自动生成一个 default 的 ServiceAccount,这个状态保证是由 serviceaccount_controller 完成(参考源码:kubernetes/pkg/controller/serviceaccount/serviceaccounts_controller.go)。

tokens_controller 的逻辑

每一个 ServiceAccount 都会有一个对应的 Secret 对象,这个状态保证是由 tokens_controller 完成(参考源码:kubernetes/pkg/controller/serviceaccount/tokens_controller.go)。

每当创建 ServiceAccount 时,tokens_controller 就会监听到这一事件,并生成对应的 Secret 对象。Secret 对象中最关键的数据是 JWT 形式的 token,其中 Payload 的格式如下 JSON 所示(参考源码:kubernetes/pkg/serviceaccount/legacy.go):

1
2
3
4
5
6
7
8
{
    "iss": "kubernetes/serviceaccount",
    "sub": "system:serviceaccount:<namespace>:<serviceaccount-name>",
    "kubernetes.io/serviceaccount/namespace": "<namespace>",
    "kubernetes.io/serviceaccount/service-account.name": "<serviceaccount-name>",
    "kubernetes.io/serviceaccount/service-account.uid": "<serviceaccount-uid>",
    "kubernetes.io/serviceaccount/secret.name": "<secret-name>"
}

tokens_controller 采用 RS256 来进行签名。kube-controller-manager 在启动的时候会传入 --service-account-private-key-file 选项,从而利用此私钥来为 JWT 签名。

由于 JWT 的 Header 和 Payload 部分是未加密状态,所以我们可以用这样来查看对应 ServiceAccount 对应的 JWT:

1
2
3
4
5
# 从 Mountable secrets 字段中找到对应的 secret
$ kubectl describe sa <serviceaccount_name> -n <namespace>

# 从上面获取到 secret name 进一步找到对应的 token
$ kubectl describe secret <secret_name> -n <namespace>

将 token 字段输入到 https://jwt.io 的在线 Encoded 中即可解析出对应的字段。

客户端如何使用 ServiceAccount

客户端通过 client-go SDK 的 rest.InClusterConfig()来使用 ServiceAccount 访问 API Server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
...
// creates the in-cluster config
config, err := rest.InClusterConfig()
if err != nil {
    panic(err.Error())
}

// creates the clientset
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
    panic(err.Error())
}

// use clientset to access API Server
...

其实在 rest.InClusterConfig() 中,其逻辑只是从对应的 token 的挂载处(由 ServiceAccount Admission Plugin 保证)读取 JWT,然后配置在 rest.Config{}BearerToken 字段中。这样每次向 API Server 发起 HTTP 请求时都将携带这个 BearerToken,API Server 通过 BearerToken 字段来进行 Authentication 和 Authorization:

1
Authorization: Bearer <token>

我们可以用如下方式来人为得以某个 ServiceAccount 访问 API Server:

1
curl -H "Authorization: Bearer <token>" -k https://10.233.0.1:443/apis

其中 token 可用上文提及的方式获取。

ServiceAccount 的安全风险

根据 proposal 中所提出的,ServiceAccount 目前有如下安全风险

  1. ServiceAccount 与之对应的 JWT 并没有 audience bound,任何人都可以接收对应 JWT 的请求
  2. JWT 以 Secret 方式提供,并挂载到对应的容器内部,这将扩大攻击面(比如任意文件访问漏洞)。只要可以看到对应 ServiceAccount 的 Secret,都可以拥有该 ServiceAccount 的对应的权限。API Server 只能根据 JWT 来区分请求者的身份
  3. JWT 没有 time bound 机制,其生命周期与对应的 ServiceAccount 一致。只要 ServiceAccount 存在,JWT 就将一直保持有效。JWT 也没有轮转机制,在其生命周期内,JWT 都将保持不变

解决方案

Bound Service Account Tokens

概述

根据 ServiceAccount 的缺点,Bound Service Account Tokens 方案结合 Service Account Token Volumes 方案共同实现了一种客户端逻辑无需额外修改逻辑的安全加强方案(可能需要升级 client-go 的版本,视具体的客户端使用情况而定)。

binding 机制和 API

根据 community 的 proposal 社区提出了 Bound Service Account Tokens 方案。之所以叫 Bound ,则是因为该方案实现了以下几种 binding:

Audience binding

方案在原来的 JWT Payload 中增加了 aud 字段。该字段接受 strings 数组(通常是 URLs)。token 的接收者(通常是 API Server)必须验证这个字段是否与自身配置匹配,如不匹配,则拒绝 token。kube-apiserver 中使用 --api-audiences 来配置自身的 audience。只有 token 中 aud 字段与 --api-audiences 配置匹配,token 才予以接受

Time binding

方案在原来的 JWT Payload 中增加了以下与 Time 相关(即时间戳)的字段:

  • exp:token 过期时间戳(expiration time);
  • nbf:token 在 nbf 时间戳之前是无效状态(not before);
  • iat:token 签发的时间戳,通常和 nbf 是一样的(issued at);

token 的接收者(通常是 API Server)必须检查 token 的有效期。如果 token 已经过期,则拒绝 token。

API Server 通过 --service-account-max-token-expiration 来设置创建 token 的最大有效期。

由于增加了时间戳字段,所以每次获取 token 时都将不一样。这实现了在对应绑定资源的生命周期内,token 将具备一定的轮转能力;

Object binding

token 可以与对应的 Object 进行绑定,目前支持与 Pod 和 Secret 对象进行绑定。只有在被绑定对象仍存活,token 才可以一直有效。这使得 token 的生命周期是与实际进行访问的资源(比如 Pod)一致的;

除了 binding 之外,方案还提出了两种 API

  • TokenRequest API

    由符合权限的 Pod 向 API 发出 TokenRequest API 来获取 token。当 Pod 拿到这个 token 之后,可以这个 token 向 API Server 进行访问;

  • TokenReview API

    TokenReview API 可以验证 token 的有效性;

Service Account Token Volumes

为了兼容原来客户端使用 ServiceAccount Token 的方式,这个 proposal 提出了 Service Account Token Volumes 机制。这个方案提出了 Token Volume Projection 的方式,即将 TokenRequest 的动作作为一个 Volume mount 进容器中(参考源码:kubernetes/pkg/volume/projected/projected.go,需要开启 Feature Gate 中 TokenRequestProjection 功能,该功能在 Kubernetes 1.14 版本默认为 beta)。在 kubelet volume manager 的 reconcile 周期,当 token 过期后,kubelet 会主动更新 token 对应的文件,从而达到一个主动轮换的功能(参考源码:kubernetes/pkg/kubelet/token/token_manager.go)。

比如文档中举的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 apiVersion: v1
 kind: Pod
 metadata:
   name: sa-token-test
 spec:
   containers:
   - name: container-test
     image: busybox
     volumeMounts:
     - name: token-vol
       mountPath: "/service-account"
       readOnly: true
   volumes:
   - name: token-vol
     projected:
       sources:
       - serviceAccountToken:
           audience: api
           expirationSeconds: 3600
           path: token

例子中容器下的文件 /service-account/token 即关联了一个 TokenRequest。当对应的 token 过期后(比如 3600 秒之后),kubelet 将自动向 API Server 发出 TokenRequest 获取新的 token 并更新对应的文件。

这个方案如果要与之前的 serviceaccount 目录下的文件达成兼容,还需要手动用 configmap 的形式注入集群公钥证书和用 Downward API 的形式获取 Namespace 的信息,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
...
      volumes:
        - name: token-vol
          projected:
            sources:
            - serviceAccountToken:
                 audience: kubernetes.svc.cluster.local
                 expirationSeconds: 600
                 path: token
            - configMap:
                name: kube-root-ca.crt
                items:
                - key: ca.crt
                  path: ca.crt
            - downwardAPI:
                items:
                - path: namespace
                  fieldRef:
                   fieldPath: metadata.namespace
...

这样配置之后,serviceaccount 目录才能与之前保持兼容。这应该是一种过渡的方案(手动在 Pod 部署文件中触发),等未来 BoundServiceAccountTokenVolume 功能开启之后,这部分动作将在 admission plugin 的环节自动完成(改功能将自动把集群根 CA 发布到 configmap 上,并在每次创建 Pod volume 是自动加上 downward API 和 configmap 等 volume 配置,可参考源码 kubernetes/plugin/pkg/admission/serviceaccount/admission.gokubernetes/cmd/kube-controller-manager/app/certificates.go)。

RBAC 规则

此方式获得的 token 尽管绑定了 Pod,但是 Subject 仍然是某一个对应的 ServiceAccount,所以 Pod 所拥有的权限仍然是对应 ServiceAccount 所拥有的权限。

client-go 周期性读取 token 文件

为了能让客户端更新对应的 token,client-go 在 transport 这一层定义了一些逻辑(参考源码:client-go/transport/token_source.go),在客户端周期性地读取 token 文件,并在发出 http 请求的时候更新对应的头部。

参数配置及其版本演化

如果要同时开启 Bound Service Account Tokens 和 Service Account Token Volumes 功能,必须同时满足以下几个条件

  • Kubernetes 社区版本大于或等于1.13 版本(client-go 版本大于或等于 1.12 版本);

  • 在 kube-apiserver 中进行相应的启动参数配置,如下文所示;

kube-apiserver 中的命令参数配置

与 Bound Service Account Tokens 相关的 kube-apiserver 命令行参数有:

  • --api-audiences:kube-apiserver 中使用 --api-audiences 来配置自身的 audience。只有 token 中 aud 字段与 --api-audiences 配置匹配,token 才予以接受。从 API Server 申请(TokenRequest)的 token 对应的 aud 字段即为该字段;

  • --service-account-issuer:service account token 的 issuer。签发的 token 中 iss 字段;

  • --service-account-key-file :API Server 用来验证 token 的证书;

  • --service-account-max-token-expiration:设置创建 token 的最大有效期;

  • --service-account-signing-key-file:用来给 token 签名的私钥;

  • 手动开启 BoundServiceAccountTokenVolume 的 Feature Gate;

对应的 Feature Gate(可参考 kubernetes/pkg/features/kube_features.go 或者对应文档):

Feature Gate 默认开启状态 功能 Kubernetes 支持版本
TokenRequest BETA - default=true 支持 TokenRequest 功能 大于或等于 Kubernetes 1.12 版本
TokenRequestProjection BETA - default=true 支持以 Project Volume 的形式启动 Bound Service Account Token Volume 大于或等于 Kubernetes 1.12 版本
BoundServiceAccountTokenVolume ALPHA - default=false 默认将 serviceaccount 下的 token 与对应的 TokenRequest 绑定,自动注入集群公钥等; 自 Kubernetes 1.13 版本起以 ALPHA 特性提供,默认关闭,直到目前最新版本 1.16 依然是 ALPHA 特性

这三者同时开启,将使能 Bound Service Account 的所有功能,且原来的用户配置无需做任何新的配置,以此兼容之前的使用方式。

Webhook

概述

K8s 还定义了一种 Webhook 的方式来对其他类型的 token 进行 Authentication(参考文档)。kube-apiserver 提供了两个启动参数:

  • --authentication-token-webhook-config-file:定义远程 Authentication 服务的配置,比如主机名和端口等;
  • --authentication-token-webhook-cache-ttl:为了提升认证的性能,API Server 默认会缓存结果,该配置设置缓存的 TTL(默认 2 分钟);

这样,我们可以将 token 的生成和 token 的认证都从 K8s 解耦出去,从而可以实现定制化更强的 token 方案。

客户端依然用 bearer token 的方式向 API Server 发出 HTTP 请求,API Server 收到请求后取出头部的 token,然后根据 Webhook Config 定义的配置,向远处认证服务发出一个 TokenReview API,从而确认 token 有效性。

开源实现

在 GitHub 上搜索了一下,发现做这一块服务的开源实现并不多,其中 AppsCode 公司的 guard 项目可以看一下。这个组件实现了一个 Webhook Authentication Server,可支持多种 Auth Provider(比如 GitHub、GitLab、Google 等)。这个项目不是很复杂,相对也不是很成熟,不过写一个 Webhook Authentication Server 也不是一件特别复杂的事情。

OpenID Connect Tokens

概述

K8s 支持以 OpenID Connect 方式接入(参考文档)。当在 kube-apiserver 配置了 OpenID Connect 的相关参数后(比如 --oidc-issuer-url 等),当 API Server 收到 OpenID Connect 形式的 token 后,会根据 OpenID Connect 的协议去验证对应 token 的有效性。

选用这种方式,我们需要提供 OpenID Connect Provider(OIDC),比如 CoreOS 的 dex。采用这种方式,我们可以接入一种更标准的方式去控制 token 的生成和鉴权规范。

方案比较

方案 优点 缺点
Bound Service Account Tokens + Service Account Token Volumes 1. 无需额外开发,只需要跟进上游代码即可(目前成熟度已经到了 beta);
2. 只需要在 Pod 的配置脚本中增加 Project Volume 的选项;
3. 客户端初始化逻辑无需做太多改动(有可能需要升级 client-go SDK);
1. 灵活性弱,token 的生成和鉴权逻辑仍依赖于 K8s ;
自定义 Token + Webhook 1. 灵活性强,将 token 的生成和鉴权逻辑都从 K8s 中解耦出去,可以做相对较大程度的控制; 1. 需要额外开发 Token Provider 和 Webhook Auth Server;
2. 非 K8s 标准化 token 接入,需要客户端修改接入逻辑,较难推进;
OIDC 1. 对接 OpenID Connect 标准协议,可充分使用标准协议的生态环境;
2. 具备方案 2 的灵活性,但是无需自己实现 Webhook Auth Server;
1. 仍需要额外开发(或选用开源)OpenID Connect Provider;
2. 非 K8s 标准化 token 接入,需要客户端修改接入逻辑,较难推进;

初步比较,方案 1 是比较安全稳妥的策略。从目前社区的演进方向来看,Bound Service Account Tokens + Service Account Token Volumes 将成为一种默认的选项(等 BoundServiceAccountTokenVolume Feature 演进到 Beta 阶段),即未来的 ServiceAccount 的 token 将至少可以解决前文中提及的安全隐患,有效提升安全性。

参考文档