Caiwen的博客

Docker笔记

2026-08-11 07:44

1. 原理

1.1 隔离

  • 使用 Linux namespace 进行视图隔离,每个容器都运行在自己的一组命名空间中(隔离了 PID,网络,挂载点,IPC 之类的)
  • 使用 cgroups 进行资源隔离,对 CPU,内存,磁盘 IO 之类的资源进行限制。

Docker 容器本质上就是一组被隔离起来的特殊进程。

其中 cgroup 有 v1 和 v2 两个版本。cgroup 的资源限制配置写在 /sys/fs/cgroup 下,v1 和 v2 的主要区别在于配置的目录树结构:

  • v1 是每个资源作为一个树的节点,然后被限制的进程挂在节点下面。一个进程会出现在多个节点

    Unknown
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /sys/fs/cgroup/ ├── cpu/ │ ├── docker/ │ │ └── <id>/ ← 这个容器在 cpu 树上的位置 │ └── system/ ├── memory/ │ ├── docker/ │ │ └── <id>/ ← 同一容器在 memory 树上「另有一份」 │ └── ... ├── blkio/ │ └── docker/<id>/ └── ...
  • v2 是每个被限制的进程作为一个节点,然后每个资源挂在节点的下面。一个进程只出现在一个节点

    Unknown
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    pod-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

1.2 镜像

Docker 容器可以跑不同的 Linux 发行版,这是因为不同 Linux 的发行版主要是 rootfs 不同。Docker 容器中的镜像更换了宿主机的 rootfs(根目录下的内容,包含了可执行程序,动态库,配置之类的)。而 Linux 内核的 ABI 相对稳定,所以一般换了 rootfs 也能跑。但是如果容器内用到了不支持的系统调用和内核特性,就不行了。

Docker 中镜像采用分层只读的结构,Docker 使用 overlay2 作为默认的存储驱动。

overlay2 在磁盘上对应的目录结构:

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/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 文件记录了父层链,内容大概为:

      Unknown
      1
      l/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 时,如果有一层已经存在本地,则直接跳过,只下载没有的层。

2. 实践

2.1 镜像构建

2.1.1 多阶段构建

Dockerfile 可以进行多阶段构建:

dockerfile
1
2
3
4
5
6
7
8
9
10
# 第一阶段 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 镜像的部分和编译出来的二进制文件,不含有在第一阶段的编译器和编译中间产物,能大大缩小产生的镜像的体积。

2.1.2 CMD / ENTRYPOINT

  • CWD 提供的是默认命令,可以被 docker run 后面跟的参数完全覆盖
  • ENTRYPOINT 设置的是容器的主命令,不会被 docker run 覆盖。

一般这两个组合使用,CWD 提供默认参数,默认参数还可被用户覆盖:

dockerfile
1
2
ENTRYPOINT ["java", "-jar", "app.jar"] CMD ["--config"]
  • docker run myapp 运行 java -jar app.jar --config
  • docker 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 实现而异),无法做到优雅停止。

2.1.3 COPY / ADD

ADD 比 COPY 多了些功能:

  • 如果源文件是压缩包,ADD 会自动解压
  • ADD 还可以直接从 URL 下载远程文件到镜像中

ADD 的行为太聪明,不够透明,一般建议使用 COPY

2.1.4 减小镜像体积

