Docker简介
Docker 是一个开放源代码软件,是一个开放平台,用于开发、交付和运行应用。 Docker允许用户将基础设施中的应用单独分割出来,形成更小的颗粒(容器),从而提高交付软件的速度。 Docker使用Go语言开发,利用Linux核心中的资源分离机制,如cgroups、namespaces等来创建独立的容器。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查看已有镜像:
可以看到,上述步骤中构建的镜像大小为972MB,明明是一个非常简单的项目,镜像体积却接近1G,问题出在哪里呢?
绝大部分原因是基础镜像的体积太大,我们Dockerfile的第一行是:
FROM node:12.10.0
意为基于官方提供的node@12.10.0版本镜像构建,但官方提供了更纯净的Alpine版本,我们将第一行改为:
FROM node:12.10.0-alpine
再重新构建一次,可以看到镜像大小变成了144M:
官方提供了三种类型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。
然后将本地的镜像重命名和远端仓库一致,我本地的镜像:
重新打tag,推送到远端:
docker tag server-template:latest deland7/server:latest
docker push deland7/server:latest
然后就可以在仓库看到自己的镜像了。