KMS 加密kubernetes secrets的正确姿势

背景

在kubernetes中, secrets默认是明文存储在etcd中,具有很大的安全风险,可以配置KMS provider进行加密。但引入KMS provider是否会对apiserver造成影响,需要从性能和可用方面进行仔细考量。

架构

目前kubernetes调用kms进行加解密,我们需要提供一个kms-provider(或称kms-plugin),其利用公司内部的kms服务来实现加解密:

kms-grpc-deployment-diagram

可以阅读官方文档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 VolumesBound 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>(raw-data)

  • 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,则总共大小为

  1. prefix: 15bytes,
  2. kms插件名: 此处假设为myKmsPlugin, 长度为11bytes+1bytes(冒号),
  3. key-len: 2bytes
  4. encrpt(key)为32+2=34 bytes
  5. 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请求,不过这些请求访问量较小,不必特别在意。

结语

虽然是一个小小的改动,但是在上线之前还是要充分弄请求其原理,进行必要的功能测试,压力测试等,设置合适充分的报警,这样才能防患于未然。

posted @ 2020-06-06 19:30  gaorong404  阅读(475)  评论(0编辑  收藏