1. 容器到底在解决什么问题:从一个部署翻车现场说起
先讲一个我再熟悉不过的场景。你在自己电脑上辛辛苦苦写好的项目,本地跑得一丝不苟,顺手把代码发给同事,同事一运行,直接报错:Python 版本对不上、依赖包缺了两个、系统库没装、配置文件里路径还是绝对路径。好不容易在同事机器上调通了,等你把它部署到服务器上,又来一轮新的“环境差异大冒险”。2026年聊容器化,很多人已经听过“Docker”这个词,但真正把它用起来的人,其实并没有想象中那么多。原因很简单:大家不缺工具,缺的是对“容器到底解决什么问题”的理解。
我刚开始接触 Docker 时也走过弯路,那时候看到满屏命令就头大,觉得这是运维才需要学的东西。直到有一次帮人部署一个带数据库的应用,服务器上必须装指定版本的数据库,还要配一堆初始化脚本,稍有不慎就把系统环境搞乱了。后来把整套应用塞进容器里,一条命令启动,一条命令停止,所有环境问题像被“打包封存”了一样。那一刻我才真正意识到:Docker 不是某个小众技巧,而是软件开发里“标准交付物”缺失时最实在的补位方案。
1.1 传统部署的三大痛点,你真的感受过吗
传统方式部署应用,麻烦从来不在于“应用本身”,而在于“环境”。第一个痛点是环境一致性。你的电脑、同事的电脑、测试环境、生产环境,四套机器的系统版本、软件版本、底层库全都不一样。同一个代码在不同环境里跑出不同结果是常态,能跑通全靠运气。
第二个痛点是依赖隔离。一台服务器上不可能只跑一个项目,而项目 A 需要的 Python 3.12 和项目 B 需要的 Python 3.8 往往没法共存;数据库版本冲突、Node 版本冲突、系统库版本冲突,这种“既要又要”的问题让多少人挠过头。今天你为了项目 B 升级了一个共享库,明天项目 A 就莫名启动不了。
第三个痛点,是资源利用率和交付效率。虚拟机很早就想做环境隔离这件事,但它隔离得太“重”了:每台虚拟机都是一套完整的操作系统,启动要几分钟,占几个 GB 内存,对一台普通服务器来说,能同时跑的虚拟机能数得过来。而且交付一个应用,通常要附带一份巨长的部署文档:先装什么、再改什么配置、哪个目录要建、哪个权限要开。文档永远赶不上现实,文档没写的坑,全靠后来人踩。
1.2 标准集装箱:理解容器最不容易错的类比
如果说 Docker 是一套系统,那它的设计思想特别像一个港口里的标准集装箱。传统的货物运输,靠的是人工装卸,不同的货物有不同的尺寸、不同的包装方式、不同的装卸要求,效率低、损耗高。集装箱出现后,所有货物被统一放进一个标准尺寸的箱子里,吊车、轮船、卡车都按同一个标准对接,运输效率大幅提升。
容器做的事情,本质上和集装箱一模一样:把你的应用代码、运行时、依赖库、配置文件,全部装进一个标准化的“箱子”——也就是镜像里。这个箱子在任何安装了容器引擎的机器上,打开方式完全一致。你不用关心对方是 Windows 还是 Linux,是开发机还是生产服务器,只要容器引擎能跑,这个“箱子”就能原样打开。
这个类比帮我理解了很多之后的概念。比如镜像就是那个“集装箱本身”,它是只读的模板,里面装好了所有东西;容器则是“箱子被吊到船上之后的状态”,是镜像的一次运行实例,有自己的生命周期,可以被启动、停止、删除。又比如数据卷,相当于你放在集装箱外面的独立货架——箱子没了货架还在,数据不会跟着容器一起被毁掉。
1.3 容器和虚拟机,别指望谁彻底替代谁
docker 刚火起来那几年,很多人爱问容器能不能完全替代虚拟机。我自己的使用感受是:它俩很像,但不是同一样东西。
虚拟机在硬件之上虚拟出一整套操作系统,每个虚拟机里都有一个完整的 kernel。好处是隔离性极强,坏处是开销大、启动慢。容器则共享宿主机内核,只隔离文件系统、进程、网络等用户态资源,所以启动只要秒级、单机可以跑几十上百个,资源占用小得多。但也正因为共享内核,容器无法运行一个与宿主机内核版本冲突的软件,Windows 上不能直接跑 Linux 专属内核模块,就是这个道理。
选型上我的经验很粗浅:需要强隔离、跑异构系统、涉及内核级定制,用虚拟机;追求资源效率、快速交付、微服务架构,用容器。两者不是竞争关系,混合使用的情况反而最常见——很多人就是在虚拟机里装了 Docker 来用。
1.4 这套东西到底适合谁来学
往大了说,凡是“交付可运行的软件”这个动作频繁发生的人,都值得学 Docker。软件开发岗位自不必说,写代码的每天都要跟环境打交道;测试岗位也适合学,把被测服务容器化之后,环境配置可以做到一次准备、重复使用;做毕业设计或者课程项目的学生,用容器把项目整体打包,交上去的不仅是代码,还有一整套可复现的运行环境,导师在任意机器上都能跑通,省去很多解释的功夫。
还有一类人可能想不到:家里有 NAS、喜欢折腾自建服务的人。装个 Docker 之后,下载镜像、起个容器,一个应用就装好了,卸载也干净,不会把系统目录搞得乱七八糟。我对这类场景一直很推荐,因为容器带来的“干净、可回滚、可迁移”,在家里服务器上同样受用。
2. 装好 Docker 后的第一次运行:环境准备与最该改的默认配置
聊了一堆理念,现在该动手了。2026 年安装 Docker,其实比前几年省心很多。各大平台都有对应的安装包,官方的一键脚本也足够成熟,基本不需要再“编内核、调参数”那种折腾。对零基础来说,最需要注意的反而不是“怎么装”,而是“装完别急着跑,先做两个小配置”。
2.1 三个常见平台的安装思路
Windows 平台现在的标准方案是安装 Docker Desktop,底层依赖 WSL 2。装完 Docker Desktop 之后,它默认会启用 WSL2 后端,你在 PowerShell 里就能直接敲 docker 命令,体验和 Linux 几乎一样。唯一可能踩的坑是 BIOS 里没开虚拟化,安装时检测不通过,这个在主板设置里打开就行。
macOS 上同样是 Docker Desktop 为主,分为 Intel 芯片和 Apple Silicon 两个版本,下载对应架构的安装包即可。Apple Silicon 上跑 x86 镜像会有兼容层,但性能有损耗,能选 arm64 的镜像就优先选 arm64。
Linux 服务器是我最常用的场景,大多数发行版直接用包管理器安装就行。比较省事的是用官方提供的安装脚本,脚本会自动检测系统、配置好软件源、安装依赖。装完之后验证一下版本:
docker --version能正常输出版本号,说明 Docker 引擎已经就位。有些 Linux 发行版默认没有配置开机启动,需要手动执行:
sudo systemctl enable --now docker这一步很容易被忽略。装完发现重启服务器后 docker 命令报“无法连接守护进程”,八成就是服务没被设为开机自启。
2.2 装完先改的配置:镜像加速
国内网络环境里,直接从默认公共镜像仓库拉镜像有时慢得让人怀疑人生。我第一次拉一个基础镜像,等了十分钟进度条都没怎么动,后来才意识到应该先配置镜像加速。做法是在 Docker 的 daemon 配置文件里指定 registry mirror。
Linux 上配置文件默认路径是 /etc/docker/daemon.json。如果没有这个文件,自己创建一个,内容大概是:
{ "registry-mirrors": ["https://你的加速地址"] }macOS 和 Windows 的 Docker Desktop 用户,可以在设置界面的 Docker Engine 选项卡里修改同一份 JSON。配置好之后重启 Docker,用 docker info 查看 Registry Mirrors 一栏有没有生效。
注意:加速地址的选择要谨慎,优先用你自己能访问、可用的镜像源。用完一次之后,很多镜像会被本地缓存,重复拉取速度会快很多。
配置完可以先拉一个小镜像做测试,比如拉一个 Nginx 的官方镜像:docker pull nginx:alpine。如果速度快了,说明配置生效,后续操作会顺畅很多。
2.3 第一个运行时的验证:hello-world 到底验证了什么
很多教程让你运行 docker run hello-world 来验证安装,我第一次跑的时候,以为它和写代码打印 hello world 是一回事。后来才明白,这个镜像的主要意义不是“跑出一个文本”,而是验证整套链路:docker 客户端能找到守护进程、守护进程能从镜像仓库拉取镜像、镜像能成功创建容器并执行。
执行:
docker run hello-world正常情况下会拉取镜像并输出一段说明文字。如果这一步成功了,说明 Docker 引擎本身是好的,可以继续往下学。我遇到过的失败案例里,最常见的报错是无法连接到 Docker 守护进程,基本都是服务没启动或者当前用户没有权限。
2.4 要不要配置免 sudo:方便与安全的权衡
Linux 上刚安装完 Docker,直接敲 docker run 大概率会提示 permission denied。这是因为 docker 守护进程默认监听在 Unix socket 上,只有 root 用户和 docker 组的成员才能访问。最简单粗暴的方法是所有命令前加 sudo,但每次都敲 sudo 实在烦人。
更常见的做法是把当前用户加入 docker 组:
sudo usermod -aG docker $USER然后重新登录一次,就能免 sudo 使用 docker 了。我个人是推荐这个做法的,因为日常开发中频繁使用 docker,加个组能省很多事。但必须说清楚:docker 组的权限约等于 root 权限,因为用户能通过挂载宿主机目录的方式访问任意文件。所以这个操作只建议在个人开发机或你完全可控的服务器上做,别给不信任的用户随便加组。
3. 镜像与容器:把“模板”和“实例”的关系彻底理清
装好 Docker、跑通了 hello-world,接下来要面对的就是整个 Docker 体系里最核心的一对概念:镜像(image)和容器(container)。我见过太多人学了几个月 Docker,命令背得滚瓜烂熟,但一说镜像和容器的区别就含糊。本质上这就是“模板”和“实例”的关系,和面向对象编程里“类”与“对象”几乎一一对应。
3.1 镜像:只读模板,一层一层叠出来的
镜像是一个只读的、分层的文件系统模板。里面包含了一个应用运行所需的全部内容:可执行文件、依赖库、配置文件、环境变量、启动命令等。what拉下来的 nginx:alpine,里面就是一套精简 Linux 系统 + Nginx 程序 + 默认配置。
理解镜像的关键在“分层”两个字。所有镜像都由多个只读层组成,每一层都是对文件系统的一次修改:装一个软件包、拷贝一份文件、修改一个配置。这样设计的好处非常明显——多个镜像可以共享底层相同的层,基础镜像被好几层引用时,本地只需要存一份。
打个比方:一个应用镜像相当于一本分层印刷的书,底层是所有应用都通用的“系统页”,中间是“语言运行环境页”,最上层是“你的代码页”。如果另一个应用也用同样的系统页和运行环境页,它俩就共享这些底层,不用各自重复下载。这也是为什么拉取一个新镜像时经常看到 “Already exists” 提示——那些层已经存在了。
3.2 容器:镜像的运行实例,多出一个可写层
镜像本身是不能被修改的,它是一个静态模板。当你用 docker run 基于某个镜像创建容器时,Docker 会在镜像的只读层之上,再叠加一个可写层。容器内产生的所有文件修改、日志写入、临时文件,都存在这个可写层里。
这也是理解“容器是临时的”这句话的关键。容器被删除时,它之上的可写层也会被一并删除。如果你在容器里建了数据库、存了文件,没有额外做持久化,那这些数据就跟着容器一起消失了。我见过不少新手把数据直接写在容器里,容器一删,数据全没了,哭都来不及。后面的章节会专门说数据卷,这里先记住结论:容器本身不存重要数据。
3.3 核心命令实操:先把这组命令用熟
零基础阶段,不需要一次背完所有 docker 命令,但有几条是天天要用的,我列成一张表,方便你对照着看:
| 命令 | 作用 | 常用参数 |
|---|---|---|
| docker pull 镜像名 | 拉取镜像到本地 | 镜像名:标签 指定版本 |
| docker images | 查看本地镜像列表 | -a 查看全部 |
| docker run 镜像名 | 创建并启动容器 | -d 后台运行,-p 端口映射,--name 命名,-v 挂载数据卷 |
| docker ps | 查看正在运行的容器 | -a 查看包括已停止的全部容器 |
| docker exec -it 容器名 sh | 进入容器内部执行命令 | -it 交互式终端 |
| docker logs 容器名 | 查看容器日志 | -f 实时跟踪 |
| docker stop 容器名 | 停止容器 | 按容器名或容器ID |
| docker rm 容器名 | 删除已停止的容器 | -f 强制删除运行中的容器 |
| docker rmi 镜像名 | 删除本地镜像 | 需要先删除依赖的容器 |
我建议从 docker ps -a 开始养成习惯:每次操作完都看一眼容器状态,你会慢慢理解容器生命周期各阶段的意义。
3.4 容器的生命周期:状态机并不复杂
一个容器从创建到删除,通常要经历 Created、Running、Paused、Exited、Deleted 这几个状态。docker run 其实做了两步操作:先基于镜像创建容器,再启动它。docker start 可以启动一个已存在的 Exited 容器,docker stop 会优雅停止容器,docker kill 则是强制杀掉。
重启策略是另一个实用点。docker run 时加 --restart always,容器异常退出或宿主机重启后会自动拉起;不加的话,容器停止后就是停止状态,不会自己恢复。生产环境的常驻服务,我基本都会加 --restart unless-stopped,意思是除了你手动 stop,其他情况都会自动重启。这个“unless-stopped”和“always”的差别,很多人用了很久才发现,实际用起来确实贴心。
4. 跑起第一个 Web 服务:端口映射与容器存活判断
理论讲了那么多,现在来一次像样的实战:让一个 Nginx Web 服务跑在容器里,通过浏览器访问到页面。这一步覆盖了日常使用中最常见的操作组合,也能帮你把端口映射这个绕不开的概念彻底搞懂。
4.1 为什么选 Nginx 做第一个实战对象
选择 Nginx 作为第一个容器化部署对象,原因有三个:镜像体积小,官方提供 alpine 精简版本,拉取快、占用少;配置简单,默认就能起一个静态页面服务器,不需要写后端代码;输出直观,浏览器打开就能看到结果,正适合验证效果。
拉取并运行 Nginx 的完整命令:
docker run -d --name webdemo -p 8080:80 nginx:alpine先解释后半部分:镜像是 nginx:alpine,--name webdemo 给容器取了个名字,往后操作都直接用 webdemo 这个名字,不用记一串随机生成的容器 ID。-d 表示后台运行,把终端空出来。
4.2 端口映射:容器内 80 和宿主机 8080 是什么关系
-p 8080:80 是这条命令里最核心的参数,它做了“端口映射”这件事。Nginx 默认监听在容器的 80 端口上,但容器的网络空间是隔离的,宿主机上的 8080 端口无法直接访问容器内的 80。端口映射就是在宿主机和容器之间搭一条桥:访问宿主机的 8080 端口,流量被转发到容器的 80 端口。
你可以理解为:宿主机上开了一扇门,门牌号是 8080,这扇门背后直通容器里 Nginx 正在服务的那扇门(80)。外界访问宿主机的 8080,就等于访问容器里的 Nginx。为什么外边用 8080 而不是直接用 80?因为宿主机的 80 端口可能已经被别的程序占了,选一个不冲突的高位端口更稳妥。
4.3 验证容器真的活着:curl、日志、进入容器
浏览器打开 http://localhost:8080,能看到 Nginx 默认欢迎页,说明容器已经正常提供 Web 服务。但页面能访问只是结果,排错时还需要掌握另外三种验证手段:
通过 curl 检查 HTTP 响应:
curl -I http://localhost:8080正常会返回 HTTP/1.1 200 OK,如果你看到 connection refused,多半是端口映射没生效或容器没起来。
通过 docker logs 查看 Nginx 访问日志:
docker logs webdemo每次浏览器访问 Nginx,日志里都会新增一条记录。容器起不来或者请求异常,日志是最直接的排查入口。
通过 docker exec 进入容器内部查看运行状态:
docker exec -it webdemo sh进入容器后会看到一个新的 shell,此时你操作的就是容器里的文件系统。可以看看 /usr/share/nginx/html 下有哪些文件,Nginx 的配置又在什么位置。有一条命令值得记住:docker exec -it webdemo nginx -t,用来测试 Nginx 配置文件是否有语法错误,非常常用。
4.4 容器停止与清理:用完别忘收拾现场
实战测试完,要把现场收拾干净:
docker stop webdemo # 停止容器 docker rm webdemo # 删除容器 docker rmi nginx:alpine # 删除镜像(可选)我建议新手在这个阶段多做几次“启动-访问-停止-删除”的循环,把 docker ps、docker stop、docker rm 之间的差别彻底搞清楚。stop 是停止进程,容器还在,可以再 start 起来;rm 才是删除容器,删除后它的一切都没了。很多人误把 stop 当成删除,结果发现容器还占着端口,就是没理解这两步的区别。
5. 用 Dockerfile 把“手糊应用”做成“标准化产物”
用 nginx 镜像跑一个静态页面,严格说不算“部署自己的应用”。要想把自己的代码容器化,核心是学会写 Dockerfile。Dockerfile 是一份“如何构建镜像”的说明书,把它和代码放在一起提交到仓库,任何人拿到代码,执行 docker build,就能得到一份一致的可运行镜像。这就是容器化最吸引人的地方:可复现。
5.1 为什么不用“把源码挂载进容器”的方式
在讲 Dockerfile 之前,先说说最常见的错误做法:拿一个基础镜像,再用 -v 把本地源码目录挂载进容器里跑。这种方式开发时很方便,改完代码刷新就能生效,但它完全不“可复现”——容器运行依赖宿主机上的源码文件,一旦换个环境没有这份源码,容器就跑不起来。
Dockerfile 的意义在于把代码 COPY 进镜像,把依赖也装进镜像,最终产物是一份“自包含”的应用镜像:把这个镜像拷到任何地方,都能跑出同样的服务。这不只是形式上的区别,而是交付方式的根本改变。
5.2 一个最小可用的应用 Dockerfile:逐行拆解
以一个简单的 Python Web 应用为例,代码是一个 Flask 应用,入口文件是 app.py:
from flask import Flask app = Flask(__name__) @app.route("/") def hello(): return "Docker 实战" if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)对应的 Dockerfile 如下:
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 5000 CMD ["python", "app.py"]逐行解释一下:
- FROM 指定基础镜像。选用 python:3.12-slim,slim 是官方提供的小体积变体,比完整版小很多,又比 alpine 版本省心,遇到需要编译的场景时不用补一堆依赖。你完全可以理解成“在一张白纸上作画,先从官方提供的底稿开始”。
- WORKDIR 设置工作目录。后续的 COPY、RUN、CMD 都会在这个目录下执行,相当于 cd /app。
- COPY requirements.txt . 把依赖清单拷进镜像,然后 RUN pip install 安装依赖。注意这里的顺序:先拷依赖清单,再装依赖,最后才拷代码。这个顺序利用了 Docker 镜像分层缓存机制——只要 requirements.txt 没变,这一层构建结果就可以被缓存复用,改代码后重新 build 时能省很多时间。
- EXPOSE 只是声明容器内服务使用的端口,属于文档性质,真正让外部访问还是要靠 docker run 里的 -p 映射。
- CMD 是容器启动时执行的默认命令。这里用列表形式写法,推荐保持这样,避免 shell 解析带来的额外复杂度。
5.3 构建并运行自己的应用镜像
在项目目录下执行:
docker build -t myflask:v1 .-t 给镜像打上名字和标签,格式是 名字:标签,后面的点表示构建上下文是当前目录。构建完成后,docker images 里就能看到 myflask:v1。
运行它:
docker run -d -p 5000:5000 --name flaskdemo myflask:v1浏览器访问 http://localhost:5000,应该就能看到 Flask 返回的内容。至此,你已经完成了“从源码到镜像再到容器”的完整闭环。
5.4 镜像瘦身:alpine、.dockerignore 和合并指令
第一次构建出来的镜像往往偏大,这不奇怪,动辄几百 MB。镜像大小影响的是传输和存储成本,所以瘦身是实战中绕不开的话题。我常用的手段有三个。
第一个是选小体积的基础镜像。Python 可以用 slim 或 alpine 变体,Node 也有对应的 slim、alpine 版本,Java 可以考虑精简版运行时。但注意,越小越精简意味着可能缺编译工具、缺系统库,如果你的依赖里有需要编译的原生模块,alpine 反而会让你在安装依赖时多花时间折腾编译环境。不要盲目追求最小,够用就好。
第二个是使用 .dockerignore 文件。它和 .gitignore 类似,声明哪些文件不进入构建上下文。比如本地已经下载好的依赖包目录、缓存目录、临时文件,都该被排除掉。不写 .dockerignore,构建时 docker 会把整个目录打包进上下文发送给守护进程,又慢又容易膨胀镜像。
第三个是合并 RUN 指令。每一条 RUN 都会生成一个新镜像层,层多了镜像就大,构建也慢。可以把多条命令合并成一条:
RUN apt-get update && \ apt-get install -y some-package && \ rm -rf /var/lib/apt/lists/*最后一步清理 apt 缓存,能明显减小该层体积。类似的思路也适用于其他包管理器。
5.5 多阶段构建:一个 Node 应用的真实示例
如果项目有编译步骤,比如前端项目要打包,或者 Go、Rust 这类需要编译的语言,多阶段构建是标配思路。原理很简单:第一阶段放全套编译工具,把源码编译成产物;第二阶段只留运行所需的精简环境,把产物复制过来。最终镜像只包含第二阶段的内容,体积会小一个数量级。
下面是一个 Node.js 前端项目的 Dockerfile:
FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80第一阶段用完整的 Node 环境执行构建,第二阶段用 Nginx 镜像把构建产物托管起来。最终镜像里没有任何源码和 node_modules,只有静态文件。第一次看到这种写法的同学通常会很震撼——同一个 Dockerfile 里居然可以“多段接力”,这是 Docker 生态里特别聪明的一招,强烈建议所有需要编译的项目都采用。
5.6 构建缓存与标签管理的两个小提醒
Docker 构建时会尽量复用已有层,这就是“构建缓存”。理论上缓存能大幅提升 rebuild 速度,但用不好也有坑:如果你在安装依赖之前就 COPY 了整个项目目录,那么只要任何文件变动,后续所有层都会失效,缓存形同虚设。所以最合理的顺序永远是“先 COPY 锁定依赖清单、安装依赖,再 COPY 项目代码”。
标签管理方面,我要单独提醒一次:不要在生产环境用 latest 标签。latest 是默认标签,拉镜像时不指定标签就会拉 latest,但它会随着镜像仓库更新而变动,导致你无法确定当前跑的到底是哪个版本。实战中请给镜像打上明确的版本号,比如 myflask:v1、myflask:v2,甚至可以精确到 commit 号,“可复现”是要靠明确的版本来保证的。
6. 容器重启后数据还在吗:数据卷与持久化方案
用 Docker 跑无状态应用,比如 Nginx 托管静态页面、API 服务,是不用操心数据存储的,因为挂了随时重建容器就好。可一旦涉及数据库、用户上传文件、日志这类需要持久保存的数据,事情就变了。容器一删除,可写层上的数据就跟着消失,这是 Docker 新手最容易栽跟头的地方。
6.1 一个让人心疼的经典翻车案例
我见过一个真实的翻车现场:某同学用 Docker 跑了一个博客应用,数据库就装在容器里,每天勤勤恳恳写文章。有一天他想升级数据库版本,于是 docker rm 了旧容器,重新拉了个新镜像跑起来,进去一看,所有文章都没了。容器里的数据本来就写在临时的可写层上,容器一删,什么都没有了。
这个例子足够说明问题:容器的生命周期独立于数据,任何重要的数据都不应该放在容器的可写层里。解决方案就是数据卷——数据卷的生命周期是独立于容器的,容器删了,数据卷还在;下次用同一个数据卷启动新容器,数据还是那些。可以理解为把数据从“集装箱内部”搬到了“港口仓库”,箱子和仓库互相独立,换箱子不影响仓库里的货物。
6.2 三种持久化方式:选哪个要看场景
Docker 提供了三种数据持久化的主要方式,我做成一张对比表:
| 方式 | 数据存放位置 | 适合场景 | 特点 |
|---|---|---|---|
| bind mount | 宿主机任意指定路径 | 开发调试、热更新代码、传配置文件 | 直接把宿主机目录挂载进容器 |
| named volume | Docker 管理的卷目录 | 数据库、应用数据等需要持久化 | 由 Docker 管理,备份迁移方便 |
| tmpfs | 宿主机内存 | 临时数据、敏感数据 | 不落盘,容器停止后数据消失 |
bind mount 和 named volume 是最常用的两个,前者更像是“把宿主机目录借给容器用”,后者才是“Docker 替你管理的持久化存储空间”。
6.3 named volume:最推荐的数据库持久化方案
创建一个命名卷并挂载到容器:
docker volume create mysqldata docker run -d --name mysql-demo \ -v mysqldata:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=yourpassword \ mysql:8.0这里 -v mysqldata:/var/lib/mysql 表示把名为 mysqldata 的卷挂载到容器内的 /var/lib/mysql 目录。数据库程序往这个目录写的数据,最终都保存在卷里。哪怕容器被删了,只要卷还在,下次用同样的挂载方式重新启动容器,数据就完好无缺。
查看卷的情况:
docker volume ls docker volume inspect mysqldata如果只想临时挂一个匿名卷也可以,比如 -v /var/lib/mysql,不写卷名,Docker 会自动生成一个随机名字的卷。但匿名卷不方便复用和备份,我一般只用命名卷。
6.4 bind mount:开发时改代码即时生效的利器
开发场景里需要频繁修改代码,每次改完都重新构建镜像太慢了,这时 bind mount 可以把宿主机目录直接映射进容器。
docker run -d --name webdev \ -p 8080:80 \ -v $(pwd)/html:/usr/share/nginx/html:ro \ nginx:alpine-p 8080:80 把容器 80 端口映射到宿主机 8080,-v 后面把当前目录下的 html 文件夹挂载到容器内 Nginx 的网页目录,末尾的 ro 表示 read-only,防止容器内部程序改成宿主机上的文件。
这样改宿主机 html 下的文件,刷新浏览器立刻生效,不需要重建镜像。要注意的是,bind mount 会“覆盖”容器内该目录原有的内容。如果你的容器目录里本来有初始化数据,挂载后那些数据会被宿主机目录盖住,看似“丢失”,其实只是被暂时遮蔽。
6.5 数据备份与恢复:用临时容器打包卷数据
数据最怕丢,备份方案要提前想好。利用 bind mount 加临时容器,可以很方便地备份一个命名卷。比如备份 mysqldata 卷:
docker run --rm \ -v mysqldata:/source \ -v $(pwd):/backup \ alpine \ tar czf /backup/mysqldata-backup.tar.gz -C /source .这条命令用了一个临时 alpine 容器,把命名卷挂载到 /source,把宿主机当前目录挂载到 /backup,然后在容器里执行 tar 打包。--rm 保证容器任务结束后自动删除,不留垃圾。
恢复时要反着来:把 tar 包解压到目标卷里。
docker run --rm \ -v mysqldata:/target \ -v $(pwd):/backup \ alpine \ tar xzf /backup/mysqldata-backup.tar.gz -C /target这套方法对任何数据卷都通用,不限于数据库。建议定期用 cron 跑一个备份脚本,把容器数据卷打包到安全位置,睡前看一眼干完没,心里踏实。
6.6 文件权限问题:uid 冲突怎么破
用过 bind mount 的人基本都会遇到权限问题:容器里的进程以 root 身份跑,往挂载目录里写文件,宿主机上看到的文件属主是 root,你当前用户反而改不了。反过来,宿主机上普通用户创建的文件,容器里以 root 运行的程序也可能因为权限不足打不开。
这个问题没有放之四海皆准的解法,取决于具体场景。一个通用思路是:容器启动时指定用户,让进程以宿主机当前用户 uid 运行。比如:
docker run -d --user $(id -u):$(id -g) -v $(pwd)/data:/data myapp:latest这样容器内进程就是以当前用户的身份在写文件,宿主机上文件属主就是你自己。但要注意,有些镜像的启动脚本依赖 root 权限,直接指定用户反而会启动失败。稳妥的做法是:先了解镜像默认运行用户,再用 --user 显式指定,实测验证没问题再固化到启动命令里。
7. 让多个容器协作干活:Compose 编排的实战用法
真实项目很少只有一个容器在跑。一个典型的 Web 应用往往需要多个组件:Web 服务、数据库、缓存、消息队列……如果每个都用 docker run 手动启动,命令会越来越长,而且还要手动处理网络、环境变量、启动顺序,维护成本直线上升。Docker Compose 就是来解决这个问题的:用一个 YAML 文件描述整个服务栈,一条命令全部启动。
7.1 从多条 docker run 到一份 docker-compose.yml
先感受一下没有 Compose 的状态。假设要启动一个带数据库的 Web 应用,你需要:
- 先启动数据库容器,并挂载数据卷、配置密码
- 再启动 Web 容器,设置环境变量指向数据库地址
- 还要确保两个容器在同一网络里,否则容器名无法互相解析
这些步骤靠 docker run 都能做到,但命令动辄五六条,散落在文档和记忆里,换个环境就重来一遍。Compose 的方式是把这些都固化成一份 docker-compose.yml,跟着项目代码一起提交,任何环境里都是 docker compose up 一步到位。
7.2 一份最常见的 Web + Redis 示例配置
我以“Flask Web 应用 + Redis 缓存”为例,写一份最小但完整的 docker-compose.yml:
services: web: build: . ports: - "5000:5000" environment: - REDIS_HOST=redis - REDIS_PORT=6379 depends_on: - redis redis: image: redis:7-alpine volumes: - redisdata:/data volumes: redisdata:使用这份配置的目录结构里,web 服务会用当前目录的 Dockerfile 来构建镜像,redis 服务则直接从镜像仓库拉取 redis:7-alpine。
7.3 核心字段:services、ports、environment、volumes
挨个说明关键字段。services 下定义每一个容器服务,每个服务都有自己的名字,比如 web、redis。ports 用于端口映射,格式和 docker run 的 -p 一样,宿主机端口在前,容器端口在后。environment 用于设置环境变量,等价于 docker run 里的 -e 参数。
volumes 字段有两层含义。服务内的 volumes 等价于 docker run 的 -v,指定容器内挂载点和数据卷;文件末尾顶级的 volumes 则是声明数据卷的“名册”。上面例子里 redis 服务挂载了 redisdata 卷,这个卷在最后被声明,Compose 会负责创建。
depends_on 指定服务启动顺序,比如 web 依赖 redis,会先启动 redis。但要说明:depends_on 只保证 redis 容器先启动,不保证 redis 内部已经 ready。真实项目里还是要靠应用自身的重试逻辑,或者依赖 health check 来保证更严谨的顺序。
7.4 Compose 常用命令:up、ps、logs、down
在包含 docker-compose.yml 的目录下执行:
docker compose up -d-d 表示后台运行。首次执行会先构建镜像(如果有 build),再启动所有服务。后续可以用的命令还有:
docker compose ps # 查看服务状态 docker compose logs -f # 跟踪所有服务日志 docker compose down # 停止并删除网络/容器我要特别提醒:docker compose down 默认会删除所有由 Compose 管理的容器和网络,但不会删数据卷。如果执行 docker compose down -v,数据卷也会被一并删除,到时候数据库数据全没了,谁也救不回来。我自己的习惯是:除非确定要清理全部数据,否则永远不碰 -v 这个参数。这是新手特别容易踩的坑。
7.5 Compose 自带的网络机制:服务名就是主机名
用 Compose 启动的服务会自动加入同一个默认网络,最直接的效果就是服务之间可以用“服务名”直接互相访问。上面例子里的 web 服务访问 Redis,代码里只需要把 Redis 地址配成 redis 这个服务名,不需要记住任何 IP 地址。
这里解释一下原理:Compose 创建的网络自带 DNS 解析功能,容器以服务名作为主机名注册到网络 DNS 里。容器之间的网络隔离又是默认的,外界无法直接访问容器内的端口,只有通过 ports 映射到宿主机后才能从外部访问。这种天然的网络隔离是安全性的基础,多环境多项目同时跑时不容易串。
我见过不少人在宿主机上起了多个 Redis 容器,结果端口冲突,然后在配置里改来改去。如果用 Compose,每个项目都有自己的网络和内部端口,外部暴露的端口只需要保证宿主机上不冲突就行,内网通信完全不用操心。
8. 从开发机到服务器:镜像迁移与部署的几条实用路径
本地开发环境里容器已经跑通了,接下来要做的是把应用真正部署到服务器。这涉及一个核心问题:怎么把镜像“搬”到目标机器上。根据网络环境和目标服务器的条件,有三条常见路径,各有适合的场景。
8.1 部署的本质:把镜像弄到目标机器上
部署容器化应用,本质上不是“拷贝代码”,而是“拷贝镜像”。你开发机上构建好的镜像,包含了一整套完整运行环境,这个镜像只要能在目标机器上运行,应用的运行效果应该和本地完全一致。这就是我前面反复强调的“可复现”在实际部署中的价值——你不再需要去目标服务器上重复“装环境”这个动作了。
基于这个理解,部署路径就不再神秘:要么把镜像推送到一个公共仓库,目标机器从仓库拉取;要么直接把镜像文件导出,拷贝到目标机器再导入;要么让目标机器直接基于源码构建。三者各有取舍。
8.2 路径A:推到镜像仓库,目标机器拉取
将本地镜像推送到公共镜像仓库,是最标准的做法。前提是你有一个仓库账号,先把本地镜像打上对应仓库命名空间的标签:
docker tag myflask:v1 你的用户名/myflask:v1 docker push 你的用户名/myflask:v1这里“你的用户名”就是你在镜像仓库平台的账号命名空间。推送成功后,在目标服务器上执行:
docker pull 你的用户名/myflask:v1 docker run -d -p 5000:5000 你的用户名/myflask:v1这套流程的优点是便捷、版本管理自然,有 tag 的概念;缺点是你的镜像会放在公共平台,私有项目需要考虑搭建私有仓库或者其他替代方案。我个人建议:即便用公共仓库,也一定要用明确的版本标签,别用 latest 推来推去,否则目标机器上拉到的镜像到底是哪个版本,很容易糊涂。
8.3 路径B:离线环境用 save 和 load
有些服务器处于隔离网络环境,拉不到外网镜像,这时候用 docker save 和 docker load 做离线迁移最直接:
docker save myflask:v1 | gzip > myflask-v1.tar.gz生成一个压缩的镜像文件,通过 U 盘或其他方式传到目标机器上:
gzip -dc myflask-v1.tar.gz | docker load现在目标机器上已经有 myflask:v1 镜像,后续的 docker run 和正常情况一样。save 和 load 是对称操作,前者导出镜像,后者导入镜像。笨是笨了点,但这种方式的优点是不依赖任何外部服务,离线环境、内网环境都能用,而且整个镜像完整保留,包括它的分层结构和历史信息。
有一点要提醒:不要在开发机上频繁 save 带 latest 标签的镜像,因为你无法从文件名判断内容到底改没改过。我会把版本号直接写进文件名,比如 myflask-v1-20260301.tar.gz,传到目标机器后也知道这份包里装的是什么。
8.4 路径C:目标机器上直接构建
目标机器有代码仓库访问权限、有构建环境的情况下,直接在服务器上拉代码、跑 docker build 也是一种常见做法。配合 Compose 更舒服:
git clone 代码仓库地址 cd 项目目录 docker compose up -d --build--build 参数会先执行构建,再启动服务。这样省去了本地构建再迁移镜像的环节,也方便在服务器上直接排代码部署最新版本。缺点是服务器上必须有完整的构建环境:Docker、代码仓库访问权限,如果构建过程依赖大量编译工具,还会占用服务器资源。
选哪条路径,我给出的判断标准很简单:能访问公共镜像仓库,优先路径 A;隔离网络,路径 B;团队直接维护同一份代码、服务器配置较好,路径 C 也很常用。三种不冲突,混着用都行。
8.5 服务器上运行容器的一些通用建议
不管用哪条路径部署到服务器,有几件事是通用的,我慢慢养成习惯之后,少了很多半夜起来修服务的经历。
第一,常驻服务一定要加 --restart unless-stopped,或者 Compose 配置里写 restart: unless-stopped。这样服务异常退出、服务器重启后,容器会自动恢复,不需要你手动登录去拉起。
第二,日志必须做轮转。Docker 默认将容器日志以 json-file 格式存到宿主机,长期不清理会占用大量磁盘空间,我见过日志文件把磁盘塞满、容器直接挂掉的案例。限制日志大小的最小操作,是在 docker run 时加:
--log-opt max-size=10m --log-opt max-file=3或者在 daemon.json 里统一配置 log 相关的参数。
第三,尽量用非 root 用户运行容器。基础镜像默认很多以 root 身份运行,一旦容器被攻破,host 上的风险就会被放大。简单的处理是在 Dockerfile 里创建普通用户并切换:
RUN useradd -m appuser USER appuser这个习惯越早养成越好,别等到出了事才后悔。
9. 实操一年后,我建议你记住这几件事
文章最后这部分,我不打算做“复习总结”,因为那些前面已经讲得很清楚了。我想分享几个只有真的用 Docker 一段时间之后,才会慢慢悟出来的经验。如果你刚学完前面的内容,这些建议也许能帮你少走一段弯路。
9.1 先跑通,再优化,不要一开始就追全流程
这是我学 Docker 最大的教训。刚接触那会儿,我看什么教程都想一次性学会:网络模式、存储驱动、安全加固、Swarm、Kubernetes……结果每个概念都只停留在“看过”的层面,真正写 Dockerfile 的时候还是手忙脚乱。
后来我调整了策略:先把最简单最直接的一条链路跑通——拉镜像、启动容器、访问服务、停掉容器。跑通之后再回来补概念,一切都变得顺理成章。容器网络、数据卷、编排这些,只有在“跑通了基础链路”之后才谈得上理解。对零基础读者,我强烈建议按本文的顺序走一遍,第一步就耐心一点,跑通一个 Web 服务再说。
9.2 常见报错速查:这几条错误我全踩过
把最常遇到的几个报错整理成一张速查表,方便你对照:
| 报错信息 | 含义 | 常见解决办法 |
|---|---|---|
| port is already allocated | 端口已被占用 | 换一个宿主机端口,或查看并停止占用该端口的容器 |
| Unable to find image ... locally | 本地没有该镜像且拉取失败 | 检查镜像名拼写、网络配置和镜像加速设置 |
| permission denied while trying to connect | 当前用户没有 Docker 权限 | 加入 docker 组或使用 sudo(见第2.4节) |
| Cannot connect to the Docker daemon | Docker 服务未启动 | 检查服务状态并启动 |
| no space left on device | 磁盘空间不足 | 清理无用镜像、容器、日志,必要时加大磁盘 |
你别指望能不用容器就不会遇到这些问题,所有玩 Docker 的人都会碰到。重要的是遇到问题后按顺序排查:先看 docker ps 判断容器状态,再看 docker logs 找应用日志,再检查端口和网络配置。绝大多数问题都能在这一套“三板斧”里定位出来。
9.3 日常清理:别让本机积累一堆“垃圾”
用一段时间之后,本地镜像、停止的容器、悬空的镜像层会积累得越来越多。查看磁盘占用可以用:
docker system df会显示镜像、容器、数据卷、构建缓存占用的磁盘空间。清理的常用命令:
docker container prune # 删除所有已停止容器 docker image prune # 删除悬空镜像 docker system prune -a # 大扫除:清理未使用的容器、镜像、网络、缓存最后一个命令要慎用,-a 会把所有没在使用的镜像都删掉,如果你的某个镜像只是临时不用、后面还要再用,就得重新拉取。我会先跑 docker system df 看清占用情况,再决定精确清理哪些对象。
9.4 把“可复现”当成容器化的第一价值
聊到这儿,我想把文章开头的那个问题再回应一遍。Docker 最大的价值不是“快”,也不是“炫”,而是“可复现”。同一个镜像,在任何装了 Docker 的机器上,都会跑出同样的结果。这意味着:开发环境和生产环境的差异可以彻底告别,团队的协作方式可以统一,交付物从一团乱的源码变得清晰可见。
我个人把一年里所有个人项目全部容器化之后,感受最深的不是省了多少部署时间,而是那种“不怕换了环境就翻车”的踏实感。每次拿到一台新服务器,不需要再从零折腾环境,docker compose up 一下,项目就活了。这种体验,值得你也认真试一次。