在容器和虚拟化广泛流行的今天,如何构建出一个安全、洁净的容器是大家都关心的问题。和安全领域对系统的要求一样"只安装必须的应用"的最小化原则也是容器构建的基本法则。一方面最小化的应用可以减小镜像的大小,节省上传下载的时间,同时减少了容器中的应用也就减少了可入侵点,使容器更安全。本文介绍我们给大家介绍了一套总结了业界实践的容器的优秀实践。目的是让容器构建更加快速,更安全,也更具弹性。本文假设读者有一定Docker和Kubernetes了解,但也可以单独作为Docker容器构建守则。
一个容器一个应用程序
当开始使用容器时,常见的一个误区是把容器当成虚拟机来使。这样做往往会让他们不能轻易满足某些需求而非常痛苦,同时也背离了容器的最大优势点。很多初学者在群里问了很多为么:为什么Docker不能aaa?怎么实现Docker bbb?然后他需要的答案其实上只是需要一个虚拟。虽然现代容器已经可以满足这些需求,但是这会极大减弱容器模型的大多数优点。以经典的Apache/MySQL/PHP堆栈为例,你可能很想在一个容器中运行所有组件。但是,最佳实践是使用两个或三个不同的容器:Apache容器,MySQL容器,和运行PHP-FPM的php容器。
由于容器设计思想是容器和托管的应用程序具有相同的生命周期,因此每个容器应当只包含一个应用程序。当容器启动时,应用程序随之启动,当容器停止时,应用也会停止。
容器如果部署了多个应用,则它们有可能具有不同的生命周期或处于不同的状态。例如,一个正在运行的容器,但其核心组件之一突然崩溃或无响应。由于没有额外的运行状况检查,整个容器管理系统(Docker或Kubernetes)将无法判断容器是否健康。在Kubernetes集群中,容器默认是不会重启的。
有些公共镜像中可能会使用如下有一些的操作,但不遵循原则:
使用进程管理系统(比如supervisor)来管理容器中的一个或多个应用。使用bash脚本作为容器中的入口点,并使其产生多个应用程序作为后台作业。
信号处理,PID 1和僵尸进程
Linux信号是控制容器内进程生命周期的主要方法。为了与前一条最佳实践保持一致,为了将应用程序的生命周期和容器关连,请确保应用程序能够正确处理Linux信号。其中最重要的一个Linux信号是SIGTERM,因为它用来终止进程。应用可能还会收到SIGKILL信号,用于非正常地终止进程,或者SIGINT信号,用于接受,键入的Ctrl + C指令。
进程标识符(PID)是Linux内核为每个进程提供的唯一标识符。 PID具有名称空间,容器具有自己的一组PID,这些PID会被映射到宿主机系统的PID。Linux内核启动时会创建第一个进程具有PID1。用来init系统用来管理其他进程,比如systemd或SysV。同样,容器中启动的第一个进程也是PID1。Docker和Kubernetes使用信号与容器内的进程进行通信。Docker和Kubernetes都只能向容器内具有PID 1的进程发送信号。
在容器环境中,需要考虑两个PIDs和Linux信号的问题。
Linux内核如何处理信号?
Linux内核处理PID 1的进程方式与对其他进程不同。PID1,不会自动注册信号量SIGTERM,所以SIGTERM或SIGINT默认对PID 1无效。默认必须使用SIGKILL信号来杀掉进程,无法优雅的关闭进程,可能会导致错误,监视数据写入中断(对于数据存储)以及一些不必要的告警。
典型的初始化系统如何处理孤立进程?
典型的初始化系统(例如systemd)也用被用来删除(捕获)孤立的僵尸进程。僵尸进程(其父进程已死亡的进程)将会被附加到具有PID 1的进程下,被其捕获关闭。但是在容器中,需要映射到容器PID 1的进程来处理。如果该进程无法正确处理,则可能会出现内存不足或其他资源不足的风险。
面对这些问题有几种常见的解决方案:
1. 以PID 1运行并注册信号处理程序
该方案用来解决第一个问题。如果应用以受控方式生成子进程(通常是这种情况)是有效的,可以避免第二个问题。最简单方法是在Dockerfile中使用CMD和/或ENTRYPOINT指令启动你的进程。例如,下面的Dockerfile中,nginx是第一个也是唯一要启动的进程。
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y nginx
- EXPOSE 80
- CMD [ "nginx", "-g", "daemon off;" ]
注意:Nginx进程注册其自己的信号处理程序。使用此解决方案,在许多情况下,你必须在应用程序代码中执行相同的操作。
有时可能需要准备容器中的环境以使进程正常运行。在这种情况下,最佳实践是让容器在启动时运行shell初始化脚本。该Shell脚本的用来配置所需的环境并启动主要进程。但是,如果采用这种方法,则shell脚本拥有PID 1,这就是为什么必须使用内置exec命令从shell脚本启动进程的原因。exec命令将脚本替换为所需的程序,进程将继承PID 1。
2. 在Kubernetes中启用进程名称空间共享
为Pod启用进程名称空间共享时,Kubernetes对该Pod中的所有容器使用单个进程名称空间。 Kubernetes Pod基础容器成为PID 1,并自动捕获孤立的进程。
3. 使用专门的初始化系统
就像在更经典的Linux环境中一样,也可以使用init系统来解决这些问题。但是,如果用于这个目的,普通的初始化系统(例如systemd或SysV)太复杂且太重,建议使用专门的容器创建的初始化系统(例如tini)。
如果使用容器专用的初始化系统,则初始化进程具有PID 1,并执行以下操作:
- 注册正确的信号处理程序。
- 确保信号对你的应用程序有效。
- 捕获所有僵尸进程。
可以通过使用docker run命令的--init选项在Docker中使用此解决方案。要在Kubernetes中使用,则必须在容器镜像中先安装init系统,并将其用作容器的入口。
优化Docker构建缓存
Docker的构建缓存可以极大的加速容器镜像的构建。在容器系统中镜像是逐层构建的,在Dockerfile中,每条指令都会在镜像中创建一个层。在构建期间,如果可能,Docker会尝试重用先前构建中一层,尽可能跳过其底层来减少构建消耗成本的步骤。Docker只有在所有先前的构建步骤都使用它的情况下,才能使用其构建缓存。尽管这种做法通常使构建更快,但需要考虑一些情况。
例如,要充分利用Docker构建缓存,必须将需要经常更改的构建步骤放在Dockerfile的后面。如果将它们放在前面,则Docker无法将其构建缓存用于其他更改频率较低的构建步骤。通常为源代码的每个新版本构建一个新的Docker镜像,所以应尽可能在Dockerfile的后面将源代码添加到镜像。如下图,你可以看到,如果要更改了STEP 1,则Docker只能重用FROM FROM debian:9步骤中的层。但是,如果更改STEP 3,则Docker可以将这些层重新用于STEP 1和STEP 2。
图中蓝色表示可以重用的层,红色表示必须重建的层。层的重用原则导致另一个后果,如果构建步骤依赖于存储在本地文件系统上的任何类型的缓存,则该缓存必须在同一构建步骤中生成。如果未生成此缓存,则可能会使用来自先前构建的过期的缓存来执行的构建步骤。通过apt或yum等程序包管理器中最常有这个问题,必须在一个RUN命令中同时安装所有必须要的库。如果更改下面Dockerfile中的第二个RUN步骤,则不会重新运行apt-get update命令,从而导致过期的apt缓存。
- FROM debian:9
- RUN apt-get update
- RUN apt-get install -y nginx
而是,在单个RUN步骤中合并两个命令:
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y nginx
删除不必要的工具
为了保护你的应用免受攻击者的侵害,请尝试通过删除所有不必要的工具来减少应用的攻击面。例如,删除诸如netcat之类的实用程序,因为可以用necat随便就能构建一个反向shell。如果容器中不安装netcat,则攻击者无法这样简单利用。
即使没有容器化,此最佳实践也适用于任何工作负载。区别在于,与经典虚拟机或裸机服务器相比,使用容器实现起来要容易得多。
其中一些工具可能对于调试有用。例如,如果你将此最佳实践推得足够远,那么详尽的日志,跟踪,概要分析和应用程序性能管理系统将成为必不可少的。实际上,你不再可以依赖本地调试工具,因为它们通常具有很高的特权。
文件系统内容
镜像中应尽可能少的保留内容。如果可以将应用程序编译为单个静态链接的二进制文件,则将该二进制文件添加到暂存镜像中将使获得最终镜像,该镜像仅包含一个应用程序,无其他内容。通过减少镜像中打包的工具数量,可以减少潜在的可在容器中执行的操作。
文件系统安全
镜像中没有工具是不够的。必须防止潜在的攻击者安装工具。可以在此处组合使用两种方法:
首先,避免以root用户身份在容器内运行。该方法提供了第一层安全性,并且可以防止攻击者使用嵌入在镜像中的包管理器(如apt-get或apk)修改root拥有的文件。为了使用该方法,必须禁用或卸载sudo命令。
以只读模式启动容器,可以通过使用docker run命令中的--read-only标志或使用Kubernetes中的readOnlyRootFilesystem选项来执行此操作。可以使用PodSecurityPolicy在Kubernetes中强制执行此操作。
注意:如果应用需要将临时数据写入磁盘,也可以使用readOnlyRootFilesystem选项,只需为临时文件添加emptyDir卷。Kubernetes中不支持emptyDir卷上的挂载,所以不能在启用noexec标志的情况下挂载该卷。
最小化镜像
生成较小的镜像具有诸如更快的上载和下载时间等优点,这对于Kubernetes中pod的冷启动时间尤为重要:镜像越小,节点下载就越快。但是,构建小型镜像很难,因为可能会在无意中给最终镜像引入了构建依赖项或未优化的镜像层。
使用最小的基础镜像
基础镜像是Dockerfile中FROM指令中所引用的镜像。Dockerfile中的所有指令均基于该镜像构建。基础镜像越小,生成的镜像就越小,下载和加载就越快。例如,alpine:3.7镜像比centos:7镜像就小好几十M。
我们甚至还可使用 scratch基础镜像,这是一个空镜像,可以在其上构建自己的运行时环境。如果需要运行的应用程序是静态链接的二进制文件,使用暂存基础镜像非常容易:
- FROM scratch
- COPY mybinary /mybinary
- CMD [ "/mybinary" ]
GoogleContainerTools的Distroless项目提供了多种语言(Java,Python(3),Golang,Node.js,dotnet)的基础镜像。镜像仅包含语言的运行时,剔除了Linux发行版的很多工具,例如Shell,应用包管理器等,下面项目的一个Golang的列子:
减少镜像无效删减
要减小镜像的大小,需要严格遵守只安装必须的应用的原则。可能有时候需要临时安装一些工具的软件包,使用后在后面的步骤中再删除。但是,这种方法也是有问题的。因为Dockerfile的每条指令都会创建一个镜像层,创建后,再在稍后的步骤中删除的方法,实际不能减少镜像的大小。(数据还在,只是被隐藏在底层而已)。比如:
错误Dockerfile:
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y \
- [buildpackage]
- RUN [build my app]
- RUN apt-get autoremove --purge \
- -y [buildpackage] && \
- apt-get -y clean && \
- rm -rf /var/lib/apt/lists/*
正确Dockerfile:
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y \
- [buildpackage] && \
- [build my app] && \
- apt-get autoremove --purge \
- -y [buildpackage] && \
- apt-get -y clean && \
- rm -rf /var/lib/apt/lists/*
在错误版本Dockerfile中,[buildpackage]和/var/lib/ap /lists/*中的文件仍然存在于与第一个RUN相对应的镜像层中。该层是镜像的一部分,尽管里面的数据在最终镜像中不可访问,但也会和其他镜像层一起上传和下载。
在正确版本Dockerfile中,所有操作都在构建的应用程序的同一层中完成。 /var/lib/apt/lists/*中的[buildpackage]和文件在最终镜像中不会存在,真正起到了删除的效果。
减少镜像无效删除的另一种方法是使用多阶段构建(Docker 17.05中引入)。多阶段构建允许在第一个"构建"容器中构建应用程序,并在使用相同Dockerfile的同时在另一个容器中使用结果。
在下面的Dockerfile中,hello二进制文件内置在第一个容器中,并注入了第二个容器。因为第二个容器是从头开始的,所以生成的镜像仅包含hello二进制文件,而不包含构建期间所需的源文件和目标文件。但是,二进制文件是必须静态链接才能正常工作。
- FROM golang:1.10 as builder
- WORKDIR /tmp/go
- COPY hello.go ./
- RUN CGO_ENABLED=0 go build -a -ldflags '-s' -o hel
- lo
- FROM scratch
- CMD [ "/hello" ]
- COPY --from=builder /tmp/go/hello /hello
尝试创建具有公共镜像层的镜像
如果必须下载Docker镜像,则Docker首先检查镜像中是否已经包含某些层。如果你具有这些镜像层,就不会下载。如果以前下载的其他镜像与当前下载的镜像具有相同的基础镜像,则当前镜像的下载数据量会少很多。
在企业内部,可以为开发人员提供一组通用的标准基础镜像来减少必要的下载。系统只会下载每个基础镜像一次,初始下载后,只需要使每个镜像中不同的镜像层,镜像的共同层越多,下载速度就越快。如下图中红色框的基础镜像就只需下载一次。
容器注册表进行漏洞扫描
对服务器和虚拟机,软件漏洞扫描是常用的一个安全手段,通过集中式软件扫描系统,列出了每台主机上安装的软件包和存在的漏洞源,并及时通知管理员修补漏洞,比如虫虫之前的文章中介绍过的Flan Scan系统。
由于容器原则上是不可变的,所以不建议对存在漏洞的情况下对其进行漏洞修补。最佳实践是对其重建镜像,打包补丁程序,然后重新部署。与服务器相比,容器的生命周期要短得多,身份标识的定义要好得多。因此,使用类似的集中检测容器中漏洞的一种不好的方法。
为了解决这个问题,可以在托管镜像的容器注册表(Container Registry)中进行漏洞扫描。这样就可以在发现容器镜像中的漏洞。将镜像上传到注册表时或漏洞库更新时,就会进行扫描,或者启动计划任务定期扫描。
检测到漏洞后,可以使用脚本来触发自动漏洞修补过程。最好是结合版本管理(比如Gitlab)CI/CD管道来持续进行镜像构建来进行漏洞修补。一般步骤如下:
- 将镜像存储在容器注册表中并启用漏洞扫描。
- 配置一个作业,该作业定期从容器注册表中获取新漏洞,并在需要时触发镜像重建。
- 构建新镜像后,通过持续部署系统CD来将镜像部署到验证环境中。
- 手动检查验证是否正常。
- 如果未发现问题,请手动推送灰度部署到生产环境。
正确标记镜像
Docker镜像通常由两个部分标识:它们的名称和标签。例如,对于centos:8.0.1镜像,centos是名称,而8.0.1是标签。如果在Docker命令中未提供最新标签,则默认使用最新标签。名称和标签对在任何给定时间都是应该唯一的。但是,可以根据需要将标签重新分配给其他镜像。构建镜像时,需要正确标记,遵循统一一致标记策略。
容器镜像是一种打包和发布软件的方法。标记镜像可让用户识别软件的特定版本进行下载。因此,将容器镜像上的标记系统关系到软件的发布策略。
使用语义版本标记
发行软件的常用方法是使用语义化版本号规范(The Semantic Versioning Specification)版本号来"标记"(如git tag命令中的)特定版本的源代码。语义化版本号规范是为了改善各种软件版本号格式混乱,语义不明的现状由semver.org提出的一种处理版本号的规整方法。在该规范中软件版本号由三部分构成:X.Y.Z,其中:
- X是主要版本,有向下不兼容的修改或者颠覆性的更新时增加。
- Y是次要版本,有向下兼容的修改或者添加兼容性的新功能时增加1。
- Z是补丁程序版本,仅仅是打一些兼容性补丁,做一些兼容性修复时增加。
- 次要版本号或补丁程序版本号中的每个增量都必须是向后兼容的更改。
如果该系统或类似系统,请按照以下策略标记镜像:
- 最新标签始终指的是最新(可能稳定)的镜像。创建新镜像后,该标签即被移动。
- X.Y.Z标签是指软件的特定版本。请不要将其移动到其他镜像。
- X.Y标记是指软件X.Y次要分支的最新修补程序版本。当发布新的补丁程序版本时,它将被移动。
- X标记是指X主要分支的最新次要版本的最新补丁程序版本。当发布新的修补程序版本或新的次要版本时,它将移动。
使用此策略可以使用户灵活地选择他们要使用的软件版本。他们可以选择特定的X.Y.Z版本,并确保镜像永不更改,或者可以通过选择不太具体的标签来自动获取更新。
用Git提交哈希标记
如果你用持续交付系统并且经常发布软件,则可能不能使用语义版本控制规范中描述的版本号。在这种情况下,处理版本号的常用方法是使用Git commit SHA-1哈希(或它的简短版本)作为版本号。根据设计原理,Git的提交哈希是不可变的,并引用到软件的特定版本。
可以将git提交哈希用作软件的版本号,也可以用作软件特定版本构建的Docker镜像的标记。这样可以使Docker镜像具有可追溯性,在这种情况下image标记是不可变的,因此可以立即知道给定容器中正在运行哪个特定版本的软件。在持续交付管道中,自动更新用于部署的版本号。
权衡公共镜像的使用
Docker的一大优点是可用于各种软件的大量公共可用镜像。这些镜像使你可以快速入门。但是,在为线上环境设计容器策略时,可能会遇到一些限制,使得公共提供的镜像无法满足要求。以下是可能导致无法使用公共镜像的一些限制示例:
- 精确控制镜像内部的内容。
- 不想依赖外部存储库。
- 想严格控制生产环境中的漏洞。
- 每个镜像都需要相同的基础操作系统。
对所有这些限制的对策都是相同的,并且但是有很高的代价,那就是必须构建自己的镜像。对于数量有限的镜像,可以自己构,但是当数目有增长的时。为了有机会大规模管理这样的系统,可以考虑使用以下方法:
- 以可靠的方式自动生成镜像,即使对于很少生成的镜像也是如此。
- 解决镜像漏洞,可以在容器注册表中漏洞扫描。企业中不同团队创建的镜像执行内部标准的方法。有几种工具可用来帮助在生成和部署的镜像上实施策略:
- container-diff:可以分析镜像的内容,甚至可以比较两个镜像之间的镜像。
- container-structure-test:可以测试镜像的内容是否符合定义一组规则。
- Grafeas:是一种工件元数据API,可以在其中存储有关镜像的元数据,以便以后检查这些镜像是否符合你的策略。
- Kubernetes具有准入控制器,在Kubernetes中部署工作负载之前,可以使用该准入控制器检查许多先决条件。
- Kubernetes还具有Pod安全策略,可用于在群集中强制使用安全选项。
- 也能采用一种混合系统:使用诸如Debian或Alpine之类的公共镜像作为基础镜像,然后基于该镜像构建其他镜像。或者可能想将公共镜像用于某些非关键镜像,并为其他情况构建自己的镜像。
关于软件可
在Docker镜像中包含第三方库和软件包之前,请确保相应的许可允许这样做。第三方许可证可能还会对重新分发施加限制,当将Docker镜像发布到公共注册表时,这些限制就适用。
总结
本文中介绍了容器构建过程中应该遵循的一些基本的原则,通过这些原则可以确保构建的容器安全、精炼,可收缩,可控,当然这些条款也只是建议性质的,在满足需求的基础上请尽量遵循。其中涉及的一些方法仅供参考,你也可以在遵守基本原则情况下使用更适合自己的解决方法。