Docker 开发环境容器化:从基本概念到 Ubuntu 20.04 安装排查

摘要:从 Image、Container、Dockerfile 和挂载建立 Docker 心智模型,并结合 Ubuntu 20.04 的安装冲突说明为什么要把旧开发环境容器化。

在这里插入图片描述
@[toc]
学习 Docker 最容易走偏的方式,是先背几十条命令,再去想这些命令解决什么问题。对于开发环境容器化,更合理的顺序正好相反:先明确工程为什么需要 Docker,再理解 Image、Container、Dockerfile、挂载和权限这些概念,最后才把命令放进实际场景。

本文从一个典型的嵌入式交叉编译环境出发:旧工程长期依赖 Ubuntu 20.04、厂商 Yocto SDK、固定 CMake、代码生成工具和若干宿主机软件。旧机器能稳定构建,但一旦换电脑、升级系统或让其他开发者接手,就很难准确复刻环境。

Docker 在这里的目标不是“把 Linux 再装一遍”,而是:

把构建环境固定成可重复生成的镜像,把日常修改的源码继续留在宿主机。

1. 为什么开发环境值得容器化

传统开发机往往随着项目迭代逐渐变成这样:

1
2
3
4
5
6
7
Ubuntu 20.04
├── apt 安装的软件
├── /opt 下的厂商 SDK
├── /usr/local 下的自定义工具
├── 多个版本的 CMake / Python
├── 临时手工修改过的权限
└── 多个项目源码

这套环境的问题不在于“不能工作”,而在于它的状态很难描述。

例如一个工程能够编译,可能依赖:

  • 某个固定版本的交叉编译器;
  • 某个已经 source 过的 SDK 环境脚本;
  • /usr/local/bin 下某个历史工具;
  • 某个开发者手工安装过、但 README 没写的软件包;
  • 某些目录恰好属于当前用户,因此权限看起来一直正常。

当这些条件全堆在宿主机里,旧机器本身就成了“唯一可工作的环境副本”。换机器时,人只能靠记忆复刻。

容器化之后,希望形成的是:

1
2
3
4
5
flowchart LR
Dockerfile["Dockerfile\n环境配方"] -->|build| Image["Image\n固定开发环境"]
Image -->|run| Container["Container\n一次开发会话"]
Source["宿主机 Git 源码"] -->|bind mount| Container
Container -->|cross build| Artifact["目标平台构建产物"]

这里最重要的边界是:

1
2
3
镜像保存:OS 用户态、SDK、编译工具、公共依赖、启动逻辑
宿主机保存:Git 源码、个人分支、编辑器配置、日常修改内容
容器负责:在固定环境中运行 configure / compile / link / test

这样以后宿主机升级到更新的 Ubuntu,旧工程仍可以在 Ubuntu 20.04 用户态容器里构建。

2. Docker Engine、CLI、Image、Container 到底是什么

很多初学者把 Docker 当成一个程序,实际上日常使用至少要区分几个对象。

2.1 Docker Engine

Docker Engine 是真正管理容器的后台系统。Linux 上通常包含 Docker daemon,并通过底层运行时管理容器生命周期。

你在终端输入:

1
docker run ubuntu:24.04

并不是 CLI 自己去创建 namespace、挂载文件系统。CLI 会向 Docker Engine 发请求,由 Engine 完成后续工作。

可以简化理解为:

1
2
3
4
5
6
7
8
9
10
Docker CLI

│ Docker API

Docker Engine / dockerd

├── Image
├── Container
├── Network
└── Volume

2.2 Image:环境模板

Image 是创建 Container 的模板。它通常包含:

  • 基础 Ubuntu 用户态;
  • /usr/bin/usr/lib 等文件;
  • 编译器和 SDK;
  • 环境变量;
  • ENTRYPOINTCMD
  • 一组只读镜像层。

查看镜像:

1
docker image ls

例如:

1
2
REPOSITORY   TAG      IMAGE ID       SIZE
arm64-dev 20.04 123456789abc 27GB

arm64-dev:20.04 只是一个镜像,不是正在运行的 Ubuntu。

2.3 Container:Image 的一次运行实例

执行:

1
docker run --rm -it arm64-dev:20.04

Docker 会基于 Image 创建一个 Container,再启动其中的进程。

参数可以拆开看:

参数 含义
docker run 创建并启动新的 Container
--rm 主进程退出后自动删除这个 Container
-i 保持标准输入打开
-t 分配伪终端,交互式 shell 更自然
arm64-dev:20.04 选择哪个 Image

一个很重要的认识是:

1
删除 Container ≠ 删除 Image

Container 是一次运行实例,Image 才是可重复创建环境的模板。

开发容器常使用 --rm,正是因为真正需要长期保存的状态应该在镜像、源码仓库或明确的持久化存储中,而不是偷偷堆在某个旧 Container 的可写层里。

2.4 Dockerfile:构建 Image 的配方

Dockerfile 是文本文件,例如:

1
2
3
4
5
6
7
8
9
FROM ubuntu:20.04

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git cmake make \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /workspace
CMD ["/bin/bash"]

它描述“镜像应该如何生成”。

所以:

1
Dockerfile --build--> Image --run--> Container

这是后面理解所有 Docker 操作的主线。

3. Docker 和虚拟机有什么区别

Docker 容器与虚拟机都能提供隔离环境,但模型不同。

虚拟机通常是:

1
2
3
4
5
Host OS
└── Hypervisor
└── Guest OS
├── Guest Kernel
└── User Space

Linux 容器更接近:

1
2
3
4
Host Linux Kernel
├── Container A user space
├── Container B user space
└── Host user space

容器共享宿主 Linux 内核,所以镜像一般比完整 VM 更轻,也更适合快速创建、删除和复制。

这对交叉编译尤其合适:开发机仍是 x86-64,容器里运行的 CMake、Thrift、GCC driver 也是 x86-64 程序,但交叉编译器生成 ARM64 目标文件。并不需要用 ARM64 虚拟机来完成普通交叉构建。

当然,Docker 不是万能虚拟机。内核驱动、USB/CAN 设备、特殊内核能力仍然和宿主有关。本文讨论的重点是用户态开发环境和交叉构建链路。

4. 为什么“环境进镜像,源码留宿主机”

如果把源码也 COPY 进镜像:

1
COPY ./project /workspace/project

每修改一次源码,镜像里的代码就过时;想继续编译就要重新 build Image。对于日常 C/C++ 开发非常别扭。

更自然的方式是:

1
2
3
4
5
6
7
宿主机
/home/dev/work/demo-project

│ bind mount

Container
/workspace

启动:

1
2
3
4
docker run --rm -it \
--mount type=bind,src=/home/dev/work/demo-project,dst=/workspace \
-w /workspace \
arm64-dev:20.04

此时:

  • 宿主机 IDE 修改源码,Container 立即看到;
  • Container 里编译生成的文件也直接出现在宿主目录;
  • 删除 Container 不会删除 Git 仓库;
  • 修改源码不需要重建几十 GB 的开发镜像。

这就是开发场景中 bind mount 的核心价值。

5. 为什么源码通常不优先放 Docker volume

Docker 常见持久化方式可以先分三类:

类型 数据实际位置 适合什么
bind mount 你明确指定的宿主机目录 源码、配置、构建产物
volume Docker 管理的宿主机存储位置 数据库、缓存、长期服务数据
Container writable layer Container 自己的可写层 临时运行数据

Docker volume 当然可以保存源码,技术上没有禁止。但“能用”和“适合日常源码开发”是两回事。

源码的特点是宿主机上的很多非 Docker 工具都需要直接操作它:

1
2
3
4
5
6
7
Git
VS Code / CLion
ripgrep
代码审查工具
脚本
备份软件
文件管理器

