目录

隐藏
  1. docker pull 下来的镜像文件都在哪?
  2. docker images 命令显示的镜像占了好大的空间,怎么办?每次都是下载这么大的镜像?
  3. docker images -a 后显示了好多 <none> 的镜像?都是什么呀?能删么?
  4. 为什么 Docker Hub 的镜像尺寸和 docker images 不一致?
  5. docker commit 怎么用啊?
  6. 为什么说不要使用 import, export, save, load, commit 来构建镜像?
  7. Dockerfile 怎么写?
  8. Dockerfile 就是 shell 脚本吧?那我懂,一行行把需要装的东西都写进去不就行了。
  9. 那我把所有命令都合并到一个 RUN 就对了吧?
  10. context 到底是一个什么概念?
  11. ENTRYPOINT 和 CMD 到底有什么不同?
  12. 拿到一个镜像,如何获得镜像的 Dockerfile ?
  13. 在你的 LNMP 的例子中,PHP 的 Dockerfile 里面的 “构建依赖” 和 “运行依赖” 都是什么意思?
  14. 应用代码是应该挂载宿主目录还是放入镜像内?
  15. 为什么在 Dockerfile 中执行(导入 .sql、service xxx start)不管用?
  16. 为什么基于 Alpine 的镜像那么小?我可以都换成基于 Alpine 的镜像么?
  17. 可以看到镜像各层的依赖关系么?
docker pull 下来的镜像文件都在哪?
初学 Docker 要反复告诫自己,Docker 不是虚拟机。

Docker不是虚拟机,Docker 镜像也不是虚拟机的 ISO 文件。Docker 的镜像是分层存储,每一个镜像都是由很多层,很多个文件组成。而不同的镜像是共享相同的层的,所以这是一个树形结构,不存在具体哪个文件是 pull 下来的镜像的问题。

具体镜像保存位置取决于系统,一般Linux系统下,在 /var/lib/docker 里。对于使用 Union FS 的系统(Ubuntu),如 aufs, overlay2 等,可以直接在 /var/lib/docker/{aufs,overlay2} 下看到找到各个镜像的层、容器的层,以及其中的内容。

但是,对于CentOS这类没有Union FS的系统,会使用如devicemapper这类东西的一些特殊功能(如snapshot)模拟,镜像会存储于块设备里,因此无法看到具体每层信息以及每层里面的内容。

需要注意的是,默认情况下,CentOS/RHEL 使用 lvm-loop,也就是本地稀疏文件模拟块设备,这个文件会位于 /var/lib/docker/devicemapper/devicemapper/data 的位置。这是非常不推荐的,如果发现这个文件很大,那就说明你在用 devicemapper + loop 的方式,不要这么做,去参照官方文档,换 direct-lvm,也就是分配真正的块设备给 devicemapper 去用。

docker images 命令显示的镜像占了好大的空间,怎么办?每次都是下载这么大的镜像?
这个显示的大小是计算后的大小,要知道 docker image 是分层存储的,在1.10之前,不同镜像无法共享同一层,所以基本上确实是下载大小。但是从1.10之后,已有的层(通过SHA256来判断),不需要再下载。只需要下载变化的层。所以实际下载大小比这个数值要小。而且本地硬盘空间占用,也比docker images列出来的东西加起来小很多,很多重复的部分共享了。
docker images -a 后显示了好多 <none> 的镜像?都是什么呀?能删么?
简单来说,就是说该镜像没有打标签。而没有打标签镜像一般分为两类,一类是依赖镜像,一类是丢了标签的镜像。

依赖镜像

Docker的镜像、容器的存储层是Union FS,分层存储结构。所以任何镜像除了最上面一层打上标签(tag)外,其它下面依赖的一层层存储也是存在的。这些镜像没有打上任何标签,所以在 docker images -a 的时候会以的形式显示。注意观察一下 docker pull 的每一层的sha256的校验值,然后对比一下中的相同校验值的镜像,它们就是依赖镜像。这些镜像不应当被删除,因为有标签镜像在依赖它们。

丢了标签的镜像

这类镜像可能本来有标签,后来丢了。原因可能很多,比如:

