在多阶段构建之前
构建图像最具挑战性的事情之一是缩小图像大小。Dockerfile 中的每条指令都会为镜像添加一层,您需要记住在继续下一层之前清除所有不需要的工件。要编写一个真正高效的 Dockerfile,传统上您需要使用 shell 技巧和其他逻辑来保持层尽可能小,并确保每一层都有它需要的来自前一层的工件,而不是其他任何东西。
实际上,将一个 Dockerfile 用于开发(其中包含构建应用程序所需的一切)和一个用于生产的精简版 Dockerfile 是很常见的,它仅包含您的应用程序以及运行它所需的内容。这被称为“构建器模式”。维护两个 Dockerfile 并不理想。
下面是一个遵循上述构建器模式的Dockerfile.buildand示例Dockerfile:Dockerfile.build:
# syntax=docker/dockerfile:1FROM golang:1.16WORKDIR /go/src/github.com/alexellis/href-counter/COPY app.go .RUN go get -d -v golang.org/x/net/html \&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
请注意,此示例还RUN使用 Bash&&运算符人为地将两个命令压缩在一起,以避免在图像中创建附加层。这很容易失败并且难以维护。例如,很容易插入另一个命令而忘记使用\字符继续该行。Dockerfile:
# syntax=docker/dockerfile:1
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY app .
CMD ["./app"]
build.sh:
#!/bin/sh
echo Building alexellis2/href-counter:build
docker build --build-arg https_proxy=$https_proxy --build-arg http_proxy=$http_proxy \
-t alexellis2/href-counter:build . -f Dockerfile.build
docker container create --name extract alexellis2/href-counter:build
docker container cp extract:/go/src/github.com/alexellis/href-counter/app ./app
docker container rm -f extract
echo Building alexellis2/href-counter:latest
docker build --no-cache -t alexellis2/href-counter:latest .
rm ./app
当您运行build.sh脚本时,它需要构建第一个映像,从中创建一个容器以复制工件,然后构建第二个映像。这两个图像都占用了您系统上的空间,并且您app 的本地磁盘上仍然有工件。
多阶段构建大大简化了这种情况!
使用多阶段构建
Docker 17.05版本以后,新增了Dockerfile多阶段构建。所谓多阶段构建,实际上是允许一个Dockerfile 中出现多个 FROM 指令。这样做有什么意义呢?
在17.05版本之前的Docker,只允许Dockerfile中出现一个FROM指令,这得从镜像的本质说起。
你可以简单理解Docker的镜像是一个压缩文件,其中包含了你需要的程序和一个文件系统。其实这样说是不严谨的,Docker镜像并非只是一个文件,而是由一堆文件组成,最主要的文件是 层。
假设基础镜像 ubuntu:16.04 已经存在5层,使用第一个Dockerfile打包成镜像 foo,则foo有6层,又使用第二个Dockerfile打包成镜像bar,则bar中有7层。
如果 ubuntu:16.04 等其他镜像不算,如果系统中只存在 foo 和 bar 两个镜像,那么系统中一共保存了多少层呢?
是7层,并非13层,这是因为,foo和bar共享了6层。层的共享机制可以节约大量的磁盘空间和传输带宽,比如你本地已经有了foo镜像,又从镜像仓库中拉取bar镜像时,只拉取本地所没有的最后一层就可以了,不需要把整个bar镜像连根拉一遍。但是层共享是怎样实现的呢?
原来,Docker镜像的每一层只记录文件变更,在容器启动时,Docker会将镜像的各个层进行计算,最后生成一个文件系统,这个被称为 联合挂载。对此感兴趣的话可以进一步了解一下 AUFS。
Docker的各个层是有相关性的,在联合挂载的过程中,系统需要知道在什么样的基础上再增加新的文件。那么这就要求一个Docker镜像只能有一个起始层,只能有一个根。所以,Dockerfile中,就只允许一个 FROM 指令。因为多个 FROM 指令会造成多根,则是无法实现的。但为什么 Docker 17.05 版本以后允许 Dockerfile支持多个 FROM 指令了呢,莫非已经支持了多根?
尝试多阶段构建
通过多阶段构建,您可以FROM在 Dockerfile 中使用多个语句。每条FROM指令都可以使用不同的基础,并且每条指令都开始构建的新阶段。您可以有选择地将工件从一个阶段复制到另一个阶段,在最终图像中留下您不想要的所有内容。为了展示它是如何工作的,让我们调整上面的 Dockerfile 以使用多阶段构建。Dockerfile:
# syntax=docker/dockerfile:1
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
您只需要单个 Dockerfile。您也不需要单独的构建脚本。就跑docker build。
$ docker build -t alexellis2/href-counter:latest .
最终结果是与以前相同的微小生产图像,但复杂性显着降低。您不需要创建任何中间映像,也不需要将任何工件提取到本地系统。
它是如何工作的?第二FROM条指令以alpine:latest镜像为基础开始一个新的构建阶段。该COPY --from=0行仅将上一阶段的构建工件复制到这个新阶段。Go SDK 和任何中间工件都会被留下,并且不会保存在最终图像中。
多个 FROM 指令的意义(栗子)
多个 FROM 指令并不是为了生成多根的层关系,最后生成的镜像,仍以最后一条 FROM 为准,之前的 FROM 会被抛弃,那么之前的FROM 又有什么意义呢?
每一条 FROM 指令都是一个构建阶段,多条 FROM 就是多阶段构建,虽然最后生成的镜像只能是最后一个阶段的结果,但是,能够将前置阶段中的文件拷贝到后边的阶段中,这就是多阶段构建的最大意义。
最大的使用场景是将编译环境和运行环境分离,比如,之前我们需要构建一个Go语言程序,那么就需要用到go命令等编译环境,我们的Dockerfile可能是这样的:
# Go语言环境基础镜像
FROM golang:1.10.3
# 将源码拷贝到镜像中
COPY server.go /build/
# 指定工作目录
WORKDIR /build
# 编译镜像时,运行 go build 编译生成 server 程序
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOARM=6 go build -ldflags '-w -s' -o server
# 指定容器运行时入口程序 server
ENTRYPOINT ["/build/server"]
基础镜像 golang:1.10.3 是非常庞大的,因为其中包含了所有的Go语言编译工具和库,而运行时候我们仅仅需要编译后的 server 程序就行了,不需要编译时的编译工具,最后生成的大体积镜像就是一种浪费。
Dockerfile的基础镜像并不需要包含Go编译环境:
# 不需要Go语言编译环境
FROM scratch
# 将编译结果拷贝到容器中
COPY server /server
# 指定容器运行时入口程序 server
ENTRYPOINT ["/server"]
提示:
scratch是内置关键词,并不是一个真实存在的镜像。FROM scratch会使用一个完全干净的文件系统,不包含任何文件。 因为Go语言编译后不需要运行时,也就不需要安装任何的运行库。FROM scratch可以使得最后生成的镜像最小化,其中只包含了 server 程序。
在 Docker 17.05版本以后,就有了新的解决方案,直接一个Dockerfile就可以解决:
# 编译阶段
FROM golang:1.10.3
COPY server.go /build/
WORKDIR /build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOARM=6 go build -ldflags '-w -s' -o server
# 运行阶段
FROM scratch
# 从编译阶段的中拷贝编译结果到当前镜像中
COPY --from=0 /build/server /
ENTRYPOINT ["/server"]
这个 Dockerfile 的玄妙之处就在于 COPY 指令的 --from=0 参数,从前边的阶段中拷贝文件到当前阶段中,多个FROM语句时,0代表第一个阶段。除了使用数字,我们还可以给阶段命名,比如:
# 编译阶段 命名为 builder
FROM golang:1.10.3 as builder
# ... 省略
# 运行阶段
FROM scratch
# 从编译阶段的中拷贝编译结果到当前镜像中
COPY --from=builder /build/server /
更为强大的是,COPY --from 不但可以从前置阶段中拷贝,还可以直接从一个已经存在的镜像中拷贝。比如,
FROM ubuntu:16.04
COPY --from=quay.io/coreos/etcd:v3.3.9 /usr/local/bin/etcd /usr/local/bin/
我们直接将etcd镜像中的程序拷贝到了我们的镜像中,这样,在生成我们的程序镜像时,就不需要源码编译etcd了,直接将官方编译好的程序文件拿过来就行了。
有些程序要么没有apt源,要么apt源中的版本太老,要么干脆只提供源码需要自己编译,使用这些程序时,我们可以方便地使用已经存在的Docker镜像作为我们的基础镜像。但是我们的软件有时候可能需要依赖多个这种文件,我们并不能同时将 nginx 和 etcd 的镜像同时作为我们的基础镜像(不支持多根),这种情况下,使用 COPY --from 就非常方便实用了。
命名您的构建阶段
默认情况下,阶段没有命名,您可以通过它们的整数来引用它们,第一FROM条指令从 0 开始。但是,您可以通过AS <NAME>在FROM指令中添加 来命名您的阶段。此示例通过命名阶段并在COPY指令中使用名称来改进前一个示例。这意味着即使 Dockerfile 中的指令稍后重新排序,COPY也不会中断。
# syntax=docker/dockerfile:1
FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
在特定的构建阶段停止
构建镜像时,您不一定需要构建整个 Dockerfile,包括每个阶段。您可以指定目标构建阶段。以下命令假定您正在使用前一个Dockerfile但在名为 的阶段停止builder:
$ docker build --target builder -t alexellis2/href-counter:latest .
这可能非常强大的一些场景是:
- 调试特定的构建阶段
- 使用
debug启用所有调试符号或工具的production阶段和精益阶段 - 使用
testing您的应用程序填充测试数据的阶段,但使用使用真实数据的不同阶段为生产构建使用外部镜像作为“平台”
使用多阶段构建时,您不仅限于从之前在 Dockerfile 中创建的阶段进行复制。您可以使用该COPY --from指令从单独的镜像复制,使用本地镜像名称、本地或 Docker 注册表上可用的标签或标签 ID。Docker 客户端在必要时拉取映像并从那里复制工件。语法是:COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf使用上一阶段作为新阶段
在使用FROM指令时,您可以通过引用它来从上一阶段停止的地方开始。例如:# syntax=docker/dockerfile:1 FROM alpine:latest AS builder RUN apk --no-cache add build-base FROM builder AS build1 COPY source1.cpp source.cpp RUN g++ -o /binary source.cpp FROM builder AS build2 COPY source2.cpp source.cpp RUN g++ -o /binary source.cpp
实例nginx-alpine多阶段最小镜像构建
#This is DockerFile based on the alpine image
#### Stage 1
FROM alpine:3.14.0 as build
LABEL MAINTAINER="zhang.kai@chzh.cn"
ENV PATH $PATH:/usr/local/nginx/sbin/
ENV LANG "en_US.UTF-8"
ENV NGINX_VERSION 1.14.2
ARG CPU_NUM
# 修改源
RUN echo "http://mirrors.aliyun.com/alpine/latest-stable/main/" > /etc/apk/repositories && \
echo "http://mirrors.aliyun.com/alpine/latest-stable/community/" >> /etc/apk/repositories && \
apk --no-cache update && \
apk add --no-cache tzdata bash && \
apk add --no-cache gcc libc-dev zlib-dev pcre-dev make openssl-dev wget && \
# 创建用户创建用户组
addgroup -S www && \
adduser -s /sbin/nologin -S -D -G www www && \
# 创建用户目录赋权绑定
mkdir -p /data/www && \
chown -R www:www /data/www && \
# 编译安装
wget -P /usr/local/ http://downloads.ichzh.com/Nginx/nginx-$NGINX_VERSION.tar.gz && \
cd /usr/local/ && \
tar -zxvf nginx-$NGINX_VERSION.tar.gz && \
cd /usr/local/nginx-$NGINX_VERSION && \
./configure --user=www \
--group=www \
--prefix=/usr/local/nginx \
--with-http_stub_status_module \
--without-http-cache \
--with-http_ssl_module \
--sbin-path=/usr/local/nginx/sbin/nginx \
--conf-path=/usr/local/nginx/conf/nginx.conf \
--error-log-path=/var/log/nginx_error.log \
--http-log-path=/var/log/nginx_access.log \
--pid-path=/usr/local/nginx/run/nginx.pid \
--lock-path=/usr/local/nginx/run/nginx.lock \
--with-compat \
--with-threads \
--with-http_addition_module \
--with-http_auth_request_module \
--with-http_dav_module \
--with-http_gzip_static_module \
--with-http_mp4_module \
--with-http_random_index_module \
--with-http_realip_module \
--with-http_secure_link_module \
--with-http_slice_module \
--with-http_sub_module \
--with-http_v2_module \
--with-mail \
--with-mail_ssl_module \
--with-stream \
--with-stream_realip_module \
--with-stream_ssl_module \
--with-stream_ssl_preread_module && \
make -j$CPU_NUM && make install && \
#清理运行时不需要的软件和安装缓存
rm -rf /var/cache/apk/* && \
rm -rf /root/.cache && \
rm -rf /tmp/* && \
rm -rf /usr/local/nginx-$NGINX_VERSION && \
rm -rf /usr/local/nginx-$NGINX_VERSION.tar.gz && \
apk del tzdata bash && \
apk del gcc libc-dev zlib-dev make openssl-dev wget
#添加本地配置文件
# ADD nginx.conf /usr/local/nginx/conf/
#### Stage 2: Serve the application from Alpine
FROM alpine:3.14.0
COPY --from=build /usr/local/nginx /usr/local/
COPY --from=build /usr/local/nginx/conf/ /usr/local/nginx/conf/
COPY --from=build /usr/local/nginx/run/ /usr/local/nginx/run/
ENV PATH $PATH:/usr/local/nginx/sbin/
ENV LANG "en_US.UTF-8"
RUN echo "http://mirrors.aliyun.com/alpine/latest-stable/main/" > /etc/apk/repositories && \
echo "http://mirrors.aliyun.com/alpine/latest-stable/community/" >> /etc/apk/repositories && \
apk add --no-cache pcre-dev net-tools vim && \
addgroup -S www && \
adduser -s /sbin/nologin -S -D -G www www && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
ln -sf /dev/stdout /var/log/nginx_access.log && \
ln -sf /dev/stderr /var/log/nginx_error.log
EXPOSE 80
WORKDIR /usr/local/nginx
CMD ["nginx","-g","daemon off;"]
#docker run -it -d --name nginx -v /Docker/nginx-jalpine-image/data:/usr/local/nginx/html/ -p 81:80 --rm nginx-1.20.1:alpine
#docker run -it -d --name nginx -p 81:80 --rm nginx-1.20.1:alpine
#docker build -t nginx:alpine --build-arg CPU_NUM=<机器CPU核数> ./
#查看核数"cat /proc/cpuinfo | grep processor | wc -l"

