Docker入坑指南

产生背景:

  • 开发人员将测试并运行正常的代码,交给运维人员部署后,部署失败,这就导致了开发人员和运维人员之间因为环境的不同出现很多矛盾,因此慢慢地出现了 DevOps 的概念。所谓的环境不同,指的是不同的操作系统、软件环境、组件版本、应用配置等。

  • 在集群环境下,每台服务器都需要配置相同的环境,配置起来十分麻烦。

  • 解决开发人员常说的 “我不管,在我的机器上是可以正常工作的” 的问题。

Docker 简介

什么是 Docker ?

Docker 是一个开源的应用容器引擎,让开发者可以打包他们开发的应用程序以及程序的依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。

Docker 使用 Google 公司推出的 Go 语言进行开发实现,基于 Linux 内核的 cgroupnamespace,以及 AUFS 类的 Union FS 等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术。由于隔离的进程独立于宿主机和其它的隔离的进程,因此也称其为容器。

Docker 在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护。使得 Docker 技术比虚拟机技术更为轻便、快捷。

容器和虚拟机

下面的图片比较了 Docker 容器技术和传统虚拟机技术的不同之处:

虚拟机(VM)是虚拟出一套硬件(CPU、Memory 等)后,在其上运行一个完整操作系统,它是将一台服务器转变为多台服务器的物理硬件的抽象。系统管理程序允许多个VM在单台计算机上运行。每个VM包含操作系统,应用程序,必要的二进制文件和库的完整副本,这种方式占用大量资源(数十GB),并且VM也可能启动缓慢。

容器将代码和依赖项打包在一起,容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。

多个容器可以在同一台计算机上运行,并与其他容器共享宿主机的内核,每个容器在用户空间中作为隔离的进程运行。容器占用的空间少于VM(容器镜像的大小通常为几十MB),可以处理更多的应用程序,启动速度更快,并且需要的 VM 和操作系统数量更少。

为什么要使用 Docker ?

作为一种新兴的虚拟化方式,Docker 跟传统的虚拟化方式相比具有众多的优势。

更高效的利用系统资源

容器不需要进行硬件虚拟以及运行完整操作系统等额外开销,Docker 对系统资源的利用率更高。无论是应用执行速度、内存损耗或者文件存储速度,都要比传统虚拟机技术更高效。因此,相比虚拟机技术,一个相同配置的物理主机,能运行的容器远大于虚拟机的数量,但是容器不能拿来当作虚拟机给客户使用。

更快速的启动时间

传统的虚拟机技术启动应用服务往往需要数分钟(实际上 OpenStack 虚拟化技术也能达到秒级启动),而 Docker 容器应用,由于直接运行于宿主内核,无需启动完整的操作系统,因此可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。

一致的运行环境

开发过程中一个常见的问题是环境一致性问题。由于开发环境、测试环境、生产环境不一致,导致有些 bug 并未在开发过程中被发现。而 Docker 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 “这段代码在我机器上没问题啊” 这类问题。

持续交付和部署

对开发和运维(DevOps)人员来说,最希望的就是一次创建或配置,可以在任意地方正常运行。使用 Docker 可以通过定制应用镜像来实现持续集成、持续交付、部署。开发人员可以通过 Dockerfile 来进行镜像构建,并结合 持续集成(Continuous Integration) 系统进行集成测试,而运维人员则可以直接在生产环境中快速部署该镜像,甚至结合 持续部署(Continuous Delivery/Deployment) 系统进行自动部署。而且使用 Dockerfile 使镜像构建透明化,不仅仅开发团队可以理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。

更轻松的迁移

由于 Docker 确保了执行环境的一致性,使得应用的迁移更加容易。Docker 可以在很多平台上运行,无论是物理机、虚拟机、公有云、私有云,甚至是笔记本,其运行结果是一致的。因此用户可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。

更轻松的维护和扩展

Docker 使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩展镜像也变得非常简单。此外,Docker 团队同各个开源项目团队一起维护了一大批高质量的 官方镜像,既可以直接在生产环境使用,又可以作为基础进一步定制,大大的降低了应用服务的镜像制作成本。

容器技术对比传统虚拟机总结

特性 容器 虚拟机
启动 秒级 分钟级
硬盘 使用一般为 MB 一般为 GB
性能 接近原生 弱于原生
系统 支持量单机支持上千个容器 一般几十个

容器技术属于进程之间的隔离,虚拟机技术可以实现系统级别隔离。物理机没有快照的概念,虚拟机的快照功能极大的提高了容灾性、数据备份等功能。我们可以授权给容器来操作宿主机,而虚拟机是无法操作宿主机的。

总而言之,容器技术虽然比虚拟机技术有着更多的优点,但是容器和虚拟机一起使用,在部署和管理应用程序时提供了很大的灵活性。

基本概念

Docker Host:安装了 Docker 程序的主机,运行 Docker 守护进程

Docker Image:镜像,将软件环境打包好的模板,用来创建容器的,一个镜像可以创建多个容器。

Docker Container:容器,运行镜像后生成的实例称为容器,每运行一次镜像就会产生一个容器,容器可以启动、停止或删除。容器使用是沙箱机制,互相隔离,是独立是安全的。可以把容器看作是一个简易版的 Linux 环境,包括用户权限、文件系统和运行的应用等。

Docker Repository:仓库,用来保存镜像,仓库中包含许多镜像,每个镜像都有不同的标签 Tag,在 官方仓库 中可以搜索出很多镜像。

安装 Docker

Docker 划分为 CE 和 EE。CE 即社区版(免费,支持周期三个月),EE 即企业版,强调安全,付费使用。在官方网站上有各种环境下的 安装指南

由于各项新技术(Systemd、Cgroup 等)的出现,以及容器技术的优势和不断成熟,进一步促进了内核更新,为了对容器技术有着更好的支持。建议使用较新版本的 Linux 系统,这里以 Docker CE 在 CentOS7 上的安装为例。

Docker CE 支持 64 位版本 CentOS 7,并且要求内核版本不低于 3.10。 CentOS 7 满足最低内核的要求,但由于内核版本比较低,部分功能(如 overlay2 存储层驱动)无法使用,并且部分功能可能不太稳定。

系统环境的配置

运行环境配置

时区,selinux、系统运行级别等的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Set timezone
timedatectl set-timezone Asia/Shanghai

# Maintain the RTC in universal time. Use RTC in UTC by calling
timedatectl set-local-rtc 0

# Sync system time to hwclock
hwclock --systohc --localtime

# Set runlevel
systemctl set-default multi-user.target

# Turn off selinux
setenforce 0
sed -ri 's/(^SELINUX=).*/\1disabled/' /etc/sysconfig/selinux /etc/selinux/config

系统服务配置

firewalld 功能更强大,但是生成的防火墙规则和链比较繁琐,使用 iptables 命令完全够用,这里将其停止并禁止开机自启:

1
systemctl disable --now firewalld.service

NetworkManager 服务早期一直用来管理图形化界面 Linux 的网络,从 CentOS8 开始,它已经完全取代了 network 服务,并且使用 nmcli 命令来管理非图形化界面 Linux 的网络。因此不建议对 NetworkManager 做操作,如果对 NetworkManager 比较了解,你也可以选择在 CentOS7 上将其关停:

1
systemctl disable --now NetworkManager.service

在图形界面 Linux 上默认会安装 Dnsmasq 来配置本地自有 DNS 服务器,这有可能会导致 nameserver 被设置为 127.0.0.1,从而导致容器无法解析域名。因此将其关停:

1
systemctl disable --now dnsmasq.service

资源使用限定

默认的 limit 数值较小,手动改 /etc/security/limits.conf 或在 /etc/security/limits.d/ 创建自定义配置文件,来修改资源的限定:

1
2
3
4
5
6
7
8
9
10
11
# Modify shell resource limits
cat > /etc/security/limits.d/my-limits.conf << EOF
* soft nproc 131072
* hard nproc 131072
* soft nofile 131072
* hard nofile 131072
root soft nproc 131072
root hard nproc 131072
root soft nofile 131072
root hard nofile 131072
EOF

对 Systemd 的资源使用做设置:

1
2
3
4
5
6
7
8
9
10
11
12
# Modify systemd resource limits
sed -ri '/^DefaultLimit(NOFILE|NPROC)/d' /etc/systemd/{system,user}.conf

cat >> /etc/systemd/system.conf << 'EOF'
DefaultLimitNOFILE=131072
DefaultLimitNPROC=131072
EOF

cat >> /etc/systemd/user.conf << 'EOF'
DefaultLimitNOFILE=131072
DefaultLimitNPROC=131072
EOF

内核参数设置

设置 iptables 不对 bridge 的数据进行处理:

1
2
3
4
5
6
7
cat << EOF > /etc/sysctl.d/docker.conf
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-arptables = 1
EOF

sysctl -p

修改内核的环境变量配置文件 /etc/default/grub,在 GRUB_CMDLINE_LINUX= 中启用内核的 user_namespaces

1
2
3
cp /boot/grub2/grub.cfg{,.old};
sed -ri '/GRUB_CMDLINE_LINUX/s#('"'"'|")$# user_namespace.enable=1 \1#' /etc/default/grub
grub2-mkconfig -o /boot/grub2/grub.cfg

使用 grubby 命令也可以直接修改 /boot/grub2/grub.cfg 中的 grub 策略:

1
grubby --args="user_namespace.enable=1" --update-kernel="$(grubby --default-kernel)"

注意: 这并不会更新 /etc/default/grub ,所以最稳妥的办法还是修改 /etc/default/grub 然后重新生成配置。

配置完成后需要重启系统才能让参数生效:

1
systemctl reboot

安装 Docker

官方提供了配置检查脚本,用来检查环境是否符合要求:

1
2
curl -s https://raw.githubusercontent.com/docker/docker/master/contrib/check-config.sh -o check-config.sh
bash ./check-config.sh

使用官方脚本安装,为了有更快的速度,可以将镜像下载源指定为 Aliyun

1
curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --mirror Aliyun

安装完成后查看版本,验证安装是否成功:

1
docker version

后续配置

安装 bash 补全包,安装后可以为 systemdipdocker 等命令做子命令的补全,当然还可以自定义。

1
yum -y install bash-completion

安装完成后重新登录机器即可生效,如果未生效则可以拷贝 Docker 自己生成的补全脚本到系统的 bash 补全配置目录下:

1
cp -a /usr/share/bash-completion/completions/docker ${BASH_COMPLETION_COMPAT_DIR}/

配置 Docker 守护进程

官方文档:

常用配置:

  • 网络:

    • 默认情况下 docker0 网卡分配的 IP 地址是 172.17.0.1,所在的地址段是 172.17.0.0/16 , 如果有冲突可以自行定义。
    • DNS:如果不想让 Docker 使用主机的 nameserver ,可以使用 dns 来指定自定义的 DNS 服务器。
  • 存储驱动:

    • 默认存储引擎和受支持的存储引擎列表取决于主机的 Linux 发行版和可用的内核驱动程序,建议使用 overlay2,详情请看 官方扫盲
    • 允许覆盖 overlay2 的 Linux 内核版本检查:在 4.0.0 版本内核中,添加了对 overlay2 所需的多个较低目录的支持。但是,可能会修补某些较旧的内核版本,以添加对 OverlayFS 的多个较低目录支持。仅在验证内核中存在此支持后,才应使用此选项。在没有此支持的情况下在内核上应用此选项将导致安装失败。
  • 日志驱动:

    • Docker 提供了通过一系列日志记录驱动程序,用来收集和查看主机上运行的所有容器的日志数据。默认的日志记录驱动程序 json-file 将日志数据写入主机文件系统上的 JSON 格式的文件。
    • 日志轮转:随着时间的推移,这些日志文件的大小会扩大,从而可能导致磁盘资源耗尽。要缓解此类问题,请配置备用日志记录驱动程序(例如 Splunk 或 Syslog ),或为默认驱动程序设置日志轮转
  • runtime:使用 --exec-opt 标志指定的选项来配置 runtime。所有标志的选项都有 native 前缀,有一个 native.cgroupdriver 选项可用。该选项指定用什么来进行容器的 cgroup 管理。只能指定 cgroupfssystemd 。如果指定 systemd 并且不可用,则系统会出错。如果没有配置 native.cgroupdriver 选项,则使用 cgroupfs 进行管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mkdir -pv /etc/docker/
cat >| /etc/docker/daemon.json << EOF
{
"bip": "172.17.0.1/16",
"dns": [
"8.8.8.8",
"8.8.4.4"
],
"storage-driver": "overlay2",
"storage-opts": [
"overlay2.override_kernel_check=true"
],
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
},
"exec-opts": [
"native.cgroupdriver=systemd"
]
}
EOF

配置 Docker 镜像加速器

国内访问 Docker Hub 下载镜像有时候会比较慢,此时可以配置镜像加速器。国内一些云服务商提供了容器镜像加速服务,例如:

如果网络质量较好,这一步可以忽略不做。以阿里云为例,以下是配置步骤:

1、注册并登录 “阿里云的容器镜像服务控制台” https://cr.console.aliyun.com/

2、查看专属的加速器地址。

3、配置自己的 Docker 加速器,注意一定要保证该文件符合 json 规范,否则 Docker 将不能启动。

1
2
3
4
5
6
7
8
vim /etc/docker/daemon.json
{
"registry-mirrors": [
"https://sswv6yx0.mirror.aliyuncs.com"
]
}
systemctl daemon-reload
systemctl restart docker.service

检查加速器是否生效:配置加速器之后,如果拉取镜像仍然十分缓慢,请手动检查加速器配置是否生效,在命令行
执行 docker info ,如果从结果中看到了如下内容,说明配置成功:

1
2
Registry Mirrors:
https://sswv6yx0.mirror.aliyuncs.com/

使用 Systemd 控制 Docker

将 docker 设置为开机自启,并启动该服务程序:

1
systemctl enable --now docker.service

查看服务的运行状态:

1
systemctl status docker.service

使用镜像

Docker 运行容器前需要本地存在对应的镜像,如果本地不存在该镜像,Docker 会从镜像仓库下载该镜像。

查找镜像

Docker 提供了一个官方专业存放镜像的地方,即镜像仓库 Docker Hub,在这上面有大量的高质量的镜像可以使用。打开 Docker Hub 后点击 “Explore” 即可进入仓库列表页面。

在搜索框可以输入相关的镜像名称进行搜索,以 Nginx 为例,在搜索到的镜像中会有 “Tags” 按钮,这就是镜像的标签列表,标签表示了不同的版本。

为什么要有标签呢?镜像 = <仓库>:[Tag]。一个 Docker Registry 中可以包含多个仓库 (Repository);每个仓库可以包含多个标签 (Tag);每个标签对应一个镜像 (Image)。

在命令行可以使用 docker search 进行搜索,命令基本语法为:

1
docker search [选项] <镜像ID/镜像名称>[:标签]

其中 [] 表示可选,<> 表示必选。镜像名称一般为 “<用户名>/<软件名>”,只有软件名的一般是官方镜像。

1
2
3
4
5
6
[root@bogon ~]# docker search nginx
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
nginx Official build of Nginx. 12311 [OK]
jwilder/nginx-proxy Automated Nginx reverse proxy for docker con… 1698 [OK]
richarvey/nginx-php-fpm Container running Nginx + PHP-FPM capable of… 746 [OK]
linuxserver/nginx An Nginx container, brought to you by LinuxS… 83

从上面的命令执行的结果中,并不能看到镜像的标签,所以要下载特定镜像还需要在网页上搜索查找。

获取镜像

从 Docker 镜像仓库获取镜像的命令是 docker pull 。其命令格式为:

1
docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签]

镜像名称的格式:

  • Docker 镜像仓库地址:地址的格式一般是 <域名/IP>[:端口号] 。默认地址是 Docker Hub。

  • 仓库名:如之前所说,这里的仓库名是两段式名称,即 <用户名>/<软件名> 。对于 Docker Hub,如果不给出用户名,则默认为 library ,也就是官方镜像。

下面的命令中没有给出 Docker 镜像仓库地址,因此将会从 Docker Hub 获取镜像。而镜像名称是 nginx:latest ,因此将会获取官方镜像 library/nginx 仓库中标签为 latest 的镜像:

1
docker pull nginx:latest

从下载过程中可以看到分层存储的概念,镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件。下载过程中给出了每一层的 ID 的前 12 位。并且下载结束后,给出该镜像完整的 sha256 的摘要信息,以确保下载一致性。

有可能你看到的层 ID 以及 sha256 的摘要和这里的不一样。这是因为官方镜像是一直在维护的,有任何新的 bug,或者版本更新,都会进行修复再以原来的标签发布,这样可以确保任何使用这个标签的用户可以获得更安全、更稳定的镜像。

列出镜像

要想列出已经下载下来的镜像,可以使用 docker image ls 命令或 docker images 命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[root@bogon ~]# docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
httpd latest 2ae34abc2ed0 12 days ago 165MB
python latest 0a3a95c81a2b 2 weeks ago 932MB
mysql latest d435eee2caa5 2 weeks ago 456MB
nginx latest 231d40e811cd 2 weeks ago 126MB
ubuntu 18.04 775349758637 5 weeks ago 64.2MB
ubuntu latest 775349758637 5 weeks ago 64.2MB
centos latest 0f3e07c0138f 2 months ago 220MB
hello-world latest fce289e99eb9 11 months ago 1.84kB
[root@bogon ~]# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
httpd latest 2ae34abc2ed0 12 days ago 165MB
python latest 0a3a95c81a2b 2 weeks ago 932MB
mysql latest d435eee2caa5 2 weeks ago 456MB
nginx latest 231d40e811cd 2 weeks ago 126MB
ubuntu 18.04 775349758637 5 weeks ago 64.2MB
ubuntu latest 775349758637 5 weeks ago 64.2MB
centos latest 0f3e07c0138f 2 months ago 220MB
hello-world latest fce289e99eb9 11 months ago 1.84kB

不加任何参数则列出所有镜像,也可以指定镜像:

1
2
3
4
5
6
7
8
9
[root@bogon ~]# docker image ls ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 18.04 775349758637 5 weeks ago 64.2MB
ubuntu latest 775349758637 5 weeks ago 64.2MB
[root@bogon ~]# docker images ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 18.04 775349758637 5 weeks ago 64.2MB
ubuntu latest 775349758637 5 weeks ago 64.2MB
[root@bogon ~]#

列表包含了 仓库名标签镜像 ID创建时间 以及 所占用的空间 。其中 镜像 ID 是镜像的唯一标识,一个镜像可以对应多个标签。因此,在上面命令执行结果中,可以看到 ubuntu:18.04ubuntu:latest 拥有相同的 ID,因为它们对应的是同一个镜像。

镜像体积

在 Docker Hub 中显示的镜像的体积是压缩后的体积。在镜像下载和上传过程中镜像是保持着压缩状态的,因此 Docker Hub 所显示的大小是网络传输中更关心的流量大小。而 docker image ls 显示的是镜像下载到本地后,展开的大小,准确说,是展开后的各层所占空间的总和,因为镜像到本地后,查看空间的时候,更关心的是本地磁盘空间占用的大小。

另外一个需要注意的问题是, docker image ls 列表中的镜像体积总和并非是所有镜像实际硬盘消耗。由于 Docker 镜像是多层存储结构,并且可以继承、复用,因此不同镜像可能会因为使用相同的基础镜像,从而拥有共同的层。由于 Docker 使用 Union FS,相同的层只需要保存一份即可,因此实际镜像硬盘占用空间很可能要比这个列表镜像大小的总和要小的多。

使用 docker system df 命令可以便捷地查看镜像、容器、数据卷所占用的空间。

1
2
3
4
5
6
7
[root@bogon ~]# docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 7 1 1.894GB 1.798GB (94%)
Containers 2 0 28B 28B (100%)
Local Volumes 0 0 0B 0B
Build Cache 0 0 0B 0B
[root@bogon ~]#

虚悬镜像

在镜像列表中,有可能看到特殊的镜像,既没有仓库名,也没有标签,均为 <none> 。这种镜像原本是有镜像名和标签的,比如原来为 mongo:3.2 ,随着官方镜像维护,发布了新版本后,重新 docker pull mongo:3.2 时,mongo:3.2 这个镜像名被转移到了新下载的镜像身上,而旧的镜像上的这个名称则被取消,从而成为了 <none> 。除了 docker pull 可能导致这种情况, docker build 也同样可以导致这种现象。由于新旧镜像同名,旧镜像名称被取消,从而出现仓库名、标签均为 <none> 的镜像。这类无标签镜像也被称为 虚悬镜像 (dangling image) ,可以用下面的命令专门显示这类镜像:

1
docker image ls --filter dangling=true

一般来说,虚悬镜像已经失去了存在的价值,是可以随意删除的,可以用下面的命令删除没有被使用到的镜像。

1
docker image prune

中间层镜像

为了加速镜像构建、重复利用资源,Docker 会利用中间层镜像。所以在使用一段时间后,可能会看到一些依赖的中间层镜像。默认的 docker image ls 列表中只会显示顶层镜像,如果希望显示包括中间层镜像在内的所有镜像的话,需要加 -a 参数。

1
docker image ls -a

这样会看到很多无标签的镜像,与之前的虚悬镜像不同,这些无标签的镜像很多都是中间层镜像,是其它镜像所依赖的镜像。这些无标签镜像不应该删除,否则会导致上层镜像因为依赖丢失而出错。实际上,这些镜像也没必要删除,因为相同的层只会存一遍,而这些镜像是别的镜像的依赖,因此并不会因为它们被列出来而多存了一份。删除镜像后,依赖的中间层镜像也会被连带删除。