docker pull 了一个同样标签但是新版本的镜像,于是该标签从旧版本的镜像转移到了新版本镜像上,那么旧版本的镜像上的标签就丢了;
docker build 时指定的标签都是一样的,那么新构建的镜像拥有该标签,而之前构建的镜像就丢失了标签。
这类镜像被称为 dangling - 虚悬镜像,这些镜像可以删除,使用 dangling=true 过滤条件即可。

手动删除 dangling 镜像

docker rmi $(docker images -aq -f "dangling=true")
对于频繁构建的机器,比如 Jenkins 之类的环境。手动清理显然不是好的办法,应该定期执行固定脚本来清理这些无用的镜像。很幸运,Spotify 也面临了同样的问题,他们已经写了一个开源工具来做这件事情: https://github.com/spotify/docker-gc

为什么 Docker Hub 的镜像尺寸和 docker images 不一致?
Docker Hub上显示的是经过 gzip 压缩后的镜像大小,这个大小也是你将下载的镜像大小,一般来说也是 Docker Hub 用户最关心的大小。

而 docker images 显示的是pull下来并解压缩后的大小,因为使用docker images的时候更关心的是本地磁盘空间占用的大小,所以这里显示的是未压缩镜像的大小。

docker commit 怎么用啊?
简单的回答就是,不要用 commit,去写 Dockerfile。

Docker 不是虚拟机。这句话要在学习 Docker 的过程中反复提醒自己。所以不要把虚拟机中的一些概念带过来。

Docker 提供了很好的 Dockerfile 的机制来帮助定制镜像,可以直接使用 Shell 命令,非常方便。而且,这样制作的镜像更加透明,也容易维护,在基础镜像升级后,可以简单地重新构建一下,就可以继承基础镜像的安全维护操作。

使用 docker commit 制作的镜像被称为黑箱镜像,换句话说,就是里面进行的是黑箱操作,除本人外无人知晓。即使这个制作镜像的人,过一段时间后也不会完整的记起里面的操作。那么当有些东西需要改变时,或者因基础镜像更新而需要重新制作镜像时,会让一切变得异常困难,就如同重新安装调试配置服务器一样,失去了 Docker 的优势了。

另外,Docker 不是虚拟机,其文件系统是 Union FS,分层式存储,每一次 commit 都会建立一层,上一层的文件并不会因为 rm 而删除,只是在当前层标记为删除而看不到了而已,每次 docker pull 的时候,那些不必要的文件都会如影随形,所得到的镜像也必然臃肿不堪。而且,随着文件层数的增加,不仅仅镜像更臃肿,其运行时性能也必然会受到影响。这一切都违背了 Docker 的最佳实践。

使用 commit 的场合是一些特殊环境,比如入侵后保存现场等等,这个命令不应该成为定制镜像的标准做法。所以,请用 Dockerfile 定制镜像。

为什么说不要使用 import, export, save, load, commit 来构建镜像?
commit 命令在前一个问答已经说过,这是制作黑箱镜像,无法维护,不应该被使用。

import 和 export 的做法,实际上是将一个容器来保存为 tar 文件,然后在导入为镜像。这样制作的镜像同样是黑箱镜像,不应该使用。而且这类导入导出会导致原有分层丢失,合并为一层,而且会丢失很多相关镜像元数据或者配置,比如 CMD 命令就可能丢失,导致镜像无法直接启动。

save 和 load 确实是镜像保存和加载,但是这是在没有 registry 的情况下,手动把镜像考来考去,这是回到了十多年的 U 盘时代。这同样是不推荐的,镜像的发布、更新维护应该使用 registry。无论是自己架设私有 registry 服务,还是使用公有 registry 服务,如 Docker Hub

Dockerfile 怎么写?
最直接也是最简单的办法是看官方文档。

这篇文章讲述具体 Dockerfile 的命令语法: https://docs.docker.com/engine/reference/builder/

然后,学习一下官方的 Dockerfile 最佳实践: https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/

最后,去 Docker Hub 学习那些 官方(Official)镜像 Dockerfile 咋写的。

Dockerfile 就是 shell 脚本吧?那我懂,一行行把需要装的东西都写进去不就行了。
不是这样的。

