Dockerfile中multi-stage(多阶段构建)详解

前言

Docker的口号是Build,Ship,and Run Any App,Anywhere,在我们使用 Docker 的大部分时候,的确能感觉到其优越性,但是往往在我们 Build 一个应用的时候,是将我们的源代码也构建进去的,这对于类似于 golang 这样的编译型语言肯定是不行的,因为实际运行的时候我只需要把最终构建的二进制包给你就行,把源码也一起打包在镜像中,需要承担很多风险,即使是脚本语言,在构建的时候也可能需要使用到一些上线的工具,这样无疑也增大了我们的镜像体积。

在应用了容器技术的软件开发过程中,控制容器镜像的大小可是一件费时费力的事情。如果我们构建的镜像既是编译软件的环境,又是软件最终的运行环境,这是很难控制镜像大小的。所以常见的配置模式为:分别为软件的编译环境和运行环境提供不同的容器镜像。比如为编译环境提供一个 Dockerfile.build,用它构建的镜像包含了编译软件需要的所有内容,比如代码、SDK、工具等等。同时为软件的运行环境提供另外一个单独的 Dockerfile,它从 Dockerfile.build 中获得编译好的软件,用它构建的镜像只包含运行软件所必须的内容。这种情况被称为构造者模式(builder pattern),本文将介绍如何通过 Dockerfile 中的 multi-stage 来解决构造者模式带来的问题。

常见的容器镜像构建过程

比如我们创建了一个 GO 语言编写了一个检查页面中超级链接的程序 app.go(请从 sparkdev (本地下载)获取本文相关的代码):

package main
import (
 "encoding/json"
 "fmt"
 "log"
 "net/http"
 "net/url"
 "os"
 "strings"
 "golang.org/x/net/html"
)
type scrapeDataStore struct {
 Internal int `json:"internal"`
 External int `json:"external"`
}
func isInternal(parsedLink *url.URL, siteUrl *url.URL, link string) bool {
 return parsedLink.Host == siteUrl.Host || strings.Index(link, "#") == 0 || len(parsedLink.Host) == 0
}
func main() {
 urlIn := os.Getenv("url")
 if len(urlIn) == 0 {
 urlIn = "https://www.cnblogs.com/"
 }
 resp, err := http.Get(urlIn)
 scrapeData := &scrapeDataStore{}
 tokenizer := html.NewTokenizer(resp.Body)
 end := false
 for {
 tt := tokenizer.Next()
 switch {
 case tt == html.StartTagToken:
 token := tokenizer.Token()
 switch token.Data {
 case "a":
 for _, attr := range token.Attr {
  if attr.Key == "href" {
  link := attr.Val
  parsedLink, parseLinkErr := url.Parse(link)
  if parseLinkErr == nil {
  if isInternal(parsedLink, siteUrl, link) {
  scrapeData.Internal++
  } else {
  scrapeData.External++
  }
  }
  if parseLinkErr != nil {
  fmt.Println("Can't parse: " + token.Data)
  }
  }
 }
 break
 }
 case tt == html.ErrorToken:
 end = true
 break
 }
 if end {
 break
 }
 }
 data, _ := json.Marshal(&scrapeData)
 fmt.Println(string(data))
}

下面我们通过容器来构建它,并把它部署到生产型的容器镜像中。

首先构建编译应用程序的镜像:

FROM golang:1.7.3
WORKDIR /go/src/github.com/sparkdevo/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 .

把上面的内容保存到 Dockerfile.build 文件中。

接着把构建好的应用程序部署到生产环境用的镜像中:

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY app .
CMD ["./app"] 

把上面的内容保存到 Dockerfile 文件中。

最后需要使用一个脚本把整个构建过程整合起来:

#!/bin/sh
echo Building sparkdevo/href-counter:build
# 构建编译应用程序的镜像
docker build --no-cache -t sparkdevo/href-counter:build . -f Dockerfile.build
# 创建应用程序
docker create --name extract sparkdevo/href-counter:build
# 拷贝编译好的应用程序
docker cp extract:/go/src/github.com/sparkdevo/href-counter/app ./app
docker rm -f extract

echo Building sparkdevo/href-counter:latest
# 构建运行应用程序的镜像
docker build --no-cache -t sparkdevo/href-counter:latest .

把上面的内容保存到 build.sh 文件中。这个脚本会先创建出一个容器来构建应用程序,然后再创建最终运行应用程序的镜像。

把 app.go、Dockerfile.build、Dockerfile 和 build.sh 放在同一个目录下,然后进入这个目录执行 build.sh 脚本进行构建。构建后的容器镜像大小:

从上图中我们可以观察到,用于编译应用程序的容器镜像大小接近 700M,而用于生产环境的容器镜像只有 10.3 M,这样的大小在网络间传输的效率是很高的。

运行下面的命令可以检查我们构建的容器是否可以正常的工作:

