Docker 提供了一些出色的构建时功能和基本映像,我们可以用它们来实现轻量、安全和高效的应用程序构建。
本文会介绍为什么 Golang 可以很好地展示这些特性,因为 Golang 可以编译为单个二进制文件(或一组二进制文件)。
这篇文章的示例所关注的焦点是极简主义。尽管这些示例很基础,但它们非常重要,你可以在这些概念基础上为大型 Golang 项目引入更多最佳实践,以提高安全性和效率。
我们将使用这个简单的 main.go 来演示本文的概念:
package main
import "fmt"
func main() {
fmt.Println("Hello Cloudreach!")
}
最少的层带来效率最大化:最佳实践
Docker 在 Dockerfile 文档中一上来就强调:尽量减少层数是一个最佳实践!这是一个重要的概念,必须从一开始就做好。
你很容易就能写一个包含很多层的 Dockerfile——它的语法就有这个倾向——结果你不知不觉中就会写出很多效率低下的内容。
最佳实践是将构建的相关阶段分组和链接在一起,例如下载依赖项、供应商文件夹集成或使用 RUN 命令设置构建环境等阶段。
你还需要考虑分组的哪些部分是可以经常更改的,然后将它们分组到 Dockerfile 中尽可能低的层,同时把静态构建依赖项、构建环境配置或应用程序资产放到 Dockerfile 中尽可能上层的位置上。
每一层,更具体地说是 Dockerfile 中以指令开头的每一行,都经过哈希处理并建立在另一层之上,最后一个映像由“堆叠(stacked)”层构成。
由于 Dockerfile 的每一层都是从下一层继承的,因此构建缓存提供了一种很好的机制,可以帮助你跳过已构建或静态的内容,然后转到你需要构建和重新哈希的部分!
尽量缩短构建时间是很重要的,因为高效的 CI/CD 系统每天都会运行这些构建很多次。当团队规模逐渐扩大后,这可能意味着大量的构建工作,可能会需要很多 Jenkins worker,有时甚至需要很长时间才能集成开发人员的代码!
Docker 有一些构建缓存功能,它们可以显著节省构建时间。等待构建的时间越短,意味着集成和自动化测试的速度也就越快,也能提升 CI/CD 流程的速度,让你的流程足以和团队规模相匹配。
尽可能为应用程序安排单独的非 root 用户也是很重要的。你只需要在 linux adduser 命令中使用 RUN 指令,然后在 Dockerfile 中使用 USER 指令就可以使用这个用户来运行二进制文件了。
下面是使用这些最佳实践构造的一个最精简的 Dockerfile main.go 示例,它只有一个基本的 main 函数,没有外部依赖项:
FROM golang:alpine
RUN mkdir /app
ADD . /app/
WORKDIR /app
RUN go build -o main .
RUN adduser -S -D -H -h /app appuser
USER appuser
CMD ["./main"]
构建后,生成的映像大小为 378MB:
$ docker build -t hellocloudreachmain:1.0 . -f Dockerfile.single
... (build output omitted)
$ docker images | grep hellocloudreachmain
hellocloudreachmain 1.0 d1c5090585bc Less than a second ago 378MB
尽可能使用基于 Alpine 的官方映像!它是基于 busybox 和 musl 构建的,最轻量级的 Linux 发行版之一。与较重的发行版相比,它的容器映像体积很小,这是一个轻松提升效率的好方法。
但我们还可以进一步简化!这些官方映像构建(比如golang:alpine)都包含很多层,里面含一些安全组件,用来构建应用程序资产时很方便;但是如果我们的应用程序不需要这些层,那就不要把它们放进去!我们需要使用其他一些 Docker 构建功能,进一步缩小文件体积。
下一步:多阶段
当需要在生产环境中运行应用程序时,我们需要让容器的设计可以确保性能和安全性。我们还需要尽可能多的可移植性,以便轻松地移动容器,并使用 DockerSwarm 和 Kubernetes 之类的编排器对其进行大规模调度。
将映像推入和拉出注册表所需的时间应尽量缩短。编写用于生产的 Dockerfile 时,要记住的一个要点就是在最终运行时映像中实践极简主义。
如果运行的时候并不需要某样东西,请不要把它放进去!
在开发环境中,有时需要一个“较重”的容器映像,也许是一个开发人员专属的 Dockerfile,方便开发人员随时扔进来一些工具,和容器一起进行调试等开发活动。也可能会附加或保留一两个卷和活动容器交互。这些当然都是很常见的情况!
但是,随着容器映像沿开发管线向上移动,一定要记得把这些东西都取出来。一条正规的安全软件供应链会要求在管道中尽早构建最终映像,对映像签名,并将经过正式签名的映像推到生产环境的各个阶段。
因此,你需要尽早在供应链中构建、验证、集成和签名这个最小化的映像;这是 Dockerfile 开发人员、QA 团队和安全工程师必须熟悉的操作!换句话说,只构建一次,然后让你的流程将生成的映像投入生产环境。
多阶段构建是实现这一目标的一个好方法!多阶段涉及的基本原理包括:调用一个临时容器以简化应用程序的构建,然后将构建的资产从这个空间复制到只有运行应用所需必要组件的容器映像中。拿之前的 Dockerfile 示例来说:
FROM golang:alpine as builder
RUN mkdir /build
ADD . /build/
WORKDIR /build
RUN go build -o main .
FROM alpine
RUN adduser -S -D -H -h /app appuser
USER appuser
COPY --from=builder /build/main /app/
WORKDIR /app
CMD ["./main"]
注意这个 Dockerfile 中的两个 FROM 指令。我们将第一个标记为“builder”,使用它来构建应用程序。
然后,我们使用第二个 FROM,这一次是从基本的“alpine”(非常轻巧!)中提取的,并将我们构建的可执行文件从该环境复制到该新环境。
这就让映像的体积比以前小了很多!另外,“builder”容器被缓存在 docker builder 上下文中,因此可以像前面的示例一样利用构建缓存来提升速度!
$ docker build -t hellocloudreachmain:1.1 . -f Dockerfile.multi
... (build output omitted)
$ docker images | grep hellocloudreachmain
hellocloudreachmain 1.0 d1c5090585bc 8 minutes ago 378MB
hellocloudreachmain 1.1 ea737df5cc64 Less than a second ago 6.16MB
这个映像的大小是 6.16MB。相比 378MB 来说,体积减少的效果很明显!
最小化整个运行环境……从头开始构建!
实际上,我们在精简之路上还可以走得更远。Golang 有很多有趣的特性,其中之一是你可以编译为单个二进制文件,并且在大多数情况下,你可以使用某些特殊的构建时参数将所有相关的库静态编译进这个二进制文件。
这样我们就能构建一个最小化的 Docker 容器,并减少额外的运行时开销,以实现我们所追求的出色性能、可移植性和安全性!
如果我们可以将 Golang 应用程序编译为单个二进制文件,并将它静态链接到依赖项上,就可以使用一个 0KB 容器来运行这个应用程序。这是 Docker 提供的一个特殊的基础映像,称为“scratch”。
在我们的 Docker 培训课程中总会遇到一个问题:所有容器内部都装有操作系统吗?答案是否定的,就是因为有这种特殊类型!
这个映像内部没有关联受支持的操作系统环境。它有一些特殊要求,最重要的是,主机的架构必须支持编译好的二进制文件的架构(x86、x64 等),然后,你实际得到的容器除了隔离功能和 Docker 容器的那些优秀特性外,不向应用程序提供任何功能或支持!尽量使用 scratch 作为基础映像,将为你的应用程序容器提供极高的简约性和安全性水平。
FROM golang:alpine as builder
RUN mkdir /build
ADD . /build/
WORKDIR /build
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main .
FROM scratch
COPY --from=builder /build/main /app/
WORKDIR /app
CMD ["./main"]
在第 6 行,FROM scratch 告诉 Docker 从头开始,就像我们在上一个多阶段示例中看到的那样,但这次使用的是 0KB 临时映像。第一个阶段与之前类似,但这次我们在构建阶段使用一些编译时参数来指示 go 编译器将运行时库静态链接到二进制文件本身:
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main.
在此示例中,最终的 Docker 映像只会包含这一个可执行文件,而无需使用容器操作系统。
$ docker build -t hellocloudreachmain:1.2 . -f Dockerfile.scratch
... (build output omitted)
$ docker images | grep hellocloudreachmain
hellocloudreachmain 1.0 d1c5090585bc 8 minutes ago 378MB
hellocloudreachmain 1.1 ea737df5cc64 4 minutes ago 6.16MB
hellocloudreachmain 1.2 bda5c99404ae 33 seconds ago 2.01MB
太棒了,生成的容器大小只有 2.01MB!与最初的 378MB 映像相比,这是一个巨大的进步!
那么这些真的有用吗?
$ docker run -it hellocloudreachmain:1.0
Hello Cloudreach!
$ docker run -it hellocloudreachmain:1.1
Hello Cloudreach!
$ docker run -it hellocloudreachmain:1.2
Hello Cloudreach!
小结
我们用一个基本的 Dockerfile 举例,然后一步步缩减最终映像的体积。通过这个简单的练习,我们很容易看到在构建 Golang 应用程序时,有很多选择可以在 Docker 构建中实践极简主义。
我们有很多办法可以利用这种语言的特性,及其在编译期间提供给开发人员的特性来减小容器的体积。由于 Golang 可以编译为静态链接的可执行文件,因此我们能利用这类特性为运行时剥离所有不必要的组件。
更复杂的应用程序和构建可能无法遵循和上面完全相同的设计模式,但是这些原理可以应用在大多数 Golang Dockerfile 上!只需花一些时间来确保自己使用行业最佳实践正确构建 Dockerfile,就能成功地在容器中构建快速、安全和可扩展的应用程序!