列出部分镜像

不加任何参数的情况下, docker image ls 会列出所有顶级镜像,但是有时候我们只希望列出部分镜像。 docker image ls 有好几个参数可以帮助做到这个事情。

根据仓库名列出镜像:

1
2
3
4
5
[root@bogon ~]# docker image ls ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 18.04 775349758637 5 weeks ago 64.2MB
ubuntu latest 775349758637 5 weeks ago 64.2MB
[root@bogon ~]#

列出特定的某个镜像,也就是说指定仓库名和标签:

1
2
3
4
[root@bogon ~]# docker image ls ubuntu:18.04 
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 18.04 775349758637 5 weeks ago 64.2MB
[root@bogon ~]#

除此以外, docker image ls 还支持强大的过滤器参数 --filter ,或者简写 -f 。之前使用过滤器来列出虚悬镜像的用法:

1
docker image ls --filter dangling=true

如果想要看到在 ubuntu:19.04 之后建立的镜像,可以用下面的命令:

1
2
3
4
5
6
7
[root@bogon ~]# docker image ls --filter since=ubuntu:19.04 
REPOSITORY TAG IMAGE ID CREATED SIZE
httpd latest 2ae34abc2ed0 12 days ago 165MB
python latest 0a3a95c81a2b 2 weeks ago 932MB
mysql latest d435eee2caa5 2 weeks ago 456MB
nginx latest 231d40e811cd 2 weeks ago 126MB
[root@bogon ~]#

想查看某个位置之前的镜像,只需要把 since 换成 before 即可。此外,如果镜像构建时,定义了 LABEL ,还可以通过 LABEL 来过滤。

1
docker image ls --filter label=com.example.version=0.1

以特定格式显示

默认情况下, docker image ls 会输出一个完整的表格,但是并非所有时候都会需要这些内容。比如,删除虚悬镜像的时候,需要利用 docker image ls 把所有的虚悬镜像的 ID 列出来,然后才可以交给 docker image rm 命令作为参数来删除指定的这些镜像,这时候就用到了 -q 参数。

1
2
3
4
5
6
7
8
9
10
11
[root@bogon ~]# docker image ls  -q
2ae34abc2ed0
0a3a95c81a2b
d435eee2caa5
231d40e811cd
51b0783967fc
775349758637
775349758637
0f3e07c0138f
fce289e99eb9
[root@bogon ~]#

使用 --filter 配合 -q 产生出指定范围的 ID 列表,然后送给另一个 docker 命令作为参数,从
而针对这组实体成批的进行某种操作。这种做法在 Docker 命令行使用过程中非常常见,不仅仅是
镜像命令,在各个命令中都可以使用这类搭配以完成很强大的功能。

1
2
3
4
5
6
7
8
9
10
[root@bogon ~]# docker image ls --filter since=nginx:latest 
REPOSITORY TAG IMAGE ID CREATED SIZE
httpd latest 2ae34abc2ed0 12 days ago 165MB
python latest 0a3a95c81a2b 2 weeks ago 932MB
mysql latest d435eee2caa5 2 weeks ago 456MB
[root@bogon ~]# docker image ls --filter since=nginx:latest -q
2ae34abc2ed0
0a3a95c81a2b
d435eee2caa5
[root@bogon ~]#

如果只是对表格的结构不满意,希望自己组织列;或者不希望有标题,这样方便其它程序解析结果等,这就用到了 Go 的模板语法。比如只接列出镜像结果,并且只包含镜像ID和仓库名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[root@bogon ~]# docker image ls --format "{{.ID}}: {{.Repository}}"            
2ae34abc2ed0: httpd
0a3a95c81a2b: python
d435eee2caa5: mysql
231d40e811cd: nginx
51b0783967fc: ubuntu
775349758637: ubuntu
775349758637: ubuntu
0f3e07c0138f: centos
fce289e99eb9: hello-world
[root@bogon ~]# docker image ls --format "{{.ID}}: {{.Repository}} {{.Size}}"
2ae34abc2ed0: httpd 165MB
0a3a95c81a2b: python 932MB
d435eee2caa5: mysql 456MB
231d40e811cd: nginx 126MB
51b0783967fc: ubuntu 70MB
775349758637: ubuntu 64.2MB
775349758637: ubuntu 64.2MB
0f3e07c0138f: centos 220MB
fce289e99eb9: hello-world 1.84kB
[root@bogon ~]#

如果想要以表格等距显示,并且有标题行,和默认一样,但是想定义列:

1
2
3
4
5
6
7
8
9
10
11
12
[root@bogon ~]# docker image ls --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}"              
IMAGE ID REPOSITORY TAG SIZE
2ae34abc2ed0 httpd latest 165MB
0a3a95c81a2b python latest 932MB
d435eee2caa5 mysql latest 456MB
231d40e811cd nginx latest 126MB
51b0783967fc ubuntu 19.04 70MB
775349758637 ubuntu 18.04 64.2MB
775349758637 ubuntu latest 64.2MB
0f3e07c0138f centos latest 220MB
fce289e99eb9 hello-world latest 1.84kB
[root@bogon ~]#

删除本地镜像

如果要删除本地的镜像,可以使用 docker image rm 命令,其格式为:

1
$ docker image rm [选项] <镜像1> [<镜像2> ...]

用 ID、镜像名、摘要删除镜像

其中, <镜像> 可以是 镜像短 ID镜像长 ID镜像名 或者 镜像摘要 。可以用镜像的完整 ID,也称为 长 ID ,来删除镜像,但是更多的时候是用 短 ID 来删除镜像。

默认情况下 docker image ls 列出的是短 ID ,一般取前3个字符以上,只要足够区分于别的镜像即可。

1
2
3
4
5
[root@bogon ~]# docker image ls redis
REPOSITORY TAG IMAGE ID CREATED SIZE
redis latest dcf9ec9265e0 2 weeks ago 98.2MB
redis alpine a49ff3e0d85f 2 weeks ago 29.3MB
[root@bogon ~]#

比如要删除 redis:alpine 镜像:

1
docker image rm a49ff3e0d85f

也可以用 镜像名 ,也就是 <仓库名>:<标签> 来删除镜像:

1
docker image rm redis:latest

当然,更精确的是使用 镜像摘要 删除镜像。

1
2
3
4
5
6
7
8
9
10
11
12
[root@bogon ~]# docker image ls --digests  --format "table{{.ID}}\t{{.Digest}}\t{{.Repository}}\t{{.Tag}}" redis 
IMAGE ID DIGEST REPOSITORY TAG
dcf9ec9265e0 sha256:1eedfc017b0cd3e232878ce38bd9328518219802a8ef37fe34f58dcf591688ef redis latest
a49ff3e0d85f sha256:ee13953704783b284c080b5b0abe4620730728054f5c19e9488d7a97ecd312c5 redis alpine
[root@bogon ~]#
[root@bogon ~]# docker image rm redis@sha256:ee13953704783b284c080b5b0abe4620730728054f5c19e9488d7a97ecd312c5
Untagged: redis@sha256:ee13953704783b284c080b5b0abe4620730728054f5c19e9488d7a97ecd312c5
[root@bogon ~]#
[root@bogon ~]# docker image ls --digests --format "table{{.ID}}\t{{.Digest}}\t{{.Repository}}\t{{.Tag}}" redis
IMAGE ID DIGEST REPOSITORY TAG
dcf9ec9265e0 sha256:1eedfc017b0cd3e232878ce38bd9328518219802a8ef37fe34f58dcf591688ef redis latest
a49ff3e0d85f <none> redis alpine

Untagged 和 Deleted

删除行为分为两类,一类是 Untagged ,另一类是 Deleted 。镜像的唯一标识是其 ID 和摘要,而一个镜像可以有多个标签。

1
2
3
4
5
[root@bogon ~]# docker image ls redis
REPOSITORY TAG IMAGE ID CREATED SIZE
redis latest dcf9ec9265e0 2 weeks ago 98.2MB
redis alpine a49ff3e0d85f 2 weeks ago 29.3MB
[root@bogon ~]

因此当删除镜像的时候,实际上是在要求删除某个标签的镜像。所以首先需要做的是将满足要求的所有镜像标签都取消,这就是我们看到的 Untagged 的信息。因为一个镜像可以对应多个标签,因此当我们删除了所指定的标签后,可能还有别的标签指向了这个镜像,如果是这种情况,那么 Delete 行行为就不会发生。所以并非所有的 docker rm 都会产生删除镜像的行为,有可能仅仅是取消了某个标签而已。

当该镜像所有的标签都被取消了,该镜像很可能会失去了存在的意义,因此会触发删除行为。镜像是多层存储结构,因此在删除的时候也是从上层向基础层方向依次进行判断删除。镜像的多层结构让镜像复用变动非常容易,因此很有可能某个其它镜像正依赖于当前镜像的某一层。这种情况,依旧不会触发删除该层的行为。直到没有任何层依赖当前层时,才会真实的删除当前层。这就是有时候明明没有别的标签指向这个镜像,但是它还是存在的原因,也是为什么有时候会发现所删除的层数和自己 docker pull 看到的层数不一样的原因。

除了镜像依赖以外,还需要注意的是容器对镜像的依赖。如果有用这个镜像启动的容器存在(即使容器没有运行),那么同样不可以删除这个镜像。容器是以镜像为基础,再加一层容器存储层,组成这样的多层存储结构去运行的。因此该镜像如果被这个容器所依赖的,那么删除必然会导致故障。如果这些容器是不需要的,应该先将它们删除,然后再来删除镜像。

用 docker image ls 命令来配合

使用 docker image ls -q 来配合使用 docker image rm ,可以批量删除镜像。比如,要删除所有仓库名为 redis 的镜像:

1
docker image rm $(docker image ls -q redis)

或者删除所有在 mongo:3.2 之前的镜像:

1
docker image rm $(docker image ls -q --filter before=mongo:3.2)

CentOS/RHEL 的注意事项

在 Ubuntu/Debian 上有 UnionFS 可以使用,如 aufs 或者 overlay2 ,而 CentOS 和 RHEL 的内核中没有相关驱动。因此对于这类系统,一般使用 devicemapper 驱动利用 LVM 的一些机制来模拟分层存储。这样的做法除了性能比较差外,稳定性一般也不好,而且配置相对复杂。Docker 安装在 CentOS/RHEL 上后,会默认选择 devicemapper ,但是为了简化配置,其 devicemapper 是跑在一个稀疏文件模拟的块设备上,也被称为 loop-lvm 。这样的选择是因为不需要额外配置就可以运行 Docker,这是自动配置唯一能做到的事情。但是 loop-lvm 的做法非常不好,其稳定性、性能更差,无论是日志还是 docker info 中都会看到警告信息。官方文档有明确的文章讲解了如何配置块设备给 devicemapper 驱动做存储层的做法,这类做法也被称为配置 direct-lvm

