欢迎您, 来到 宁时修博客.^_^

Docker系列5--Dockerfile指令(续)和多阶段构建

2018/11/18 林木立 Docker 903
Docker容器技术

一、Docker指令续    

    9、VOLUME  定义数据卷

    格式:

VOLUME ["<路径1>", "<路径2>"...]
VOLUME <路径>


    容器运行时应尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于 卷(volume) 中。为了防止容器运行时,用户忘记将动态文件所保存目录挂载为卷,在  Dockerfile  中,可以事先指定某些目录挂载为卷,这样在容器运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。

VOLUME /data

     /data  目录会在运行时自动挂载为匿名卷,任何向  /data  中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。运行时也可以覆盖这个挂载设置。    

    比如:

docker run -d -v mydata:/data xxxx

    在这行命令中,使用了  mydata  这个命名卷挂载到了  /data  这个位置,替代了  Dockerfile  中定义的匿名卷的挂载配置。



    10、EXPOSE  声明端口

    格式

EXPOSE <端口1> [<端口2>...]

    

    EXPOSE  指令是声明容器运行时提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是  docker run -P 时,会自动随机映射  EXPOSE  的端口。

    要将  EXPOSE  和在运行时使用  -p <宿主端口>:<容器端口>  区分开来。 -p  ,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE  仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。



    11、WORKDIR 指定工作目录

    格式

 WORKDIR <工作目录路径>

  

    使用  WORKDIR  指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在, WORKDIR  会帮你建立目录。

    之前提到一些初学者常犯的错误是把  Dockerfile  等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:

RUN cd /app
RUN echo "hello" > world.txt

    如果将这个  Dockerfile  进行构建镜像运行后,会发现找不到/app/world.txt  文件,或者其内容不是  hello  。原因其实很简单,在 Shell中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在  Dockerfile  中,这两行  RUN  命令的执行环境根本不同,是两个完全不同的容器。这就是对  Dockerfile  构建分层存储的概念不了解所导致的错误。


    每一个  RUN  都是启动一个容器、执行命令、然后提交存储层文件变更。第一层  RUN cd /app  的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。

    因此如果需要改变以后各层的工作目录的位置,那么应该使用  WORKDIR  指令。



    12、USER 指定当前用户

    格式: 

USER <用户名>[:<用户组>]

    

    USER  指令和  WORKDIR  相似,都是改变环境状态并影响以后的层。 WORKDIR是改变工作目录, USER  则是改变之后层的执行  RUN  ,  CMD  以及ENTRYPOINT  这类命令的身份。

    当然,和  WORKDIR  一样, USER  只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。

RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]


    如果以  root  执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用  su  或者  sudo  ,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。建议使用  gosu  。

# 建立 redis 用户,并使用 gosu 换另一个用户执行命令
RUN groupadd -r redis && useradd -r -g redis redis

# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/
releases/download/1.7/gosu-amd64" \
&& chmod +x /usr/local/bin/gosu \
&& gosu nobody true

# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]



    13、HEALTHCHECK 健康检查

    格式:

HEALTHCHECK [选项] CMD <命令>  :设置检查容器健康状况的命令
HEALTHCHECK NONE  :如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令


    HEALTHCHECK  指令是告诉 Docker 应该如何进行判断容器的状态是否正常,这是Docker 1.12 引入的新指令。


    在没有  HEALTHCHECK  指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。在 1.12 以前,Docker 不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。

    而自 1.12 之后,Docker 提供了  HEALTHCHECK  指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。    

    当在一个镜像指定了  HEALTHCHECK  指令后,用其启动容器,初始状态会为starting  ,在  HEALTHCHECK  指令检查成功后变为  healthy  ,如果连续一定次数失败,则会变为  unhealthy  。


    HEALTHCHECK  支持下列选项:

        --interval=<间隔>  :两次健康检查的间隔,默认为 30 秒;

        --timeout=<时长>  :健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;

        --retries=<次数>  :当连续失败指定次数后,则将容器状态视为unhealthy  ,默认 3 次。


    和  CMD  ,  ENTRYPOINT  一样, HEALTHCHECK  只可以出现一次,如果写了多个,只有最后一个生效。

    在  HEALTHCHECK [选项] CMD  后面的命令,格式和  ENTRYPOINT  一样,分为shell  格式,和  exec  格式。命令的返回值决定了该次健康检查的成功与否: 0  :成功; 1  :失败; 2  :保留,不要使用这个值。


    假设有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web服务是否在正常工作,我们可以用  curl  来帮助判断,其  Dockerfile  的HEALTHCHECK  可以这么写:

FROM nginx
RUN yum install -y curl \
    && rm -fr /var/cache/yum/*
HEALTHCHECK --interval=5s --timeout=3s \
    CMD curl -fs http://localhost/ || exit 1

    这里设置了每 5 秒检查一次(这里为了试验所以间隔非常短,实际应该相对较长),如果健康检查命令超过 3 秒没响应就视为失败,并且使用  curl -fs http://localhost/ || exit 1  作为健康检查命令。


    使用  docker build  来构建这个镜像:

docker build -t myweb:v1 .

    构建完毕,启动一个容器:

docker run -d --name web -p 80:80 myweb:v1

    当运行该镜像后,可以通过  docker container ls  看到最初的状态为(health: starting)  :

[root@docker web]# docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                             PORTS                NAMES
cbe93ec4931f        myweb:v1            "nginx -g 'daemon of…"   12 seconds ago      Up 11 seconds (health: starting)   0.0.0.0:80->80/tcp   web

    在等待几秒钟后,再次  docker container ls ,就会看到健康状态变化为了(healthy) 。如果健康检查连续失败超过了重试次数,状态就会变为  (unhealthy) 。


    为了帮助排障,健康检查命令的输出(包括  stdout  以及  stderr  )都会被存储

于健康状态里,可以用  docker inspect  来查看。

docker inspect --format '{{json .State.Health}}' web | python -m json.tool



    14、ONBUILD 为他人做嫁衣裳

    格式: 

ONBUILD <其它指令>


    ONBUILD  是一个特殊的指令,它后面跟的是其它指令,比如  RUN  ,  COPY  等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。

    Dockerfile  中的其它指令都是为了定制当前镜像而准备的,唯有  ONBUILD  是为了帮助别人定制自己而准备的。


    假设要制作 Node.js 所写的应用的镜像。我们都知道 Node.js 使用  npm  进行包管理,所有依赖、配置、启动信息等会放到  package.json  文件里。在拿到程序代码后,需要先进行  npm install  才可以获得所有需要的依赖。然后就可以通过  npm start  来启动应用。因此,一般来说会这样写  Dockerfile  :

FROM node:slim
RUN mkdir /app
WORKDIR /app
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
CMD [ "npm", "start" ]

    把这个  Dockerfile  放到 Node.js 项目的根目录,构建好镜像后,就可以直接拿来启动容器运行。但是如果我们还有第二个 Node.js 项目也差不多呢?好吧,那就再把这个  Dockerfile  复制到第二个项目里。那如果有第三个项目呢?再复制么?文件的副本越多,版本控制就越困难,让我们继续看这样的场景维护的问题。

    如果第一个 Node.js 项目在开发过程中,发现这个  Dockerfile  里存在问题,比如敲错字了、或者需要安装额外的包,然后开发人员修复了这个  Dockerfile  ,再次构建,问题解决。第一个项目没问题了,但是第二个项目呢?虽然最初Dockerfile  是复制、粘贴自第一个项目的,但是并不会因为第一个项目修复了他们的  Dockerfile  ,而第二个项目的 Dockerfile  就会被自动修复。


    那么我们可不可以做一个基础镜像,然后各个项目使用这个基础镜像呢?这样基础镜像更新,各个项目不用同步  Dockerfile  的变化,重新构建后就继承了基础镜像的更新?好吧,可以,让我们看看这样的结果。那么上面的这个  Dockerfile就会变为:

FROM node:slim
RUN mkdir /app
WORKDIR /app
CMD [ "npm", "start" ]


    这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为  my-node  的话,各个项目内的自己的  Dockerfile  就变为:

FROM my-node
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/

    基础镜像变化后,各个项目都用这个  Dockerfile  重新构建镜像,会继承基础镜像的更新。

    那么,问题解决了么?没有。准确说,只解决了一半。如果这个  Dockerfile  里面有些东西需要调整呢?比如  npm install  都需要加一些参数,那怎么办?这一行  RUN  是不可能放入基础镜像的,因为涉及到了当前项目的./package.json  ,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的  Dockerfile  的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。

    

    ONBUILD  可以解决这个问题。让我们用  ONBUILD  重新写一下基础镜像Dockerfile:

FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

      这次我们回到原始的  Dockerfile  ,但是这次将项目相关的指令加上ONBUILD  ,这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的Dockerfile  就变成了简单地:

FROM my-node

    是的,只有这么一行。当在各个项目目录中,用这个只有一行的  Dockerfile  构建镜像时,之前基础镜像的那三行  ONBUILD  就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行  npm install  ,生成应用镜像。



二、Docker 多阶段构建

    在 Docker 17.05 版本之前,我们构建 Docker 镜像时,通常会采用两种方式:

    1)全部放入一个 Dockerfile

    一种方式是将所有的构建过程编包含在一个  Dockerfile  中,包括项目及其依赖库的编译、测试、打包等流程,这里可能会带来的一些问题:

        Dockerfile  特别长,可维护性降低;

        镜像层次多,镜像体积较大,部署时间变长;

        源代码存在泄露的风险。


    

    2)分散到多个 Dockerfile

    另一种方式,就是我们事先在一个  Dockerfile  将项目及其依赖库编译测试打包好后,再将其拷贝到运行环境中,这种方式需要我们编写两个  Dockerfile  和一些编译脚本才能将其两个阶段自动整合起来,这种方式虽然可以很好地规避第一种方式存在的风险,但明显部署过程较复杂。


    

    使用多阶段构建

    Docker v17.05 开始支持多阶段构建 ( multistage builds  )。使用多阶段构建我们就可以很容易解决前面提到的问题,并且只需要编写一个Dockerfile  :

FROM golang:1.9-alpine as builder
RUN apk --no-cache add git
WORKDIR /go/src/github.com/go/helloworld/
RUN go get -d -v github.com/go-sql-driver/mysql
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest as prod
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/go/helloworld/app .
CMD ["./app"]

    构建镜像

docker build -t go/helloworld:3 .

    


    只构建某一阶段的镜像

    可以使用  as  来为某一阶段命名,例如:

FROM golang:1.9-alpine as builder

    当我们只想构建  builder  阶段的镜像时,我们可以在使用  docker build命令时加上  --target  参数即可:

docker build --target builder -t username/imagename:tag .


    构建时从其它镜像复制文件

    上面例子中我们使用  COPY --from=0 /go/src/github.com/go/helloworld/app .  从上一阶段的镜像中复制文件,我们也可以复制任意镜像中的文件。

$ COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf


点赞
说说你的看法

所有评论: (0)

# 加入组织

1、用手机QQ扫左侧二维码

2、搜Q群:1058582137

3、点击 宁时修博客交流群