1. 先把Docker的命令心智模型搭起来:镜像、容器、数据卷到底在管什么
1.1 你每天操作的三个对象,不是三个概念
我见过太多人一上来就背命令,docker run、docker ps、docker exec背得滚瓜烂熟,但遇到实际问题就懵:为什么容器删了数据就没了?为什么改了代码镜像还是旧的?为什么两个容器互相访问不了?这些问题的根源,不是命令没记住,而是没搞清楚Docker命令的管理对象。
Docker的命令体系虽然庞大,但80%的日常操作都围绕三个对象展开:镜像(Image)、容器(Container)、数据卷(Volume)。再加一个辅助角色:网络(Network)。
可以打个比方。镜像是一张光盘母盘——它只读、不可变、可以被复制无数次。容器是拿这张光盘装进光驱运行起来的那台播放器——它有自己的状态、会读写临时文件、可以被启动、暂停、销毁。数据卷是插在播放器上的移动硬盘——播放器坏了换一台,硬盘拔下来插到新播放器上,数据一点不丢。网络是连接各台播放器的网线——让它们能互相通信。
这个类比能解释很多困惑。比如你在容器里装了一个软件,然后执行docker commit把容器提交成新镜像——这相当于把一台调试好的播放器连里面的临时配置一起重新压了一张母盘。又比如你改了代码,重新docker build之后发现容器里还是旧代码——因为你启动容器时根本没有把代码目录挂载进去,容器里跑的始终是镜像里那一份拷贝。
1.2 从hello-world开始的命令全链路拆解
安装好Docker之后,绝大多数教程让你跑的第一条命令是:
docker run hello-world这条命令背后发生了四件事,理解了这四件事,后面所有命令都能串联起来:
- 本地没有
hello-world这个镜像,Docker自动去Docker Hub拉取; - 拉取成功后,Docker根据镜像创建一个容器;
- 容器启动,执行镜像里预设的入口程序,输出一段欢迎信息;
- 程序执行完毕,容器退出,但容器本身还存在(只是状态变成了Exited)。
很多人忽略第4点,以为跑完就结束了。实际上你可以立刻执行:
docker ps -a会看到那个已经退出的hello-world容器安静地躺在列表里。这就是理解Docker命令体系的关键起点:run是“创建并启动”的复合命令,不是“运行一下”这么简单。由这个起点延伸出去,你才会真正明白什么时候该用start,什么时候该用restart,什么时候需要加-d,什么时候需要加--rm。
2. 容器生命周期命令:从run到rm的完整闭环
2.1 run、start、restart、stop、kill到底怎么选
日常操作中,最常被问到的就是:start和run有什么区别?stop和kill又有什么区别?restart会不会重置容器里的数据?
先从最核心的docker run说起。一个带常用参数的实际示例:
docker run -d --name nginx-web -p 8080:80 -v /opt/nginx/html:/usr/share/nginx/html nginx:latest逐个拆解:
-d:后台运行。不加这个参数,容器会以前台方式运行,直接占据当前终端,Ctrl+C之后容器就停了。对生产环境服务来说,-d几乎是必加的。--name nginx-web:给容器起一个固定名字。不起名字Docker会随机分配一个,比如focused_hoover,你要exec进去操作时还得先docker ps查容器ID,麻烦且容易搞错。-p 8080:80:端口映射。宿主机8080端口收到的请求转发到容器内的80端口。这里有一个常见的认知误区:容器内的端口和宿主机端口是隔离的。你在容器里启动了一个服务监听80端口,宿主机上直接访问localhost:80是访问不到的,必须做映射。-v:挂载目录。把宿主机的/opt/nginx/html目录映射到容器内的/usr/share/nginx/html,这样修改宿主机上的HTML文件,容器里立即生效,不需要重新构建镜像。
不加-d的run适合什么场景?调试和日志观察。比如你启动一个服务,想直接看它打出来的日志,可以先用前台方式跑一次,确认没问题了再改成-d。
start和run的区别很直白:docker start只能启动一个已存在的容器(Exited状态),它不会重新创建容器,也不会重新执行run时的创建逻辑。容器还是那个容器,文件系统状态和之前一样。run则是每次都是“新建一个容器”。所以如果你习惯用docker run来重启服务,恭喜你,你正在批量制造僵尸容器——每次run都会多一个容器实例,哪怕名字冲突了也只能先rm再run。
stop和kill的区别更微妙。stop是优雅停机:Docker先给容器内的主进程发送SIGTERM信号,让应用有机会做收尾工作(比如保存状态、关闭数据库连接),默认等待10秒后如果还没退出,再发送SIGKILL强制杀死。kill则是直接发送SIGKILL,立即终止,不给任何善后机会。实际使用中,除非容器已经卡死无法停止,否则优先用stop。
restart有个容易被忽略的细节:它执行的是“先stop再start”,容器ID和容器内的数据都保留,但容器内的进程状态会重置。如果你改了容器里的配置文件,想让它重新加载,restart就是最快的办法。
2.2 查看与交互:ps、logs、exec、attach的适用边界
容器跑起来之后,你需要观察它、进入它、操作它。这一块的方法如果不区分清楚,很容易把环境搞得一团糟。
先看查看类命令。docker ps列出正在运行的容器,docker ps -a列出所有容器(包括已退出的)。建议直接用:
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"--format参数用Go模板语法自定义输出列,对容器数量多的情况非常有用。默认的输出列太多,小屏幕上经常换行,反而看不清楚。你也可以把这条命令配成shell别名,比如alias dp="docker ps -a --format ...",能省不少事。
docker logs是排错第一工具。常用参数:
docker logs -f --tail 100 nginx-web-f是持续跟踪输出(类似tail -f),--tail 100表示只看最后100行。注意一点:logs命令能看到的日志,是容器内主进程(PID 1)的标准输出和标准错误。如果你的应用写日志写到了文件里,docker logs是看不到的。所以一个建议:Docker容器内的应用尽量把日志输出到stdout/stderr,而不是写文件,这样日志才能被Docker统一管理。
docker exec和docker attach是最容易混淆的一对命令。
exec是在运行中的容器里新起一个进程,常用于进入容器执行命令:
docker exec -it nginx-web bash-i保持标准输入打开,-t分配一个伪终端,两个参数几乎总是同时使用。前面提过的nginx-web这个容器,因为用了-d后台运行,你无法直接看到它的命令行界面,exec bash就是进入容器的标准姿势。
attach则不同,它是把当前终端连接到容器的主进程上,就像把屏幕接到那台播放器的HDMI口上。问题在于:如果主进程没有开启交互输入,attach进去之后你可能什么都做不了,而且一不小心按了Ctrl+C,SIGINT信号会直接发给主进程导致容器停止。对大多数人来说,exec足够应付99%的场景,attach更适合调试那些需要交互式输入的应用。
3. 镜像管理命令:从拉取到清理的完整操作组合
3.1 pull、images、tag、rmi的日常配合方式
镜像管理是所有命令的基础,因为容器再花哨,源头还是那一张“母盘”。先看拉取镜像:
docker pull mysql:8.0拉取时建议总是显式指定tag。docker pull mysql默认拉的是latest标签,而latest在官方镜像仓库里往往是一个“滚动更新”的指针。今天拉的和下周拉的可能是不同版本,这给复现环境埋了很大的坑。更稳妥的做法是明确指定大版本甚至具体小版本,比如mysql:8.0.36。同理,docker run时如果不写tag,也是默认latest。
查看本地镜像:
docker images输出里有一列是IMAGE ID,是镜像的短ID。很多人不知道的是:IMAGE ID才是镜像的唯一标识,而repository:tag只是一个人类可读的别名。同一张镜像可以打多个tag,比如:
docker tag nginx:latest my-nginx:v1 docker tag nginx:latest my-nginx:backup-20250101执行完这两条命令后,docker images里会出现三条记录,但注意看IMAGE ID——它们完全一样。也就是说同一个镜像占用一份磁盘空间,只是有了三个不同的“名字”。这在生产环境做版本归档时特别实用:发布前先打个带日期的tag,回滚时直接docker run对应tag即可,不需要重新拉取镜像。
删除镜像是风险操作:
docker rmi my-nginx:v1如果该镜像已经被某个容器使用(哪怕容器已经停止),rmi也会报错,提示image is being used by stopped container。这种时候需要先删掉那个容器再删镜像,或者用docker rmi -f强制删除。但我的建议是:除非确认无误,否则不要用-f。强制删除会把镜像和容器之间的关联切掉,留下一个状态异常的容器,排查起来非常痛苦。
还有一个磁盘清理的好工具:
docker system df它像磁盘分析器一样告诉你:镜像占了多少、容器占了多少、数据卷占了多少、构建缓存占了多少。然后:
docker system prune -a会清理所有未被容器使用的镜像、所有停止的容器、所有未使用的网络和构建缓存。注意-a会把所有没有被正在运行的容器引用的镜像全部清掉,如果你本地有想保留的镜像但没跑容器,也会被删。稳妥姿势是先用docker images看一眼有哪些想留的,再决定是否加-a。
3.2 build、commit、save、load在哪些场景下真正好用
docker build是构建镜像的主路径,需要一个Dockerfile。一个最小可用的例子:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple COPY . . CMD ["python", "app.py"]构建命令:
docker build -t my-app:1.0.0 .最后那个点指构建上下文路径。这里有个常见误区:很多人以为.是“当前目录所有文件都打进镜像”,其实它是把当前目录作为构建上下文发送给Docker守护进程,Dockerfile里的COPY . .才会把这些文件复制进镜像。如果你的目录里有node_modules、venv这类大目录,构建会很慢,正确做法是加一个.dockerignore文件,语法和.gitignore类似。
docker commit是把一个运行中的容器固化成镜像。我承认这是一个有用的功能,但强烈建议只在临时调试时使用。比如你进容器手把手装了一堆库、改了一堆配置,最后想把这份“现场成果”保存下来,commit是快的。但它的致命问题是:镜像不可复现。你无法从Dockerfile知道镜像里到底做了什么改动,连镜像维护者自己隔一个月都说不清。团队协作时,这种“黑盒镜像”就是灾难。用一句话概括:commit是不得已而为之的后门,不是常规路径。
docker save和docker load用于离线传输镜像:
docker save my-app:1.0.0 | gzip > my-app.tar.gz docker load < my-app.tar.gz或者用-o直接输出成文件(新增Docker版本支持):
docker save -o my-app.tar my-app:1.0.0 docker load -i my-app.tar这个组合在内网离线环境、跨机器拷贝镜像时非常实用。注意docker save保存的是完整镜像(包含所有历史层),所以体积通常比docker export导出的容器快照大。export只是把容器文件系统打成tar包,不包含镜像层的元数据,无法用load恢复成镜像,只能import成一张新镜像,且历史结构会丢失。日常使用中,镜像迁移用save/load,容器快照用export/import,两者别混。
4. 数据卷与挂载命令:解决容器重启后的数据丢失焦虑
4.1 绝对路径挂载与命名卷的区别
“为什么容器重启后我写入的数据全没了?”这是新手最常见的问题,背后就是对数据卷机制的不理解。容器本身是无状态的——它的可写层是临时的,容器一旦删除,这一层就没了。要想数据持久化,必须把它放到数据卷里。
数据卷有三种主要形式,这里只讲最常用的两种。
第一种是绑定挂载(bind mount),把宿主机的一个绝对路径映射到容器内:
docker run -d --name mysql-db -v /opt/mysql-data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=123456 mysql:8.0宿主机上/opt/mysql-data目录就是MySQL真正存储数据的地方。容器随便删随便重建,下次启动时只要挂载同一个宿主机路径,数据全在。这种方式所见即所得,备份就是把/opt/mysql-data整个拷贝走,排查问题也直接进宿主机目录看文件,特别适合开发环境。
第二种是命名卷(named volume):
docker volume create mysql-data docker run -d --name mysql-db -v mysql-data:/var/lib/mysql mysql:8.0这里mysql-data是一个由Docker管理的卷,它对应的实际物理路径在/var/lib/docker/volumes/mysql-data/_data(Linux环境下)。你不需要关心它具体存在哪,Docker统一管理。查看卷列表:
docker volume ls查看某个卷的详细信息:
docker volume inspect mysql-data能直接看到Mountpoint字段,这就是卷在宿主机上的真实路径。
那绑定挂载和命名卷怎么选?我的经验是:需要频繁在宿主机和容器之间共享文件时用绑定挂载(比如前端代码目录挂进去热更新,Nginx配置目录挂进去改配置),纯粹为了数据持久化、不需要在宿主机直接操作时用命名卷(比如数据库的数据目录)。命名卷还有一个额外优势:跨平台迁移时不用处理路径差异,docker-compose.yml里同一个volume配置在Linux、macOS、Windows上都能跑。
4.2 数据卷备份与迁移的实用命令组合
既然数据是命根子,备份就绕不开。命名卷的备份方式比较优雅,用--volumes-from配合临时容器:
docker run --rm --volumes-from mysql-db -v /opt/backup:/backup alpine tar czf /backup/mysql-data.tar.gz /var/lib/mysql这条命令拆开理解:--volumes-from mysql-db让临时容器继承mysql-db的所有挂载卷,-v /opt/backup:/backup把宿主机备份目录挂进去,然后临时容器用alpine镜像执行tar打包,把/var/lib/mysql目录压缩成/opt/backup/mysql-data.tar.gz。跑完临时容器自动删除(--rm),干净利落。
恢复同理:
docker run --rm -v mysql-data:/var/lib/mysql -v /opt/backup:/backup alpine sh -c "tar xzf /backup/mysql-data.tar.gz -C /"把压缩包解压回那个命名卷挂载的位置。注意这里-v mysql-data:/var/lib/mysql指定的卷必须是空的或者你确认可以覆盖的,否则可能出现文件层级错乱。
再分享一个我在实际运维中踩过的坑:MySQL类数据库用docker run启动时,如果-v挂载了一个非空目录,而该目录里没有MySQL预期的初始化文件,MySQL有时会跳过初始化直接启动,导致连接报Unknown database。解决办法是第一次启动时挂载一个空目录,让MySQL自行完成初始化后再挂回去;或者直接用命名卷,让Docker帮你在卷创建时处理好权限问题。
数据备份这件事,我的原则是:能挂载就不要让数据活在被删除的容器可写层里。很多开发者的习惯是直接docker exec进容器操作,数据写进了容器可写层,容器一删全没了。正确姿势是提前把可变数据目录全部挂载出来,然后你随便造、随便删容器,数据都不受影响。
5. 容器网络命令:让多个容器可以互相访问
5.1 三种内置网络模式的命令级表现
Docker的容器网络,初看很容易懵,因为“容器互相访问”和“宿主机访问容器”是两个独立的问题。先看网络列表:
docker network ls默认有三个网络:bridge、host、none。还有一个container:<name>模式不是独立网络,而是直接复用另一个容器的网络栈。
bridge模式是默认模式。每个容器会得到一个独立的网络命名空间,有自己的虚拟网卡和IP地址,通过docker0网桥与宿主机通信。在命令行里可以这样确认:
docker run -d --name app1 nginx docker inspect app1 | grep IPAddress能看到类似"IPAddress": "172.17.0.2"的输出。多个bridge容器之间,通过IP地址可以互相访问。但IP是动态分配的,容器重建后IP可能变化,所以生产环境不推荐直接用IP通信,后面会说自定义网络的解法。
host模式让容器直接使用宿主机的网络栈,没有独立的IP。启动方式:
docker run -d --network host nginx这种模式下,容器里的服务监听80端口,就等于宿主机监听80端口,不需要-p端口映射。优点是性能好、无NAT转换开销;缺点是端口冲突风险大、隔离性差。适合对网络性能要求高的场景,或者容器数量少、端口规划清晰的场景。
none模式完全没有网络:
docker run -d --network none alpine sleep 3600容器里只有lo回环接口,无法对外通信。用在哪些场景?跑离线计算任务、不需要网络的批处理任务,或者故意要隔离网络的安全敏感容器。实际开发中很少用,但知道它的存在有助于理解Docker网络的隔离粒度。
5.2 自定义网络与容器间通信的配置方法
默认bridge网络有个麻烦:容器间不能用容器名互相访问,只能靠IP。而IP是变化的,这给服务发现带来很大麻烦。解决办法是创建自定义bridge网络:
docker network create my-net docker run -d --name mysql-db --network my-net mysql:8.0 docker run -d --name backend --network my-net my-backend:1.0在自定义网络里,Docker内置了DNS解析,容器名就是主机名。也就是backend容器里可以直接通过mysql-db:3306去连接数据库,不需要查IP。这在微服务架构里是刚需。
如果你有一个容器在启动时忘了指定网络,可以用network connect补救:
docker network connect my-net backend这样backend会同时处于两个网络里。注意:一个容器可以连接多个网络,但只有在同一个网络里的容器才能直接通过名字互通。跨网络访问要通过不同的IP或额外配置路由,日常开发中尽量避免这种复杂拓扑。
排查网络问题最常用的命令是:
docker exec backend ping mysql-db docker exec backend curl http://mysql-db:3306如果发现容器名解析不了,先确认两个容器是否在同一个自定义网络里:
docker inspect backend | grep -A 20 "Networks"Networks字段会列出该容器所属的所有网络以及对应IP。这两个命令组合起来,能定位90%的容器间通信问题。
再提一个端口映射的细节。-p 8080:80是把宿主机8080映射到容器80,但如果宿主机8080已经被占用,启动会直接报错port is already allocated。这时候你有三个选择:换一个宿主机端口、杀掉占用端口的进程、或者干脆用-P让Docker随机分配一个宿主机端口(大写P),然后执行docker port <container>查看映射结果。
docker port backend输出类似:
80/tcp -> 0.0.0.0:32768表示宿主机的32768端口映射到了容器80端口。-P适合快速验证多个服务的场景,但生产环境端口一定要固定,否则外部访问会出现不确定性。
6. 容器出问题怎么查:从inspect到stats的排错链
6.1 拿到最小可行信息:inspect、logs、events
容器起不来,或者起来之后行为诡异,是每个人都会遇到的。我的排错习惯是一条链走下来的,缺一环都可能多耗一小时。
第一步是看基本状态:
docker ps -a重点看STATUS列。Exited (1)里的退出码能告诉你很多信息。退出码0表示正常退出,1通常表示应用内部错误,137表示被SIGKILL杀死(常见原因是OOM),139是段错误。看到退出码后再查日志:
docker logs --tail 200 <container>如果日志没输出或者看不出问题,第二步是docker inspect。这个命令的输出非常长,所以学会用过滤是基本功:
docker inspect <container> --format '{{.State.Status}}' docker inspect <container> --format '{{.State.ExitCode}}' docker inspect <container> --format '{{.Config.Cmd}}' docker inspect <container> --format '{{.Mounts}}'这几个字段分别告诉你:容器当前状态、退出码、启动时执行的命令、挂载了哪些数据卷。有一个很常见的场景:容器一直重启(STATUS显示Restarting),大概率是启动命令有问题或依赖的服务没就绪。你可以看State.Error字段确认具体错误内容。
第三步是docker events,这是流式的Docker事件监控命令:
docker events --filter container=<container> --since 1h它能列出该容器过去一小时内的所有事件,包括创建、启动、停止、OOM等。在排查“容器为什么无缘无故被杀了”这种问题时,events是最直接的证据来源。
6.2 资源与性能问题定位:stats、top、port、diff
容器能跑但性能有问题、CPU飙升、内存吃紧,这套命令就派上用场了。
docker stats是最直观的资源监控工具:
docker stats它会持续刷新显示每个正在运行的容器的CPU、内存、网络I/O和磁盘I/O。不加参数会流式刷新所有容器,也可以指定单个容器:
docker stats --no-stream backend--no-stream表示只输出一次当前状态,适合在脚本里采数。当某个容器内存持续增长、接近上限时,基本可以断定存在内存泄漏,需要去业务代码里找问题,而不是在Docker层硬扛。
docker top查看容器内正在运行的进程:
docker top backend输出格式和宿主机ps类似,可以看到容器内所有进程的PID、父进程、CPU和内存占用。当我们发现容器内主进程挂了但容器还在运行时(比如一个守护进程fork出来的子进程死了),top能帮你把进程树看明白。
docker port前面提过,用于查看端口映射关系。它在排查“为什么宿主机访问不了容器里的服务”时非常有用。先确认docker ps看容器是否在运行,再docker port看映射是否存在,接着可以试试直接访问容器IP加端口:
curl http://$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' backend):8080如果直接访问容器IP能通、但通过宿主机映射端口不通,问题出在-p配错或宿主机的防火墙规则上;如果直接访问容器IP也不通,问题出在容器内部的应用监听地址上。这一条排查路径能帮你在网络层面少走弯路。
还有一个命令容易被忽略:docker diff,它显示容器文件系统相对于镜像的改动:
docker diff backend输出里A开头的行表示新增文件,C表示修改,D表示删除。当你不确定某个文件到底改没改、某个进程有没有偷偷写文件时,diff可以快速给出答案。我在排查“容器里日志写在哪”的问题时,经常用它来定位实际产生文件变化的位置。
7. Docker Compose:把常用命令沉淀成可复用的项目配置
7.1 为什么团队协作更需要Compose而不是一堆run命令
当你只需要跑一两个容器时,docker run完全够用。但项目一旦涉及多个服务——数据库、缓存、后端、前端——事情就开始失控了。每次启动都要敲一大串docker run,端口、挂载、环境变量一旦有一个记错,环境就起不起来。而且这些命令只存在于某一个人的终端历史里,别人根本不知道你用了哪些参数。这时候就需要Docker Compose上场。
Compose的核心思想很简单:把容器的配置从命令行参数迁移到YAML文件里,变成项目的一部分,放进Git仓库。团队成员clone代码后,一条命令就能把整套环境拉起来。
一个典型配置示例:
version: "3.8" services: mysql: image: mysql:8.0 container_name: project-mysql restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: app_db ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql networks: - app-net redis: image: redis:7-alpine container_name: project-redis restart: always ports: - "6379:6379" networks: - app-net backend: build: ./backend container_name: project-backend restart: always depends_on: - mysql - redis environment: DB_HOST: mysql DB_PORT: 3306 REDIS_HOST: redis ports: - "8080:8080" volumes: - ./backend:/app networks: - app-net volumes: mysql-data: networks: app-net:注意几个关键点:
container_name指定容器名,否则Compose会自动加上项目名前缀。depends_on控制依赖关系,但它只保证依赖容器的启动顺序,不保证依赖服务“就绪”。比如MySQL容器启动了,不代表MySQL能接受连接了。更稳妥的方式是在后端业务代码里做重试连接。build: ./backend表示后端服务从./backend目录下的Dockerfile构建镜像,如果已经有现成镜像也可以改用image:。volumes段落声明命名卷,networks段落声明自定义网络。这些都能在Docker命令行里被同样管理起来,Compose只是帮你自动创建。
启动命令:
docker compose up -d查看服务状态:
docker compose ps查看日志:
docker compose logs -f backend清理整组服务(保留数据卷):
docker compose down连数据卷一起删(慎用):
docker compose down -v7.2 常用Compose命令与docker命令的映射关系
很多人学Compose的时候,会觉得这是另一套东西。其实Compose命令只是对docker原生命令的编排和简化,理解了对应关系后,心智负担会小很多:
| docker命令 | docker compose命令 | 说明 |
|---|---|---|
docker run -d <img> | docker compose up -d | 创建并启动服务 |
docker ps | docker compose ps | 查看当前项目服务状态 |
docker logs -f <c> | docker compose logs -f <service> | 查看某个服务日志 |
docker stop <c> | docker compose stop | 停止所有服务(不删除容器) |
docker rm <c> | docker compose rm | 删除停止的服务容器 |
docker build -t <img> . | docker compose build | 构建服务的自定义镜像 |
docker exec -it <c> bash | docker compose exec <service> bash | 进入服务容器执行命令 |
docker network create <net> | 在YAML里声明networks | 创建项目网络 |
注意到没有,Compose命令的粒度是“服务”,而docker命令的粒度是“容器”。这一个抽象层次的提升,让你可以用一句话操作整个项目环境,而不是挨个容器去敲命令。
一个非常实用的场景是:开发时因为改了代码需要重启后端容器,你不需要docker stop再docker start,直接:
docker compose restart backend或者因为改了Dockerfile需要重建镜像并启动:
docker compose up -d --build backend--build参数会在启动前重新构建镜像,确保你跑的是最新代码。
再说一个Compose里容易被坑的点:环境变量。environment字段可以直接写死,也可以通过env_file从文件加载,但最灵活的做法是用宿主机变量配合${VAR}语法:
environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}这样同一个docker-compose.yml可以在不同环境(开发、测试、生产)复用,只要在每台机器上定义不同的环境变量值即可。配合.env文件(Compose会自动读取项目根目录下的.env文件),可以做到配置文件不进Git仓库仍能正常部署。
还有关于Compose版本的一个说明。老文章会强调version: "3.8"这个字段,但新版Docker Compose(V2版本)已经把它标记为可选,甚至推荐删掉。用docker compose version查看你的Compose版本,如果是V2,直接忽略version字段也完全没问题。
我在实际项目里的感受是:Compose不仅解决了多容器编排的问题,更重要的是它强制你把环境配置“代码化”了。有了docker-compose.yml,新人加入团队拉下代码一条命令就能起环境,不再需要“你去问一下老张,他那边的启动命令是啥”这种知识孤岛。单看命令本身,Compose并没有多高深的技术含量,但这种把配置沉淀成项目的思维方式,才是它最大的价值。
最后分享一点个人体会
使用Docker这么多年,我最大的感受是:命令本身从来不是瓶颈,能不能理解每个命令背后的对象和状态,才是区分熟练与不熟练的分水岭。我自己前期也走过弯路,背了一堆命令却总是“用完就忘”,后来把镜像、容器、数据卷、网络这四个抽象对象彻底想明白之后,大部分命令都能自己推导出来,遇到报错也不再慌,而是按对象去排查:是镜像问题就查build、是容器问题就查logs和inspect、是数据问题就查mounts和volumes、是网络问题就查network和port。
如果你刚开始学,建议给自己一个小目标:把docker run -d --name -p -v这几个核心参数用得滚瓜烂熟,再花半天时间彻底搞懂数据卷和自定义网络,日常开发就完全够用。剩下的命令,用到再查也来得及——Docker的命令行设计得很一致,docker <对象> <操作>这个结构一旦适应了,你会发现自己能猜出一大半没见过的命令。
最后分享一个小技巧:善用--help和官方文档的示例。docker run --help的输出里,每一个参数都配有简短说明,遇到不熟悉的参数先查一遍,比直接搜网上的中文教程要准确得多。Docker的版本迭代很快,很多旧博客里的参数可能在新版本里已经废弃或改名,以你本机的--help输出为准,是最不容易出错的做法。