Dockerfile 不等于 .sh 脚本

Dockerfile 确实是描述如何构建镜像的,其中也提供了 RUN 这样的命令,可以运行 shell 命令。但是和普通 shell 脚本还有很大的不同。

Dockerfile 描述的实际上是镜像的每一层要如何构建,所以每一个RUN是一个独立的一层。所以一定要理解“分层存储”的概念。上一层的东西不会被物理删除,而是会保留给下一层,下一层中可以指定删除这部分内容,但实际上只是这一层做的某个标记,说这个路径的东西删了。但实际上并不会去修改上一层的东西。每一层都是静态的,这也是容器本身的 immutable 特性,要保持自身的静态特性。

所以很多新手会常犯下面这样的错误,把 Dockerfile 当做 shell 脚本来写了:

RUN yum update
RUN yum -y install gcc
RUN yum -y install python
ADD jdk-xxxx.tar.gz /tmp
RUN cd xxxx && install
RUN xxx && configure && make && make install
这是相当错误的。除了无畏的增加了很多层,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。

正确的写法应该是把同一个任务的命令放到一个 RUN 下,多条命令应该用 && 连接,并且在最后要打扫干净所使用的环境。比如下面这段摘自官方 redis 镜像 Dockerfile 的部分:

RUN buildDeps='gcc libc6-dev make' \
&& set -x \
&& apt-get update && apt-get install -y $buildDeps --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& wget -O redis.tar.gz "$REDIS_DOWNLOAD_URL" \
&& echo "$REDIS_DOWNLOAD_SHA1 *redis.tar.gz" | sha1sum -c - \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& rm redis.tar.gz \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps

那我把所有命令都合并到一个 RUN 就对了吧?
不是把所有命令都合为一个 RUN,要合理分层,以加快构建和部署。

合理分层就是将具有不同变更频繁程度的层,进行拆分,让稳定的部分在基础,更容易变更的部分在表层,使得资源可以重复利用,以增加构建和部署的速度。

node.js 的应用示例镜像为例,其中的复制应用和安装依赖的部分,如果都合并一起,会写成这样:

COPY . /usr/src/app
RUN npm install
但是,在 node.js 应用镜像示例中,则是这么写的:

COPY package.json /usr/src/app/
RUN npm install
COPY . /usr/src/app
从层数上看,确实多了一层。但实际上,这三行分开是故意这样做的,其目的就是合理分层,充分利用 Docker 分层存储的概念,以增加构建、部署的效率。

在 docker build 的构建过程中,如果某层之前构建过,而且该层未发生改变的情况下,那么 docker 就会直接使用缓存,不会重复构建。因此,合理分层,充分利用缓存,会显著加速构建速度。

第一行的目的是将 package.json 复制到应用目录,而不是整个应用代码目录。这样只有 pakcage.json 发生改变后,才会触发第二行 RUN npm install。而只要 package.json 没有变化,那么应用的代码改变就不会引发 npm install,只会引发第三行的 COPY . /usr/src/app,从而加快构建速度。

而如果按照前面所提到的,合并为两层,那么任何代码改变,都会触发 RUN npm install,从而浪费大量的带宽和时间。

合理分层除了可以加快构建外,还可以加快部署,要知道,docker pull 的时候,是分层下载的,并且已存在的层就不会重复下载。

比如,这里的 RUN npm install 这一层,往往会几百 MB 甚至上 GB。而在 package.json 未发生变更的情况下,那么只有 COPY . /usr/src/app 这一层会被重新构建,并且也只有这一层会在各个节点 docker pull 的过程中重新下载,往往这一层的代码量只有几十 MB,甚至更小。这对于大规模的并行部署中,所节约的东西向流量是非常显著的。特别是敏捷开发环境中,代码变更的频繁度要比依赖变更的频繁度高很多,每次重复下载依赖,会导致不必要的流量和时间上的浪费。

context 到底是一个什么概念?
context,上下文,是 docker build 中很重要的一个概念。构建镜像必须指定 context:

docker build -t xxx
或者 docker-compose.yml 中的

app:
build:
context:
dockerfile: dockerfile
这里都需要指定 context。

