Docker 容器本质上就是一组被隔离起来的特殊进程。
其中 cgroup 有 v1 和 v2 两个版本。cgroup 的资源限制配置写在 /sys/fs/cgroup 下,v1 和 v2 的主要区别在于配置的目录树结构:
v1 是每个资源作为一个树的节点,然后被限制的进程挂在节点下面。一个进程会出现在多个节点
Unknown123456789101112/sys/fs/cgroup/ ├── cpu/ │ ├── docker/ │ │ └── <id>/ ← 这个容器在 cpu 树上的位置 │ └── system/ ├── memory/ │ ├── docker/ │ │ └── <id>/ ← 同一容器在 memory 树上「另有一份」 │ └── ... ├── blkio/ │ └── docker/<id>/ └── ...
v2 是每个被限制的进程作为一个节点,然后每个资源挂在节点的下面。一个进程只出现在一个节点
Unknown1234567891011pod-A/ ├── cgroup.procs # 属于本组的 PID(叶子更常见) ├── cgroup.controllers # 本节点可用的控制器 ├── cgroup.subtree_control# 下放给子节点的控制器 +cpu +memory ... ├── cpu.max ├── memory.max ├── io.max └── container-1/ # 子 cgroup ├── cgroup.procs ├── cpu.max └── memory.max
Docker 容器可以跑不同的 Linux 发行版,这是因为不同 Linux 的发行版主要是 rootfs 不同。Docker 容器中的镜像更换了宿主机的 rootfs(根目录下的内容,包含了可执行程序,动态库,配置之类的)。而 Linux 内核的 ABI 相对稳定,所以一般换了 rootfs 也能跑。但是如果容器内用到了不支持的系统调用和内核特性,就不行了。
Docker 中镜像采用分层只读的结构,Docker 使用 overlay2 作为默认的存储驱动。
overlay2 在磁盘上对应的目录结构:
Unknown123456789101112131415/var/lib/docker/overlay2/ ├── l/ # 短名 → 各层 diff 的符号链接池 │ ├── ABCDEF... -> ../<layer-id>/diff │ └── ... ├── <image-layer-id>/ # 镜像层(只读,可被多容器共享) │ ├── diff/ │ ├── link │ ├── lower # 非最底层才有 │ └── committed # 已提交标记 └── <container-rw-id>/ # 某个容器的可写层 ├── diff/ # upperdir ├── work/ # workdir ├── merged/ # 运行时联合挂载点 ├── lower # lower 链(短名列表) └── link
Docker 镜像的每一层都对应一个 <image-layer-id> 目录。
diff 文件夹表示这一层的文件增量,相对于父层的文件变更
link 文件包含了一个短字符串,为这一层的短名。传递参数时如果直接带上层的 id,可能会超出命令行参数长度限制。
lower 文件记录了父层链,内容大概为:
Unknown1l/M9PQ1B:l/ZZ11AA:l/YY00BB
左边更靠近本层。
committed 为一个标记文件。在构建镜像的过程中,可能会先写临时层,写完之后就会创建这个文件,表示该层已经搞好了。
每个容器都有一个 <container-rw-id> 文件夹,表示这个容器的文件系统的内容。
diff 文件夹是这个容器在运行时,在镜像的基础上发生的文件变化。docker commit 本质就是把这个 diff 变成新的镜像层。merge 文件夹是容器启动后,容器看到的完整的 rootfs,在启动容器时就会把容器的 / 挂载到这个文件夹上。work 文件夹用来在写下层文件时拷贝的临时目录。在写下层文件时,会把文件先复制到这个临时目录,然后再直接把文件移动到 diff 文件夹(文件移动是原子的)。如果是直接把文件拷贝到 diff 文件夹的话,则可能出现拷贝到一半发生故障,然后容器再恢复的时候在 diff 里面看到一个脏文件。lower 文件和镜像层一样,记录了父层链。总结就是 Docker 镜像的每一层都是只读的,容器启动时会在这个镜像的基础上再叠一个可写层。容器对文件进行修改时通过 copy on write 机制实现。
这样做有如下好处:
docker pull 时,如果有一层已经存在本地,则直接跳过,只下载没有的层。Dockerfile 可以进行多阶段构建:
dockerfile12345678910# 第一阶段
FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN go build -o app
# 第二阶段
FROM alpine:3.18
COPY --from=builder /src/app /app
CMD ["/app"]
最终的镜像只有基于 alpine 镜像的部分和编译出来的二进制文件,不含有在第一阶段的编译器和编译中间产物,能大大缩小产生的镜像的体积。
docker run 后面跟的参数完全覆盖。docker run 覆盖。一般这两个组合使用,CWD 提供默认参数,默认参数还可被用户覆盖:
dockerfile12ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["--config"]
docker run myapp 运行 java -jar app.jar --configdocker run myapp --server.port=9090 运行 java -jar app.jar --server.port=9090同时,ENTRYPOINT 和 CMD 都有两种语法:数组形式和非数组形式。
exec,PID=1 就是启动的进程,能够接收到 SIGTERM 信号,优雅停止。CMD java -jar app.jar 就是非数组形式,容器启动后 PID=1 其实是 sh,容器停止的时候信号传不到应用进程(sh 默认也不会转发这个信号,或者是因 sh 实现而异),无法做到优雅停止。ADD 比 COPY 多了些功能:
ADD 的行为太聪明,不够透明,一般建议使用 COPY
有如下常见手段:
上面刚说的多阶段构建
选择较小的基础镜像,比如 alpine,或是一些 slim 版本的镜像
合并 RUN 指令。每个 RUN 都会产生一个新层,用 && 合成一条 RUN 可以减小层数
清理构建的中间产物,比如在 apt 安装完了对应的工具之后把包管理器的索引删了:
dockerfile12RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
使用 .dockerignore 来避免把一些没必要的文件 COPY 到镜像里面
避免在镜像中安装无关调试工具。一些调试工具可以在需要时直接 docker exec 临时进入容器安装。
有如下几个容器数据持久化方式:
Volume 数据卷 -v mydata:/app/data,数据卷由 Docker 管理,默认存储在 /var/lib/docker/volumes 下,可以用 docker volume 系列命令管理。
直接把主机的文件夹挂载过去:-v /home/user/code:/app
匿名挂载卷:
如下两种方式会产生匿名挂载卷:
-v /var/lib/mysqlVOLUME:VOLUME /app/uploads匿名挂载卷会为这个挂载卷生成一个随机 id
Unknown1/var/lib/docker/volumes/<随机ID>/_data/
需要注意的是,有可能容器删了,匿名挂载卷还存在,除非使用 docker rm -v 显式删除或是 docker run --rm。并且每次新容器创建,都会创建一个新的匿名挂载卷,不会沿用上一个容器的。
tmpfs:docker run --tmpfs /run:size=64m,会直接把文件夹开到内存里面,读写速度会快一点。适合存放敏感的临时数据,确保敏感数据不落盘。但是容器停止数据就没。
network_mode: container:xxx / service:xxx 直接和指定的另外一个容器共享一个网络栈,主要用途有这几个:
network_mode: container:vpn,来让流量全部走 vpn。127.0.0.1 进行通信,又不想改引用,那么就可以使用这个网络模式。docker run --network container:目标 -it nicolaka/netshoot 在目标容器的网络里抓包,curl。默认 bridge 网络:通过容器的 IP 直接进行访问,但是容器的 IP 不固定,也不支持容器名 DNS 解析。
自定义 bridge 网络:
shell123docker network create mynet docker run -d --name redis --network mynet redis docker run -d --name app --network mynet myapp
于是 app 容器里就可以直接通过 redis:6379 来访问 redis。
host 模式:直接共享了宿主机网络,所以直接可以通过 localhost:port 来访问。
通过 docker compose
| 重启策略 | 正常退出(退出码为 0) | 异常退出(退出码不为 0) | 手动 stop 后重启 docker 服务 |
|---|---|---|---|
| no(默认) | 不重启 | 不重启 | 不拉起 |
| on-failure | 不重启 | 重启 | 不拉起 |
| always | 重启 | 重启 | 拉起 |
| unless-stopped | 重启 | 重启 | 不拉起 |
还可以设置:on-failure:5,表示失败最多重启 5 次。
如果是手动 stop,那么所有的重启策略都不会再自动重启
选择:
shell12docker exec -it <container> bash docker exec -it <container> sh
参数含义:
-i:交互式(保留 stdin)-t:分配一个伪终端 TTYdocker exec 是直接在容器里面启动一个新的进程,退出时不会影响容器的主进程。
一个老办法是 docker attach,但是不推荐,attach 进去之后直接 exit 会直接杀掉容器的主进程。
docker commit <container> <image>:把一个正在运行的容器的当前状态打包成镜像,相当于快照。镜像中发生了什么只有操作者自己知道。
docker build -f Dockerfile -t <image> .:根据 Dockerfile 脚本自动构建镜像,过程是可复现,可追溯,可版本控制的。
docker commit 只适合修改完容器之后想保存然后快速验证一下。事后还是要把改动写到 Dockerfile 里面。
-d:后台运行-it:交互式+分配终端-p 8080:80:端口映射(宿主机:容器)-v /host:/container 或是 -v volume:/container:目录/数据卷挂载--name web:给容器明明--network mynet:指定容器网络-e KEY=VALUE:设置环境变量--rm:容器停止后自动删除--restart always / unless-stopped:自动重启策略-m 512m --cpus=1:内存/CPU 限制清理停止的容器:docker container prune
清理悬空镜像:docker image prune
一般是重新 build 了同名 tag,但是旧的镜像还在,但是名字已经变成了哈希值,没有具体名字
清理所有无用镜像:docker image prune -a
没有被容器使用。只要容器还存在,即使是正在停止,其对应的镜像也不算无用镜像。
清理无用的卷:docker volume prune
清理无用的网络:docker network prune
一键清理所有无用资源:
shell123docker system prune docker system prune -a # 加 -a 顺带清理没被使用的所有镜像 docker system prune -a --volumes # 再 --volumes 顺带清理没被使用的无用卷
Docker 负责怎么把一个容器跑起来,K8S 负责怎么把一大堆容器跨多台机器跑起来,管起来。
K8S 需要一个底层容器运行时来实际运行容器,历史上 K8S 默认使用 Docker 作为运行时,但从 K8S 1.24 起,K8S 已经移除了对 Docker Engine 的直接支持,改用符合 CRI 规范的运行时。
但是 docker build 打出来的镜像依然可以在 K8S 上面跑(镜像格式遵循 OCI 标准)。因此开发者还是可以使用 Docker 来构建镜像和本地调试。
容器启动后立刻退出,如何排查
先看日志,docker logs <container>
看退出码,使用 docker ps -a,STATUS 列会显示 Exited(X)
Docker 容器的主进程必须是前台运行的,不能是后台运行的。比如写成 nginx 就错了,应该写成 nginx -g 'daemon off;' 来让 nginx 在前台运行
可以临时把 ENTRYPOINT 改成 shell,进入容器后运行原来的 ENTRYPOINT 来调试:
Unknown1docker run -it --entrypoint sh <image>
检查资源限制:是否因为 cgroups 把资源限制的太紧(比如内存限制)而 OOM ,可以使用 docker inspect 看到 OOMKilled: true