$ docker run -e url=https://www.cnblogs.com/ sparkdevo/href-counter:latest
$ docker run -e url=http://www.cnblogs.com/sparkdev/ sparkdevo/href-counter:latest

OK,我们写的程序正确的统计了博客园首页和笔者的首页中超级链接的情况。

采用上面的构建过程,我们需要维护两个 Dockerfile 文件和一个脚本文件 build.sh。能不能简化一些呢? 下面我们看看 docker 针对这种情况提供的解决方案:multi-stage。

在 Dockerfile 中使用 multi-stage

multi-stage 允许我们在 Dockerfile 中完成类似前面 build.sh 脚本中的功能,每个 stage 可以理解为构建一个容器镜像,后面的 stage 可以引用前面 stage 中创建的镜像。所以我们可以使用下面单个的 Dockerfile 文件实现前面的需求:

FROM golang:1.7.3
WORKDIR /go/src/github.com/sparkdevo/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/sparkdevo/href-counter/app .
CMD ["./app"]

把上面的内容保存到文件 Dockerfile.multi 中。这个 Dockerfile 文件的特点是同时存在多个 FROM 指令,每个 FROM 指令代表一个 stage 的开始部分。我们可以把一个 stage 的产物拷贝到另一个 stage 中。本例中的第一个 stage 完成了应用程序的构建,内容和前面的 Dockerfile.build 是一样的。第二个 stage 中的 COPY 指令通过 --from=0 引用了第一个 stage ,并把应用程序拷贝到了当前 stage 中。接下来让我们编译新的镜像:

$ docker build --no-cache -t sparkdevo/href-counter:multi . -f Dockerfile.multi

这次使用 href-counter:multi 镜像运行应用:

$ docker run -e url=https://www.cnblogs.com/ sparkdevo/href-counter:multi
$ docker run -e url=http://www.cnblogs.com/sparkdev/ sparkdevo/href-counter:multi

结果和之前是一样的。那么新生成的镜像有没有特别之处呢:

好吧,从上图我们可以看到,除了 sparkdevo/href-counter:multi 镜像,还生成了一个匿名的镜像。因此,所谓的 multi-stage 不过时多个 Dockerfile 的语法糖罢了。但是这个语法糖还好很诱人的,现在我们维护一个结构简洁的 Dockerfile 文件就可以了!

使用命名的 stage

在上面的例子中我们通过 --from=0 引用了 Dockerfile 中第一个 stage,这样的做法会让 Dockerfile 变得不容易阅读。其实我们是可以为 stage 命名的,然后就可以通过名称来引用 stage 了。下面是改造后的 Dockerfile.mult 文件:

FROM golang:1.7.3 as builder
WORKDIR /go/src/github.com/sparkdevo/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/sparkdevo/href-counter/app .
CMD ["./app"]

我们把第一个 stage 使用 as 语法命名为 builder,然后在后面的 stage 中通过名称 builder 进行引用 --from=builder。通过使用命名的 stage, Dockerfile 更容易阅读了。

总结

Dockerfile 中的 multi-stage 虽然只是些语法糖,但它确实为我们带来了很多便利。尤其是减轻了 Dockerfile 维护者的负担(要知道实际生产中的 Dockerfile 可不像 demo 中的这么简单)。需要注意的是旧版本的 docker 是不支持 multi-stage 的,只有 17.05 以及之后的版本才开始支持。好了,是不是该去升级你的 docker 版本了?

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

参考:

(0)