context 是工作目录,但不要和构建镜像的Dockerfile 中的 WORKDIR 弄混,context 是 docker build 命令的工作目录。

docker build 命令实际上是客户端,真正构建镜像并非由该命令直接完成。docker build 命令将 context 的目录上传给 Docker 引擎,由它负责制作镜像。

在 Dockerfile 中如果写 COPY ./package.json /app/ 这种命令,实际的意思并不是指执行 docker build 所在的目录下的 package.json,也不是指 Dockerfile 所在目录下的 package.json,而是指 context 目录下的 package.json。

这就是为什么有人发现 COPY ../package.json /app 或者 COPY /opt/xxxx /app 无法工作的原因,因为它们都在 context 之外,如果真正需要,应该将它们复制到 context 目录下再操作。

docker build -t xxx . 中的这个.,实际上就是在指定 Context 的目录,而并非是指定 Dockerfile 所在目录。

默认情况下,如果不额外指定 Dockerfile 的话,会将 Context 下的名为 Dockerfile 的文件作为 Dockerfile。所以很多人会混淆,认为这个 . 是在说 Dockerfile 的位置,其实不然。

ENTRYPOINT 和 CMD 到底有什么不同?
Dockerfile 的目的是制作镜像,换句话说,实际上是准备的是主进程运行环境。那么准备好后,需要执行一个程序才可以启动主进程,而启动的办法就是调用 ENTRYPOINT,并且把 CMD 作为参数传进去运行。也就是下面的概念:

ENTRYPOINT "CMD"
假设有个 myubuntu 镜像 ENTRYPOINT 是 sh -c,而我们 docker run -it myubuntu uname -a。那么 uname -a 就是运行时指定的 CMD,那么 Docker 实际运行的就是结合起来的结果:

sh -c "uname -a"
如果没有指定 ENTRYPOINT,那么就只执行 CMD;
如果指定了 ENTRYPOINT 而没有指定 CMD,自然执行 ENTRYPOINT;
如果 ENTRYPOINT 和 CMD 都指定了,那么就如同上面所述,执行 ENTRYPOINT "CMD";
如果没有指定 ENTRYPOINT,而 CMD 用的是上述那种 shell 命令的形式,则自动使用 sh -c 作为 ENTRYPOINT。
注意最后一点的区别,这个区别导致了同样的命令放到 CMD 和 ENTRYPOINT 下效果不同,因此有可能放在 ENTRYPOINT 下的同样的命令,由于需要 tty 而运行时忘记了给(比如忘记了docker-compose.yml 的 tty:true)导致运行失败。

这种用法可以很灵活,比如我们做个 git 镜像,可以把 git 命令指定为 ENTRYPOINT,这样我们在 docker run 的时候,直接跟子命令即可。比如 docker run git log 就是显示日志。

拿到一个镜像,如何获得镜像的 Dockerfile ?
  • 直接去 Docker Hub 上看:大多数 Docker Hub 上的镜像都会有 Dockerfile,直接在 Docker Hub 的镜像页面就可以看到 Dockerfile 的链接;
  • 如果是自己公司做的,最简单的办法就是打个电话、发个消息问一下。别看这个说法看起来很傻,不少人都宁可自己琢磨也不去问;
  • 如果没有 Dockerfile,一般这类镜像就不应该考虑使用了,这类黑箱似的镜像很容有有问题。如果是什么特殊原因,那继续往下看;
  • docker history 可以看到镜像每一层的信息,包括命令,当然黑箱镜像的 commit 看不见操作;
  • docker inspect 可以分析镜像很多细节。
  • 直接运行镜像,进入shell,然后根据上面的分析结果去进一步分析日志、文件内容及变化。
  • 经过分析后,自己写 Dockerfile 还原操作。

在你的 LNMP 的例子中,PHP 的 Dockerfile 里面的 “构建依赖” 和 “运行依赖” 都是什么意思?
这里所提到的是我的那个 LNMP 例子的 php 服务的 Dockerfile: https://coding.net/u/twang2218/p/docker-lnmp/git/blob/master/php/Dockerfile