除了前面说到的问题外, devicemapper + loop-lvm 还有一个缺陷,因为它是稀疏文件,所以它会不断增长。用户在使用过程中会注意到 /var/lib/docker/devicemapper/devicemapper/data 不断增长,而且无法控制。很多人会希望删
除镜像或者可以解决这个问题,结果发现效果并不明显。原因就是这个稀疏文件的空间释放后基本不进行垃圾回收的问题。因此往往会出现即使删除了文件内容,空间却无法回收,只能随着使用这个稀疏文件一直在不断增长。所以对于 CentOS/RHEL 的用户来说,在没有办法使用 UnionFS 的情况下,一定要配置 direct-lvmdevicemapper ,无论是为了性能、稳定性还是空间利用率。或许有人注意到了 CentOS 7 中存在被 backports 回来的 overlay 驱动,不过 CentOS 里的这个驱动达不到生产环境使用的稳定程度,所以不推荐使用。

操作容器

启动容器

启动容器有两种方式,一种是基于镜像新建一个容器并启动,另外一个是将在终止状态( stopped )的容器重新启动。

新建并启动

所需要的命令主要为 docker run 。例如运行一个容器,执行 echo 命令输出一个 “Hello World”,之后终止容器:

1
2
3
[root@bogon ~]# docker run centos:latest /bin/echo "Hello World"
Hello World
[root@bogon ~]#

如果要为容器分配一个伪终端,并进入用户交互式的 bash 终端:

1
2
[root@bogon ~]# docker run --tty --interactive  centos /bin/bash         
[root@876dabff426e /]#

其中, --tty 选项让 Docker 分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上, --interactive 则让容器的标准输入保持打开。

在交互模式下,用户可以通过所创建的终端来输入命令,例如:

1
2
3
4
5
6
7
8
[root@876dabff426e /]# uname -r
3.10.0-1062.el7.x86_64
[root@876dabff426e /]# cat /etc/centos-release
CentOS Linux release 8.0.1905 (Core)
[root@876dabff426e /]# pwd
/
[root@876dabff426e /]# ls
bin dev etc home lib lib64 lost+found media mnt opt proc root run sbin srv sys tmp usr var

当利用 docker run 来创建容器时,Docker 在后台运行的标准操作包括:

  • 检查本地是否存在指定的镜像,不存在就从公有仓库下载。

  • 利用镜像创建并启动一个容器。

  • 分配一个文件系统,并在只读的镜像层外面挂载一层可读写层。

  • 从宿主主机配置的网桥接口中桥接一个虚拟接口到容器中去。

  • 从地址池配置一个 ip 地址给容器。

  • 执行用户指定的应用程序。

  • 执行完毕后容器被终止。

启动已终止容器

可以利用 docker container start 命令,指定容器名字或者容器 ID 来直接将一个已经终止的容器启动运行。

容器的核心为所执行的应用程序,所需要的资源都是应用程序运行所必需的。除此之外,并没有其它的资源。可以在伪终端中利用 pstop 来查看进程信息。

1
2
3
4
5
6
[root@bogon ~]# docker run --tty --interactive centos /bin/bash
[root@486ea60a9965 /]# ps
PID TTY TIME CMD
1 pts/0 00:00:00 bash
14 pts/0 00:00:00 ps
[root@486ea60a9965 /]#

可见,容器中仅运行了指定的 bash 进程。这种特点使得 Docker 对资源的利用率极高,是货真价实的轻量级虚拟化。

后台运行

多数情况下,需要让 Docker 在后台运行而不是直接把执行命令的结果输出在当前宿主机下。此时,可以通过添加 -d 参数来实现。

如果不使用 -d 参数运行容器,容器会把输出的结果 (STDOUT) 打印到宿主机上面:

1
2
3
4
5
6
7
8
9
10
11
12
[root@bogon ~]# docker run centos /bin/bash -c 'for i in {1..10}; do echo ${i}: hello world; sleep 1; done' 
1: hello world
2: hello world
3: hello world
4: hello world
5: hello world
6: hello world
7: hello world
8: hello world
9: hello world
10: hello world
[root@bogon ~]#

如果使用了 -d 参数运行容器,此时容器会在后台运行并不会把输出的结果 (STDOUT) 打印到宿主机上面(输出结果可以用 docker container logs 查看)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[root@bogon ~]#  docker run -d centos /bin/bash -c 'for i in {1..10}; do echo ${i}: hello world; sleep 1; done'
18bf6e162699c582f52e6b07c5aa1fbaa037db6dbab6ad852efd27a0d25cc22a
[root@bogon ~]# docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
18bf6e162699 centos "/bin/bash -c 'for i…" 4 seconds ago Up 3 seconds hardcore_edison
[root@bogon ~]# docker container logs 18bf6e162699
1: hello world
2: hello world
3: hello world
4: hello world
5: hello world
6: hello world
7: hello world
8: hello world
9: hello world
10: hello world
[root@bogon ~]#

注意: 容器是否会长久运行,和 docker run 指定的命令有关,和 -d 参数无关。

使用 -d 参数启动后会返回一个唯一的 id,也可以通过 docker container lsdocker ps 命令来查看容器信息。

终止容器

可以使用docker container stop 来终止一个运行中的容器。

此外,当 Docker 容器中指定的应用程序停止运行时,容器也自动终止。终止状态的容器可以用 docker container ls -a 命令看到。

1
2
3
4
5
6
7
8
9
10
[root@bogon ~]# docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
[root@bogon ~]# docker container ls -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
18bf6e162699 centos "/bin/bash -c 'for i…" 6 minutes ago Exited (0) 5 minutes ago hardcore_edison
a048af484e83 centos "/bin/bash -c 'for i…" 8 minutes ago Exited (0) 7 minutes ago goofy_liskov
19a32d81172c centos "/bin/bash -c 'for i…" 16 minutes ago Exited (0) 16 minutes ago zen_dubinsky
bfb5bce2a413 centos "/bin/bash -c 'for i…" 16 minutes ago Exited (0) 16 minutes ago hungry_black
3ebbcd2fdad0 centos "/bin/bash -c 'for i…" 17 minutes ago Exited (0) 16 minutes ago affectionate_panini
[root@bogon ~]#

处于终止状态的容器,可以通过 docker container start 命令来重新启动。如果使用 docker container restart 命令,则会将一个运行态的容器终止,然后再重新启动它。

进入容器

在使用 -d 参数时,容器启动后会进入后台运行,某些时候需要进入容器进行操作,这时候就要用到 docker attach 命令或 docker exec 命令,建议使用 docker exec 命令,原因会在下面说明。

attach 命令

docker attach 是 Docker 自带的命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[root@bogon ~]# docker run -d --interactive --tty ubuntu
251418a512097c21296454a39caee45c882645774827437b9cfde0f23bb94688

[root@bogon ~]# docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
251418a51209 ubuntu "/bin/bash" 36 seconds ago Up 35 seconds festive_agnesi


[root@bogon ~]# docker attach 251418a51209
root@251418a51209:/# ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@251418a51209:/# exit
exit


[root@bogon ~]# docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
[root@bogon ~]# docker container ls -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
251418a51209 ubuntu "/bin/bash" About a minute ago Exited (0) 19 seconds ago festive_agnesi

注意: 如果从这个 stdin 中 exit,会导致容器的停止。

exec 命令

使用 docker exec 命令,后边可以跟多个参数,这里主要说明 --interactive --tty 参数。

  • exec 后只用 --interactive 参数时,由于没有分配伪终端,界面没有我们熟悉的 Linux 命令提示符,但命令执
    行结果仍然可以返回。

  • --interactive --tty 参数一起使用时,则可以看到我们熟悉的 Linux 命令提示符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[root@bogon ~]# docker run -d --interactive --tty ubuntu
0df9982d6e2049883dda10f2129e77d90fe9b2c48571202be7e2db5a7293c4ed

[root@bogon ~]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
0df9982d6e20 ubuntu "/bin/bash" 18 seconds ago Up 17 seconds hopeful_austin

[root@bogon ~]# docker exec --interactive 0df9982d6e20 /bin/bash
ls
bin
boot
dev
.........

[root@bogon ~]# docker exec --interactive --tty 0df9982d6e20 /bin/bash
root@0df9982d6e20:/# ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@0df9982d6e20:/#

如果从这个 stdin 中 exit,不会导致容器的停止。这就是为什么建议使用 docker exec

导出和导入容器

导出容器

如果要导出本地某个容器,可以使用 docker export 命令。

1
2
3
4
5
[root@bogon ~]# docker ps -a    
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
0df9982d6e20 ubuntu "/bin/bash" 5 minutes ago Up 5 minutes hopeful_austin

[root@bogon ~]# docker export 0df9982d6e20 > ubuntu.tar

这样将导出容器快照到本地文件。

导入容器快照

可以使用 docker import 从容器快照文件中再导入为镜像,例如:

1
2
3
4
5
6
7
8
9
10
[root@bogon ~]# docker import ubuntu.tar 
sha256:1c23f8ca16af29220e2b1a02fb24cae5b000e29cb360ee967ce7bc32bfa51b99
[root@bogon ~]#

[root@bogon ~]# docker import ubuntu.tar test/ubuntu:v1.0
sha256:a3c9818fb4521028df427dd0737a6d6b0404e879bdd472945171b990501dd762

[root@bogon ~]# docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
test/ubuntu v1.0 a3c9818fb452 6 seconds ago 64.2MB

还可以通过指定 URL 或者某个目录来导入,例如:

1
docker import http://example.com/exampleimage.tgz example/imagerepo

注意:用户既可以使用 docker load 来导入镜像存储文件到本地镜像库,也可以使用 docker import 来导入一个容器快照到本地镜像库。这两者的区别在于容器快照文件将丢弃所有的历史记录和元数据信息(即仅保存容器当时的快照状态),而镜像存储文件将保存完整记录,体积也要大。此外,从容器快照文件导入时可以重新指定标签等元数据信息。

删除

删除容器

可以使用 docker container rm 来删除一个或多个处于终止状态的容器。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[root@bogon ~]# docker ps -a --filter exited=0                       
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9e0a785c5a9f centos "/bin/bash" 15 minutes ago Exited (0) 15 minutes ago beautiful_tereshkova
714d20ff760e centos "/bin/bash" 16 minutes ago Exited (0) 16 minutes ago great_bhabha
251418a51209 ubuntu "/bin/bash" 23 minutes ago Exited (0) 21 minutes ago festive_agnesi
18bf6e162699 centos "/bin/bash -c 'for i…" 36 minutes ago Exited (0) 35 minutes ago hardcore_edison
a048af484e83 centos "/bin/bash -c 'for i…" 38 minutes ago Exited (0) 37 minutes ago goofy_liskov
19a32d81172c centos "/bin/bash -c 'for i…" 46 minutes ago Exited (0) 46 minutes ago zen_dubinsky
bfb5bce2a413 centos "/bin/bash -c 'for i…" 46 minutes ago Exited (0) 46 minutes ago hungry_black
3ebbcd2fdad0 centos "/bin/bash -c 'for i…" 47 minutes ago Exited (0) 46 minutes ago affectionate_panini