相关推荐

  • 使用Docker多阶段构建来减小镜像大小的方法

    本文讲述了如何通过 Docker 的多阶段构建功能来大幅度减小镜像大小,适用于需要在 Dockerfile 中构建程式(如 javac),且需要另外安装编译工具链的镜像.(如 Java) 先来学习单词(本文全部采用中文词汇,如需查询外文文档可对照该词汇表.理论上个人不赞成翻译术语): multi-stage 多阶段 build 构建 image 镜像 stage 阶段 再来看一下效果: 原 110M+,现 92M. 对比一下 Dockerfile 优化前 Dockerfile: FROM ope

  • Docker多阶段镜像构建的实现

    从Docker版本 17.05.0-ce 开始,就支持了一种新的构建镜像的方法,叫做:多阶段构建(Multi-stage builds),旨在解决Docker构建应用容器中的一些痛点.在日常构建容器的场景中,经常会遇到在同一个容器中进行源码的获取,编译和生成,最终才构建为镜像.这样做的劣势在于: 不得不在容器中安装构建程序所必须的运行时环境 不得不在同一个容器中,获取程序的源码和构建所需的一些生态工具 构建出的镜像甚至包含了程序源码和一些不必要的文件,导致容器镜像尺寸偏大 当然,还有一种稍微优雅

  • Dockerfile中multi-stage(多阶段构建)详解

    前言 Docker的口号是Build,Ship,and Run Any App,Anywhere,在我们使用 Docker 的大部分时候,的确能感觉到其优越性,但是往往在我们 Build 一个应用的时候,是将我们的源代码也构建进去的,这对于类似于 golang 这样的编译型语言肯定是不行的,因为实际运行的时候我只需要把最终构建的二进制包给你就行,把源码也一起打包在镜像中,需要承担很多风险,即使是脚本语言,在构建的时候也可能需要使用到一些上线的工具,这样无疑也增大了我们的镜像体积. 在应用了容器技

  • java 中maven pom.xml文件教程详解

    maven pom.xml文件教程详解,具体内容如下所示: <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.x

  • Linux 中常用的Rpm命令实例详解

    rpm命令是RPM软件包的管理工具.rpm原本是Red Hat Linux发行版专门用来管理Linux各项套件的程序,由于它遵循GPL规则且功能强大方便,因而广受欢迎.逐渐受到其他发行版的采用.RPM套件管理方式的出现,让Linux易于安装,升级,间接提升了Linux的适用度. 语法 rpm(选项)(参数) 选项 -a:查询所有套件: -b<完成阶段><套件档>+或-t <完成阶段><套件档>+:设置包装套件的完成阶段,并指定套件档的文件名称: -c:只列出

  • node.js中grunt和gulp的区别详解

    node.js中grunt和gulp的区别详解 自nodeJS登上前端舞台,自动化构建变得越来越流行.目前最流行的当属grunt和gulp,这两个光看名字挺像,功能也差不多,不过gulp能在grunt这位大哥如日中天的境况下开辟出自己的一片天地,有着她独到的优点. 易用 Gulp相比Grunt更简洁,而且遵循代码优于配置策略,维护Gulp更像是写代码. 高效 Gulp相比Grunt更有设计感,核心设计基于Unix流的概念,通过管道连接,不需要写中间文件. 高质量 Gulp的每个插件只完成一个功能

  • java中Executor,ExecutorService,ThreadPoolExecutor详解

    java中Executor,ExecutorService,ThreadPoolExecutor详解 1.Excutor 源码非常简单,只有一个execute(Runnable command)回调接口 public interface Executor { /** * Executes the given command at some time in the future. The command * may execute in a new thread, in a pooled thre

  • C++中auto_ptr智能指针的用法详解

    智能指针(auto_ptr) 这个名字听起来很酷是不是?其实auto_ptr 只是C++标准库提供的一个类模板,它与传统的new/delete控制内存相比有一定优势,但也有其局限.本文总结的8个问题足以涵盖auto_ptr的大部分内容. auto_ptr是什么? auto_ptr 是C++标准库提供的类模板,auto_ptr对象通过初始化指向由new创建的动态内存,它是这块内存的拥有者,一块内存不能同时被分给两个拥有者.当auto_ptr对象生命周期结束时,其析构函数会将auto_ptr对象拥有

  • 基于canvas粒子系统的构建详解

    前面的话 本文将从最基本的imageData对象的理论知识说开去,详细介绍canvas粒子系统的构建 imageData 关于图像数据imageData共有3个方法,包括getImageData().putImageData().createImageData() [getImageData()] 2D上下文可以通过getImageData()取得原始图像数据.这个方法接收4个参数:画面区域的x和y坐标以及该区域的像素宽度和高度 例如,要取得左上角坐标为(10,5).大小为50*50像素的区域的

  • java中注解机制及其原理的详解

    java中注解机制及其原理的详解 什么是注解 注解也叫元数据,例如我们常见的@Override和@Deprecated,注解是JDK1.5版本开始引入的一个特性,用于对代码进行说明,可以对包.类.接口.字段.方法参数.局部变量等进行注解.它主要的作用有以下四方面: 生成文档,通过代码里标识的元数据生成javadoc文档. 编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证. 编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码. 运行时动态处理,运行时通过代码里标识

  • Java中EnumMap代替序数索引代码详解

    本文研究的主要是Java中EnumMap代替序数索引的相关内容,具体介绍如下. 学习笔记<Effective Java 中文版 第2版> 经常会碰到使用Enum的ordinal方法来索引枚举类型. public class Herb { public enum Type { ANNUAL, PERENNIAL, BIENNIAL }; private final String name; private final Type type; Herb(String name, Type type)

  • vue-cli中的babel配置文件.babelrc实例详解

    本文介绍vue-cli脚手架工具根目录的babelrc配置文件 介绍 es6特性浏览器还没有全部支持,但是使用es6是大势所趋,所以babel应运而生,用来将es6代码转换成浏览器能够识别的代码 babel有提供专门的命令行工具方便转码,可以自行去了解 vue-cli脚手架的.babelrc文件 { // 此项指明,转码的规则 "presets": [ // env项是借助插件babel-preset-env,下面这个配置说的是babel对es6,es7,es8进行转码,并且设置amd

随机推荐