FROM php:7-fpm
RUN set -xe \
# "构建依赖"
&& buildDeps=" \
build-essential \
php5-dev \
libfreetype6-dev \
libjpeg62-turbo-dev \
libmcrypt-dev \
libpng12-dev \
" \
# "运行依赖"
&& runtimeDeps=" \
libfreetype6 \
libjpeg62-turbo \
libmcrypt4 \
libpng12-0 \
" \
# "安装 php 以及编译构建组件所需包"
&& apt-get update \
&& apt-get install -y ${runtimeDeps} ${buildDeps} --no-install-recommends \
# "编译安装 php 组件"
&& docker-php-ext-install iconv mcrypt mysqli pdo pdo_mysql zip \
&& docker-php-ext-configure gd \
--with-freetype-dir=/usr/include/ \
--with-jpeg-dir=/usr/include/ \
&& docker-php-ext-install gd \
# "清理"
&& apt-get purge -y --auto-remove \
-o APT::AutoRemove::RecommendsImportant=false \
-o APT::AutoRemove::SuggestsImportant=false \
$buildDeps \
&& rm -rf /var/cache/apt/* \
&& rm -rf /var/lib/apt/lists/*
这里是针对 php 镜像进行定制,默认情况下 php:7-fpm 中没有安装所需的 mysqli, pdo_mysql, gd 等组件,所以这里需要安装,而且,部分组件还需要编译。

因此,这里涉及了两类依赖库/工具,一类是安装、编译阶段所需要的依赖;另一类是运行时所需的依赖。要记住 Dockerfile 的最佳实践中要求最终镜像只应该保留最小的所需依赖,因此安装构建的依赖应该在安装结束后清除,这一层只保留真正需要的运行时依赖。

因此,遵循最佳实践的要求,这里区分了 buildDeps 和 runtimeDeps 后,可以在安装结束后,卸载、清理 buildDeps 的依赖。这样确保没有无关的东西还在该层中。

应用代码是应该挂载宿主目录还是放入镜像内?
两种方法都可以。

如果代码变动非常频繁,比如开发阶段,代码几乎每几分钟就需要变动调试,这种情况可以使用 --volume 挂载宿主目录的办法。这样不用每次构建新镜像,直接再次运行就可以加载最新代码,甚至有些工具可以观察文件变化从而动态加载,这样可以提高开发效率。

如果代码没有那么频繁变动,比如发布阶段,这种情况,应该将构建好的应用放入镜像。一般来说是使用 CI/CD 工具,如 Jenkins, Drone.io, Gitlab CI 等,进行构建、测试、制作镜像、发布镜像、以及分步发布上线。

对于配置文件也是同样的道理,如果是频繁变更的配置,可以挂载宿主,或者动态配置文件可以使用卷。但是对于并非频繁变更的配置文件,应该将其纳入版本控制中,走 CI/CD 流程进行部署。

需要注意的一点是,绑定宿主目录虽然方便,但是不利于集群部署,因为集群部署前还需要确保集群各个节点同步存在所挂载的目录及其内容。因此集群部署更倾向于将应用打入镜像,方便部署。

为什么在 Dockerfile 中执行(导入 .sql、service xxx start)不管用?
这是典型的对 Dockerfile 以及镜像、容器的基本概念不了解。

Dockerfile 不是 shell 脚本,而是定制 rootfs 的脚本。它并不是在运行时运行的,而是在构建时运行的。

导入 .sql 文件到数据库,实际上修改的是数据库数据文件,而数据库的数据文件存储于卷,默认为匿名卷,因此当导入行为结束后,构建该层的容器停止运行,匿名卷被抛弃,所有导入行为都会丢失,因此所谓的导入 .sql 的行为在 Dockerfile 里实际上完全没有意义。

而 service xxxx start 也完全没有意义,这是启动后台服务,且不说 Docker 中不用后台服务,这种启动行为对文件系统根本没影响,这仅仅是让后台在构建所用的容器中运行一下,完全没有意义。最后运行容器的时候,是另一个进程了,该没启动的东西还是不会启动。

但是不要因此就盲目的得出 Dockerfile 无法初始化数据库的结论。所有官方镜像都考虑到了定制的问题,去看特定官方镜像的文档,基本都会看到定制、初始化的方法。

比如官方 mysql 镜像中,可以把初始化的 .sql 脚本文件在 Dockerfile 中 COPY 至 /docker-entrypoint-initdb.d/ 目录中,在容器第一次运行的时候,如果所挂载的卷是空的,那么就会依次 执行该目录中的文件,从而完成数据库初始化、导入等功能。

FROM mysql:5.7
COPY mysql-data-backup.sql /docker-entrypoint-initdb.d/


为什么基于 Alpine 的镜像那么小?我可以都换成基于 Alpine 的镜像么?
Alpine Linux 体积小是因为它所使用的基础命令来自精简的 busybox,并且它使用的是简化实现的 musl 作为库支持,而并非完整的  glibc。musl 体积小,但是有可能有不兼容的情况,因此一般不用 Alpine 的镜像,除非空间受限,体积大小很关键时才会使用。

过去出现过兼容问题,但是随着 Docker 的使用,对 Alpine 的需求会越来越多,更多的兼容问题会被发现、修复,所以相信在未来这应该是个不错的选择。但是如果现在就要使用,一定要进行重复的测试,确保没有会影响到自己的 bug。

可以看到镜像各层的依赖关系么?
镜像是分层存储的,镜像之间也可以依赖,因此利用 Docker 镜像很容易实现重复的部分复用。那么我们有没有办法可以可视化的看到镜像的依赖关系呢?

很早以前,Docker 有个 docker images --tree 的命令的,后来随着镜像分层平面化后,这个命令就取消了。幸运的是,Nate Jones 写了一个工具,用于可视化镜像分层依赖,叫做 dockviz: https://github.com/justone/dockviz

对于 Mac 平台的用户,可以很方便的使用 brew 来进行安装:

brew install dockviz
对于其它平台的用户,可以直接去 发布页面下载。

安装好后,直接执行 dockviz images --tree 即可:

$ dockviz images --tree
├─<missing> Virtual Size: 55.3 MB
│ └─<missing> Virtual Size: 55.3 MB
│   └─<missing> Virtual Size: 55.3 MB
│     └─<missing> Virtual Size: 55.3 MB
│       └─<missing> Virtual Size: 55.3 MB
│         └─<missing> Virtual Size: 108.3 MB
│           └─<missing> Virtual Size: 108.3 MB
│             └─<missing> Virtual Size: 108.3 MB
│               └─<missing> Virtual Size: 108.3 MB
│                 └─0b5dec81616c Virtual Size: 108.3 MB Tags: nginx:latest
└─<missing> Virtual Size: 100.1 MB
  └─<missing> Virtual Size: 100.1 MB
    └─<missing> Virtual Size: 123.9 MB
      └─<missing> Virtual Size: 131.2 MB
        ├─<missing> Virtual Size: 272.8 MB
        │ └─<missing> Virtual Size: 274.2 MB
        │   └─<missing> Virtual Size: 274.2 MB
        │     └─<missing> Virtual Size: 274.2 MB
        │       └─<missing> Virtual Size: 274.2 MB
        │         └─<missing> Virtual Size: 274.2 MB
        │           └─<missing> Virtual Size: 274.2 MB
        │             └─<missing> Virtual Size: 274.2 MB
        │               └─<missing> Virtual Size: 274.2 MB
        │                 └─<missing> Virtual Size: 737.9 MB
        │                   └─4551430cfe80 Virtual Size: 738.3 MB Tags: openjdk:latest
        └─<missing> Virtual Size: 132.4 MB
          └─<missing> Virtual Size: 132.4 MB
            └─<missing> Virtual Size: 132.4 MB
...
                            └─<missing> Virtual Size: 276.0 MB
                                └─<missing> Virtual Size: 292.4 MB
                                └─<missing> Virtual Size: 292.4 MB
                                    └─<missing> Virtual Size: 292.4 MB
                                    └─72d2be374029 Virtual Size: 292.4 MB Tags: tomcat:latest
如果觉得文本格式太繁杂,也可以生成 DOT 图),使用命令 dockviz images -d | dot -Tpng -o image_tree.png 就可以将你的镜像依赖关系绘制成图( https://imagebin.ca/v/3ZhFvSPeqAi0)。