如何正确且快速构建Docker最佳安全镜像


新钛云服已为您服务977

可能每个人都听说过Docker,并且大多数开发人员都熟悉并使用过Docker,诸如构建Docker镜像之类的基本操作。一般而言,构建镜像非常简单,只需运行docker built -t name:tag .,但其实还有很多其他可优化的东西,尤其是在优化构建过程和所创建的最终镜像方面。

因此,在本文中,我们将研究如何优化Docker镜像的构建过程,以使我们能够在最短构建时间内构建最小、最安全的满足生产需求的Docker镜像。


缓存以加快构建速度

镜像的构建时间大都花在系统软件包和应用程序依赖包的下载和安装。但是,这些通常不会经常变更,因此推荐进行缓存。

从系统包和工具开始——通常在FROM后运行,以确保已将其缓存。无论您使用哪个Linux发行版作为基本镜像,都应该得到如下所示的结果:

FROM ... # any viable base image like centos:8, ubuntu:21.04 or alpine:3.12.3

# RHEL/CentOS
RUN yum install ...
# Debian
RUN apt-get install ...
# Alpine
RUN apk add ...

# Rest of the Dockerfile (COPY, RUN, CMD...)

另外,您甚至可以将这些相关命令提取到独立的Dockerfile以构建自己的基础镜像。然后可以将该镜像推送到镜像仓库,以便您和其他人可以在其他的Dockerfile中引用。

这样,您无需再去担心系统包以及相关的依赖项,除非您需要升级它们或添加与删除某些内容。

在系统包之后,我们通常要安装应用程序依赖项。这些可能是来自Maven存储库中的Java库(默认存储在.m2目录中),JavaScript模块node_modules或Python库venv

与系统依赖项相比,这些更改的频率更高,但不足以保证每次构建都能进行完整的重新下载和重新安装。但是如果对应Dockerfile写得不好,您会注意到,即使未修改依赖项,也不会使用缓存:

FROM ...  # any viable base image like python:3.8, node:15 or openjdk:15.0.1

# Copy everything at once
COPY . .

# Java
RUN mvn clean package
# Or Python
RUN pip install -r requirements.txt
# Or JavaScript
RUN npm install
# ...
CMD [ "..." ]

这是为什么?问题出在COPY . .,Docker在构建的每个步骤中都使用缓存,直到它遇到新的或已修改的命令/层。

在这种情况下,当我们将所有内容复制到镜像中时—包括未更改的依赖关系列表以及已修改的源代码。

Docker会继续进行并重新下载且重新安装所有依赖关系。因为修改过源码文件,它不再能够在该层使用缓存。为避免这种情况,我们必须分两个步骤复制文件:

FROM ...  # any viable base image like python:3.8, node:15 or openjdk:15.0.1

COPY pom.xml ./pom.xml                    # Java
COPY requirements.txt ./requirements.txt  # Python
COPY package.json ./package.json          # JavaScript

RUN mvn dependency:go-offline -B          # Java
RUN pip install -r requirements.txt       # Python
RUN npm install                           # JavaScript

COPY ./src ./src/
# Rest of Dockerfile (build application; set CMD...)

首先,我们添加列出所有应用程序依赖项的文件并安装它们。如果此文件没有更改,则将缓存所有更改。

只有这样,我们才能将其余(修改过的)源码复制到镜像中,并运行应用程序代码的测试和构建。

对于更多的“高级”方法,我们使用Docker的BuildKit及其实验功能进行相同的操作:

# syntax=docker/dockerfile:experimental

FROM ...  # any viable base image like python:3.8, openjdk:15.0.1
COPY pom.xml ./pom.xml                    # Java
COPY requirements.txt ./requirements.txt  # Python

RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B             # Java
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt   # Python

上面的代码显示了如何使用命令--mount选项RUN来选择缓存目录。如果您要显式使用非默认缓存位置,这将很有帮助。

但是,如果要使用此功能,则必须包括指定语法版本的标题行(如上所述),并使用来运行构建,比如:DOCKER_BUILDKIT=1 docker build name:tag .

