Docker 是现在的开发人员都已经很熟悉的平台。它使得我们可以更容易地在容器中创建、部署和运行应用程序。所需的依赖会被“打包”并且以进程的方式运行在主机操作系统上,而不是像虚拟机那样为每个工作负载都重复使用操作系统。这就避免了机器之间微小的配置差异。
因为 Docker 使这种方式流行了起来,所以很多人都在讨论 Docker 容器和 Docker 镜像。实际上,镜像和容器并不一定非“Docker”不可,它们可以基于类似的框架。
随着云原生编程的普及,Docker 本身和 Docker 这种方式也在不断发展。云原生这个术语有多种定义,但是它主要指的是在云基础设施上运行应用程序,这里所说的应用程序很可能是基于微服务架构的。它会使用自动化工具,以及云供应商的资源和功能。在这种编程风格中,像 Docker 这样的容器化工具通常会很有用,因为容器的内容和搭建过程会形成一个可重复的环境,不受底层系统的影响。
如果你正在使用 Docker 的话,你可能也想知道它对你的应用来讲是否足够安全。和许多系统一样,我们不能简单地用是或者不是来回答“Docker 是否安全?”这个问题。以安全的方式使用 Docker 是可以实现的,但是我们需要考虑采取一些行动才行。
在本文中,我们将会探讨 Docker 相关的最重要的安全因素。
Docker 与 Docker 镜像
为了解决 Docker 的安全问题,我们需要理解在容器中运行镜像的 Docker 以及 Docker 镜像本身的差异。
我们会从一个 Docker 镜像来启动容器。Docker 镜像是一个分层的结构,我们会在这里定义要运行的进程以及运行该进程所需的文件。例如,如果你是一位 Jakarta EE 开发人员的话,这可能会是 Jakarta EE 服务器的安装程序以及你的应用。
Docker Hub 是一个存储库,我们可以在这里存储和共享 Docker 镜像。我们可以使用这里的镜像直接启动一个容器,也可以扩展这些镜像,根据需要定制化并使用它们。定制化镜像的方式,也就是选择要包含哪些二进制文件以及它们的权限,这会对应用程序的安全性产生影响。
随后,我们要有一个实际运行容器的程序。它会有一个守护进程(不受用户直接控制的后台进程),负责托管镜像、容器、网络和存储卷。这可能是 Docker Engine,也可能是其他的实现。它会负责以隔离的方式运行你的进程。如何运行容器也会对安全性产生影响。
镜像的安全考虑因素
我们所构建的容器镜像符合开放容器倡议(Open Container Initiative,OCI),它不一定要提供开箱即用的全面安全性。我们可以采取一些步骤确保这个进程在容器和主机系统中是相当安全的。
在容器中运行这个进程的主要问题在于当应用被人“入侵”时,它可以通过底层主机获取权限,从而对许多系统带来安全风险。
当在容器中运行时,我们需要更加警惕安全相关的问题,因为与虚拟机相比,容器与主机有着更紧密的集成(正如前文所述,它运行在主机的操作系统中)。当安全漏洞在容器中出现时,它会更加严重。这是因为,当应用程序在不同的物理机上运行时,它们在一定程度上是相互分离的。但是,当容器软件中出现漏洞时,某个应用 / 进程有可能会访问另外一个容器,因此会访问自己的漏洞或者将自己的漏洞对外暴露出去。
对于容器中的应用程序或进程,我们应该采取的一个预防措施就是,它绝不应该以“root”用户身份运行。如果作为 root 用户运行的话,该进程会有更多的权限,因此可以访问更多低层级的资源进程。我们始终应该在容器脚本的某个地方声明运行主进程的用户:
USER myuser
理想情况下,进程和应用程序的所有二进制文件其拥有者都应该是 root,但是运行进程的用户只应该有读取和执行的权限。通过这种方式,进程本身无法修改容器中构成应用程序的二进制文件和脚本,因此在出现漏洞时,情况也不会太严重。
上述的场景就是最小权限原则的具体实施:强制代码以尽可能低的权限运行。当进程没有权限的时候,它也就不会被滥用了。另外一个原则就是减少潜在的攻击面。镜像不应该包含非严格需要的二进制文件,它们可能会成为安全漏洞的来源。
所以,镜像中只应包含绝对必要的二进制文件。如果可能的话,从一个“空白(scratch)”镜像开始,并且只添加那些所需的二进制文件。或者,也可以从一个很小的镜像开始,比如 Alpine 镜像。二进制文件和可执行文件越少,出现安全漏洞的几率就会越低。
要删除镜像中不必要的组成部分,还有第三个方案,那就是使用多阶段构建,如果使用“镜像”本身来构建需要在容器中运行的最终的应用程序,尤其需要这样做,所有额外的步骤都可以在一个单独的阶段中完成。这样我们只能在层中将镜像组织起来,但是我们在运行时能够将不必要的所有东西移除掉。
# Build stage
#
FROM maven:3.6.0-jdk-11-slim AS build
COPY src /home/app/src
COPY pom.xml /home/app
RUN mvn -f /home/app/pom.xml clean package
#
# Package stage
#
FROM payara/micro:5.2021.10-jdk11
COPY --from=build /home/app/target/hello.war ${DEPLOY_DIR}
上述的多阶段构建展示了一个样例,那就是在最终镜像中只保留需要的文件和进程。在最终的镜像中,源码和 maven 工具没有任何用处,我们只需要 web 应用程序的 war 文件。通过使用两个独立的阶段,我们能够确保运行时不会包含不必要的东西。围绕进程和应用程序我们应该使用相同的方法,即便它们可能是某些标准镜像的一部分。如果可能的话,我们应该从一个基础镜像开始,并添加真正需要的东西。
容器运行时的安全考虑因素
运行镜像的方式和使用的软件也可能导致安全漏洞。
我们已经说过,不应该使用 root 用户在容器中运行进程。但是,在启动容器的时候,我们依然可以指定它以特权方式(privileged)运行。通过该标记,我们能够把所有的能力交给容器,同时也解除了设备 cgroup 控制器所强制要求的所有限制。因此在出现安全问题时,它的影响会更大。
容器应该运行在一个“沙箱”中,所以它们能够与主机以及其他正在运行的容器进行隔离。这个特权标记会移除沙箱,因此永远都不应该使用它。另外,还要避免设置 --net=host 选项,因为它也可以影响沙箱。该选项允许容器像 root 进程那样打开一些数值较低的端口,这可能会影响到隔离性。
运行容器时,如果使用主机网络选项的话,端口映射不会生效,也没有主机网络的隔离。容器会使用与主机相同的网络资源。数值范围较低的端口一般会被认为是众所周知的端口,通常只有超级用户进程才会连接到这些端口,因此人们在连接这样的端口时可能不太注意。但容器进程也可以访问整个网络栈,并可能对其他熟知的端口进行扫描。这些端口可能无法从外部访问,但可以在容器的进程内进行轮询,因为容器使用的是主机的网络。
Docker 运行时不是唯一可以使用 Docker 镜像来启动容器的程序。如前所述,Docker 是使容器流行起来的工具,由于它是第一个实现,所以,多年以来人们对这样一个容器运行时应该如何操作有了很多的了解。随着 Kubernetes 中容器运行时接口(Container Runtime Interface,CRI)的定义,出现了其他遵循 CRI 的更好、更安全的实现。
如今,containerd 和 CRI-O 运行时也可以用来运行基于 Docker 镜像的容器。这些实现方案删掉了一些二进制文件和进程,从而使它们更精简、更快速、更安全。例如,它们并不像 dockerd(Docker 运行时进程的名称)那样,默认将 SSH 访问包含到运行中的容器里面。由于攻击面较小,所以可能出现的问题也会比较少。
但是,即便有了这些较新的二进制文件,安全风险仍然不是零。因此,建议根据你的进程来定制安全。有一个与容器相关的默认安全配置文件,但是我们可以通过 AppArmor Linux 安全模块对其进行微调。你可以定义诸如文件夹访问、网络访问以及读取、允许(或拒绝)写入或执行文件的权限等能力。在 AppArmor 文件中定义以下条目,拒绝对 /etc 和 /home 目录的写入和列出操作:
deny /etc/** wl,deny /home/** wl,
基于对容器内进程要求的理解,你应该只开放那些应用程序正常运行所需的权限。这个配置文件可以在我们运行一个容器时进行指定。
docker run <other options> --security-opt apparmor=my_profile <container-image>
结论:细致调节以获得最大的安全性
由于 Docker 镜像和容器需要在各种情况下使用,你需要根据具体使用情况对它们进行调整。安全的一般准则依然能够指导我们针对具体的场景应该采取哪些策略。
最低权限原则说的是,我们应该在实现功能的同时给予尽可能少的权限,以避免出现安全漏洞。对于容器化场景,这意味着我们不应该用 root 用户在容器中运行主进程。我们还应该对文件采取适当的权限,并使用特定的 AppArmor 配置文件限制访问。
为了减少攻击面,我们应该只包括场景中所严格需要的东西,使用较新的实现,如 containerd 和 CRI-O 来运行我们的容器,因为它们包括较少的二进制文件和进程。