在多阶段构建之前

构建图像最具挑战性的事情之一是缩小图像大小。Dockerfile 中的每条指令都会为镜像添加一层,您需要记住在继续下一层之前清除所有不需要的工件。要编写一个真正高效的 Dockerfile,传统上您需要使用 shell 技巧和其他逻辑来保持层尽可能小,并确保每一层都有它需要的来自前一层的工件,而不是其他任何东西。
实际上,将一个 Dockerfile 用于开发(其中包含构建应用程序所需的一切)和一个用于生产的精简版 Dockerfile 是很常见的,它仅包含您的应用程序以及运行它所需的内容。这被称为“构建器模式”。维护两个 Dockerfile 并不理想。
下面是一个遵循上述构建器模式的Dockerfile.buildand示例Dockerfile
Dockerfile.build

  1. # syntax=docker/dockerfile:1
  2. FROM golang:1.16
  3. WORKDIR /go/src/github.com/alexellis/href-counter/
  4. COPY app.go .
  5. RUN go get -d -v golang.org/x/net/html \
  6. && 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"

alpine-nginx-duojieduan.gif