[root@bogon ~]# docker container rm $(docker ps -aq --filter exited=0)
9e0a785c5a9f
714d20ff760e
251418a51209
18bf6e162699
a048af484e83
19a32d81172c
bfb5bce2a413
3ebbcd2fdad0

[root@bogon ~]# docker ps -a --filter exited=0
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
[root@bogon ~]#

如果要删除一个运行中的容器,可以添加 -f 参数。Docker 会发送 SIGKILL 信号给容器。

1
2
3
4
5
6
7
[root@bogon ~]# docker ps 
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
0df9982d6e20 ubuntu "/bin/bash" 15 minutes ago Up 15 minutes hopeful_austin

[root@bogon ~]# docker rm -f 0df9982d6e20
0df9982d6e20
[root@bogon ~]#

清理所有处于终止状态的容器用 docker ps -a 命令可以查看所有已经创建的包括终止状态的容器,如果数量太多要一个个删除可能会很麻烦,用下面的命令可以清理掉所有处于终止状态的容器。

1
docker container prune

构建镜像

利用 commit 理解镜像构成

注意: docker commit 命令除了学习之外,还有一些特殊的应用场合,比如被入侵后保存现场等。但是,不要使用 docker commit 定制镜像,定制镜像应该使用 Dockerfile 来完成。

镜像是容器的基础,每次执行 docker run 的时候都会指定哪个镜像作为容器运行的基础。镜像是多层存储,每一层是在前一层的基础上进行的修改;而容器同样也是多层存储,是在以镜像为基础层,在其基础上加一层作为容器运行时的存储层。以定制一个 Web 服务器为例,来认识镜像是如何构建的。

1
docker run --name WebServer01 -d -p 80:80 nginx

这条命令会用 nginx 镜像启动一个容器,命名为 WebServer01 ,并且把宿主机的 80 端口映射到了容器的 80 端口,这样用浏览器访问宿主机就会被映射到容器了。

直接用浏览器访问的会看到默认的 Nginx 欢迎页面。现在,假设想要修改这个欢迎页面,改成欢迎 Docker 的文字,可以使用 docker exec 命令进入容器,修改其内容。

1
2
3
4
5
[root@bogon ~]# docker exec --interactive --tty WebServer01 /bin/bash
root@93cbd0a33ac5:/# echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
root@93cbd0a33ac5:/# exit
exit
[root@bogon ~]#

上面的命令时以交互式终端方式进入 WebServer01 容器,并执行了 /bin/bash 命令,也就是获得一个可操作的 Shell 。然后,我们用 <h1>Hello, Docker!</h1> 覆盖了 /usr/share/nginx/html/index.html 的内容。再刷新浏览器会发现内容被改变了。

修改了容器的文件,也就是改动了容器的存储层。可以通过 docker diff 命令看到具体的改动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[root@bogon ~]# docker diff WebServer01 
C /root
A /root/.bash_history
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
C /usr/share/nginx/html/index.html
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
A /var/cache/nginx/client_temp

现在定制好了变化,我们希望能将其保存下来形成镜像。当运行一个容器的时候(如果不使用卷的话),在容器中做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来成为镜像。换句话说,就是在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像。以后运行这个新镜像的时候,就会拥有原有容器最后的文件变化。

docker commit 的语法格式为:

1
docker commit [选项] <容器ID或容器名> [<仓库名>[:<标签>]]

可以用下面的命令将容器保存为镜像:

1
2
[root@bogon ~]# docker commit --author "Silence" --message "修改了默认欢迎页" WebServer01 nginx:v1.1
sha256:0827c34a921a886018f25687f12587564985805afe4a7d6b38839cfb330b23b7

其中 --author 是指定修改的作者,而 --message 则是记录本次修改的内容。这和 git 版本控制相似,不过这里这些信息可以省略不写。使用 docker image ls 可以看到这个新定制的镜像:

1
2
3
4
5
[root@bogon ~]# docker image ls nginx
REPOSITORY TAG IMAGE ID CREATED SIZE
nginx v1.1 0827c34a921a 17 seconds ago 126MB
nginx latest 231d40e811cd 2 weeks ago 126MB
[root@bogon ~]#

还可以用 docker image history 具体查看镜像内的历史记录,如果比较 nginx:latest 的历史记录,可以发现新增了刚刚提交的这一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[root@bogon ~]# docker image history nginx:v1.1 
IMAGE CREATED CREATED BY SIZE COMMENT
0827c34a921a 4 minutes ago nginx -g daemon off; 99B 修改了默认欢迎页
231d40e811cd 2 weeks ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon… 0B
<missing> 2 weeks ago /bin/sh -c #(nop) STOPSIGNAL SIGTERM 0B
<missing> 2 weeks ago /bin/sh -c #(nop) EXPOSE 80 0B
<missing> 2 weeks ago /bin/sh -c ln -sf /dev/stdout /var/log/nginx… 22B
<missing> 2 weeks ago /bin/sh -c set -x && addgroup --system -… 57.1MB
<missing> 2 weeks ago /bin/sh -c #(nop) ENV PKG_RELEASE=1~buster 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ENV NJS_VERSION=0.3.7 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ENV NGINX_VERSION=1.17.6 0B
<missing> 2 weeks ago /bin/sh -c #(nop) LABEL maintainer=NGINX Do… 0B
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:bc8179c87c8dbb3d9… 69.2MB

[root@bogon ~]# docker image history nginx:latest
IMAGE CREATED CREATED BY SIZE COMMENT
231d40e811cd 2 weeks ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon… 0B
<missing> 2 weeks ago /bin/sh -c #(nop) STOPSIGNAL SIGTERM 0B
<missing> 2 weeks ago /bin/sh -c #(nop) EXPOSE 80 0B
<missing> 2 weeks ago /bin/sh -c ln -sf /dev/stdout /var/log/nginx… 22B
<missing> 2 weeks ago /bin/sh -c set -x && addgroup --system -… 57.1MB
<missing> 2 weeks ago /bin/sh -c #(nop) ENV PKG_RELEASE=1~buster 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ENV NJS_VERSION=0.3.7 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ENV NGINX_VERSION=1.17.6 0B
<missing> 2 weeks ago /bin/sh -c #(nop) LABEL maintainer=NGINX Do… 0B
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:bc8179c87c8dbb3d9… 69.2MB
[root@bogon ~]#

新的镜像定制好后,就可以来运行这个镜像了:

1
docker run --name WebServer02 -d -p 81:80 nginx:v1.1

这里容器命名为新的服务为 WebServer02 ,并且映射宿主机 81 端口到容器的 80 端口。在浏览器直接访问看到的结果,其内容应该和之前修改后的 WebServer01 一样。

慎用 docker commit

使用 docker commit 命令虽然可以比较直观地帮助理解镜像分层存储的概念,但是生产环境中并不会这样使用。

首先,如果仔细观察之前的 docker diff WebServer01 的结果,会发现除了真正想要修改的 /usr/share/nginx/html/index.html 文件外,由于命令的执行,还有很多文件被改动或添加了。这仅仅是最简单的操作,如果是安装软件包、编译构建,就会有大量的无关内容被添加进来,如果不小心清理,将会导致镜像非常臃肿。

此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。而且,即使是这个制作镜像的人,过一段时间后也无法记清具体在操作的。虽然 docker diff 或许可以告诉得到一些线索,但是远远不到可以确保生成一致镜像的地步。这种黑箱镜像的维护工作是非常痛苦的。

而且,按照镜像所使用的分层存储的概念,除当前层外,之前的每一层都是不会发生改变的,换句话说,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。如果使用 docker commit 制作镜像,以及后期修改的话,每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。这会让镜像更加臃肿。

使用 Dockerfile 定制镜像

镜像的定制实际上就是定制每一层所添加的配置、文件。如果可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是 Dockerfile。

Dockerfile 是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。还以定制 Nginx 镜像为例,现在使用 Dockerfile 来定制。

在一个空白目录中,建立一个文本文件,并命名为 Nginx-Dockerfile

1
2
3
4
[root@bogon ~]# mkdir -pv mynginx
mkdir: created directory ‘mynginx’
[root@bogon ~]# cd mynginx
[root@bogon mynginx]# > Nginx-Dockerfile

其内容为:

1
2
3
4
5
6
7
8
FROM nginx
# 指定基础镜像,即当前新镜像是基于哪个镜像的

MAINTAINER Silence
# 指定作者(维护者)

RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
# 指定构建过程中要运行的命令

FROM 指定基础镜像

定制镜像,是以一个镜像为基础,在其之上进行定制。就像之前运行了一个 nginx 镜像的容器,再进行修改一样,基础镜像是必须指定的。而 FROM 就是指定基础镜像,因此一个 Dockerfile 中 FROM 是必备的指令,并且必须是第一条指令。

Docker Hub 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginxredismongomysqlhttpdphptomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 nodeopenjdkpythonrubygolang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntudebiancentosfedoraalpine 等,这些操作系统的软件库为提供了更广阔的扩展空间。

除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch 。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。

1
FROM scratch

如果以 scratch 为基础镜像,意味着不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。

不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,比如 swarm 、 coreos/etcd 。对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接 FROM scratch 会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。

RUN 执行命令

RUN 指令是用来执行命令行命令的。由于命令行的强大能力, RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:

  • shell 格式: RUN <命令> ,就像直接在命令行中输入的命令一样。例如 RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

  • exec 格式: RUN ["可执行文件", "参数1", "参数2"] ,这更像是函数调用中的格式。

RUN 就像 Shell 脚本一样可以执行命令,那么也就可以像 Shell 脚本一样把每个命令对应一个 RUN 。比如:

1
2
3
4
5
6
7
8
FROM debian:jessie
RUN apt-get update
RUN apt-get install -y gcc libc6-dev make
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install

Dockerfile 中每一个指令都会建立一层, RUN 也不例外。每一个 RUN 的行为,都会新建立一层,在此之上执行这些命令,执行结束后, commit 这一层的修改,构成新的镜像。

上面的这种写法,创建了 7 层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。

Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层。

上面的 Dockerfile 正确的写法应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM debian:jessie

RUN buildDeps='gcc libc6-dev make' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \
&& rm redis.tar.gz \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps

首先,之前所有的命令只有一个目的,就是编译、安装 redis 可执行文件。因此没有必要建立很多层,这只是一层的事情。因此,这里没有使用很多个 RUN 对一一对应不同的命令,而是仅仅使用一个 RUN 指令,并使用 && 将各个所需命令串联起来。将之前的 7 层,简化为了1 层。在撰写 Dockerfile 的时候,要经常提醒自己,这并不是在写 Shell 脚本,而是在定义每一层该如何构建。

并且,这里为了格式化还进行了换行。Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及行首 # 进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。

此外,还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。这是很重要的一步,因为镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。

构建镜像

