在整个容器生态体系开发活动过程中,Image (镜像)的编排显得愈来愈重要,毕竟,Image 是整个容器的灵魂,而镜像则通常是基于 Dockerfile 进行编排生成,故了解以及学习如何通过在镜像构建过程中应用一组快速的 Dockerfile 最佳实践来预防安全问题并优化容器化应用程序是一项必备的技能。
众所周知,基于云原生“工程思维”的角度,一个接近完美、精心设计的 Dockerfile 应将避免需要特权容器,暴露不必要的端口,安装未使用的软件包,泄露的凭据等,或者可以用于攻击的任何东西。因此,在实际的业务场景中,针对源头事件的提前布局以及已知风险的规避或多或少有助于降低我们应用的安全管理和运营开销。
基于以下使用的工具的最佳实践,模式和建议将有助于大家避免常见的错误和必要的陷阱。本文潜入了守护的 Docker 安全最佳实践列表,这些实践集中在编写 Dockerfiles 和 Container 安全性,而且还涵盖了其他相关主题,如镜像优化。
在实际的云原生开发活动过程中,DockerFile 最佳实践只是整个容器开发过程中的其中重要一环。后续的镜像的编译、部署及容器运行也是不可或缺的一部分。
1、镜像基线化
此种方式为 Dockerfile 最佳实践的重要参考标准之一,镜像基线化,从广义角度而言,主要涉及以下内容:
(1)、减少依赖组件数量
(2)、容量最小化
基于我们的实际业务场景,通常为了使应用服务能够正常、安全稳定运行,我们尽可能避免不重要的或没必要的端口的暴露、各种第三方没必要组件的植入,比如:Lsof、Netstat 及其他类似的辅助组件等。毕竟,在容器运行过程中,所依赖或者暴露的组件越多,其越难以维护,从而引入更多不可控的风险因素。
因此,为了保证我们的镜像“基线化”,通常,我们可以借助以下操作进行:
(1)选用受信任的基础镜像
若我们构建在不受信任或不明意的镜像之上,此时,所运行的容器可能将会从此镜像中继承到所有潜在的问题和漏洞,从而影响应用的正常运行。因此,在实际的业务场景中,我们在甄选所引用的基础镜像时,尽可能选用官方所指定的参考规范及标准,以尽可能减少风险的产生。当然,针对所引用的第三方镜像,我们可以借助相关扫描工具进行检测,以辨别镜像的合规性。
(2)及时更新镜像
在镜像的演进过程中,所依赖的组件难免出现各种各样的功能或性能 Bug 或漏洞,这是一个长期维护的过程,故此,我们需要实时关注每一次技术的迭代,每一次迭代所修复的内容以及更新的功能,及时更新在业务运行过程中所依赖的镜像版本,从而,保证服务的健康运行。
(3)多级构建
多级构建功能在镜像的编译过程中显得尤为重要,基于此,我们可以在容器内进行重复构建,以满足实际的需求。在多级构建的过程中,我们仅需要创建一个“中间”载体,所有所依赖的组件或工具都可以编译或生成最终的可执行文件,而无需其他开发依赖项或临时构建文件。基于此种策略,所生成的镜像体积更小、更安全。基于 Go 应用程序,多级构建的示例,我们可参考如下所示:
#This is the "builder" stage FROM golang:1.15 as builder WORKDIR /my-go-app COPY app-src . RUN GOOS=linux GOARCH=amd64 go build ./cmd/app-service #This is the final stage, and we copy artifacts from "builder" FROM gcr.io/distroless/static-debian10 COPY --from=builder /my-go-app/app-service /bin/app-service ENTRYPOINT ["/bin/app-service"]
基于上述 Dockerfile 指令,我们使用 golang:1.15 Container,其中包括所有 Go 工具链。
FROM golang:1.15 as builder
此时,我们可以在此处进源代码的复制以及对其进行构建操作,具体如下所示:
WORKDIR /my-go-app COPY app-src . RUN GOOS=linux GOARCH=amd64 go build ./cmd/app-service
然后,我们基于Debian distroless 镜像定义另一个阶段(参见下一个技巧)。
FROM gcr.io/distroless/static-debian10
此时,我们使用 --from=builder 标志从 Builder 阶段复制生成的可执行文件。最终的镜像将只包含 distroless/static-debian-10 镜像和应用程序可执行文件中的最小库集。即所谓的没有构建工具链,没有源代码。
2、授权控制
在实际的业务场景中,尽可能遵循最小特权原则,即我们的应用程序或服务尽可能只限于访问执行其目的所需的资源和信息。通常,为了保证容器因授权产生的漏洞,我们可借助以下策略进行实施。
(1)非 Root 访问
通常,容器需要以 root 用户身份进行执行的用例场景较少,除非特殊情况。因此,我们在进行容器构建过程中默认情况下阻止以 root 用户身份运行的容器(即,Openshift需要额外的SecurityContextConstraints)。
以非 root 用户身份运行可能需要在 Dockerfile 中执行几个附加步骤,具体需要以下:
A:确保用户说明中指定的用户存在于容器中。
B:在进程将读取或写入的位置提供适当的文件系统权限。
FROM alpine:3.12 # Create user and set ownership and permissions as required RUN adduser -D myuser && chown -R myuser /myapp-data # ... copy application files USER myuser ENTRYPOINT [“/myapp”]
我们可能会看到容器以 root 开始,然后使用 gosu 或 su exec 放到标准用户。此外,如果容器需要以 root 身份运行非常特定的命令,那么它可能依赖 sudo 指令。虽然这两种方法比以 root 用户身份运行要好,但它们可能无法在像 Openshift 这样的受限环境中工作。
(2)可执行文件不可写
通常,容器中的每个可执行文件都归 root 用户所有,即使它是由非根用户执行的,并且不应该是所有用户可写的。基于此,可阻止执行用户修改现有的二进制文件或脚本,从而导致不同的攻击。通过遵循这个最佳实践,我们可以有效地增强容器的不变性。不可变容器不会在运行时自动更新其代码,基于此种方式,可以防止正在运行的应用程序被意外或恶意修改。
要遵循此最佳实践,请尽量避免以下操作,具体如下所示:
... WORKDIR $APP_HOME COPY --chown=app:app app-files/ /app USER app ENTRYPOINT /app/my-app-entrypoint.sh
大多数时候,我们可以直接丢弃 --chown app:app 操作 (或运行 chown 相关命令操作)。应用程序用户只需要文件的执行权限,而不需要所有权。
(3)不绑定特定 UID
此种场景在 Openshift 体系下使用较为广泛。通常,以非根用户身份运行容器,但不要将该用户 UID 作为要求。默认情况下,Openshift 在运行容器时将使用随机 UID。
强制使用特定的 UID(即 uid1000 的第一个标准用户)需要调整任何绑定装载的权限,例如用于数据持久性的主机文件夹。或者,如果使用主机UID运行容器(-docker中的u选项),则在尝试从容器中的文件夹读写时,可能会中断服务。
RUN mkdir /myapp && chown -R myuser /myapp USER myuser ENTRYPOINT [“/myapp”]
若使用不同于 myuser 的 UID 运行此容器,则会出现问题,因为应用程序将无法写入 /myapp 文件夹。
不要使用只能由 myuser 写入的硬编码路径。相反,将临时数据写入 /tmp(任何用户都可以在其中写入,这要归功于粘性位权限)。使资源具有可读性(即0644而不是0640),并确保在 UID 更改时一切正常。
... USER myuser ENV APP_TMP_DATA=/tmp ENTRYPOINT [“/myapp”]
在上述示例中,我们的应用程序将使用APP_TMP_DATA 环境变量中的路径。默认值 /tmp 将允许应用程序作为任何 UID 执行,并且仍然将临时数据写入 /tmp。将 path 作为一个可配置的环境变量并不总是必要的,但是在为持久性设置和装载卷时,它会使事情变得更容易。
3、数据管理
在处理容器时,数据管理是一件非常重要的活动。以下 Dockerfile 最佳实践将提供一些关于处理容器凭据的建议,以及如何避免意外泄漏不需要的文件或信息。具体如下场景所示。
(1)证书及保密
切勿在 Dockerfile 指令中放入任何机密或凭据(环境变量、参数或硬编码到任何命令中)。对复制到容器中的文件要格外小心。即使一个文件在 Dockerfile 的后面的指令中被删除,它仍然可以在前面的层上被访问,因为它并没有被真正删除,只是“隐藏”在最终的文件系统中。因此,在构建镜像时,我们尽可能遵循以下规范:
A:如果应用程序支持通过环境变量进行配置,则使用它们来设置执行时的机密(docker run 中的 -e 选项),或者使用 docker secrets、Kubernetes secrets 来提供作为环境变量的值。
B:使用配置文件并绑定在 docker 中装载配置文件,或者从 Kubernetes 机密中装载它们。
此外,我们的镜像不应包含机密信息或配置值,这些信息或配置值将它们与某些特定环境(即生产、暂存等)联系起来。相反,允许通过在运行时注入值(尤其是秘密)来定制镜像。例如,我们应该只在配置文件中包含安全值或伪值。
(2)文件操作命令
在进行 Dockerfile 编写时,ADD 和 COPY 命令使用较为广泛,两者具有相同的功能,但是,在某些场景下却存在差异。通常来讲,COPY 命令更为明确。除非我们真的需要添加功能,比如从 URL 或 tar 文件添加文件,否则请使用 COPY 。拷贝更容易预测,也更不容易出错。
在某些情况下,最好使用 RUN 指令代替 ADD命令进行基于 curl 或 wget下载包,提取包时,然后在一个步骤中删除原始文件,从而减少层的数量。
多阶段构建也解决了这个问题,并帮助我们遵循 Dockerfile 的最佳实践,允许您仅复制前一阶段的最终提取文件。
4、操作优化
在进行 Dockerfile 编写的过程中,命令行的正确使用,对于容器的构建也会产生一定影响。因此,只有进行合理的命令操作,才能使得我们的镜像更加完美、健壮。
(1)分层健全性
请谨记:Dockerfile 指令中的顺序是非常重要的。由于 RUN、COPY、ADD 和其他指令将创建一个新的容器层,因此将多个命令组合在一起将减少层的数量。具体可参考如下示例:
FROM ubuntu RUN apt-get install -y wget RUN wget https://…/downloadedfile.tar RUN tar xvzf downloadedfile.tar RUN rm downloadedfile.tar RUN apt-get remove wget
然而,在实际的业务场景中,我们针对Dockerfile的最佳做法是:
FROM ubuntu RUN apt-get install wget && wget https://…/downloadedfile.tar && tar xvzf downloadedfile.tar && rm downloadedfile.tar && apt-get remove wget
除此,针对部分规范性操作指令,例如,前置的缓存类,首先放置不太可能更改且更容易缓存的命令。具体如下所示:
FROM ubuntu RUN apt-get install nodejs COPY source/* . ENTRYPOINT [“/usr/bin/node”, “/main.js”]
我们应使用上述 Dockerfile文件编译次序,而非下面的,具体如下:
FROM ubuntu COPY source/* . RUN apt-get install nodejs ENTRYPOINT [“/usr/bin/node”, “/main.js”]
同理,执行 rm 命令会删除下一层上的文件,但它仍然可用并且可以访问,因为最终的映像文件系统由所有上一层组成。因此不要复制机密文件然后删除它们,它们在最终容器文件系统中不可见,但仍然很容易访问。
(2)数据标签
在构建镜像时,我们可以借助标签对元数据进行标注。标签将有助于镜像管理,比如包括应用程序版本、网站链接、如何联系维护人员等等。具体,可以查看 OCI 镜像规范中的预定义注释。
(3)安全校验
基于已完成的 Dockerfile 文件,通常,可以借助相关工具,诸如 “Haskell Dockerfile Linter(hadolint)” 来检测 Dockerfile 中的错误语法或者漏洞,甚至暴露 RUN 指令执行的 shell 命令中的问题。可考虑在 CI 管道中加入这样一个工具,以方便进行文件的安全校验。
(4)镜像扫描
镜像扫描是在运行容器之前检测潜在问题的另一种方法。为了遵循镜像扫描最佳实践,我们应该在镜像生命周期的不同阶段执行扫描,此外,还应该在镜像已推送到容器注册表时执行扫描。
应用“左移安全性”范例是一种安全性最佳实践,在将镜像推送到注册表之前,在 CI 管道中直接扫描镜像。这还包括在开发人员计算机中使用 Sysdig 内联扫描程序,它提供了与 CI/CD 工具(如 Jenkins、Github actions 等)的不同集成。
然而,现在扫描的镜像可能是“安全的”。但随着它的老化和新的漏洞被发现,它可能会变得危险。因此,我们必须时刻关注镜像中所依赖组件的相关轨迹,及时更新至最新版本以减少不必要的潜在风险。
综上为在进行 Dockerfile 制作过程中所积累的最佳实践总结,当然,在后续的镜像编译、部署及容器运行时也涉猎相关准则,暂不在本文中描述。