如果源码放在 Docker 管理的 volume 中,宿主机不再有一个自然、明确、应该直接操作的工程目录。官方文档也明确指出:当文件需要同时由 Host 与 Container 访问时,bind mount 更合适;Volume 更适合由 Docker 管理的持久数据。

因此开发项目通常选择:

1
2
3
4
Git 源码        -> bind mount
ccache -> volume(可选)
数据库数据 -> volume
临时输出 -> Container layer / tmpfs(按需要)

注意,bind mount 默认可写,Container 对 /workspace 的删除、改名和 chown 都可能直接改变宿主文件。这也引出了后面很重要的 UID/GID 问题。

6. -v--mount 怎么选

同一个 bind mount 可以写成:

1
docker run -v /home/dev/work/demo-project:/workspace image

也可以写成:

1
2
3
docker run \
--mount type=bind,src=/home/dev/work/demo-project,dst=/workspace \
image

短写 -v 输入快;--mount 语义更明确,也更适合工程脚本。

另一个实用差异是:Host 源路径不存在时,--mount 默认会直接报错,而 -v 在某些 bind mount 用法中会创建目录。

对于开发环境,路径拼错时“立即失败”通常比“悄悄创建一个空目录”更安全,所以正式脚本更适合使用明确的 mount 配置或 Compose 长语法。

7. 为什么 bind mount 会带来 UID/GID 问题

Linux 文件权限真正识别的是数字 UID/GID。

假设宿主机用户:

1
uid=1001 gid=1001

而 Container 里的开发用户是:

1
uid=1000 gid=1000

即使两边都叫 devuser,Linux 仍认为它们不是同一个身份。

于是可能出现:

  • Container 不能修改 Host 源码;
  • Container 创建的文件在 Host 上显示成另一个 UID;
  • 如果直接用 root 编译,Host 上产生 root-owned build/

因此开发容器通常会把宿主的 UID/GID传给 Container,在入口脚本中调整普通开发用户的数字 ID,再以普通用户执行真正的构建命令。

这里有一个必须坚持的边界:

不要因为权限问题就在 entrypoint 中对 bind-mounted /workspacechown -R

因为 /workspace 背后就是 Host 的真实 Git 工作树。递归 chown 实际是在改宿主文件权限。

8. Ubuntu 20.04 为什么是一个特殊场景

截至 2026-09-03,Docker Engine 官方 Ubuntu 安装页列出的 64 位 Ubuntu 版本是:

  • Ubuntu 26.04 LTS;
  • Ubuntu 25.10;
  • Ubuntu 24.04 LTS;
  • Ubuntu 22.04 LTS。

Ubuntu 20.04 已不在当前官方支持列表中。

必须区分:

1
2
3
不在官方支持矩阵

技术上绝对不能运行

旧机器上可能已经有能够工作的 Docker 版本,但不能因为“我这里能运行”就推导成“当前官方仍支持 Focal”。

对于一个不能马上升级的遗留开发机,更务实的目标不是强行追最新 Docker CE,而是先得到一套稳定 Docker 环境,把依赖 Ubuntu 20.04 的旧工具链固化进 Image。以后 Host 再升级到新的 Ubuntu,旧 userspace 继续由 Container 保留。

9. docker.io 安装失败:为什么不是“包不存在”

遗留 Ubuntu 20.04 上可能执行:

1
sudo apt install -y docker.io

却得到:

1
2
docker.io : Depends: containerd (>= ...)
E: Unable to correct problems, you have held broken packages.

这段错误应该读成:

1
2
3
4
5
6
7
APT 已经找到 docker.io

docker.io 依赖发行版 containerd

APT 当前无法选出可安装的 containerd 组合

依赖解析失败

而不是:

1
docker.io 不存在

Docker 生态里还有两组很容易混淆的软件包:

1
2
3
4
5
6
7
8
9
10
11
Ubuntu / Debian 发行版:
docker.io
containerd
runc