回到之前定制的 nginx 镜像的 Dockerfile 来构建这个镜像。在 Dockerfile 文件所在目录执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker image build --file ./Nginx-Dockerfile  --tag 'nginx:v3' .
Sending build context to Docker daemon 2.048kB
Step 1/3 : FROM nginx
---> f7bb5701a33c
Step 2/3 : MAINTAINER Silence
---> Using cache
---> 5c2f0d4390e4
Step 3/3 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
---> Running in 59e17fb13c2b
Removing intermediate container 59e17fb13c2b
---> 96de03b40a2f
Successfully built 96de03b40a2f
Successfully tagged nginx:v3

Step 3 中,如同之前所说, RUN 指令启动了一个容器 59e17fb13c2b ,执行了所要求的命令,并最后提交了这一层 96de03b40a2f ,随后删除了所用到的这个容器 59e17fb13c2b

这里使用了 docker image build 命令进行镜像构建。其格式为:

1
docker image build [选项] <上下文路径/URL/->

在这里指定了最终镜像的名称 --tag nginx:v3 ,构建成功后就可以运行这个镜像了。

镜像构建上下文(Context)

如果注意,会看到 docker image build 命令最后有一个 . ,这个 ../ 相同,表示的是当前目录。这其实是在指定上下文路径。那么什么是上下文呢?

首先要理解 docker image build 的工作原理。Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而 docker 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让操作远程服务器的 Docker 引擎变得轻而易举。

当进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、 ADD 指令等。而 docker image build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种 客户端/服务端 的架构中,如何才能让服务端获得本地文件呢?

这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径, docker image build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

如果在 Dockerfile 中这么写:

1
COPY ./package.json /app/

这并不是要复制执行 docker image build 命令所在的目录下的 package.json ,也不是复制 Dockerfile 所在目录下的 package.json ,而是复制指定的上下文(context) 目录下的 package.json

因此, COPY 这类指令中的源文件的路径不管看上去是相对路径还是绝对路径,其实都是 相对路径,是相对于上下文路径来说的。很多时候,COPY ../package.json /app 或者 COPY /opt/xxxx /app 无法工作,都是因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

如果观察 docker image build 输出,其实可以看到这个发送上下文的过程:

1
2
$ docker image build --file ./Nginx-Dockerfile  --tag 'nginx:v3' ./
Sending build context to Docker daemon 2.048kB

如此一来,docker image build 就会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像。

理解构建上下文对于镜像构建是很重要的, 可以避免犯一些不应该的错误。 比如有些人在发现 COPY /opt/xxxx /app 不工作后, 于是干脆将 Dockerfile 放到了硬盘根目录去构建, 结果发现 docker image build 执行后, 在发送一个几十 GB 的东西, 极为缓慢而且很容易构建失败。 那是因为这种做法是在让 docker image build 打包整个硬盘, 这显然是错误的用法。

一般情况下,应该将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore ,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。

那么为什么会有人误以为 . 是指定 Dockerfile 所在目录呢?这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。

这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile ,而且并不要求必须位于上下文目录中,比如可以用 --file ../Dockerfile.php 参数指定某个文件作为 Dockerfile

其它 docker image build 的用法

  • 从 URL 构建,比如可以直接从 Git repo 中构建:
1
2
3
4
5
6
$ docker image build https://github.com/twang2218/gitlab-ce-zh.git\#:8.14
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM gitlab/gitlab-ce:8.14.0-ce.0
8.14.0-ce.0: Pulling from gitlab/gitlab-ce
aed15891ba52: Already exists
773ae8583d14: Already exists

这行命令指定了构建所需的 Git repo,并且指定默认的 master 分支,构建目录为 /8.14/ ,然后 Docker 就会自己去 git clone 这个项目、切换到指定分支、并进入到指定目录后开始构建。

  • 用给定的 tar 压缩包构建:
1
$ docker build http://server/context.tar.gz

如果所给出的 URL 不是个 Git repo,而是个 tar 压缩包,那么 Docker 引擎会下载这个包,并自动解压缩,并把它作为上下文,开始构建。

  • 从标准输入中读取 Dockerfile 进行构建:
1
2
3
docker build - < Dockerfile
# 或
cat Dockerfile | docker build -

如果标准输入传入的是文本文件,则将其视为 Dockerfile ,并开始构建。这种形式由于直接从标准输入中读取 Dockerfile 的内容,它没有上下文,因此不可以像其他方法那样可以将本地文件 COPY 进镜像。

  • 从标准输入中读取上下文压缩包进行构建:
1
$ docker build - < context.tar.gz

如果发现标准输入的文件格式是 gzipbzip2 以及 xz 的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。

Dockerfile 指令详解

COPY 复制文件

格式:

  • COPY <源路径>... <目标路径>
  • COPY ["<源路径1>", "<源路径2>",... "<目标路径>"]

和 RUN 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。COPY 指令将从构建上下文目录中 <源路径> 的文件目录复制到新的一层的镜像内的 <目标路径> 位置。比如:

1
COPY package.json /usr/src/app/

<源路径> 可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 filepath.Match 规则,如:

1
2
COPY hom* /mydir/
COPY hom?.txt /mydir/

<目标路径> 可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用 WORKDIR 指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。

此外,还需要注意一点,使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。

ADD 更高级的复制文件

ADD 指令和 COPY 的格式和性质基本一致。但是在 COPY 基础上增加了一些功能。

比如 <源路径> 可以是一个 URL ,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 <目标路径> 去。下载后的文件权限自动设置为 600 ,如果这并不是想要的权限,那么还需要增加额外的一层 RUN 进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 RUN 指令进行解压缩。所以不如直接使用 RUN 指令,然后使用 wget 或者 curl 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。

如果 <源路径> 为一个 tar 压缩文件的话,压缩格式为 gzip , bzip2 以及 xz 的情况下, ADD 指令将会自动解压缩这个压缩文件到 <目标路径> 去。在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像 ubuntu 中:

1
2
FROM scratch
ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz /

但在某些情况下,如果只是希望复制个压缩文件进去,而不解压缩,这时就不可以使用 ADD 命令了。

在 Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 COPY ,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是所提及的需要自动解压缩的场合。

另外需要注意的是, ADD 指令会让镜像构建缓存失效,从而可能会使镜像构建变得比较缓慢。

因此在 COPYADD 指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用 COPY 指令,仅在需要自动解压缩的场合使用 ADD

CMD 容器启动命令

CMD 指令的格式和 RUN 相似,也是两种格式:

  • shell 格式: CMD <命令>
  • exec 格式: CMD ["可执行文件", "参数1", "参数2"...]
  • 参数列表格式: CMD ["参数1", "参数2"...] 。在指定了 ENTRYPOINT 指令后,用 CMD 指定具体的参数。

容器不是虚拟机,说到底就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。CMD 指令就是用于指定默认的容器主进程的启动命令的。

在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如, ubuntu 镜像默认的 CMD/bin/bash ,如果直接 docker run --interactive --tty ubuntu 的话,会直接进入 bash 。也可以在运行时指定运行别的命令,如 docker run -it ubuntu cat /etc/os-release 。这就是用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令了,输出了系统版本信息。

在指令格式上,一般推荐使用 exec 格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 " ,而不要使用单引号。

如果使用 shell 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:

1
CMD echo $HOME

在实际执行中,会将其变更为:

1
CMD [ "sh", "-c", "echo $HOME" ]

这就是可以使用环境变量的原因,因为这些环境变量会被 shell 进行解析处理。

提到 CMD 就不得不提容器中应用在前台执行和后台执行的问题。Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 upstart/systemd 去启动后台服务,容器内没有后台服务的概念。

如果将 CMD 写为:

1
CMD service nginx start

然后发现容器执行后就立即退出了。甚至在容器内去使用 systemctl 命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。

而使用 service nginx start 命令,则是希望 upstart 来以后台守护进程形式启动 nginx 服务。而刚才说了 CMD service nginx start 会被理解为 CMD [ "sh", "-c", "service nginxstart"] ,因此主进程实际上是 sh 。那么当 service nginx start 命令结束后, sh 也就结束了, sh 作为主进程退出了,自然就会令容器退出。

正确的做法是直接执行 nginx 可执行文件,并且要求以前台形式运行。比如:

1
CMD ["nginx", "-g", "daemon off;"]

ENTRYPOINT 入口点

ENTRYPOINT 的格式和 RUN 指令格式一样,分为 exec 格式和 shell 格式。ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。 ENTRYPOINT 在运行时也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 --entrypoint 来指定。

当指定了 ENTRYPOINT 后, CMD 的含义就发生了改变,不再是直接的运行其命令,而是将 CMD 的内容作为参数传给 ENTRYPOINT 指令,换句话说实际执行时,将变为:

1
<ENTRYPOINT> "<CMD>"

那么有了 CMD 后,为什么还要有 ENTRYPOINT 呢?这种 <ENTRYPOINT> "<CMD>" 有什么好处么?来看几个场景。

场景一:让镜像变成像命令一样使用

假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用 CMD 来实现:

1
2
3
4
5
6
7
FROM ubuntu:16.04

RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*

CMD [ "curl", "-s", "https://ip.cn" ]

假如使用 docker image build --file ./Dockerfile.myip --tag myip . 来构建镜像的话,如果需要查询当前公网 IP,只需要执行:

1
2
$ docker run myip
{"ip": "1.202.112.148", "country": "北京市", "city": "电信"}

看起来好像可以直接把镜像当做命令使用了,不过命令总会有参数,如果现在希望加参数呢?比如从上面的 CMD 中可以看到实质的命令是 curl ,那么如果现在希望显示 HTTP 头信息,就需要加上 -i 参数。那么可以直接加 -i 参数给 docker run myip 么?

1
2
3
$ docker run myip -i
docker: Error response from daemon: OCI runtime create failed: container_linux.go:346: starting container process caused "exec: \"-i\": executable file not found in $PATH": unknown.
ERRO[0000] error waiting for container: context canceled

可以看到可执行文件找不到的报错, executable file not found 。之前说过,跟在镜像名后面的是 command ,运行时会替换 CMD 的默认值。因此这里的 -i 替换了原来的 CMD ,而不是添加在原来的 curl -s https://ip.cn 后面。而 -i 根本不是命令,所以自然找不到。

如果希望加入 -i 这参数,就必须重新完整的输入这个命令:

1
$ docker run myip curl -s https://ip.cn -i

这样就非常不人性化,而使用 ENTRYPOINT 就可以解决这个问题。现在重新用 ENTRYPOINT 来实现这个镜像:

1
2
3
4
5
6
7
8
FROM ubuntu:16.04

RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get purge -y --auto-remove

ENTRYPOINT [ "curl", "-s", "https://ip.cn" ]

删除之前的残留,重新构建镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker container rm $(docker container ps -a | awk '/myip/{print $1}')
2244e4afa733
3795eaaaaa15
96dbcb291be5
ef39e73b52b5

