第一次把应用跑进容器的时候,你大概没想过一个问题:容器停了、删了,写进容器里的数据还在吗?答案是——没了。这是Docker新手几乎都会碰到的第一次“数据惊吓”。而Docker为这个问题准备的标准答案就是数据卷(Volume);和它经常一起被问到的,还有容器直连,也就是容器与容器之间该怎么互相访问。我自己也是吃了好几次亏才把这两个概念彻底搞明白,所以这篇想把它们一次讲透,适合刚学完docker run、docker exec这些基础命令、准备把真实业务放进容器的开发者,也适合那些数据已经丢过一次、正在补课的人。
1. 明明容器是隔离的,为什么还要单独搞一套“卷”
先说数据卷这件事。
容器本身是一个隔离的环境,里面跑着进程和文件系统。刚学Docker时,很多人会默认“容器里的文件就是数据”,然后直接在容器里写入数据、存配置,用着还挺顺手。但只要执行一次docker rm,容器连同可写层一起被删掉,里面所有文件就彻底消失了。这个行为不是bug,而是容器设计上的固有特点——容器天然就是“一次性”的。
1.1 生命周期解耦,才是数据卷存在的理由
数据卷的核心思想很简单:把数据写到容器外面的独立存储区域,让数据的生命周期和容器的生命周期解耦。容器可以随便删、随便重建,数据还在宿主机上好好待着。你可以把容器理解成一次性的快餐盒,吃完就扔;而数据卷是你家楼下的调料仓库,盒子扔了,仓库还在,下一份饭随时能重新做出来。
Docker里实现这种解耦有三种方式:绑定挂载(bind mount)、具名数据卷(volume)、临时文件系统(tmpfs)。三者各有各的使用场景,我整理了一个对照表,建议收藏。
| 方式 | 数据在哪 | 谁创建 | 典型场景 | 生命周期 |
|---|---|---|---|---|
| bind mount | 宿主机任意路径 | 你指定路径,Docker负责挂载 | 开发环境直接改源码、配置 | 跟你指定路径存在的时间一致 |
| volume | Docker管理的宿主机目录 | docker volume create或运行容器时自动创建 | 数据库数据、应用持久化数据 | 手动删除卷才消失 |
| tmpfs | 宿主机内存 | Docker自动创建 | 临时敏感数据,不落盘 | 容器停止即消失 |
生产环境和数据库这类场景,绝大多数应该选 volume。bind mount 适合开发阶段频繁改代码,tmpfs 适合存一些不想写入磁盘的临时信息,比如临时Token。
1.2 为什么不能寄希望于docker commit
有人会说:“那我定期执行docker commit把容器保存成镜像,数据不就在了吗?”理论上是能保住,但千万别这么干。镜像文件每一层都是完整的目录快照,提交一次就把整个可写层打成一个镜像层,几次提交下来,镜像体积会膨胀到非常夸张。而且 commit 只适合保存“状态”,不适合作为数据备份方案——你总不能每次写一条数据都 commit 一次吧。
数据卷没有这些毛病。它绕过了容器的可写层,直接挂在宿主机上,读写走的是宿主机磁盘,性能和可写层相比也更可控,尤其是数据库这类对I/O敏感的应用,更要把数据目录放到卷里。记住一个判断标准:凡是删了容器之后你不想丢的东西,都应该放进数据卷。
2. 数据卷实操:创建、挂载、备份一个都不落
数据卷的命令不算多,但细节非常多,很多坑都藏在参数写法和权限上。
2.1-v和--mount选哪个:老司机建议的挂载写法
挂载数据卷有两种写法,新手最容易懵:
docker run -d -v mydata:/app/data myimage docker run -d --mount type=volume,src=mydata,dst=/app/data myimage两条命令效果一样,但-v看起来很短,实际埋了个大坑。-v会根据你写的第一个参数自动判断是“具名卷”还是“bind mount”:如果第一个参数以/开头,它会被当作宿主机路径,执行的是 bind mount;如果第一个参数是普通名字,它才被当作 volume 名称。很多时候你明明想挂卷,结果写成了/mydata,Docker 就当成宿主机的/mydata目录来挂,数据完全落到了另一个地方,排查半天才发现。
--mount的要求就严格得多,必须显式声明type=volume还是type=bind,字段也语义化,src、dst、volume-opt一目了然。我的建议是:写脚本、写文档一律用--mount,少用-v这种“聪明”的简写。另外注意,数据库镜像一般自带VOLUME声明,比如 MySQL 默认声明的/var/lib/mysql,你不手动挂,Docker 也会帮你建一个匿名卷,但匿名卷在容器删除时不会自动清理,容易变成宿主机上的“丧尸数据”,所以建卷就建具名卷,别偷懒。
2.2 权限问题:为什么容器里的文件在宿主机上变成了“未定义操作”
有个特别常见的现象:用 MySQL 镜像挂载了数据卷,跑到宿主机上看数据目录,发现属主是一个奇怪的数字,比如999,甚至根本删不掉某些文件。这不是系统坏了,而是容器内部的用户ID和宿主机用户ID不一致导致的。
容器镜像大多不是用 root 运行的,比如 MySQL 镜像默认用户是mysql,这个用户在镜像里的 UID 通常是999。当你把数据卷挂进去,容器往里面写文件时,文件属主就会变成 UID 999。宿主机上如果没有对应 UID 用户,只能显示成一个数字。反之,如果你把宿主机上某个目录 bind mount 进容器,但宿主机的目录权限是普通用户的 1000,容器内的 MySQL 用 UID 999 去写,也会直接报 Permission denied。
遇到权限问题,先查两件事:容器默认用户是什么,宿主机目录属主是什么。解决办法不是杀到容器里乱改权限,更不要在宿主机上对数据目录直接chmod 777,而是要么用--user指定运行用户,要么在镜像构建时把目录属主改成容器用户。对已经产生的数据,备份后重建才是正道。我的经验是:新建卷之前,先用docker run --rm 镜像 id或docker inspect确认镜像默认 UID,再决定宿主机目录要不要做准备。
2.3 备份与恢复:一条 tar 命令把数据搬回家
数据卷持久化了,但宿主机也会故障,所以卷一样要备份。Docker 官方的备份方案其实很朴素:起一个临时容器,把数据卷挂进去,再挂一个备份目录,然后在容器里执行tar打包。
备份命令长这样:
docker run --rm \ --volumes-from demo-db \ -v $(pwd):/backup \ mysql:8.0 \ tar cvf /backup/db-backup.tar /var/lib/mysql--volumes-from的作用是直接把demo-db容器上挂的所有卷共享给这个临时容器,它会把/var/lib/mysql整个目录连同内容打包到宿主机当前目录。恢复则是反着来:临时容器把备份卷和目标卷都挂上,然后在容器里解包,把数据还原进卷里面。
docker run --rm \ -v mydata:/var/lib/mysql \ -v $(pwd):/backup \ mysql:8.0 \ sh -c "cd / && tar xvf /backup/db-backup.tar"执行恢复前,最好先停掉所有使用该卷的容器,避免运行中的进程往数据目录里写东西,解包时把新数据和旧数据搅在一起。备份是个习惯问题,不是技术问题,建议每周至少跑一次。别等到数据没了才想起这茬,那会儿想哭都没地方哭。
3. 容器直连:别再把IP写死在配置里
数据卷解决“数据活下来”,接下来容器直连解决的是“容器互相找得到”。
3.1 默认 bridge 网络为什么“不够用”
Docker 安装完默认会建一个叫bridge的网桥(对应宿主机上docker0),新容器默认都接在这个网络上,Docker 会从网段里给每个容器分配一个 IP。两个容器要想通信,直接在配置里写对方IP就行,听起来没什么问题,但实际用起来会非常痛苦——容器的IP不是固定的。只要容器重启、停止再启动,甚至只是Docker daemon重启,IP就可能变。你辛辛苦苦把IP写在配置文件里,一重启就全断了。
那--link呢?旧教程里经常出现这个参数。它的原理更接近“手动抄写地址”:在启动容器时,把目标容器的IP写进/etc/hosts,给一个名字做映射。问题是它没有解决IP漂移的根本问题,而且 Docker 官方已经把它标记为遗留功能,现代做法已经不再依赖--link。真正要做的,是放弃默认 bridge 网络,使用自定义 bridge 网络。
3.2 自定义网络才是正经的直连姿势
创建一个自定义网络很简单:
docker network create --driver bridge --subnet 172.28.0.0/16 mynet先解释一下这里做了什么。--driver bridge表示用桥接模式,--subnet指定网段。你可以不指定网段,让 Docker 自己分配,但我习惯手动指定,主要是方便排查问题——你知道这个网络里的容器都会落在172.28.0.0/16,看docker network inspect的输出时心里有数。
最关键的是,自定义 bridge 网络自带 DNS 服务发现。同一网络里的容器,直接用容器名就能互相访问,不需要写IP。比如我在mynet里启动两个容器:
docker run -d --name app-a --network mynet alpine sleep 3600 docker run -d --name app-b --network mynet alpine sleep 3600 docker exec -it app-a sh -c "ping app-b -c 1"ping app-b直接通,DNS 会自动把app-b解析成对应容器IP。容器重启、IP怎么变都没关系,你始终只用记住名字。
这带来的好处是配置层面的解耦。微服务彼此调用时,配置文件里只需要写http://app-b:8080这种地址,无论底层IP怎么换,服务入口不变。自定义网络还支持网络别名--network-alias,比如给app-b加个别名,或者叫db、叫user-service之类的语义化名字,方便以后替换容器而不改调用方配置。
3.3 那些年被误解的-p端口映射
说到容器互访,就不得不提一个更常见的误解:很多人以为容器A要访问容器B的8080端口,就必须给B做-p 8080:8080映射。这是把“宿主机访问”和“容器间访问”搞混了。
-p解决的是外部客户端访问容器的问题:把容器端口映射到宿主机端口之后,别人可以通过宿主机IP:端口访问容器。但容器和容器只要在同一个自定义网络里,是直接通过网络互通消息的,访问对端端口根本不需要-p,直接用容器名:端口就行。-p只是给宿主机留了一扇门,你还拿它做内部门禁,就有点绕远路了。
还有一个容易踩的坑:如果你把不同容器里的多个服务都映射到宿主机端口,比如-p 8080:80和另一个容器-p 8080:8081,端口冲突就来了。容器间访问不会遇到这种问题,因为它们的网络是隔离的,端口在容器内部是完整的。这也解释了为什么业务量一大,大家宁可把服务拆开放容器里通过网络互访,而不是把所有端口全部映射到宿主机一层层往外翻。
4. 一个模拟项目把数据卷和网络串起来
光讲概念容易飘,拿一个具体的例子走一遍才实在。我模拟了一个最典型的场景:一个笔记应用,前端/业务服务容器要访问 MySQL 数据库容器,数据库数据要持久化到宿主机,容器之间不能写死IP。
4.1 创建数据卷和自定义网络
先把基础设施准备好:
docker volume create note-data docker network create --driver bridge --subnet 172.30.0.0/16 note-netnote-data是 MySQL 的数据卷,note-net是数据库和应用容器共同使用的自定义网络。网络和卷都是资源,可以脱离容器独立存在,这正是我们想要的解耦。
4.2 启动数据库容器并挂载数据卷
docker run -d --name demo-db \ --network note-net \ --mount type=volume,src=note-data,dst=/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=note-secret \ mysql:8.0这里把 MySQL 默认数据目录/var/lib/mysql挂到了note-data卷上,同时接入note-net网络。第一次启动时 MySQL 需要初始化数据目录,要等十几秒到几十秒,可以循环执行docker exec -i demo-db mysql -e "select 1"来判断就绪,看到1就说明服务起来了。
登录数据库建一张表,写一条数据:
docker exec -i demo-db mysql -uroot -pnote-secret <<'SQL' CREATE DATABASE IF NOT EXISTS note; USE note; CREATE TABLE IF NOT EXISTS items ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(64) ); INSERT INTO items(title) VALUES ('hello-docker'); SELECT * FROM items; SQL一切正常的话,查询结果里能看到hello-docker这条数据。
4.3 让应用容器通过名字访问数据库
再启动一个模拟业务应用的容器:
docker run -d --name demo-app \ --network note-net \ alpine sleep 3600然后进入demo-app容器,验证它能不能通过容器名访问数据库:
docker exec -it demo-app sh -c "getent hosts demo-db"getent hosts demo-db会输出demo-db解析出来的IP,比如172.30.0.2。这说明两个容器在同一网络里,通过内置DNS发现了对方。后面如果应用代码里写连接地址,直接写demo-db:3306就行,IP是什么不重要。
4.4 验证数据持久化:删容器,数据还在
现在来验证数据卷的威力。把demo-db整个容器删掉:
docker rm -f demo-db然后重新用同一个卷和网络建一个新容器:
docker run -d --name demo-db2 \ --network note-net \ --mount type=volume,src=note-data,dst=/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=note-secret \ mysql:8.0等待初始化完成,查询刚才那张表:
docker exec -i demo-db2 mysql -uroot -pnote-secret -e "SELECT * FROM note.items;"hello-docker还在。这背后发生的原理是:note-data卷独立于容器存在,MySQL 容器删除时卷并没有被删,新的容器重新把卷挂到/var/lib/mysql,数据自然原封不动。这个过程跑一遍,你对“数据卷”三个字的理解会比看十遍文档都深。
5. 常见报错与排查记录:坑几乎都在这几类
实操多了,总会碰到报错。把常用的排查思路整理成一张速查表,之后遇到问题直接按表索骥。
| 现象 | 可能原因 | 排查思路与解决 |
|---|---|---|
-v挂着挂着,数据写到了宿主机不知名目录 | 第一个参数以/开头,被当成 bind mount | 改用--mount type=volume;docker volume ls看有没有多出无名卷 |
| 容器一删,数据全无 | 根本没挂卷,或挂的是匿名卷而没有重新挂载 | 确认--mount参数,统一使用具名卷 |
| 容器内挂载目录报 Permission denied | 容器用户 UID 与宿主机目录属主不一致 | 先docker inspect查默认用户,再调整目录属主;不要乱chmod 777 |
docker network connect xxx报 network not found | 网络名拼写错误,或网络已被删除 | docker network ls查看现有网络 |
两个容器 ping 不通:Network is unreachable | 不在同一个网络里 | docker network inspect查看容器挂接情况,用docker network connect接入 |
| 容器间互相访问用 IP 没问题,但用名字不通 | 容器不在自定义网络上,默认 bridge 没有完善的DNS发现 | 创建自定义网络,把所有互访容器都接到同一个自定义网络 |
docker system prune -a --volumes后所有卷和数据都被清了 | prune 命令带了--volumes,清理的是所有未使用卷 | 执行任何带--volumes参数的命令前,先确认这个卷确实不要了 |
排查时有几个命令非常趁手。第一个是docker inspect -f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{end}}' 容器名,可以快速看到某个容器到底挂了哪些卷、挂载关系对不对。第二个是docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 容器名,直接取出容器IP,判断它在哪个网段。第三个是docker network inspect 网络名,它会列出网络里所有容器和各自IP,是整个网络的“实时地图”。
还有一个经常被忽视的细节:创建自定义网络时,别和宿主机现有网络段冲突。比如你公司局域网已经是192.168.1.0/24,你就别把容器网络也设成这个段,否则容器访问宿主机其他物理机器时,路由表会和内网网关打架。选一个不常见的保留网段,比如172.30.0.0/16,是最省心的做法。
说实话,数据卷和容器直连这两个知识点,是我觉得Docker从“玩玩具”变成“干活工具”的一道分水岭。我刚开始跑业务容器时也犯过傻,把数据库数据直接放在容器可写层,结果一次升级容器重启,数据全没了,好在是测试环境,推倒重来不难,但从那以后我养成了两条铁律:凡是数据,必须用卷;凡是服务互访,必须用网络名。最后再分享一个小技巧:给业务服务起容器名的时候,多用--network-alias加一个语义化别名,比如数据库容器除了demo-db再加一个db别名,调用方永远只认别名,你以后偷偷把数据库容器换掉、名字随便改,调用方都不用动一行配置。数据卷这块,别贪图方便把docker system prune --volumes当日常清理命令用,清理前先问自己一句:这个卷还要不要?希望这个知识点能让你少走几次弯路。