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 | Ubuntu 20.04 |
这套环境的问题不在于“不能工作”,而在于它的状态很难描述。
例如一个工程能够编译,可能依赖:
- 某个固定版本的交叉编译器;
- 某个已经
source过的 SDK 环境脚本; /usr/local/bin下某个历史工具;- 某个开发者手工安装过、但 README 没写的软件包;
- 某些目录恰好属于当前用户,因此权限看起来一直正常。
当这些条件全堆在宿主机里,旧机器本身就成了“唯一可工作的环境副本”。换机器时,人只能靠记忆复刻。
容器化之后,希望形成的是:
1 | flowchart LR |
这里最重要的边界是:
1 | 镜像保存:OS 用户态、SDK、编译工具、公共依赖、启动逻辑 |
这样以后宿主机升级到更新的 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 | Docker CLI |
2.2 Image:环境模板
Image 是创建 Container 的模板。它通常包含:
- 基础 Ubuntu 用户态;
/usr/bin、/usr/lib等文件;- 编译器和 SDK;
- 环境变量;
ENTRYPOINT与CMD;- 一组只读镜像层。
查看镜像:
1 | docker image ls |
例如:
1 | REPOSITORY TAG IMAGE ID SIZE |
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 | FROM ubuntu:20.04 |
它描述“镜像应该如何生成”。
所以:
1 | Dockerfile --build--> Image --run--> Container |
这是后面理解所有 Docker 操作的主线。
3. Docker 和虚拟机有什么区别
Docker 容器与虚拟机都能提供隔离环境,但模型不同。
虚拟机通常是:
1 | Host OS |
Linux 容器更接近:
1 | Host Linux Kernel |
容器共享宿主 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 | 宿主机 |
启动:
1 | docker run --rm -it \ |
此时:
- 宿主机 IDE 修改源码,Container 立即看到;
- Container 里编译生成的文件也直接出现在宿主目录;
- 删除 Container 不会删除 Git 仓库;
- 修改源码不需要重建几十 GB 的开发镜像。
这就是开发场景中 bind mount 的核心价值。
5. 为什么源码通常不优先放 Docker volume
Docker 常见持久化方式可以先分三类:
| 类型 | 数据实际位置 | 适合什么 |
|---|---|---|
| bind mount | 你明确指定的宿主机目录 | 源码、配置、构建产物 |
| volume | Docker 管理的宿主机存储位置 | 数据库、缓存、长期服务数据 |
| Container writable layer | Container 自己的可写层 | 临时运行数据 |
Docker volume 当然可以保存源码,技术上没有禁止。但“能用”和“适合日常源码开发”是两回事。
源码的特点是宿主机上的很多非 Docker 工具都需要直接操作它:
1 | Git |
如果源码放在 Docker 管理的 volume 中,宿主机不再有一个自然、明确、应该直接操作的工程目录。官方文档也明确指出:当文件需要同时由 Host 与 Container 访问时,bind mount 更合适;Volume 更适合由 Docker 管理的持久数据。
因此开发项目通常选择:
1 | Git 源码 -> bind mount |
注意,bind mount 默认可写,Container 对 /workspace 的删除、改名和 chown 都可能直接改变宿主文件。这也引出了后面很重要的 UID/GID 问题。
6. -v 和 --mount 怎么选
同一个 bind mount 可以写成:
1 | docker run -v /home/dev/work/demo-project:/workspace image |
也可以写成:
1 | docker run \ |
短写 -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
/workspace做chown -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 | 不在官方支持矩阵 |
旧机器上可能已经有能够工作的 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 | docker.io : Depends: containerd (>= ...) |
这段错误应该读成:
1 | APT 已经找到 docker.io |
而不是:
1 | docker.io 不存在 |
Docker 生态里还有两组很容易混淆的软件包:
1 | Ubuntu / Debian 发行版: |
Docker 官方安装文档明确要求在安装官方 Docker Engine 前处理可能冲突的发行版 containerd / runc 等包。反过来,如果机器历史上装过 Docker 官方的 containerd.io,后来又切回 Ubuntu 的 docker.io,同样可能出现依赖来源冲突。
10. 遇到 APT 冲突时先看证据
不要一看到依赖错误就立刻删包。先做只读检查。
看当前安装了什么
1 | dpkg -l | grep -E 'docker|containerd|runc' |
重点看有没有同时存在:
1 | docker.io + docker-ce |
看 APT 准备选择哪个版本
1 | apt-cache policy docker.io containerd containerd.io runc |
Installed 是当前版本,Candidate 是 APT 准备安装/升级的版本。来源地址还能帮助判断包来自 Ubuntu 还是 Docker 官方仓库。
看有没有 hold
1 | apt-mark showhold |
如果关键依赖被 hold,APT 可能无法自动调整版本。
看软件源
1 | grep -R --no-filename -h '^deb ' \ |
目标不是“源越多越好”,而是确认有没有混用不同发行渠道、错误 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 | CLI 能连接 Engine |
这才算完成最小 Docker 链路验证。
12. 一条开发命令应该怎样阅读
以后可能看到:
1 | docker run --rm -it \ |
不要把它当一串需要背的参数,而是按对象读:
1 | docker run |
这条命令背后的设计比参数本身重要:
1 | Image 是环境 |
13. 这一阶段应该真正记住什么
Docker 初学阶段不需要研究所有 runtime internals。先建立下面几个判断已经足够:
- Dockerfile 是环境配方,Image 是模板,Container 是运行实例。
- 开发源码优先留在 Host,通过 bind mount 给 Container 使用。 Volume 更适合 Docker 管理的持久数据和缓存。
- Container 与虚拟机不是同一种东西。 容器共享 Host kernel,但可以固定一套旧 Ubuntu userspace。
- Ubuntu 20.04 已不在 Docker 当前官方 Ubuntu Engine 支持列表中。 遗留机要把“能工作”和“官方支持”分开。
- APT 依赖错误先看包来源和候选版本。
docker.io/containerd与docker-ce/containerd.io不要无目的混装。 - 容器化完成的标准不是
hello-world。 最终还要让真实工程在新 Container 中完成 configure、compile 和 link。
下一篇进入真正的工程化部分:当厂商 SDK 本身已经达到约 25 GiB 时,为什么要使用 BuildKit/Buildx named context,怎样设计 Dockerfile、Compose、entrypoint 和一组脚本,让这个开发环境可以构建、验证和迁移。
参考资料
- Docker Engine on Ubuntu: https://docs.docker.com/engine/install/ubuntu/
- Docker Engine storage: https://docs.docker.com/engine/storage/
- Docker bind mounts: https://docs.docker.com/engine/storage/bind-mounts/
- Docker volumes: https://docs.docker.com/engine/storage/volumes/