$ docker image rm myip
Untagged: myip:latest
Deleted: sha256:f27178606c7dcd69f942d85049ad05298f97cfa5831909079faf845fe934906c
Deleted: sha256:baebe74eefc04cf55256aa4d6400a0b1f2ce2f7ac1d3a707c924505fe1ddc8f8
Deleted: sha256:b4fba85e9cb69380a6ef088b8224332989989ac306e069cdefa562b741942a10

$ docker image build --file ./Dockerfile.myip --tag 'myip' .

再次尝试直接使用 -i 选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ docker run myip
{"ip": "1.202.112.148", "country": "北京市", "city": "电信"}

$ docker run myip -i
HTTP/1.1 200 OK
Date: Fri, 03 Jan 2020 05:19:52 GMT
Content-Type: application/json; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Set-Cookie: __cfduid=d8a583dfdf42b5b551f86b83368b6264d1578028792; expires=Sun, 02-Feb-20 05:19:52 GMT; path=/; domain=.ip.cn; HttpOnly; SameSite=Lax
CF-Cache-Status: DYNAMIC
Expect-CT: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
Server: cloudflare
CF-RAY: 54f2852f6ddae7fd-LAX

{"ip": "1.202.112.148", "country": "北京市", "city": "电信"}

可以看到,这次成功了。这是因为当存在 ENTRYPOINT 后, CMD 的内容将会作为参数传给 ENTRYPOINT ,而这里 -i 就是新的 CMD ,因此会作为参数传给 curl ,从而达到了预期的效果。

场景二:应用运行前的准备工作

启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。比如 mysql 类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的 mysql 服务器运行之前解决。

此外,可能希望避免使用 root 用户去启动服务,从而提高安全性,而在启动服务前还需要以 root 身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用 root 身份执行,方便调试等。

这些准备工作是和容器 CMD 无关的,无论 CMD 是什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入 ENTRYPOINT 中去执行,而这个脚本会将接到的参数(也就是 <CMD> )作为命令,在脚本最后执行。比如官方镜像 redis 中就是这么做的:

1
2
3
4
5
6
7
8
9
10
11
FROM alpine:3.4
# .........

RUN addgroup -S redis && adduser -S -G redis redis
# .........

COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]

EXPOSE 6379
CMD [ "redis-server" ]

可以看到其中为 redis 服务创建了 redis 用户,并在最后指定了 ENTRYPOINTdocker-entrypoint.sh 脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh
set -e

# first arg is `-f` or `--some-option`
# or first arg is `something.conf`
if [ "${1#-}" != "$1" ] || [ "${1%.conf}" != "$1" ]; then
set -- redis-server "$@"
fi

# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
find . \! -user redis -exec chown redis '{}' +
exec gosu redis "$0" "$@"
fi

exec "$@"

该脚本的内容就是根据 CMD 的内容来判断,如果是 redis-server 的话,则切换到 redis 用户身份启动服务器,否则依旧使用 root 身份执行。比如:

1
2
$ docker run -it redis id
uid=0(root) gid=0(root) groups=0(root)

ENV 设置环境变量

格式有两种:

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>...

这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如 RUN , 还是容器内运行时的应用程序,都可以直接使用这里定义的环境变量。

1
2
ENV VERSION=1.0 DEBUG=on \
NAME="Happy Feet"

这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和 Shell 下的行为是一致的。定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 node 镜像 Dockerfile 中,就有类似这样的代码:

1
2
3
4
5
6
7
8
ENV NODE_VERSION 7.2.0
RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
&& grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
&& tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
&& rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
&& ln -s /usr/local/bin/node /usr/local/bin/nodejs

在这里先定义了环境变量 NODE_VERSION ,其后的 RUN 这层里,多次使用 $NODE_VERSION 来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新 7.2.0 即可, Dockerfile 构建维护变得更轻松了。

下列指令可以支持环境变量展开:

ADDCOPYENVEXPOSELABELUSERWORKDIRVOLUMESTOPSIGNALONBUILD

环境变量可以使用的地方很多,通过环境变量,可以让一份 Dockerfile 制作更多的镜像,只需使用不同的环境变量即可。

ARG 构建参数

格式: ARG <参数名>[=<默认值>]

构建参数和 ENV 的效果一样,都是设置环境变量。所不同的是, ARG 所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 ARG 保存密码之类的信息,因为 docker history 还是可以看到所有值的。

Dockerfile 中的 ARG 指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 docker image build 中用 --build-arg <参数名>=<值> 来覆盖。

在 1.13 之前的版本,要求 --build-arg 中的参数名,必须在 Dockerfile 中用 ARG 定义过了,换句话说,就是 --build-arg 指定的参数,必须在 Dockerfile 中使用了。如果对应参数没有被使用,则会报错退出构建。从 1.13 开始,这种严格的限制被放开,不再报错退出,而是显示警告信息,并继续构建。这对于使用 CI 系统,用同样的构建流程构建不同的 Dockerfile 的时候比较有帮助,避免构建命令必须根据每个 Dockerfile 的内容修改。

VOLUME 定义匿名卷

格式为:

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

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

1
VOLUME /data

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

1
docker run -d -v mydata:/data xxxx

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

EXPOSE 声明暴露的端口

格式为 EXPOSE <端口1> [<端口2>...]

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

此外,在早期 Docker 版本中还有一个特殊的用处。以前所有容器都运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个 Docker 引擎参数 --icc=false ,当指定该参数后,容器间将默认无法互访,除非互相间使用了 --links 参数的容器才可以互通,并且只有镜像中 EXPOSE 所声明的端口才可以被访问。这个--icc=false 的用法,在引入了 docker network 后已经基本不用了,通过自定义网络可以很轻松的实现容器间的互联与隔离。

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

WORKDIR 指定工作目录

格式为 WORKDIR <工作目录路径>

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

不应该把 Dockerfile 看成是在写 Shell 脚本,因为可能会导致出现下面这样的错误:

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

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

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

USER 指定当前用户

格式:USER <用户名>

USER 指令和 WORKDIR 相似,都是改变环境状态并影响以后的层。 WORKDIR 是改变工作目录, USER 则是改变之后层的执行 RUN , CMD 以及 ENTRYPOINT 这类命令的身份。当然,和 WORKDIR 一样, USER 只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。

1
2
3
4
5
RUN groupadd -r redis && useradd -r -g redis redis

USER redis

RUN [ "redis-server" ]

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

1
2
3
4
5
6
7
8
9
10
# 建立 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" ]

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 来帮助判断,其 DockerfileHEALTHCHECK 可以这么写:

1
2
3
4
5
6
FROM nginx

RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

HEALTHCHECK --interval=5s --timeout=3s \
CMD curl -fs http://localhost/ || exit 1

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

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

1
$ docker image build --file ./Dockerfile.mynginx --tag 'mynginx:v1' .

构建好了后,启动一个容器,并查看状态:

1
$ docker container run -d   --name web01 -p 80:80  mynginx:v1 && docker container ls

当运行该镜像后,通过 docker container ls 看到最初的状态为 (health: starting) 。在等待几秒钟后,再次 docker container ls ,就会看到健康状态变化为了 (healthy)。如果健康检查连续失败超过了重试次数,状态就会变为 (unhealthy)

为了帮助排障,健康检查命令的输出(包括 stdout 以及 stderr )都会被存储于健康状态里,可以用 docker container inspect 来查看。

1
$ docker container inspect --format '{{json .State.Health}}' web01 | python -m json.tool

ONBUILD 为他人做嫁衣裳

格式: ONBUILD <其它指令>

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

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

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

1
2
3
4
5
6
7
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 就会变为:

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

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

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

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

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

1
2
3
4
5
6
7
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 就变成了简单地:

1
FROM my-node

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

参考文档

多阶段构建

之前的做法

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

全部放入一个 Dockerfile

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

  • Dockerfile 特别长,可维护性降低
  • 镜像层次多,镜像体积较大,部署时间变长
  • 源代码存在泄露的风险

例如,编写 app.go 文件,该程序输出 Hello World!

1
2
3
4
5
6
package main
import "fmt"

func main(){
fmt.Printf("Hello World!");
}

编写 Dockerfile.one 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM golang:1.9-alpine

RUN apk --no-cache add git ca-certificates

WORKDIR /go/src/github.com/go/helloworld/

COPY app.go .

RUN go get -d -v github.com/go-sql-driver/mysql \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . \
&& cp /go/src/github.com/go/helloworld/app /root

WORKDIR /root/

CMD ["./app"]

构建镜像

1
$ docker image build --tag 'go/helloworld:1' --file Dockerfile.one .

分散到多个 Dockerfile

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

例如,编写 Dockerfile.build 文件:

1
2
3
4
5
6
7
8
9
FROM golang:1.9-alpine

RUN apk --no-cache add git

WORKDIR /go/src/github.com/go/helloworld/

COPY app.go .
RUN go get -d -v github.com/go-sql-driver/mysql \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

编写 Dockerfile.copy 文件:

1
2
3
4
5
6
7
8
9
FROM alpine:latest

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY app .

CMD ["./app"]

新建 build.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh

echo 'Building go/helloworld:build'

docker build --tag go/helloworld:build . --file Dockerfile.build

docker create --name extract go/helloworld:build
docker cp extract:/go/src/github.com/go/helloworld/app ./app
docker rm -f extract

echo 'Building go/helloworld:2'

docker build --no-cache -t go/helloworld:2 . -f Dockerfile.copy
rm ./app

现在运行脚本即可构建镜像:

1
2
$ chmod +x build.sh
$ ./build.sh

对比两种方式生成的镜像大小

1
2
3
4
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
go/helloworld 2 f7cf3465432c 22 seconds ago 6.47MB
go/helloworld 1 f55d3e16affc 2 minutes ago 295MB

使用多阶段构建

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

例如:

编写 Dockerfile 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM golang:1.9-alpine

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

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=0 /go/src/github.com/go/helloworld/app .

CMD ["./app"]

构建镜像

1
$ docker build -t go/helloworld:3 .

对比三个镜像大小

1
2
3
4
5
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
go/helloworld 3 d6911ed9c846 7 seconds ago 6.47MB
go/helloworld 2 f7cf3465432c 22 seconds ago 6.47MB
go/helloworld 1 f55d3e16affc 2 minutes ago 295MB

很明显使用多阶段构建的镜像体积小,同时也完美解决了上边提到的问题。

其它制作镜像的方式

除了标准的使用 Dockerfile 生成镜像的方法外,由于各种特殊需求和历史原因,还提供了一些其它方法用以生成镜像。

从 rootfs 压缩包导入

格式: docker import [选项] <文件>|<URL>|- [<仓库名>[:<标签>]]

压缩包可以是本地文件、远程 Web 文件,甚至是从标准输入中得到。压缩包将会在镜像 / 目录展开,并直接作为镜像第一层提交。

比如要创建一个 OpenVZ 的 Ubuntu 14.04 模板 的镜像:

1
2
3
4
5
$ docker import \
http://download.openvz.org/template/precreated/ubuntu-14.04-x86_64-minimal.tar.gz \
openvz/ubuntu:14.04
Downloading from http://download.openvz.org/template/precreated/ubuntu-14.04-x86_64-mi nimal.tar.gz
sha256:f477a6e18e989839d25223f301ef738b69621c4877600ae6467c4e5289822a79B/78.42 MB

这条命令自动下载了 ubuntu-14.04-x86_64-minimal.tar.gz 文件,并且作为根文件系统展开导入,并保存为镜像 openvz/ubuntu:14.04

导入成功后,可以看到这个导入的镜像:

1
2
3
4
$ docker image ls openvz/ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
openvz/ubuntu 14.04 f477a6e18e98 55 seconds ago 214.9MB
MB

如果查看其历史的话,会看到描述中有导入的文件链接:

1
2
3
$ docker history openvz/ubuntu:14.04
IMAGE CREATED CREATED BY SIZE COMMENT
f477a6e18e98 About a minute ago 214.9 MB Imported from http://download.openvz.org/template/precreated/ubuntu-14.04-x86_64-minimal.tar.gz

docker save 和 docker load

Docker 还提供了 docker loaddocker save 命令,用以将镜像保存为一个 tar 文件,然后传输到另一个位置上,再加载进来。这是在没有 Docker Registry 时的做法,现在已经不推荐,镜像迁移应该直接使用 Docker Registry,无论是直接使用 Docker Hub 还是使用内网私有 Registry 都可以。

保存镜像

使用 docker save 命令可以将镜像保存为归档文件。比如保存 ubuntu 镜像:

1
2
3
$ docker image ls ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 775349758637 2 months ago 64.2MB

保存镜像的命令为:

1
$ docker image save ubuntu | gzip > ubuntu-latest.tar.gz

然后将 ubuntu-latest.tar.gz 文件复制到到了另一个机器上,用下面这个命令加载镜像:

1
2
$ docker load -i ubuntu-latest.tar.gz
Loaded image: ubuntu:latest

镜像的实现原理

Docker 镜像是怎么实现增量的修改和维护的?

每个镜像都由很多层次构成,Docker 使用 Union FS 将这些不同的层结合到一个镜像中去。

通常 Union FS 有两个用途, 一方面可以实现不借助 LVM、RAID 将多个 disk 挂到同一个目录下,另一个更常用的就是将一个只读的分支和一个可写的分支联合在一起,Live CD 正是基于此方法可以允许在镜像不变的基础上允许用户在其上进行一些写操作。

Docker 在 AUFS 上构建的容器也是利用了类似的原理。

访问仓库

仓库( Repository )是集中存放镜像的地方。

一个容易混淆的概念是注册服务器( Registry )。实际上注册服务器是管理仓库的具体服务器,每个服务器上可以有多个仓库,而每个仓库下面有多个镜像。从这方面来说,仓库可以被认为是一个具体的项目或目录。例如对于仓库地址 dl.dockerpool.com/ubuntu 来说, dl.dockerpool.com 是注册服务器地址, ubuntu 是仓库名。

大部分时候,并不需要严格区分这两者的概念。

Docker Hub

目前 Docker 官方维护了一个公共仓库 Docker Hub,其中已经包括了数量超过 15,000 的镜像。大部分需求都可以通过在 Docker Hub 中直接下载镜像来实现。

注册

可以在 https://hub.docker.com/signup 免费注册一个 Docker 账号。

登录

可以通过执行 docker login 命令交互式的输入用户名及密码来完成在命令行界面登录 Docker Hub。通过 docker logout 退出登录。

拉取镜像

通过 docker search 命令来查找官方仓库中的镜像,并利用 docker pull 命令来将它下载到本地。

例如以 centos 为关键词进行搜索:

1
2
3
4
5
6
7
8
9
10
11
$ docker search centos
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
centos The official build of CentOS. 5758 [OK]
ansible/centos7-ansible Ansible on Centos7 126 [OK]
jdeathe/centos-ssh OpenSSH / Supervisor / EPEL/IUS/SCL Repos - … 114 [OK]
consol/centos-xfce-vnc Centos container with "headless" VNC session… 107 [OK]
centos/mysql-57-centos7 MySQL 5.7 SQL database server 67
imagine10255/centos6-lnmp-php56 centos6-lnmp-php56 57 [OK]
tutum/centos Simple CentOS docker image with SSH access 44
centos/postgresql-96-centos7 PostgreSQL is an advanced Object-Relational … 39
kinogmt/centos-ssh CentOS with SSH 29 [OK]

可以看到返回了很多包含关键字的镜像,其中包括镜像名字、描述、收藏数(表示该镜像的受关注程度)、是否官方创建、是否自动创建。

官方的镜像说明是官方项目组创建和维护的,automated 资源允许用户验证镜像的来源和内容。

根据是否是官方提供,可将镜像资源分为两类。

一种是类似 centos 这样的镜像,被称为基础镜像或根镜像。这些基础镜像由 Docker 公司创建、验证、支持、提供。这样的镜像往往使用单个单词作为名字。

还有一种类型,比如 tianon/centos 镜像,它是由 Docker 的用户创建并维护的,往往带有用户名称前缀。可以通过前缀 username/ 来指定使用某个用户提供的镜像,比如 tianon 用户

另外,在查找的时候通过 --filter=stars=N 参数可以指定仅显示收藏数量为 N 以上的镜像。

下载官方 centos 镜像到本地。

1
2
3
4
5
6
$ docker pull centos
Pulling repository centos
0b443ba03958: Download complete
539c0211cd76: Download complete
511136ea3c5a: Download complete
7064731afe90: Download complete

推送镜像

用户也可以在登录后通过 docker push 命令来将自己的镜像推送到 Docker Hub。以下命令中的 username 请替换为你的 Docker 账号用户名。

1
2
3
4
5
$ docker tag ubuntu:17.10 username/ubuntu:17.10
$ docker image ls

$ docker push username/ubuntu:17.10
$ docker search username

自动创建

自动创建(Automated Builds)功能对于需要经常升级镜像内程序来说,十分方便。

有时候,用户创建了镜像,安装了某个软件,如果软件发布新版本则需要手动更新镜像。

而自动创建允许用户通过 Docker Hub 指定跟踪一个目标网站(目前支持 GitHub 或 BitBucket )上的项目,一旦项目发生新的提交或者创建新的标签(tag),Docker Hub 会自动构建镜像并推送到 Docker Hub 中。

要配置自动创建,包括如下的步骤:

  • 创建并登录 Docker Hub,以及目标网站;

  • 在目标网站中连接帐户到 Docker Hub;

  • 在 Docker Hub 中 配置一个自动创建;

  • 选取一个目标网站中的项目(需要含 Dockerfile )和分支;

  • 指定 Dockerfile 的位置,并提交创建。

之后,可以在 Docker Hub 的 自动创建页面中跟踪每次创建的状态。

私有仓库

有时候使用 Docker Hub 这样的公共仓库可能不方便,用户可以创建一个本地仓库供私人使用。

docker-registry 是官方提供的工具,可以用于构建私有的镜像仓库。

安装运行 docker-registry

安装运行 docker-registry 可以通过获取官方 registry 镜像来运行。

1
$ docker run -d -p 5000:5000 --restart=always --name registry registry

这将使用官方的 registry 镜像来启动私有仓库。默认情况下,仓库会被创建在容器的 /var/lib/registry 目录下。可以通过 -v 参数来将镜像文件存放在本地的指定路径。例如下面的例子将上传的镜像放到本地的 /opt/data/registry 目录。

1
2
3
4
$ docker run -d \
-p 5000:5000 \
-v /opt/data/registry:/var/lib/registry \
registry

在私有仓库上传、搜索、下载镜像

创建好私有仓库之后,就可以使用 docker tag 来标记一个镜像,然后推送它到仓库。例如私有仓库地址为 127.0.0.1:5000

先在本机查看已有的镜像。

1
2
3
$ docker image ls ubuntu
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 775349758637 2 months ago 64.2MB

使用 docker tagubuntu:latest 这个镜像标记为 127.0.0.1:5000/ubuntu:latest

格式为 docker tag IMAGE[:TAG] [REGISTRY_HOST[:REGISTRY_PORT]/]REPOSITORY[:TAG]

1
2
3
4
5
$ docker tag ubuntu:latest 127.0.0.1:5000/ubuntu:latest
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 775349758637 2 months ago 64.2MB
127.0.0.1:5000/ubuntu latest 775349758637 2 months ago 64.2MB

使用 docker push 上传标记的镜像。

1
2
3
4
5
6
7
8
9
$ docker push 127.0.0.1:5000/ubuntu:latest
The push refers to repository [127.0.0.1:5000/ubuntu]
373a30c24545: Pushed
a9148f5200b0: Pushed
cdd3de0940ab: Pushed
fc56279bbb33: Pushed
b38367233d37: Pushed
2aebd096e0e2: Pushed
latest: digest: sha256:fe4277621f10b5026266932ddf760f5a756d2facd505a94d2da12f4f52f71f5a size: 1568

用 curl 查看仓库中的镜像。

1
2
$ curl 127.0.0.1:5000/v2/_catalog
{"repositories":["ubuntu"]}

这里可以看到 {"repositories":["ubuntu"]} ,表明镜像已经被成功上传了。先删除已有镜像,再尝试从私有仓库中下载这个镜像。

1
2
3
$ docker image rm 127.0.0.1:5000/ubuntu:latest
$ docker pull 127.0.0.1:5000/ubuntu:latest
$ docker image ls

注意事项

如果不想使用 127.0.0.1:5000 作为仓库地址,比如想让本网段的其他主机也能把镜像推送到私有仓库。就得把例如 192.168.199.100:5000 这样的内网地址作为私有仓库地址,这时会发现无法成功推送镜像。

这是因为 Docker 默认不允许非 HTTPS 方式推送镜像。可以通过 Docker 的配置选项来取消这个限制,或者配置能够通过 HTTPS 访问的私有仓库。

  • Ubuntu 14.04, Debian 7 Wheezy

对于使用 upstart 的系统而言,编辑 /etc/default/docker 文件,在其中的 DOCKER_OPTS 中增加如下内容:

1
DOCKER_OPTS="--registry-mirror=https://registry.docker-cn.com --insecure-registries=192.168.199.100:5000"

重新启动服务。

1
$ sudo service docker restart
  • Ubuntu 16.04+, Debian 8+, centos 7

对于使用 systemd 的系统,请在 /etc/docker/daemon.json 中写入如下内容(如果文件不存在需要新建该文件):

1
2
3
4
5
6
7
8
{
"registry-mirror": [
"https://registry.docker-cn.com"
],
"insecure-registries": [
"192.168.199.100:5000"
]
}

文件必须符合 json 文件规范,否则 docker 服务将无法启动。

1
2
systemctl restart docker
systemctl status docker

私有仓库高级配置

有钱任性,请我吃包辣条
0%