目前,Docker 主要有两个形态:Docker Desktop 和 Docker Engine。
Docker Desktop 是专门针对个人使用而设计的,支持 Mac(已支持arm架构的M系芯片) 和 Windows 快速安装,具有直观的图形界面,还集成了许多周边工具,甚至可以在docker engine 中直接创建出一个 kubernetes(kubernetes in docker方案,与 kind 类似),极大的方便个人用户使用。
不过,我并不推荐使用Docker Desktop,原因有两个:第一,Docker Desktop是商业化产品,有一些非通用的东西,不利于对容器相关技术的进一步学习。第二,Docker Desktop 只对个人学习免费,受条款限制不能商用,万一工作中使用可能会“踩到雷区”。
Docker Engine 恰恰和 Docker Desktop 相反,它完全免费,但目前还只能在 Linux 运行,只能使用命令行操作,缺乏辅助工具,需要用户自行 DIY 运行环境。实际上,Docker Engine 才是 Docker 真正的形态,也是核心功能,Docker Desktop 只也是在其基础之上封装而来的,以便为非IT用户提供了一个易于使用的界面和工具,以简化 Docker 的安装、配置和管理过程。但 Docker Engine 是现在各个公司在生产环境中实际使用的 Docker 产品,毕竟机房里 99% 的服务器运行的操作系统都是 Linux。
因为大多数用户主要使用 Linux 版本 Docker,这里也只列出 Linux 系统下 docker 的安装
目前 Linux 服务器市场主要有两类系统分庭抗礼,请按自己操作系统选择即可
# RHEL 系
yum install -y docker
# Debian 系
apt install -y docker
安装完毕以后执行以下命令确实是否完成
# 输出 Docker 客户端和服务器各自的版本信息
docker version
# 显示当前 Docker 系统相关的信息,如 CPU、内存、容器数量、镜像数量、容器运行时、存储文件系统等
docker info
docker run busybox echo hello world
启动 busybox 镜像,使用 echo 命令输出 hell word,这也正是 Solomon Hykes 在大会上所展示的最精彩的那部分。当使用 run 命令启动容器时,Docker Daemon 会先检查本地是存在镜像,若没有则从远端镜像仓库拉取,当然也可以使用 docker pull busybox
命令将用到的镜像提前下载到本地。
下面的这张图来自 Docker 官网,精准地描述了 Docker Engine 的内部角色和工作流程:
刚才我所使用的 docker 命令实际是一个客户端 client,它的作用是与 Docker Engine 里的后台服务 Docker daemon 通信,并将我发起的各个指令(run、pull、build 等)转换为 Docker daemon 能够识别的命令并由 Docker daemon 真正执行相关任务。此外,镜像是存储在元旦仓库里,客户端无法直接访问镜像仓库,只能有 Docker daemon 访问。
因此,在 Docker Engine 里,真正干活的是默默运行在后台的 Docker daemon(是不是像极了在公司的你)。
在上面的例子中,我们运行的容器,显然不是从零开始的,而是要先拉取一个“镜像”(image),然后从这个“镜像”来启动容器。那么,“镜像”和“容器”到底是什么,两者又有什么关系呢?
在其他场合中我们也遇到过“镜像”一词,比如:光盘镜像、重装电脑时的硬盘镜像、使用 VMWare 时的虚拟机系统镜像。说到这里,有没有发现他们的一些相同点?是的,这些“镜像”都是只读的,不允许修改,它们是以标准格式存储了一系列的文件,只在我们需要的时候从中提取数据运行起来,而运行起来的这个东西可理解为“容器”。
容器是由操作系统动态创建,因此必然可以将它的初始化环境固定下来,保存成一个静态文件,这样就可以方便存放、传输、版本化管理了。
可以将镜像比喻为“样板间”,它把运行进程所需要的文件系统、依赖库、环境变量、启动参数等信息打包整合到一起,之后镜像文件无论放在哪里,操作系统都能根据这个“样板间”快速重建容器。
从功能上来看,镜像和常见的rpm、deb 等安装包一样,都打包了应用程序,但最大的不同点在于它里面不仅有应用程序的可执行文件,还有应用程序运行时的整个系统环境,开发者可以在 CentOS 系统上开发,然后打包成镜像,再去 Ubuntu 系统上运行,完全不需要考虑环境依赖的问题。
此外,镜像的完整名字由两个部分组成,名字和标签,中间用 : 连接起来。名字代表镜像仓库地址,标签也就是 tag 代表版本号。上面我们执行 docker run busybox ...
命令时没有指定标签,则 默认使用 latest
作为标签。
(base) ➜ ~ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
busybox latest a416a98b71e2 3 months ago 4.26MB
上面我们只介绍了 docker run
和 docker pull
命令,下面列举几个工作中常用的命令
########### 镜像相关 ###########
# 查看镜像
docker images
# 删除镜像
docker rmi busybox:latest
########### 容器相关 ###########
# 运行容器
## -id:后台运行(执行命令后终端依旧是)
## --name:设置容器名
## bash:容器中运行的命令
docker run -id --name busybox busybox:latest bash
## -it:开启一个交互式操作的 Shell,直接进入容器内部
docker run -it --name busybox busybox:latest bash
# 查看容器(正在运行)
docker ps
# 查看容器(全部容器,包含正在运行和已退出)
docker ps -a
# 进容器并执行 bash 命令
docker exec -it busybox bash
# 停止容器
docker stop busybox
# 启动容器
docker start busybox
# 删除容器
docker rm busybox
镜像内部并不是一个平坦的结构,而是由许多的镜像层组成的,每层都是只读不可修改的一组文件,相同的层可以在镜像之间共享,然后多个层像搭积木一样堆叠起来,再使用一种叫“Union FS 联合文件系统”的技术把它们合并在一起,就形成了容器最终看到的文件系统。
我们可以使用 docker inspect busybox:latest
查看 busybox 镜像的分层信息:
通过这张图可以看到,busybox:latest 镜像里一共有 1 个 Layer,这相比其他镜像已经小很多了,在工作中遇到的镜像有5~10个 Layer 是再正常不过的事情了。
如果有体验过前面 docker 命令的话,这里会明白在使用 docker pull、docker rmi 等命令操作镜像的时候,那些“奇怪”的输出信息就是镜像里的各个 Layer。每次镜像操作 Docker 会检查是否有重复的层,如果本地已经存在就不会重复下载,如果层被其他镜像共享就不会删除,这样可以节约磁盘和网络成本。
上面我们将镜像比喻为“样板间”,要造出这个“样板间”,就必然需要一个“施工图纸”,又它来规定怎样建造地基、铺设水电、开窗撘门等动作,而这个“施工图纸”就是 Dockerfile。
与镜像、容器相比而言,Dockerfile 非常普通,它仅仅是一个纯文本,只不过里面记录了一系列的构建指令,比如选择基础镜像、拷贝文件、运行命令等,几乎每个指令都会生成一个 Layer,而 Docker 顺序执行这个文件里的所有步骤,最后就会创建出一个新的镜像出来。
我们可以将上面运行 busybox 镜像输出 hello word 信息的容器封装为一个新的镜像,其 Dockerfile 如下(我这里将文件命名为 Dockerfile)
# 选择基础镜像
FROM busybox
# 启动容器时默认运行的命令
CMD echo "hello world"
接下来执行命令构建新的镜像,注意:docker 版本不同,构建镜像时的输出信息也不一定完全相同,
# -t:指定镜像名字
# -f:指定 Dockerfile 文件名,若为 Dockerfile 也省略该参数
docker build -t busybox:v1 -f Dockerfile .
这里还需要特别注意命令的格式,用 -f 参数指定 Dockerfile 文件名,后面必须跟一个文件路径,叫做“构建上下文”(build’s context),这里只是一个简单的点号,表示当前路径的意思,构建上下文里的信息会被复制到容器中,所以建议这个构建上下文的目录只保留需要拷贝到容器中的内容。
查看新镜像信息
docker inspect busybox:v1
运行容器并查看输出信息
docker run -id --name busybox busybox:v1
了解 Dockerfile 之后,下面来一起了解 Dockerfile 的一些常用指令和最佳实践,以便在今后的工作中少踩些坑。
假设当前目录有一个名为 main.go
的 golang 源码文件和 Dockerfile 文件
package main
import "fmt"
func main() {
fmt.Println("hello world.")
}
Dockerfile 文件内容如下:
FROM golang:1.21.3
WORKDIR /root/
COPY main.go /root/
RUN go build -o hello-world main.go
CMD ["/root/hello-world"]
构建镜像
docker build -t hello-world:go -f Dockerfile .
启动容器
docker run -id --name hello-world hello-world:go
上面就是一个从开发到构建镜像再到运行服务的完整流程,接下来解释一下之前没有遇到过的 Dockerfile 中的 WORKDIR、COPY 和 RUNUN 命令。
WORKDIR 命令的意思是设置容器内部的默认工作目录(working directory),在指定 WORK /root/
后,之后的 RUN、CMD、ENTRYPOINT 等命令将在该目录下执行。如果指定的目录不存在,Docker 将自动创建它。注意:大多数原生的基础镜像默认工作目录都是 /
。
在本机上开发时会产生源码、配置等文件,需要将它们打包进镜像里,这时就可以使用 COPY 或 ADD 命令,用法和 Linux 的 cp 差不多,不过拷贝的源文件必须是“构建上下文”路径里存在的文件。
RUN 通常会是 Dockerfile 里最复杂的指令,会包含很多的 Shell 命令,但 Dockerfile 里一条指令只能是一行。为了避免使用多个 RUN 指令导致镜像层过多且镜像较大,我们往往会尽量使用一个 RUN 指令,并且在每行的末尾使用续行符 \,命令之间也会用 && 来连接,这样保证在逻辑上是一行,就像下面这样:
RUN apt-get update \
&& apt-get install -y \
build-essential \
tar \
vim \
wget \
binutils \
curl \
gcc \
gcc-c++ \
&& apt-get clean all \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
在上面的 Dockerfile 示例中,大家是否发现了一个问题:最终运行的程序是 hello-world
二进制文件,并不需要 main.go
文件和 golang 的环境,那这里保留这两类文件岂不是占用了一定的空间,且可能会带来一定的安全隐患。(生产环境容器镜像追求“最小化原则”),那么我们可以怎样解决这个问题呢?
方案一:在本机先通过 go build
命令将源码构建为二进制文件,再通过 Dockerfile 的 COPY 指令将二进制文件拷贝到镜像中:
# 本机编译
go build -o docker/hello-world main.go
FROM debian:12.2
WORKDIR /root/
COPY hello-world /root/
CMD ["/root/hello-world"]
那么问题又来了,这里为了保证构件上下文的“最小化”,我们要单独创建一个目录,这个目录仅存在编译后的二进制文件和 Dockerfile。
(base) ➜ example tree .
.
├── docker
│ ├── Dockerfile
│ └── hello-world
└── main.go
此外,在本机编译也需要依赖 golang 环境的,在工作中,构建任务往往运行在流水线中,开发人员仅需编写 Dockerfile 即可,编译、构建均在“构建机”执行。既然依赖构建机,保证构建机的一致性和稳定性又是一个比较重要的工作了,为了减少依赖项,我们可以通过“多段构建”,在一个文件中编写“两个” Dockerfile,第一个执行编译,第二个是构建出我们最终需要的镜像。
# 第一次构建(编译)
FROM golang:1.21.3 AS builder
## 设置工作目录
WORKDIR /root/
## 复制构建上下文的全部文件到指定目录(构建上下文包含源码 main.go)
COPY . /root/
# 执行编译
RUN go build -o hello-world main.go
# 第二次构建
FROM debian:12.2
## 设置工作目录
WORKDIR /root/
## 从第一次构建的命令中获取所需文件
COPY --from=builder /root/hello-world /root/
## 设置启动命令
CMD ["/root/hello-world"]
这样一来,既不依赖本机或“构建机”的编译环境,也不会将不需要的文件打包到最终镜像中。
虽然我们已经解决了编译环境和不必要依赖的问题,但依然还有一些没有考虑到的内容,下面我来全方位梳理 Dockerfile 的最佳实践。
构建镜像的第一条指令必须是 FROM,所以基础镜像的选择是非常关键的。基础镜像使用不当会对造成生产环境的诸多风险。如果应用程序可独立运行而不依赖系统库等,那么一般会选择scratch,就像著名的分布式键值存储 etcd 一样;如果关注的是镜像的安全和大小,那么一般会选择 Alpine;如果关注的是应用的稳定性,那么可能会选择 Debian、Ubuntu和CentOS;如果我们需要在镜像里将源码编译,那么可能会选择对应语言的基础镜像,如:golang:1.21.3、openjdk:22等
FROM scratch # 选择 scratch 镜像
FROM debian:12.2 # 选择 Debian 镜像
FROM ubuntu:23.04 # 选择 Ubuntu 镜像
FROM alpine:3.18 # 选择Alpine镜像
FROM centos:8 # 选择 CentOS 镜像
对于以上基础镜像,如果我们程序很简单,不需要系统环境和依赖库等支撑,完全可以选择 scratch 镜像,就比如上面 go 语言版的 hello world 就可以使用该镜像,这样构建出的镜像会很小。结果如下图所示:
(base) ➜ ~ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world debian bb7b64c8c159 2 minutes ago 118MB
hello-world scratch 1a66709e9ad0 2 hours ago 1.8MB
对于需要系统环境和依赖库的应用程序(这也更符合大多数应用的场景),我个人推荐基础镜像的顺序是:debian -> ubuntu -> alpine --> centos,主要原因有以下几点:
总之,若不熟悉 Linux 底层知识,建议直接选择 debian 镜像。
此外,基础镜像尽量使用具体标签,而不是 latest
,因为每次新版本发布后,latest 标签都会指向新版本镜像。若本次构建和上次构建前,恰好基础镜像有过更新,那两次构建出的镜像并不相同,这对生产环境而言是非常危险的。
比如,我有一个 Dockerfile,内容如下:
# 第一次构建
FROM golang:1.21.3 AS builder
WORKDIR /data/workspace/
## 复制代码至相关文件
COPY . /data/workspace/
## 下载依赖
RUN go mod tidy
## 编译(二进制文件统一命名为server, 业务也可自定义)
RUN mkdir cmd && go build -o /data/workspace/cmd/server
# 第二次构建
FROM debian:12.2
WORKDIR /data/workspace/
## 下载所需命令
RUN apt-get update
RUN apt-get install -y tar procps vim less which binutils lsof telnet iputils wget net-tools tcpdump nc lrzsz bind-utils
RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN apt-get clean all
## 从第一次构建的命令中获取所需文件(二进制名统一定位server, 业务也可自定义)
COPY --from=builder /data/workspace/cmd/server /data/workspace/
## 设置启动命令
CMD ["/data/workspace/server"]
每一个 RUN 指令都是可缓存的执行单元。太多的 RUN 指令会增加镜像的层数,增大镜像体积。当使用包管理器(apt、yum、dnf)安装软件时,一般会先更新软件索引信息,然后再安装软件。个人更推荐将更新索引和安装软件放在同一个 RUN 指令中,这样可以形成一个可缓存的执行单元,修改后的 Dockerfile 内容如下:
# 第一次构建
FROM golang:1.21.3 AS builder
WORKDIR /data/workspace/
## 复制代码至相关文件
COPY . /data/workspace/
## 下载依赖
RUN go mod tidy
## 编译(二进制文件统一命名为server, 业务也可自定义)
RUN mkdir cmd && go build -o /data/workspace/cmd/server
# 第二次构建
FROM debian:12.2
WORKDIR /data/workspace/
## 下载所需命令
RUN apt-get update \
&& apt-get install -y tar procps vim less which binutils lsof telnet iputils wget net-tools tcpdump nc lrzsz bind-utils \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& apt-get clean all
## 从第一次构建的命令中获取所需文件(二进制名统一定位server, 业务也可自定义)
COPY --from=builder /data/workspace/cmd/server /data/workspace/
## 设置启动命令
CMD ["/data/workspace/server"]
还有,上面 RUN 命令中的最后一步 apt-get clean all
也很关键,该命令会将包管理工具维护的缓存清空,进一步减少镜像体积。而且,我更推荐在每一个 RUN 指令的末尾执行该命令,如果在下一条指令中执行,镜像的体积并不会减少。
此外,在多个 Dockerfile 中,指令相同的 layer 应尽量放在相同的步骤,这样多个 Dockerfile 构建时能够使用构建缓存,减少构建时长。
容器中的 root 和主机上的 root 相同,但会受到 docker 守护程序配置的限制。无论有什么限制,如果程序突破了容器,他也能够找到一种方法来获取访问主机的完整权限。
这里可以说一下我之前在维护 kubernetes 集群时的一个故事:因为工作交接没做好,同事离职后,集群中的一台服务器密码忘记了,这时我通过亲和性在该服务器启动了一个容器,并将服务器的 /etc 目录挂载到容器中,进入容器执行了修改服务器密码的命令,就成功修改了容器所在服务器的密码。由此可见,安全性是多么的重要。
因此,我们可以指定一个用户而不使用默认 root 用户,可以在一定程度上提升容器的安全性:
# 第一次构建
FROM golang:1.21.3 AS builder
WORKDIR /data/workspace/
## 复制代码至相关文件
COPY . /data/workspace/
## 下载依赖
RUN go mod tidy
## 编译(二进制文件统一命名为server, 业务也可自定义)
RUN mkdir cmd && go build -o /data/workspace/cmd/server
# 第二次构建
FROM debian:12.2
USER server
WORKDIR /home/server/
## 从第一次构建的命令中获取所需文件(二进制名统一定位server, 业务也可自定义)
COPY --from=builder /data/workspace/cmd/server /home/server/
## 设置启动命令(第一个server是用户目录,第二个server是二进制文件)
CMD ["/home/server/server"]