有如下常见手段:

  • 上面刚说的多阶段构建

  • 选择较小的基础镜像,比如 alpine,或是一些 slim 版本的镜像

  • 合并 RUN 指令。每个 RUN 都会产生一个新层,用 && 合成一条 RUN 可以减小层数

  • 清理构建的中间产物,比如在 apt 安装完了对应的工具之后把包管理器的索引删了:

    dockerfile
    1
    2
    RUN apt-get update && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/*
  • 使用 .dockerignore 来避免把一些没必要的文件 COPY 到镜像里面

  • 避免在镜像中安装无关调试工具。一些调试工具可以在需要时直接 docker exec 临时进入容器安装。

2.2 容器数据持久化

有如下几个容器数据持久化方式:

  • Volume 数据卷 -v mydata:/app/data,数据卷由 Docker 管理,默认存储在 /var/lib/docker/volumes 下,可以用 docker volume 系列命令管理。

  • 直接把主机的文件夹挂载过去:-v /home/user/code:/app

  • 匿名挂载卷:

    如下两种方式会产生匿名挂载卷:

    • 不写主机路径或是数据卷的名称:-v /var/lib/mysql
    • Dockerfile 声明:VOLUMEVOLUME /app/uploads

    匿名挂载卷会为这个挂载卷生成一个随机 id

    Unknown
    1
    /var/lib/docker/volumes/<随机ID>/_data/

    需要注意的是,有可能容器删了,匿名挂载卷还存在,除非使用 docker rm -v 显式删除或是 docker run --rm。并且每次新容器创建,都会创建一个新的匿名挂载卷,不会沿用上一个容器的。

  • tmpfs:docker run --tmpfs /run:size=64m,会直接把文件夹开到内存里面,读写速度会快一点。适合存放敏感的临时数据,确保敏感数据不落盘。但是容器停止数据就没。

2.3 网络

2.3.1 网络模式

  • bridge(默认):默认情况下 Docker 创建一个 docker0 的虚拟网桥,每个容器分配独立的 IP,容器之间通过网桥通信,访问外网通过 NAT。
    • 由于 bridge 模式下,外网流量进来之后会直接被转发到这个虚拟网桥,由这个网桥再去转发到某个进程,而不是直接进入某个进程所以 UFW 这种防火墙无法对 docker 容器起作用。但是如果容器使用 host 模式就会起作用了。
    • 如果是 docker compose,一个 compose 会创建一个独立的虚拟网桥(不用公用的 docker0 虚拟网桥)。docker compose 内的各个 service 可以直接通过 service 的名称作为域名来相互访问。也可以在 docker compose 中自定义这个 compose 的网桥的名称。
    • 也可以在创建容器的时候自定义这个容器网络使用的网桥名称。自定义网桥名称还可以做到直接使用容器的名称来作为域名来互相访问。
  • host:容器直接使用宿主机的网络栈,不做隔离(也就是不能端口号映射了),性能最好,但会和宿主机抢端口。
  • none:容器没有任何网络,只有 loopback,适合不需要网络的任务。
  • container:network_mode: container:xxx / service:xxx 直接和指定的另外一个容器共享一个网络栈,主要用途有这几个:
    • sidecar / vpn:可以先启动 VPN 或是代理的容器,然后业务容器通过 network_mode: container:vpn,来让流量全部走 vpn。
    • 如果两个进程必须使用 127.0.0.1 进行通信,又不想改引用,那么就可以使用这个网络模式。
    • 需要排障抓包时,可以直接:docker run --network container:目标 -it nicolaka/netshoot 在目标容器的网络里抓包,curl。
  • overlay:跨宿主机的网络,主要在 Docker Swarm 和 K8S 中使用

2.3.2 同一宿主机容器通信

  • 默认 bridge 网络:通过容器的 IP 直接进行访问,但是容器的 IP 不固定,也不支持容器名 DNS 解析。

  • 自定义 bridge 网络:

    shell
    1
    2
    3
    docker 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

2.4 重启策略

重启策略 正常退出(退出码为 0) 异常退出(退出码不为 0) 手动 stop 后重启 docker 服务
no(默认) 不重启 不重启 不拉起
on-failure 不重启 重启 不拉起
always 重启 重启 拉起
unless-stopped 重启 重启 不拉起
  • 还可以设置:on-failure:5,表示失败最多重启 5 次。

  • 如果是手动 stop,那么所有的重启策略都不会再自动重启

选择:

  • 开发/一次性:no
  • 关键服务,不能停:always/unless-stopped,如果希望 docker 服务重启之后也跟着拉起,则只能选 always
  • 只对异常退出重试,正常退出算结束:on-failure

2.5 指令

2.5.1 进入已经执行的容器

shell
1
2
docker exec -it <container> bash docker exec -it <container> sh

参数含义:

  • -i:交互式(保留 stdin)
  • -t:分配一个伪终端 TTY

docker exec 是直接在容器里面启动一个新的进程,退出时不会影响容器的主进程。

一个老办法是 docker attach,但是不推荐,attach 进去之后直接 exit 会直接杀掉容器的主进程。

2.5.2 docker commit/build

docker commit <container> <image>:把一个正在运行的容器的当前状态打包成镜像,相当于快照。镜像中发生了什么只有操作者自己知道。

docker build -f Dockerfile -t <image> .:根据 Dockerfile 脚本自动构建镜像,过程是可复现,可追溯,可版本控制的。

docker commit 只适合修改完容器之后想保存然后快速验证一下。事后还是要把改动写到 Dockerfile 里面。

2.5.3 docker run

  • -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 限制

2.5.4 清理镜像、容器、卷

  • 清理停止的容器:docker container prune

  • 清理悬空镜像:docker image prune

    一般是重新 build 了同名 tag,但是旧的镜像还在,但是名字已经变成了哈希值,没有具体名字

  • 清理所有无用镜像:docker image prune -a

    没有被容器使用。只要容器还存在,即使是正在停止,其对应的镜像也不算无用镜像。

  • 清理无用的卷:docker volume prune

  • 清理无用的网络:docker network prune

一键清理所有无用资源:

shell
1
2
3
docker system prune docker system prune -a # 加 -a 顺带清理没被使用的所有镜像 docker system prune -a --volumes # 再 --volumes 顺带清理没被使用的无用卷

3. 其他

3.1 Docker 和 K8S 关系

Docker 负责怎么把一个容器跑起来,K8S 负责怎么把一大堆容器跨多台机器跑起来,管起来。

  • Docker 是一个容器运行时和镜像构建工具,解决的是:单机上如何打包,分发,运行一个容器
  • K8S 是一个容器编排系统,解决的是:跨多台机器如何调度,扩缩容,自愈,负载均衡,服务发现,配置管理,发布回滚

K8S 需要一个底层容器运行时来实际运行容器,历史上 K8S 默认使用 Docker 作为运行时,但从 K8S 1.24 起,K8S 已经移除了对 Docker Engine 的直接支持,改用符合 CRI 规范的运行时。

但是 docker build 打出来的镜像依然可以在 K8S 上面跑(镜像格式遵循 OCI 标准)。因此开发者还是可以使用 Docker 来构建镜像和本地调试。

3.2 问题解决

容器启动后立刻退出,如何排查

  1. 先看日志,docker logs <container>

  2. 看退出码,使用 docker ps -a,STATUS 列会显示 Exited(X)

  3. Docker 容器的主进程必须是前台运行的,不能是后台运行的。比如写成 nginx 就错了,应该写成 nginx -g 'daemon off;' 来让 nginx 在前台运行

  4. 可以临时把 ENTRYPOINT 改成 shell,进入容器后运行原来的 ENTRYPOINT 来调试:

    Unknown
    1
    docker run -it --entrypoint sh <image>
  5. 检查资源限制:是否因为 cgroups 把资源限制的太紧(比如内存限制)而 OOM ,可以使用 docker inspect 看到 OOMKilled: true

最后更新于:2026-08-11 07:59

Caiwen
本文作者
一只蒟蒻,爱好编程和算法