当前位置: 首页 > 科技观察

Docker容器构建的良好实践

时间:2023-03-14 01:17:26 科技观察

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