Docker 官方仓库:
docker-ce
docker-ce-cli
containerd.io
docker-buildx-plugin
docker-compose-plugin

Docker 官方安装文档明确要求在安装官方 Docker Engine 前处理可能冲突的发行版 containerd / runc 等包。反过来,如果机器历史上装过 Docker 官方的 containerd.io,后来又切回 Ubuntu 的 docker.io,同样可能出现依赖来源冲突。

10. 遇到 APT 冲突时先看证据

不要一看到依赖错误就立刻删包。先做只读检查。

看当前安装了什么

1
dpkg -l | grep -E 'docker|containerd|runc'

重点看有没有同时存在:

1
2
docker.io + docker-ce
containerd + containerd.io

看 APT 准备选择哪个版本

1
apt-cache policy docker.io containerd containerd.io runc

Installed 是当前版本,Candidate 是 APT 准备安装/升级的版本。来源地址还能帮助判断包来自 Ubuntu 还是 Docker 官方仓库。

看有没有 hold

1
apt-mark showhold

如果关键依赖被 hold,APT 可能无法自动调整版本。

看软件源

1
2
3
grep -R --no-filename -h '^deb ' \
/etc/apt/sources.list \
/etc/apt/sources.list.d/*.list 2>/dev/null

目标不是“源越多越好”,而是确认有没有混用不同发行渠道、错误 Ubuntu codename 或失效源。

真正处理时应尽量选定一套包体系,而不是继续混装。

11. Docker 安装后怎样做最小验证

只运行:

1
docker --version

只能证明 CLI 存在。

更有效的是:

1
docker version

它会同时尝试获取 Client/Server 信息。

再看:

1
docker info

这一步会连接 daemon,可以看到 storage driver、Docker Root Dir、镜像和 Container 数量等信息。

最后运行:

1
docker run --rm hello-world

成功至少证明:

1
2
3
4
5
6
7
CLI 能连接 Engine

Image 能获取/读取

Container 能创建

Container 主进程能启动和退出

这才算完成最小 Docker 链路验证。

12. 一条开发命令应该怎样阅读

以后可能看到:

1
2
3
4
5
docker run --rm -it \
--name arm64-dev-shell \
--mount type=bind,src=/home/dev/work,dst=/workspace \
-w /workspace \
arm64-dev:20.04

不要把它当一串需要背的参数,而是按对象读:

1
2
3
4
5
6
7
8
docker run

├── --rm 本次 Container 退出即删除
├── -it 交互式终端
├── --name 给运行实例一个名字
├── --mount Host 源码映射到 Container
├── -w 初始工作目录
└── arm64-dev... 使用哪个 Image

这条命令背后的设计比参数本身重要:

1
2
3
Image 是环境
Container 是临时会话
Host 目录是真实源码

13. 这一阶段应该真正记住什么

Docker 初学阶段不需要研究所有 runtime internals。先建立下面几个判断已经足够:

  1. Dockerfile 是环境配方,Image 是模板,Container 是运行实例。
  2. 开发源码优先留在 Host,通过 bind mount 给 Container 使用。 Volume 更适合 Docker 管理的持久数据和缓存。
  3. Container 与虚拟机不是同一种东西。 容器共享 Host kernel,但可以固定一套旧 Ubuntu userspace。
  4. Ubuntu 20.04 已不在 Docker 当前官方 Ubuntu Engine 支持列表中。 遗留机要把“能工作”和“官方支持”分开。
  5. APT 依赖错误先看包来源和候选版本。 docker.io/containerddocker-ce/containerd.io 不要无目的混装。
  6. 容器化完成的标准不是 hello-world 最终还要让真实工程在新 Container 中完成 configure、compile 和 link。

下一篇进入真正的工程化部分:当厂商 SDK 本身已经达到约 25 GiB 时,为什么要使用 BuildKit/Buildx named context,怎样设计 Dockerfile、Compose、entrypoint 和一组脚本,让这个开发环境可以构建、验证和迁移。

参考资料