在 Kubernetes 上使用策略对部署行为进行限制,仅允许运行有签名的镜像。
我们希望借助本文,让读者了解到如何在 Kubernetes 中使用可信镜像,其中依赖两个著名的 CNCF 开源项目:Notary 和 OPA。主要思路是使用 OPA 策略来定义自己的内容限制策略。
主要内容如下:
如果读者已经熟知 Notary 或者 OPA 的相关内容,可以跳过上述的两节基本概念部分。
如果要遵循后续的安装步骤,需要下列准备:
如果是 Kubernetes 集群,至少启用了 MutatingAdmissionWebhook
和ValidatingAdmissionWebhook
;如果是 Minikube,应该使用如下启动方式:
私有镜像库,或者一个 Docker Hub ID,用于推送签名镜像。
从我们的 Github 仓库获取用于安装 OPA、Notary 以及 Notary-Wrapper 的 Helm Chart。
将代码、可执行文件或者脚本进行签名,保障仅有受信内容才可运行,这是一个已知的最佳实践。软件签名不是什么新概念,有很多相关的供应商和方案,每个组织都有自己的方式来处理制品的签署和信任。然而如果把目光投向容器领域,可能会发现并没有那么多选择。
你可能已经听说过 Notary,这是一个基于 TUF 项目的用于软件制品签名的开源软件。
首先说说 Notary 的核心概念。Notary 使用角色和元数据文件对受信集合内容进行签署,这些内容被称为全局唯一名称(GUN——Global Unique Name)。
以 Docker 镜像为例,GUN 相当于 [registry]/[repository name]:[tag]
。
[registry]
是镜像的源仓库,[repository name]
是镜像的名称。[tag]
对镜像进行标记(通常代表版本)。
Notary 借助 TUF 的角色和密钥层级关系对镜像进行签名。有五种密钥类型用于对元数据文件进行签署,并用 .json
的方式保存到 Notary 数据库。下图描述了密钥层级以及这些密钥的典型存储位置。
管理密钥的 Notary 服务架构包括两个组件:
Docker 文档中这张 Notary 的示意图很好的概括了客户端与 Notary Server 以及 Signer 之间的通信。下图是一个简化版本:
如果时间戳过期,Notary 服务器会重新完成流程,生成新的时间戳,申请 Signer 签名,并在数据库中保存新签署的时间戳。然后发送新的时间戳以及用户请求的其它元数据。
Notary 签署过程看起来很复杂,不过一个好消息就是,Docker 客户端中集成了用 Notary 签署镜像的能力。可以轻松地使用环境变量在本地设备上启用镜像信任机制:
DOCKER_CONTENT_TRUST=1
:在客户端启用 NotaryDOCKER_CONTENT_TRUST_SERVER=””
:使用自己的 Notary 服务提供信任关系设置这些之后,Docker 客户端就会在拉取之前检查签名,并在推送之前请求签署凭据来对镜像进行签名。Docker HUB 还提供了自己的缺省 Notary 服务 https://notary.docker.io
,如果启用了内容信任,会用它对推送镜像进行签署。
如果拉取镜像是有签名的,可以简单的使用 docker trust inspect
来检查签名情况:
$ docker trust inspect nginx:latest
[
{
"Name": "nginx:latest",
"SignedTags": [
{
"SignedTag": "latest",
"Digest": "b2xxxxxxxxxxxxx4a0395f18b9f7999b768f2",
"Signers": [
"Repo Admin"
]
}
],
"Signers": [],
"AdministrativeKeys": [
{
"Name": "Root",
"Keys": [
{
"ID": "d2fxxxxxxx042989d4655a176e8aad40d"
}
]
},
...
]
}
]
复制
除了使用 docker trust
之外,也可以下载 Notary 客户端,直接和服务器进行通信。
到现在我们已经对 Notary 的工作机制有了个初步的认识。我们可以更进一步,在 Kubernetes 上安装自己的 Notary 服务。我们准备了两个 Shell 脚本和 Helm Chart,这样就可以很方便的进行安装了。开始之前请克隆我们的代码仓库:
$ git clone https://github.com/k8s-gadgets/k8s-content-trust
...
复制
进入 notary-k8s
目录。
可选项目:构建 Notary 并加入自己的镜像库。 要从头构建最新的 Notary 镜像,需要从
build
目录开始。如果要构建和推送 Notary 镜像到你自己的镜像仓库,可以编辑build.sh
文件,编辑REGISTRY
变量,使之匹配自己的镜像库,并执行build.sh
脚本。
$ bash build.sh
...
复制
接下来需要进入 helm/notary
目录,并生成 TLS 证书,来确保和 Notary 服务通信的安全性:
$ cd helm/notary
...
$ bash generateCerts.sh
...
复制
在准备好 Docker 镜像并把 TLS 证书写入 Chart 之后,就可以使用 Helm 在 Kubernetes 上进行部署了。另外也可以看看 values.yaml
文件,修改一些必要的参数,例如缺省密码(passwordalias1Name
、 passwordalias1Value
)或者私有仓库。
然后就是创建命名空间并安装 Helm Chart:
$ kubectl create namespace notary
# 切换到 notary 命名空间
$ helm install notary notary
复制
检查镜像是否已经启动运行:
$ kubectl get pods –n notary
...
复制
如果 Pod 已经运行,就表明 Notary 安装成功了。然而在我们试用 Notary 服务之前,我们应该提交最后生成的 Notary Wrapper 模板。
Notary Wrapper
是我们写的一个扩展,借助这个扩展,OPA 就能就能和 Notary 服务进行交互了。这是一个 CLI REST 界面,仅实现了获取已签名镜像哈希以及在服务上检查信任数据的功能。
从 notary-k8s/helm/certs
复制证书文件到 helm/notary-wrapper/certs
:
进入源码的 notary-wrapper
子目录。创建 OPA 命名空间并执行 Helm 安装过程。
$ kubectl create namespace opa
# switch to namespace opa
helm install notary-wrapper notary-wrapper
复制
组件安装结束之后,就可以开始用我们的信任数据来测试 Notary 了,下图展示了这个过程:
我们需要签署一些本地镜像作为测试素材,所以首先从 Docker Hub 拉取一些镜像:
如果你已经启用了
DOCKER_CONTENT_TRUST
,并且没有指定DOCKER_CONTENT_TRUST_SERVER
,或者指定到了你的新服务器,拉取过程可能会失败。
docker pull nginx:latest
docker pull busybox:latest
复制
下一步就要连接我们的 Notary 客户端和服务器了:
把 Notary 服务器加入 /etc/hosts
:127.0.0.1 notary-server-svc
在终端中打开第二个 Tab,并为 Notary Server 的 Pod 创建一个端口转发,以便本地使用:kubectl port-forward notary-server-<...> 4443:4443
第一次要签名之前,要把你的 root-ca.crt
从安装目录拷贝到你的 .docker/tls
目录:
回到第一个终端 Tab,启用内容信任机制:
Notary 已经启动,应该已经无法拉取任何没有被你的 Notary 服务签名的镜像了。不过可以打标签、签名和推送镜像(在我们的例子中,我们会简单的推送到我们自己的 Docker Hub 空间,使用的是我们自己的镜像签名):
docker tag nginx:latest docker.io//nginx:1
docker push docker.io//nginx:1
docker tag busybox:latest docker.io/busybox:1
docker push docker.io//busybox:1
复制
这个推送命令会提示生成密码,用于请求签名密钥。这些步骤完成后,镜像会被推送到 Docker Hub,信任数据则会保存到 Notary Server。要进行校验,可以使用前面提到的 docker trust inspect
命令,如果安装了 Notary 客户端,也可以用 notary list
命令。命令执行结果类似:
$ notary -s https://notary-server-svc:4443 --tlscacert $HOME/.docker/tls/notary-server-svc:4443/root-ca.crt list docker.io//nginx
# output
NAME DIGEST SIZE (BYTES) ROLE
---- ------ ------------ ----
1 cccef6d6bdea671c394954b0dxxxxxxxx 948 targets
复制
如果必须重新部署 Notary,并使用新的密钥进行镜像签署,必须删除之前存储在
.docker/tls
目录中保存的密钥。另外还需要删除.docker/trust/tuf
中现存的需要重新签署的镜像的信任数据。
现在可以开始测试 Notary Wrapper。再新开一个终端 Tab,在 /etc/hosts 文件中加入该服务的地址:127.0.0.1 notary-wrapper-svc
。
保存之后,对端口 4445 进行端口转发:
# switch to namespace opa
kubectl port-forward notary-wrapper-<...> 4445:4445
复制
完成后就可以使用两个操作来检查 GUN、Tag 后者哈希的信任数据了,因为我们用的是 TLS 连接,要信任前面生成的根证书:
把 GUN 和 Tag 数据提交给 https://notary-wrapper-svc:4445/list
,获取最新的镜像信任数据,例如:
把 GUN 和哈希码发送到 https://notary-wrapper-svc:4445/verify
验证这个哈希对应的信任数据是否存在(返回码 200 或 404)。如果不知道哈希吗,可以使用 docker inspect GUN:Tag
命令查看。
后面会使用 Notary Wrapper 来实现内容信任。完成这个测试之后,就可以关闭端口转发,继续下面的内容了。
现在我们已经可以签署镜像生成信任数据了,拼图还差最后一块——在 Kubernetes 上实施内容信任策略。这临门一脚的难处在于,Kubernetes 中并没有提供什么开关可以激活内容信任。
又一个可能的方案就是依赖底层的 Docker 引擎,调用镜像验证插件,启用 DOCKER_CONTENT_TRUST
(可以参考这个 Issue),这种方法有两个弊端:
DOCKER_CONTENT_TRUST
是个非此即彼的开关,打开之后,无法拉取没有在 Notary 上签名的镜像。DOCKER_CONTENT_TRUST
只能检查一个镜像是否存在签名元数据,但是并不负责检查该签名是否属于这个 Tag。为了克服几个弊端,我们把注意力放在了 Kubernetes Admission Control 上。
长话短说。Kubernetes Admission Controller 是一种插件机制,可以用来对集群上的资源进行校验和配置。它的作用包含在 Kubernetes API 请求的生命周期之中,除了内置的 30 个控制器(例如 PodSecurity Policy)之外,还会有使用自己的控制规则的需要。就可以创建自己的 Validating 或者 Mutating Webhook 了。
Admission Control 触发的顺序是非常重要的知识点:
Kubernetes 会首先执行 Mutating 过程,然后才是进行验证。这样就能确保被变更过的请求对象能够正确地被校验。OPA 就是最好的实现 Mutaiting 和 Validating Webhook 的方法之一。
OPA 是一个通用的策略引擎,它使用一种高级的声明式语言(Rego)编写策略。下图展示了 OPA 集成到 Kubernetes API 生命周期的形式:
我们希望在 Kubernetes 上借助 OPA/Rego 的弹性策略实现内容信任机制。然而在开始之前,首先要在集群上部署 OPA。
假设你已经有了符合条件的集群,在完成命名空间创建和 Notary 步骤之后,就可以开始进入仓库中的 OPA 目录开始安装了。
Kubernetes 和 OPA 之间的通信必须是 TLS 加密的,因此需要给 OPA 创建额外的证书和密钥。
# copy the root-ca
cp ~/PATH/TO/k8-content-trust/notary-k8s/helm/notary/certs/root-ca.crt ~/PATH/TO/k8-content-trust/open-policy-agent/helm/opa/certs
# generate the additional OPA certs
cd helm/opa
bash generateCerts.sh
复制
OPA 在安装后是自动生效的,因此应该排除一些命名空间:
kubectl label ns kube-system openpolicyagent.org/webhook=ignore
kubectl label ns opa openpolicyagent.org/webhook=ignore
kubectl label ns notary openpolicyagent.org/webhook=ignore
复制
接下来我们要确认一下 values.yaml
中的 validating
和 mutating
是否已经配置(晚些时候我们会设置 mutating: true
):
# open-policy-agent/helm/opa/values.yml
...
validating: true
mutating: false
...
复制
# switch to namespace opa
helm upgrade --install opa opa
复制
在安装结束之后,可以在终端打开一个新 Tab,会看到 OPA 日志中 API Server 的进入请求。
# ctrl-c to exit
kubectl logs -n opa -f opa-deploy-<...> opa
复制
总算到了有意思的部分了,开始实现内容信任机制。Notary 和 OPA 都已整装待发,首先我们想拒绝一切不受信任的镜像。要完成这个任务,要先搞清楚 Docker Tag 和哈希之间的关系。
一般来说,我们会使用 GUN 以及标签来部署镜像。然而多数人会忽略一个事实,镜像标签是可以覆盖的,因此它的唯一性是靠不住的。一个集合的所有者能够用同样的 Tag 多次推送变更了的已签署镜像。为了避免这种情况,应该使用唯一摘要进行镜像拉取。
我们定义两条 Rego 规则来完成这个 Webhook:
latest
)的部署。已经随 Helm 安装好。
先看看第一条规则(helm/opa/policy/validating/rules.rego
)
package policy.validating
operations := {"CREATE", "UPDATE"}
kind := {"Pod", "Deployment"}
# rule to deny digests for pods and deployments
deny[msg] {
operations[input.request.operation]
kind[input.request.kind.kind]
image = get_images[_]
not contains(image.name, "@sha256:")
msg := sprintf("%v contains tag; only images with checksum are allowed", [image.name])
}
# rule deny if digest is not in notary
deny[msg] {
operations[input.request.operation]
kind[input.request.kind.kind]
image = get_images[_]
contains(image.name, "@sha256:")
# Example to mock digest comparison
# parts := split_image(image.name)
# not parts.digest == "@sha256:50"
get_checksum_status(image.name) != 200
msg := sprintf("No trust data found for the following image: %v ", [image.name])
}
# helper rules
# get images if pod
get_images[x] {
input.request.kind.kind == "Pod"
name := input.request.object.spec.containers[i].image
x := {
"index": i,
"name": name,
}
}
## get images if deployment
get_images[x] {
input.request.kind.kind == "Deployment"
name := input.request.object.spec.template.spec.containers[i].image
x := {
"index": i,
"name": name,
}
}
# rule to split gun and tag
split_image(image) = x {
parts := split(image, "@sha256:")
x := {
"gun": parts[0],
"digest": parts[1],
}
}
# rule to get digest from notary-wrapper
get_checksum_status(image) = status {
wrapperRootCa := "/etc/certs/notary/root-ca.crt"
notaryWrapperURL = "https://notary-wrapper-svc.opa.svc:4445/verify"
parts := split_image(image)
body := {
"GUN": parts.gun,
"SHA": parts.digest,
"notaryServer": "notary-server-svc.notary.svc:4443",
}
headers_json := {"Content-Type": "application/json"}
output := http.send({"method": "post", "url": notaryWrapperURL, "headers": headers_json, "body": body, "tls_ca_cert_file": wrapperRootCa})
status := output.status_code
}
复制
上面的规则会检查尝试创建或更新 Pod 或者 Deployment 类型的 API 请求。
根据资源类型,get_image[x]
规则会确保遍历请求中的所有容器,检查这些容器是否用摘要(例如 [GUN]@sha256:[digest hash]
)进行拉取。
因此简单的检查一下,镜像是否用了 @sha256
就可以了。否则我们会认为此次尝试部署的是一个用 Tag 标识的镜像。如果这一规则被触发,请求就会被阻拦,并得到返回的错误消息。
接下来我们继续定义第二个规则,拒绝没有被 Notary 信任的摘要。
在这个规则里,我们在 get_checksum_status(image)
中用了 OPA 中集成的 http.send
函数。首先会从请求中获取每个镜像的哈希,然后在 get_checksum_status(image)
中发送镜像的 GUN 和摘要到 Notary Wrapper,Notary Wrapper 会检查每个镜像是否都已签名。如果请求返回的不是 200,那么部署动作会被制止。
简单说 http.send
函数在目标不可用时不会返回响应(可以参考 OPA 的一个功能申请)。在我们这里因为有了 Notary Wrapper,只要它正常工作,就不会遇到这个困扰。然而一旦 Notary Wrapper 不可用,OPA 也会故障,会被 ValidatingWebhookConfiguration
中的 failurePolicy: Fail
定义所捕获。
上面描述的两条规则就足以在 Kubernetes 集群中完成对内容信任的控制了。
要进行测试,只需要简单的部署一个新的 Pod:
# trust-pinning-test
apiVersion: v1
kind: Pod
metadata:
name: trust-pinning-test
namespace: default
spec:
containers:
# trigger rule 1:
- image: GUN//nginx:1
# trigger rule 2:
# - image: GUN//nginx@sha256:89cce606b29fb2xxxxx
# valid deployment:
# - image: GUN//nginx@sha256:
复制
另外在 open-policy-agent/tests
中还包含了多个针对不同需求的过个测试。
接下来的示意图展示了我们目前的工作成果:
每次部署都会发出 API 请求,随即开始校验过程:
到此为止,我们已经成功的实现了内容信任机制。然而查询 RepoDigests
是个很麻烦的事情。如果能基于 Tag 使用内容信任就两全其美了。
Mutating Webhook 是用于在校验之前对请求内容进行变更的,我们接下来会编写这样一个功能。每次用户尝试部署一个带标签的镜像时,就启动 Webhook,自动将镜像引用改为哈希模式。大致工作流程如下:
API 请求流经 Webhook:
http.send
请求到 Notary Wrapper,向 Notary 服务器发起查询。RepoDigest
给 OPA,否则报错。RepoDigest
从可信的仓库拉取镜像,并完成部署。因为我们已经在安装过程中给 OPA 注册了 Mutating Webhook,我们只需要加入新的 Rego 规则就可以了。最简单的方式就是回到本地的 Helm 目录,启用 mutating
,然后执行 helm upgrade
:
# open-policy-agent/helm/opa/values.yml
...
validating: true
mutating: true
复制
# switch to namespace opa
helm upgrade --install opa opa
复制
OPA 中的 Mutating Webhook 是 main
方法的一部分,这个方法会在 API 请求时发起变更。helm upgrade
会加入下面的新规则:
package policy.mutating
import data.k8s.matches
main = {
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": response,
}
default uid = "missing-uid"
uid = input.request.uid
# default allow without patch
response = r {
count(patch) == 0
r := {
"uid": uid,
"allowed": true,
}
}
# response with patch
response = {
"uid": input.request.uid,
"allowed": true,
"patchType": "JSONPatch",
"patch": patch_bytes,
} {
count(patch) > 0
patch_json = json.marshal(patch)
patch_bytes = base64url.encode(patch_json)
}
# patch
default patch = []
patch = result {
operations := {"CREATE", "UPDATE"}
kind := {"Pod", "Deployment"}
operations[input.request.operation]
kind[input.request.kind.kind]
# construct patch for each image in the container array that requires it.
result := [p |
image = get_images[_]
not contains(image.name, "@sha256:")
parts := split_image(image.name)
# format: registry/project@sha256:xxx
patchedImage := concat("", [parts.gun, "@sha256:", get_digest(image.name)])
# cconstruct JSON Patch for the deployment.
# kube-apiserver expects changes to be represented as
# JSON Patch operation against the resource.
# the JSON Patch must be JSON serialized and base64 encoded.
p := {
"op": "replace",
"path": get_path(image.index),
"value": patchedImage,
}
]
}
# helper rules
# rule to compute images set
# the first line ensures that its matched to the right k8s resource
# the second line iterates over each container and extracts the image
get_images[x] {
input.request.kind.kind == "Pod"
name := input.request.object.spec.containers[i].image
x := {
"index": i,
"name": name,
}
}
get_images[x] {
input.request.kind.kind == "Deployment"
name := input.request.object.spec.template.spec.containers[i].image
x := {
"index": i,
"name": name,
}
}
# construct and returns json path for "Pods"
get_path(index) = path {
input.request.kind.kind == "Pod"
path := concat("/", ["", "spec", "containers", format_int(index, 10), "image"])
}
# construct and returns json path for "Deployment"
get_path(index) = path {
input.request.kind.kind == "Deployment"
path := concat("/", ["", "spec", "template", "spec", "containers", format_int(index, 10), "image"])
}
split_image(image) = x {
parts := split(image, ":")
x := {
"gun": parts[0],
"tag": parts[1],
}
}
# helper rule to retrieve the digest from notary using notary-wrapper
get_digest(image) = digest {
wrapperRootCa := "/etc/certs/notary/root-ca.crt"
notaryWrapperURL = "https://notary-wrapper-svc.opa.svc:4445/list"
parts := split_image(image)
body := {
"GUN": parts.gun,
"Tag": parts.tag,
"notaryServer": "notary-server-svc.notary.svc:4443"
}
headers_json := {"Content-Type": "application/json"}
output := http.send({"method": "post", "url": notaryWrapperURL, "headers": headers_json, "body": body, "tls_ca_cert_file": wrapperRootCa})
digest := output.body.Digest
}
复制
简单说一下这段代码的功能:
response
规则中的代码加入需要的响应。response
针对的是无需变更的请求,允许任意的 API 请求通过。response
会调用 patch
规则。patch
规则会对任何面向 Pod
或者 Deployment
的 API 请求进行变更。结果参数首先会获取 API 请求中的镜像,检查是否每个镜像都是使用哈希进行拉取的(URL 中包含了 @shar256:
)。split_image
规则将镜像分为名称和标签两部分。split_image
返回的是一个数组,get_digest
中使用这个数组调用 http.send
函数通过 Notary Wrapper 向 Notary 请求哈希。如果 Notary 没有对应的哈希,会得到 404 的返回值。.json
格式的补丁。.json
补丁(赋值给 p
)需要在 path
参数中指定的路径上执行 replace
操作,从而替换原有的拉取方式。在 Pod 和 Deployment 中,镜像字段的路径是不同的,我们需要创建两个 get_digest
和 get_path
来应对两种情况。如果想要测试这个 Webhook,可以看看 open-policy-agent/tests
,如果保存了前面的校验 Webhook,可以测试一下有效和无效的 Tag 或者哈希。下表总结了 Webhook 的响应情况:
最终,我们成功地在 Kubernetes 集群上,无需改动部署习惯的情况下,实现了内容信任机制,除了这个,OPA 还能做很多其它的校验工作。
我们知道这篇文章很长,但是我希望尽可能多地为读者提供更多细节。我们认为,虽然有很多的容器扫描和加固方面的技术,镜像签署和信任是目前容器安全方面的最大盲区之一。
下一步需要做点什么呢?还有很多细节我们没能说明:
感谢阅读全文,希望对你有所助益。这里尤其要感谢来自 OPA/Styra 的 Asad、Torin 以及 Jeff,对我们编写的规则作出很多支持。
https://github.com/k8s-gadgets/k8s-content-trust
https://theupdateframework.github.io/
https://docs.docker.com/notary/service_architecture/
https://docs.docker.com/notary/service_architecture/
https://github.com/theupdateframework/notary/releases
https://github.com/kubernetes/kubernetes/issues/30603#issuecomment-430889781