镜像被打包出来之后一般被保存在镜像仓库中,对于Docker场景下,我们可以使用Docker Hub来保存和分发镜像文件。如果读者使用的类如阿里云提供的容器镜像服务ACR,那么镜像文件会被安全的保存在云供应商的数据中心,开发人员只需要简单的使用push和pull来推送和拉取镜像文件。
从标准化的角度看,目前社区正在基于OCI规范来制定容器镜像分发规范,这个规范最终肯定会和主流的如Docker Hub,阿里云ACR仓库兼容,虽然说规范的标准化工作尚未完成,但向后兼容确保现有的容器镜像分发服务能够工作是规范制定过程中最高的优先级。
容器镜像在Docker Hub仓库上每层(layer)以二进制大对象blob的形式被保存,我们可以通过唯一的hash(摘要)来确定组成容器镜像的每一层。为了节省空间,相同的层只会被保存一次,但是可以被多个容器镜像同时引用。另外在仓库中除了保存layer数据之外,每个容器镜像还有一个manifest文件,这个文件可以看成镜像的配置文件,里边主要的信息是组成镜像所有层的信息。
我们通过对镜像的manifest进行摘要,得到的哈希值就是镜像的digest,如果我们对镜像的数据做了修改,比如增加一层,或者对原有的层进行了修改,那么重新计算镜像的digest会的到不同的值。笔者这样要强调的是,这个摘要信息是我们唯一确定镜像的唯一标识。可能有些同学听说过镜像的tag,笔者在前边的文章中也多次用过k8ssample:v1.2这样的镜像,冒号后边的v1.2就是tag,但是大家要注意,tag并不能唯一的确定一个容器镜像,比如latest这个标签,因为你今天拉取的latest和明天拉取的latest可能是完全不同的镜像。
我们可以在自己的机器上运行docker image ls --digests命令,来输出本地机器上容器镜像的摘要:
➜ source docker image ls --digests
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
qigaopan/agents-jvm latest sha256:7fac45cce6181348c98c7331db85a047808e370d25dda7f28c8085dcfd02551a eb37d3272627 5 weeks ago 466MB
当我们使用docker pull来拉取镜像的时候,我们可以使用摘要(digests)来精确的引用到唯一的镜像,说到这里,我们接着来看看如何引用到一个具体的镜像。通常情况下我们引用镜像的URL模式如下:
<Registry URL>/<Organization or user name>/<repository>@sha256:<digest>
<Registry URL>/<Organization or user name>/<repository>:<tag>
第一部分<Registry URL>标识了被引用镜像具体保存在哪个仓库中,如果我们在引用容器镜像的URL中不包含这部分,那么就默认指向本地或者Docker Hub。
第二部分<Organization or user name>用来指定用户或者组织的账户名称,最后一部分是我们要引用的镜像名称和tag信息。咱们用上篇中的sensitive这个镜像为例,我们可以通过以下两种方式来引用这个镜像文件:
docker pull qigaopan/sensitive
docker pull qigaopan/sensitive:sha256:db77c6f39d1e75619c49e724ba44becef79323ae466e9c2085fc8e
是用户使用的角度看,通过摘要来引用镜像文件略显得繁琐和不人道,因此大部分情况下,无论是在Docker中还是在Kubernetes中,我们一般都是通过tag来引用具体的镜像文件。tag是逻辑上的概念,我们可以给一个镜像文件关联多个tag,并且也可以把某个tag从一个镜像文件移动到另外一个镜像文件(比如不同的版本)。比如咱们在前边多篇文章中使用的镜像k8ssamples,我们用版本作为镜像的tag来标注不同版本的应用程序,比如我们在Docker Hub上看到的这个应用的v1.5版本,
笔者要提醒大家的是,由于tag可以从一个镜像被移动到另外一个镜像文件,因此通过k8ssamples:v1.5这样应用到的镜像无法保证唯一性。这句话听起来很绕,咱们来举个例子说明一下,比如k8ssamples:v1.5这个镜像文件,如果我今天在应用中修复了一个缺陷,然后通过docker build重新打包构建了这个标签的镜像并推送到Docker hub仓库,那么如果生产环境中有个容器实例挂了,重建后docker pull就会拉取到最新的版本(包含缺陷修复的版本),造成的结果是,生产环境中3个容器实例,有两个运行着没有修复缺陷的版本,而1个运行着修复了缺陷的版本,这会造成我们所说的配置漂移问题。
那么如何解决这个问题呢?我们可以通过摘要来唯一的引用某个镜像文件,因为从原理上讲,如果我们给镜像增加了某些功能,会产生不同的摘要,因此结果就是不同的镜像文件了。不过通过tag来引用镜像文件很多时候符合我们的版本策略,比如咱们的k8ssamples:v1.5镜像的例子,如果我们修复了一些缺陷,打包的时候会复用主版本号和次版本号,因此结果就是打包出来的容器的tag和上个未修复这些缺陷的tag一致,好处是下次我们再pull镜像的时候,就可以得到修复了缺陷的版本。笔者在过往的几个项目上也采用了这个策略,当有重大缺陷修复的时候,会在业务低峰时候缩容然后扩容,目的就是来主动的拉取到更新缺陷后的应用程序镜像。
不过在有些场景下,镜像的唯一性非常重要,特别是安全相关的场景。举个例子,很多企业都会对镜像进行扫描,特别是部署到生产环境之前,需要确保镜像中没有静态的风险。在Kuberntes环境中,我们一般会使用admission controller来对容器镜像文件的漏洞,风险进行扫描,扫描通过的会记录下来,下次就不会再次扫描,那么我们就需要对镜像有唯一性判断。如果采用tag的方式来记录哪些镜像扫描过,那么就会有重大安全漏洞,因为admission controller可能会漏掉某些已经被恶意攻击者植入恶意代码的镜像,虽然这些镜像看起来tag没有发生变化。有了上边这些信息的铺垫,接着咱们就可以名正言顺的聊聊镜像安全了。
对于镜像安全来说,最核心的问题是如何保障镜像的完整性,也就是你怎么知道docker pull拉取到的镜像是你”以为的“镜像文件?恶意攻击者完全可以截获到你的docker pull请求,然后给你返回注入恶意代码的镜像,让你觉得这是你预期的镜像文件,然后在自己的生产环境将容器实例基于这个镜像运行起来,这个时候恶意攻击者基本上可以干任何他自己想干的事情了。上边的例子只是冰山一角,对于容器镜像来说,如何保障从构建,保存到运行这几个阶段的安全,是需要我们从整体上要来考虑的,那么具体会遇到哪些安全风险呢?请读者仔细看下图:
如上图所示,我们从左到右来仔细梳理一下。对于开发人员来说,必须确保开发的代码没有安全漏洞,我们一般通过代码的静态扫描来降低代码和依赖的安全风险。另外从技术管理的角度,我们也需要定期的代码评审机制,安全测试来持续的提升代码的安全风险。笔者单独把代码这部分拿出来说是有原因的,因为容器化部署和传统的部署方式在代码这个层面,安全保障手段基本是一致的。接下来我们来看看在代码层面之外的安全风险。
首先从镜像构建开始,镜像构建简单理解就是将Dockerfile文件转换成容器镜像的过程,虽然说这一步看起来很简单直接,实际上这个步骤有很多安全风险需要我们来应对。构建镜像的指令来自于Dockerfile,并且在构建的过程中,Dockerfile中包含的所有指令都会被执行来生成最终的镜像文件,如果恶意攻击者篡改了Dockerfile,那么最终生成的镜像文件就有很大的安全风险,这也是我们保护镜像的第一道防线,风险具体包括:
- 植入恶意代码或者挖矿程序
- 窃取敏感数据的代码
- 扫描生产环境的网络拓扑结构
- 攻击镜像构建机器来篡改镜像数据
为了缓解这些风险,首先要做的就是让Dockerfile的访问和修改需要授权,其次我们需要确保Dockerfile包含的指令和构建信息没有安全风险,比如基础镜像等。笔者将镜像涉及到的安全建议梳理如下:
- 确保基础镜像的安全。笔者经历的大部分容器化部署项目在客户侧都有专门的基础镜像来部署应用程序,并且尽量确保基础镜像只包含支持应用程序运行的依赖,不要包含太多无关的功能。镜像的尺寸越小,攻击面越小,并且传输起来更快
- 在构建的过程中使用多阶段构建模式。multi-stage构建模式可以让我们构建出来的镜像尺寸更小,比如我们构建go应用程序的时候,需要go编译器,但是在最终的镜像中,go编译器不是运行应用所必须的,因为我们可以将构建的过程分为两个阶段:第一个阶段通过go编译器来构建二进制可执行文件,第二阶段接着第一阶段将构建出来的二进制文件和所有的依赖打包成镜像文件。
- Non-root用户,在Dockerfile文件中,指定非root用户来运行容器应用,因为默认情况下(不指定),容器实例以root权限运行。
- 需要特别关注RUN命令,因为这是很多恶意攻击者用来下载恶意程序到镜像中的常用方式。
- 检查容器的挂载信息,确保没有敏感数据被挂载到容器中。
- 不要在容器中包含敏感数据,具体原因我们在(上)这篇文章中详细介绍过。
- 不要在容器中包含任何设置了setuid位的可执行程序,因为这些程序会以文件的owner账户的权限运行。
- 容器镜像自包含,尽量杜绝容器在启动运行的时候,需要从外部下载依赖包,这也是immutable image的基础。
理解了如何保护容器镜像之后,接下来我们首先看看容器进项构建机器可能遭遇的安全攻击,以及解决方案。具体来说,构建容器镜像的机器主要会受到来自如下两种类型的攻击:
- 如果恶意攻击者攻破了构建机器,那么就可以以容器镜像构建机器为跳板,访问其他的机器资源。
- 如果恶意攻击者攻破了构建机器,那么就可以在构建的过程中,植入恶意代码,造成毁灭性的损失。比如在交易系统中嵌入交易信息截取代码,让公司的核心数据泄漏。
因此我们必须像对待生产环境一样来提升构建机器的安全性。比如减少构建机器上安装的应用程序,构建机器必须授权才能访问以及为构建机器设置独立的VPC和防火墙规则等。基于笔者过往的经验,将构建机器和生产环境隔离开来是大家最常用的一种安全策略,这样即便是构建机器被攻破,那么恶意攻击者也无法轻易的就获取到生产环境的访问权限。
解决了构建机器的安全问题后,接着我们需要确保构建出来的容器镜像被安全的存储。如果恶意攻击者很容易就用篡改过的镜像来代替我们辛辛苦苦构建出来的镜像,那么运行在生产环境的应用程序就有极大的风险包含恶意代码。
笔者过往的项目中,大部分客户都会运行自己的Harbor仓库来保存容器镜像,并且在Harbor上开启权限控制,这样就能最大限度的确保镜像的存储安全。这种私有部署的仓库也完全解决了DNS劫持的风险,因此建议大家项目上如果对镜像安全要求很高,这种私有化部署是最佳的选项。
如果项目对阿里云的ACR或者Docker Hub这样的外部仓库可以接受,那么建议国内的用户最好用阿里云提供的ACR,无论从安全性还是易用性,以及和阿里云的EDAS,ACK集成度来看,阿里云的ACR都是最佳的选择。
最后我们来聊聊部署安全。首先需要确保部署使用的YAML文件的安全,这和我们保护Dockerfile安全的手段一致,笔者这里不再累述。另外有时候我们可能会从外网下载YAML文件,笔者这里要强调的是,下载的YAML文件一定要经过严格的评审,要不然可能会包含恶意的指令来下载或者挂载恶意程序,导致敏感数据被窃取。
在Kuberntes部署环境中,我们可以使用admission control机制来评估所有需要部署到集群中资源的安全风险,比如基础镜像是不是符合公司的安全规范要求等,如果评估结果不通过,那么资源是不会被写到etcd,也就不会被部署到集群中。具体来说admission controller可以进行如下列出的安全检查:
- 镜像是否已经经过安全的静态扫描
- 镜像是否来自于可信的仓库
- 镜像是否持有可行的签名
- 镜像是否被批准可以部署
- 镜像是否以root权限运行
通过admission controller我们就可以严格的控制资源是否可以被部署到集群中,这是很多集群的标配。建议读者使用阿里的ACK托管服务,通过简单的配置就可以实现admission controller提供的所有安全保障。
今天这篇文章的主要内容就这么多了,在文章的结尾处,我们来聊一下GitOps,Gitops是一套通过源代码控制系统来管理系统状态的方法论。在GitOps模式下,如果我们要对生产环境做变更,我们要做的是修改YAML文件到期望的状态(比如服务实例的个数从3个变成30个来支持即将到来的双十一大促),然后将修改过的YAML文件check in到YAML文件管理仓库,后台运行的自动化工具(我们一般称之为GitOps operator)将修改后的YAML文件apply到集群,通过集群中的控制器来将应用的状态拉到期望的状态。读者需要注意的是,GitOps模式下,我们不再直接修改系统的状态,而是以源代码管理的方式来提交系统状态的期望。
而Gitops这种模式对系统安全来说,具有深远的意义,因为用户不再直接访问生产系统,从而造成的结果就是系统的攻击面大大减小了,如下图所示:
咱们下篇文章继续讨论如何确保镜像中应用程序的安全,敬请期待!