在这些文档(https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/syntax.md#run---mounttypecache)中可以找到有关实验功能的更多信息。

到目前为止,所有内容仅适用于本地构建—对于CI,情况则不同,并且通常每个工具/提供程序都会有所不同,但对于其中的任何一个,您将需要一些持久性卷来存储缓存/依赖项 。例如,对于Jenkins,您可以在代理中使用存储。

对于在Kubernetes上运行的Docker构建(无论是使用JenkinsXTekton还是其他),您将需要Docker守护进程,该守护进程可以在Docker(DinD)中使用Docker进行部署,DinD是在Docker容器中运行的Docker守护进程。

至于构建本身,您将需要一个连接到DinD socket的pod(容器)来运行docker build命令。

为了演示和简化操作,我们可以使用以下pod进行操作:

apiVersion: v1
kind: Pod
metadata:
  name: docker-build
spec:
  containers:
  - name: dind  # Docker in Docker container
    image: docker:19.03.3-dind
    securityContext:
      privileged: true
    env:
    - name: DOCKER_TLS_CERTDIR
      value: ''
    volumeMounts:
      - name: dind-storage
        mountPath: /var/lib/docker
  - name: docker  # Builder container
    image: docker:19.03.3-git
    securityContext:
      privileged: true
    command: ['cat']
    tty: true
    env:
    - name: DOCKER_BUILDKIT
      value: '1'
    - name: DOCKER_HOST
      value: tcp://localhost:2375
  volumes:
  - name: dind-storage
    emptyDir: {}
  - name: docker-socket-volume
    hostPath:
      path: /var/run/docker.sock
      type: File

上面的容器由2个容器组成—一个用于DinD,一个用于镜像构建。要使用构建容器运行构建,可以访问其shell,克隆一些存储库并运行构建流程:

~ $ kubectl exec --stdin --tty docker-build -- /bin/sh  # Open shell session
~ # git clone https://github.com/username/reponame.git  # Clone some repository
~ # cd reponame
~ # docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t name:tag --cache-from username/reponame:latest .
...
 => importing cache manifest from martinheinz/python-project-blueprint:flask
...
 => => writing image sha256:...
 => => naming to docker.io/library/name:tag
 => exporting cache
 => => preparing build cache for export

最终docker build使用了一些新选项—--cache-from image:tag,来告诉Docker它应该使用(远程)仓库中的指定镜像作为缓存源。这样,即使缓存的层未存储在本地文件系统中,我们也可以利用缓存的优点。

另一个选项----build-arg BUILDKIT_INLINE_CACHE=1用于在创建缓存元数据时将其写入镜像。这必须用于--cache-from工作,有关更多信息,请参阅文档(https://docs.docker.com/engine/reference/commandline/build/#specifying-external-cache-sources)。

最小镜像

快速构建确实很让人高兴,但是如果您拥有真正的thick图像,则仍然需要花费很长的时间才能push/pull它们,而且胖镜像很可能还包含许多无用的库,工具以及诸如此类的东西,这些都使镜像变得更加臃肿。

易受攻击,因为它会造成更大的攻击面。

制作更小的镜像的最简单方法是使用Alpine Linux之类的基础镜像,而不是基于Ubuntu或RHEL的镜像。另一个好的方法是使用多步骤Docker构建,其中您使用一个镜像进行构建(第一个FROM命令),而使用另一个更小的镜像来运行应用程序(第二个/最后一个FROM),例如:

# 332.88 MB
FROM python:3.8.7 AS builder

COPY requirements.txt /requirements.txt
RUN /venv/bin/pip install --disable-pip-version-check -r /requirements.txt

# only 16.98 MB
FROM python:3.8.7-alpine3.12 as runner

# copy only the dependencies installation from the 1st stage image
COPY --from=builder /venv /venv
COPY --from=builder ./src /app

CMD ["..."]

上面显示了我们首先在基本的Python 3.8.7镜像中准备了应用程序及其依赖项,该镜像很大,为332.88 MB。在此处,我们安装了应用程序所需的虚拟环境和库。

然后,我们切换到更小的基于Alpine的镜像,该镜像仅为16.98 MB。我们将先前创建的整个虚拟环境以及源代码复制到该镜像。这样,我们最终得到的图像要小得多,镜像层更少,同时也有更少的不必要的工具和二进制文件。

要记住的另一件事是我们在每次构建过程中产生的层数。FROMCOPYRUN以及CMD是都会生成新的层。至少在RUN的情况下,我们可以通过将所有RUN命令合并成这样的一个命令来轻松地减少它创建的层的数量:

# Bad, Creates 4 layers
RUN yum --disablerepo=* --enablerepo="epel"
RUN yum update
RUN yum install -y httpd
RUN yum clean all -y

# Good, creates only 1 layer
RUN yum --disablerepo=* --enablerepo="epel" && \
    yum update && \
    yum install -y httpd && \
    yum clean all -y

我们可以更进一步,完全摆脱可能很重的基础镜像。为此,我们将使用特殊的FROM scratch信号通知Docker应使用最小的基本镜像,而下一个命令将是最终镜像的第一层。

这对于以二进制文件运行且不需要大量工具的应用程序特别有用,例如Go,C ++或Rust应用程序。但是,这种方法要求二进制文件是静态编译的,因此它不适用于Java或Python之类的语言。FROM scratchDockerfiles的示例可能像这样:

FROM golang as builder

WORKDIR /go/src/app
COPY . .
# Static build is required so that we can safely use 'scratch' base image
RUN CGO_ENABLED=0 go install -ldflags '-extldflags "-static"'

FROM scratch
COPY --from=builder /go/bin/app /app
ENTRYPOINT ["/app"]

很简单,对吧?借助这种Dockerfile,我们可以生成仅约3MB的镜像!

锁定版本

速度和大小是大多数人关注的两件事,而镜像的安全性成为人们的事后考虑。有几种简单的方法可以将镜像锁定下来,并限制攻击者可以利用的攻击面。

最基本的建议是锁定所有库、包、工具和基本镜像的版本,这不仅对安全性很重要,而且对镜像的稳定性也很重要。如果您对镜像使用最新标记,或者您没有在Python的requirements.txt或JavaScript的package.json中指定版本,您在构建期间下载的镜像/库可能与应用程序代码不兼容,或者使容器暴露于漏洞中。

当您想将所有内容锁定到特定版本时,还应该定期更新所有这些依赖项,以确保您拥有所有可用的最新安全补丁程序和修补程序。

即使您真的很努力地避免所有依赖中的任何漏洞,仍然会有一些您错过或尚未修复/发现的漏洞。所以,为了减轻任何可能的攻击的影响,最好避免以根用户身份运行容器。

因此,应该在Dockerfiles中包含用户1001,以表示从Dockerfiles创建的容器应该并且可以作为非根用户(理想情况下是任意用户)运行。当然,这可能需要您修改应用程序并选择正确的基本镜像,因为一些常见的基本映像(如nginx)需要根权限(例如,由于特权端口)。

通常很难在Docker镜像中找到与避免漏洞,但是如果镜像仅包含运行应用程序所需的最低限度,则可能会更容易一些。Google发行的Distroless(https://github.com/GoogleContainerTools/distroless)是一个这样的镜像。

将Distroless镜像修剪到甚至没有shell或软件包管理器的程度,这使得它们比Debian或基于Alpine的镜像在安全性方面要好得多。如果您使用的是多步骤Docker构建,那么大多数情况下,切换到Distroless runner映像非常简单:

FROM ... AS builder

# Build the application ...

# Python
FROM gcr.io/distroless/python3 AS runner
# Golang
FROM gcr.io/distroless/base AS runner
# NodeJS
FROM gcr.io/distroless/nodejs:10 AS runner
# Rust
FROM gcr.io/distroless/cc AS runner
# Java
FROM gcr.io/distroless/java:11 AS runner

# Copy application into runner and set CMD...

# More examples at https://github.com/GoogleContainerTools/distroless/tree/master/examples

除了最终镜像及其容器中可能存在的漏洞外,我们还必须考虑用于构建镜像的Docker守护程序和容器运行时。因此,与我们的所有镜像一样,我们不应允许Docker与root用户一起运行,而应使用所谓的rootless模式。

这个文档(https://docs.docker.com/engine/security/rootless/)是关于如何在Docker中进行设置的完整指南,如果您不想调整配置,那么您可能要考虑切换到podmanpodman默认情况下在rootlessdaemonless 下运行的。

结论

容器和Docker已有很长的历史了,每个人都可以学习和使它用,而不仅仅是简简单单的使用。本文中的技巧和示例应该可以提高您的Docker知识并改善所使用的Docker镜像质量。

但是,在构建Docker镜像之外,还有许多其他事情可以改善我们处理镜像和容器的方式。例如,应用seccomp策略,使用cgroups或可能使用完全不同的容器运行时与引擎来限制资源消耗。

*本文翻译自 https://martinheinz.dev/blog/42?utm_source=tds&utm_medium=referral&utm_campaign=blog_post_42,如有侵权请联系删除

*本文部分图片源于网络,如有侵权请联系删除

了解新钛云服

当IPFS遇见云服务|新钛云服与冰河分布式实验室达成战略协议

新钛云服正式获批工信部ISP/IDC(含互联网资源协作)牌照

深耕专业,矗立鳌头,新钛云服获千万Pre-A轮融资

新钛云服,打造最专业的Cloud MSP+,做企业业务和云之间的桥梁

新钛云服一周年,完成两轮融资,服务五十多家客户

上海某仓储物流电子商务公司混合云解决方案

往期技术干货

低代码开发,全民开发,淘汰职业程序员!

国内主流公有云VPC使用对比及总结

万字长文:云架构设计原则|附PDF下载

刚刚,OpenStack 第 19 个版本来了,附28项特性详细解读!

Ceph OSD故障排除|万字经验总结

七个用于Docker和Kubernetes防护的安全工具

运维人的终身成长,从清单管理开始|万字长文!

OpenStack与ZStack深度对比:架构、部署、计算存储与网络、运维监控等

什么是云原生?

IT混合云战略:是什么、为什么,如何构建?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值