【Docker】镜像打造指南

1,415 阅读4分钟

Docker简介

Docker 是一个开放源代码软件,是一个开放平台,用于开发、交付和运行应用。 Docker允许用户将基础设施中的应用单独分割出来,形成更小的颗粒(容器),从而提高交付软件的速度。 Docker使用Go语言开发,利用Linux核心中的资源分离机制,如cgroupsnamespaces等来创建独立的容器。Linux对namespace的支持完全隔离了工作环境中应用程序的视野,包括行程树、网络、用户ID与挂载文件系统,而核心的cgroup提供资源隔离,包括CPU、存储器、block I/O与网络。 —— Wikipedia

本文旨在介绍打造Docker镜像的实战和优化,不对docker的原理和运行机制作过多的探讨,为保证良好的阅读体验,读者需对docker的镜像(Image)、容器(Container)、仓库(Repository)等概念有基础的了解。

Docker镜像(Image)

Docker本质上是一个运行在Linux操作系统上的应用,而Linux操作系统分为内核用户空间,内核启动后,通过挂载root文件系统(Root FileSystem)来提供用户空间,Docker镜像就是一个Linux的root文件系统

Docker镜像提供容器运行时所需的程序、库、资源、配置等文件,以及一些为运行时准备的配置参数(如匿名卷、环境变量、用户等)。

镜像有几个特征:分层、无状态、只读

镜像不包含任何动态数据(无状态),其内容在构建之后也不会被改变(只读)。

分层

Docker镜像的分层基于UnionFS(联合文件系统)。UnionFS是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。

分层一个最大的好处是可以共享资源。例如本地有多个镜像都是基于一个基础镜像构建而成,那磁盘中就只需要保存一份基础镜像;并且镜像的某一层也可以被多个镜像共享。利用分层做缓存的会在下文的构建优化中详细介绍。

构建镜像实战

尝试构建一个简单的基于express的node server镜像,代码库目录如下:

|--Dockerfile
|--src/
| |--index.js
|--package.json
|--yarn.lock

src/index.js:

const express = require('express')

const app = express()

const port = 8047

app.get('/', (req, res) => {
    res.send('Hello World!')
})

