背景
在kubernetes中, secrets默认是明文存储在etcd中,具有很大的安全风险,可以配置KMS provider进行加密。但引入KMS provider是否会对apiserver造成影响,需要从性能和可用方面进行仔细考量。
架构
目前kubernetes调用kms进行加解密,我们需要提供一个kms-provider(或称kms-plugin),其利用公司内部的kms服务来实现加解密:
可以阅读官方文档Using a KMS provider for data encryption了解更多。
每次用户创建secrets的时候会请求apisever,apisever采用信封加密模型加密该secetes,具体流程为:
- apiserver生成随机 Data Encryption Key (DEK),用该DEK加密用户secrets;
- 然后调用kms-plugin加密该DEK,生成EncryptedDEK,在kms-plugin内部会继续调用kms服务进行加密;
- 加密完成后,EncryptedDEK被附加到secrets前面作为header,然后存入ETCD,以便后续读取时能够通过该header找到对应DEK解密数据。
当用户读取该secrtes的时候,执行该逆过程。apiserver与kms-plugin通过local domain socket
gRPC通信。
kubernetes为了提高性能,允许用户在apiserver中使用LRU算法缓存DEK与对应的EncryptedDEK,对于secrets读取请求,每次先去cache中查找,如果找不到对应记录,才去调用kms-plugin。
可用性
- 根据上述流程,对于可能产生异常的点主要是kms-plugin 和kms服务。由于kms被kms-pluigin调用,当kms异常的时候我们可以使kms-plugin随之抛出异常, 所有问题转换为如何使kms-plugin挂掉的时候,自动failover。
- 要实现kms-plugin的高可用,可以设置多个副本,apiserver通过LoadBalancer访问该多个副本。但是遗憾的是目前kubernetes不支持对kms-plugin的远程调用,只能通过domain socket与本地kms-plugin通信。如果修改kubernetes代码实现远程调用,内部维护代码差异成本较大,且需要考虑调用直接如何进行认证授权。
- 如果非要让apisever通过本地调用kms-plugin, 那么在kms-plugin挂掉的时候,就做到服务降级。服务降级: 在服务不可用的时候不能影响核心功能的正常使用,核心功能定义为secrets的读取。服务降级是利用apiserver内部cache实现: 设置足够大的cache size,使每个key都能缓存在其中,每次secrets读取操作都能够被cache命中,即使kms-plugin挂掉下,也不影响secrets的正常读取。此时写入/更新操作会失败,但secrets写入操作较少。设置足够大的cache size带来的性能和内存空间占用问题参见下文性能部分。
- 如果利用cache进行服务降级,也是有一些问题:目前apiserve每次写入ETCD都会重新生成一个DEK,即使同一个secrets update也会生成新的DEK,该DEK缓存到cache中。那么就会出现如果一个secrets在短时间内多次更新,该DEK会迅速占慢整个缓存,导致其他secrets DEK被挤出去。当kms-plugin挂掉的时候,如果该DEK又没在cache中,如果用户请求这些secrets就会失败。解决方式 可以短期内先上线缓存的方式, 通过配置报警监测: 1). 短时间内大量的secret写请求,2). cache size的空间变化。通过运维方式解决该问题。 长期方案需要收集更多的场景再决定是否需要支持远程调用,从根本上解决问题。
性能
性能问题需要弄清楚当前集群内secrets的:使用方式,使用场景,使用规模,apiserver内存overhead,etcd存储空间overhead,QPS,请求时延等情况。
kubernetes中的内部使用方式
当前kubernetes用户使用secrets用来存放serviceAccount token,docker镜像的拉取token, 证书以及用户自定义的其他secrets。secrets在k8s中使用方式如下:
kubelet侧使用
secrets用来存放用户敏感信息,在容器启动后会注入到容器中, 目前kubelet会在pod启动前拉取secrets, 由kubelet中secretsManager负责。无论是imagePullSecrets 还是serviceAccountsecrets,不同种类的 secrets 处理方式相同。kubelet中secretsManager可以通过三种方式管理secrets:
- get: 每次需要时kubelet都会重新调用get请求获取
- cache: 每次获取之后缓存起来,在失效时间内可以重复使用
- watch: 会使用informer监听secrets变化,使用informer内置的cache。使用watch机制可以避免大规模的get请求,减轻apiserver的负
由于kubelet对于每一个pod都会定期resync, resync时会由kubelet中volume Manager判断是否需要remount secrets volume, resync周期默认是1~1.5min。
对于volume 文件形式使用的secrets, 在secrets发生更新后会及时同步到容器内,以env使用的secrets则会在下次重启才能生效。
此外社区增加了imutable secrets,其实现也比较简单, 只是在kubelet中做判断,如果是imutable的类型,在采用watch机制时,第一次拉取过来后就会停止这个secrets的reflector 不再监听随后的更新事件。
apiserver侧使用
apiserver侧处理流程和其他的资源处理流程相同,只是多了一个加解密过程,此处不再赘述。除此之外一些细节包括:
- apiserve在启动的时候会用informer获取所有的secrets用来做authentication,一个serviceAccout Token请求过来之后,会判断对应的secrets是否已经删除,信息是否发生改变,该参数可以由
--service-account-lookup
指定是否开启。 kms-plugin挂掉可能会导致authentication功能有部分缺失。该authendication功能会在apiserver启动的时候用informer list所有的secrets资源, 此时就会初始化apiserver中的key cache。 - apiserver 暴露的/healthz 接口里可以查看kms-plugin是否正常, kube-controller-manager在启动的时候会请求该接口,如果接口返回成功才会启动。
使用场景
kubernetes中secrets的使用场景主要是service account token, docker images token及用户自己创建的各种token,这里主要介绍一下service account token,其他token较为简单。
service Account
默认每个service Acount都会关联一个secrets,当namespace创建完成后, controller-manager中service account controller会自动创建一个service account, 同时token controller会自动创建一个token关联该service accout并存储在secrets中。 该token为jwt token, 包含了service account的信息, 用户可以用该token请求apisever, apiserver 收到该token后在authentication模块中校验该jwt token是否有效,然后取出token中的身份信息,该认证过程并不涉及secrets 的访问。该token只做认证并不会授权,如果用户希望有特定的权限,需要为该serviceAcount绑定到对应的Role上进行授权。 该service account对应的token一般创建后变更较少,serviceAccout不删除则对应的sercrets就不会变化。
pod中使用的service account的关联secrets 是在apiserver admission controller中自动注入的, 会以volume的形式挂载进容器中,然后动态同步更新变化。如果想关闭该secrets的自动挂载,可以1.从pod中单独关闭,2.可以在service account的定义中关闭, 3.也可以在apiserver中关闭这个admission plugin。关闭自动挂载secrets功能对于已经有的正常运行的pod没有影响, 新创建pod如果要访问apiserver就需要用户手动挂载service account对应的secrets。
对于service account的token 如果开启了 Service Account Token Volumes
和Bound Service Account Tokens
新feature之后, 使用的方式会发生变化, token是带有失效期的, 每次需要重新请求token request的接口来重新生成service account。
空间占用:
空间占用我们需要从两方面衡量: ETCD内存储空间overhead,apiserver key cache内存空间overhead。
ETCD:
要想弄明白ETCD的存储空间overhead,我们首先得明白secrets在etcd中的存储格式。由于kms在加密数据的时候可能会造成数据长度发生变化,这部分长度变化也需要仔细衡量,根据所采用的kms而略有不同。
secrets被写入ETCD时,格式为: prefix + kms插件名 + key-len + encrpt(key) + aes
- key: 32 bytes, 随机生成
- encrpt(): keycenter加密函数,根据不同的加密方式略有差异
- key-len: 标识key的长度, 目前占用两个字节来存放信息
- prefix: 固定为: k8s:enc:kms:v1:
- kms插件名: 自定义,在配置文件中指定
举例来说: 当执行kubectl create secret generic secret1 -n default --from-literal=mykey=mydata
命令时,创建secrets原始数据raw-data大小为: 221 byte。假设encrpy函数调用kms-plugin之后增加大小为2byte,则总共大小为
- prefix: 15bytes,
- kms插件名: 此处假设为myKmsPlugin, 长度为11bytes+1bytes(冒号),
- key-len: 2bytes
- encrpt(key)为32+2=34 bytes
- aes (raw-data)分为: blockSize+len(data)+paddingSize = 16+221+3 = 240byte
则上述总共加起来大小为: 303 bytes。
[root@k8s-test-master01 ~]# etcdctl get /registry/secrets/default/secret1 -w=json | jq .
{
"header": {
"cluster_id": 14841639068965180000,
"member_id": 10276657743932975000,
"revision": 5688234,
"raft_term": 20
},
"kvs": [
{
"key": "L3JlZ2lzdHJ5L3NlY3JldHMvZGVmYXVsdC9zZWNyZXQx",
"create_revision": 5688228,
"mod_revision": 5688228,
"version": 1,
"value": "azhzOmVuYzprbXM6djE6bXlLbXNQbHVnaW46ACJlbiZ71Al+94uK9wUqKhrKzCoykswbx6mgdeL/9OPuj774yFIS06TsmxTf4qYMzWhirz3jz4w9ttBl8eqZZXtqwpH/jUWrRus8uoC4jH7Ezy7nn3tFXZ+ykPb6xfnje0lr9ZsWJ11QHu6wfP27p96tydL84TfG9dgHGYLRYblW5XZU3kNO+YDjlm/ybaDCbn22t6qG2OhDhbEbIpiv/UZuye9NbEIPyHtEFFJHC9QRX+XjjW/kdZUqzgZqMbsHaXa0VqePWpwJH84r+KsDdqZnldiC1qfQ83vdTp1IKtwyEeozkhFiYA4z/0LX6K38jvS3hUT80tacQehn664LeEgHiBGsRPB7M+rSmU6aneUzkqQp"
}
],
"count": 1
}
[root@k8s-test-master01 ~]# echo azhzOmVuYzprbXM6djE6bXlLbXNQbHVnaW46ACJlbiZ71Al+94uK9wUqKhrKzCoykswbx6mgdeL/9OPuj774yFIS06TsmxTf4qYMzWhirz3jz4w9ttBl8eqZZXtqwpH/jUWrRus8uoC4jH7Ezy7nn3tFXZ+ykPb6xfnje0lr9ZsWJ11QHu6wfP27p96tydL84TfG9dgHGYLRYblW5XZU3kNO+YDjlm/ybaDCbn22t6qG2OhDhbEbIpiv/UZuye9NbEIPyHtEFFJHC9QRX+XjjW/kdZUqzgZqMbsHaXa0VqePWpwJH84r+KsDdqZnldiC1qfQ83vdTp1IKtwyEeozkhFiYA4z/0LX6K38jvS3hUT80tacQehn664LeEgHiBGsRPB7M+rSmU6aneUzkqQp | base64 -d | wc -c
303
综上所述,使用该测试kms加密secrets对于etcd中每一个secrets在空间上的overhead大小约为160个字节左右。
apiserver 内存overhead
衡量apiserver中用来存放secrets key的cache占用内存大小较为困难,由于只是存放key的明文和密文的对应关系,初步估计不会占用太多空间。因为我们无法区分内存的增长是由于cache内数据增加了还是由于其他的操作申请了更多的内存,此处只能粗略计算。一种合理的测试方式为: 启动apiserver,不请求secrets,保持该cache为空,等到apiserver内存平稳之后,请求一定数目的secrets, 此时会填充该cache, 对比前后内存的占用量。但是前面提到,apisever内部会有一个secrets informer,在启动的时候就会list一遍所有的secrets,这导致该cache在启动是就被填满了。为了防止启动时就填充该cache,笔者修改了apiserver代码,关闭了所有k8s组件请求secrets地方,最后通过压测模拟,10w 个secrets cache初始化前后的apiserver内存占用,发现cache size大概占用小于200Mib/10w secrets。这个内存占用量还是可以接收的。
请求延时
请求secrets时,如果secrets key时没有被cache命中,就需要重新获取加解密的key, 需要重新调用kms服务,所以请求延时主要在于kms的请求延时,这部分也根据不同的kms服务略有差异。此外对于CRUD请求延迟影响较小,影响最大的当属list请求,不过这些请求访问量较小,不必特别在意。
结语
虽然是一个小小的改动,但是在上线之前还是要充分弄请求其原理,进行必要的功能测试,压力测试等,设置合适充分的报警,这样才能防患于未然。