app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`)
})

Dockerfile

Dockerfile是如何构建镜像的描述文件,下面是一个简单的例子:

FROM node:12.10.0

# 设置环境变量
ENV SERVER_PATH=/server

# 设置工作目录
WORKDIR $SERVER_PATH

# 安装pm2,使用pm2作为进程守护工具启动node服务
RUN yarn global add pm2

# 把当前目录下的所有文件拷贝到镜像的工作目录下
COPY . $SERVER_PATH

# 安装依赖
RUN yarn

# 暴露端口
EXPOSE 8047

# 镜像运行后执行的node服务启动命令
CMD ["pm2", "start", "src/index.js", "--no-daemon"]

注意点:

  • pm2启动服务时默认在后台运行,但是docker运行时需保持一个进程在前台,所以要加上--no-daemon参数

构建镜像

代码准备完成后,运行docker build命令即可构建镜像,格式如下:

docker build [OPTIONS] PATH | URL | -

如:

# 最后的 . 是指基于当前目录下的dockerfile构建镜像
docker build -t server-template:latest .

# 运行镜像
docker run -p 8047:8047 server-template:latest

镜像优化

使用更小的基础镜像版本

运行docker images查看已有镜像:

image.png

可以看到,上述步骤中构建的镜像大小为972MB,明明是一个非常简单的项目,镜像体积却接近1G,问题出在哪里呢?

绝大部分原因是基础镜像的体积太大,我们Dockerfile的第一行是:

FROM node:12.10.0

意为基于官方提供的node@12.10.0版本镜像构建,但官方提供了更纯净的Alpine版本,我们将第一行改为:

FROM node:12.10.0-alpine

再重新构建一次,可以看到镜像大小变成了144M:

image.png

官方提供了三种类型node镜像的选择:

  • node:<version>:官方默认镜像,基于debian构建,此类镜像的优点是安装的依赖很全,例如curl、wget,缺点是体积过大。

  • node:<version>-slim:删除冗余依赖后的精简版本镜像,同样是基于 debian 构建,体积上比默认镜像小很多,删除了很多公共的软件包,只保留了最基本的 node 运行环境。

  • node:<version>-alpine:基于 alpine 镜像构建,比 debian 的 slim 版本还要小。 Alpine 使用 musl 代替 glibc,一些 c/c++ 环境的软件可能不兼容,因此项目如果没使用 C++ 拓展的话,基本都能用该镜像跑起来

镜像的选择不是绝对的,可以根据自身需求来决定。

选择官方版本虽然体积更大,但是功能较为齐全,不用担心以后的拓展问题,而且基于镜像的分层特性,基础镜像下载一次之后可以被多个镜像共享。选择alpine镜像则可以显著提升第一次构建的速度,以及在不同机器上拉取或者构建镜像的速度。

减少层数

前面提到过docker镜像的分层特性,实际上,dockerfile里每执行一个指令,就会创建一个镜像层,通过减少层数,也可以减小镜像体积和优化构建速度。

我们可以通过合并多个指令来减少层数,例如:

FROM node:12.10.0-alpine

ADD . /app

ENV SERVER_PATH=/server

ENV APP_PATH=/app

RUN cd /app

RUN yarn

RUN yarn build

CMD yarn start

可以合并成:

FROM node:12.10.0-alpine

ADD . /app

# 设置多个环境变量
ENV SERVER_PATH=/server \
    APP_PATH=/app

# 一次执行多条命令
RUN cd /app \
    && yarn \
    && yarn build

CMD yarn start

前置不常变动的命令,充分利用缓存

我们应该把变化最少的指令放在 Dockerfile 前面,这样可以充分利用镜像缓存。

例如常见的npm包依赖缓存,原Dockerfile:

FROM node:12.10.0-alpine

EXPOSE 8047

WORKDIR /server

# 将当前目录的所有文件复制到工作区
COPY . .

# 安装依赖
RUN yarn

CMD yarn start

上面的例子中,我们先将项目的源码都复制到了工作区,然后执行安装依赖的命令。但是由于源码经常变化,导致每次构建镜像时都会重新安装依赖。

因此我们可以先复制package.json和yarn.lock,然后安装依赖,最后再复制其他的源码,这样只要package.json和yarn.lock不变,即使源码发生变动也能复用前面安装了依赖的层的缓存。

使用下面的写法:

FROM node:12.10.0-alpine

EXPOSE 8047

WORKDIR /server

# 先将package.json和yarn.lock复制到工作区安装依赖
COPY package.json yarn.lock ./

RUN yarn

COPY . .

CMD yarn start

每个 RUN 指令后删除多余文件

镜像构建时,会一层层构建。前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在当前这一层。

比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。最终容器运行的时候该文件其实依然存在。

因此,在构建镜像的时候,每一层应该尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。

例如我们的node项目在启动前会使用rollup等构建工具进行ts和babel的转译,则构建完成后之后最好只保留打包之后的代码。

使用多阶段构建(multistage builds)

Docker v17.05 开始支持多阶段构建,可以解决镜像层数过多、体积过大的问题,同时可以让dockerfile可读性更好、层次更清晰。

以下面的构建为例:

FROM node:12.10.0-alpine

# 设置环境变量
ENV SERVER_PATH=/server

# 暴露端口
EXPOSE 8047

# 设置工作目录
WORKDIR $SERVER_PATH

# 安装pm2
RUN yarn global add pm2

COPY package.json yarn.lock ./

RUN yarn

# 把当前目录下的所有文件拷贝到镜像的工作目录下
COPY . .

# 镜像运行后执行的node服务启动命令
CMD ["pm2", "start", "src/index.js", "--no-daemon"]

使用多阶段构建改造将其改造:

# 基础镜像
FROM node:12.10.0-alpine AS base

ENV SERVER_PATH=/server

EXPOSE 8047

WORKDIR $SERVER_PATH

RUN yarn global add pm2

# 基于base镜像 安装依赖阶段
FROM base AS install

COPY package.json yarn.lock ./

RUN yarn

# 回到基础镜像
FROM base

# 将安装依赖阶段中生成的node_modules目录复制到工作目录
COPY --from=install $SERVER_PATH/node_modules ./node_modules

COPY . .

CMD ["pm2", "start", "src/index.js", "--no-daemon"]

添加HEALTHCHECK

在没有 HEALTHCHECK 指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。

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

HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 1

以上命令会默认每隔30秒向容器的服务发起一个请求,连续失败三次则会终止容器。结合docker的运行参数 --restart always 可以让无法再提供服务的容器自动重启。

镜像仓库

在本地构建好镜像后,可以将镜像推送到远端仓库,这样就可以在任何机器上拉取镜像并运行了。

首先在Docker Hub注册一个账号,再创建一个Repository,例如我的仓库deland7/server

然后将本地的镜像重命名和远端仓库一致,我本地的镜像:

image.png

重新打tag,推送到远端:

docker tag server-template:latest deland7/server:latest

docker push deland7/server:latest

然后就可以在仓库看到自己的镜像了。

参考文档