如果你刚从“在本机怎么都跑不起来,在同事电脑上却一切正常”的噩梦中醒来,如果你刚入职一家公司,光搭开发环境就花掉整整两天,或者你正被“明明测试环境没问题,一上生产就崩”这种经典剧情反复折磨,那么这篇文章是为你准备的。
先说判断:Docker 并不是什么新潮的“容器技术”名词,它是当前软件开发流程中解决“环境一致性”和“项目交付”最直接的工程手段。很多人把它学成了命令大全,背了一堆 docker run 参数,到了真正要“把一个项目打包好交给别人运行”的时候依然无从下手。这是因为没有抓住 Docker 最核心的两层抽象:镜像(images)和容器(container)。
这篇文章不会停留在概念罗列,我会从实际开发者的视角,把 Docker 的安装、镜像管理、容器生命周期、Dockerfile 编写、数据卷挂载,以及“把一个完整项目环境打包给别人用”这条主线完整走一遍。你可以跟着操作,也可以在遇到具体问题时回来查对应章节。读完你至少能解决三个问题:第一,搞清镜像和容器到底是什么关系;第二,能独立完成镜像的拉取、构建和容器运行;第三,能把自己写的项目连同运行环境一起打包,分发给同事或部署到服务器。
1. Docker 真正解决的问题是什么
在接触 Docker 之前,很多团队是这样部署项目的:
- 新同事入职,按文档一步步装 JDK、装 MySQL、装 Redis、配环境变量、调配置文件。
- 开发完成后,把 jar 包或 war 包发给运维,运维在服务器上再走一遍“装环境”流程。
- 某个依赖库升级了,测试环境升了,生产环境没升,结果行为不一致。
- 一台服务器上想同时跑两个不同版本的 MySQL,装上就冲突。
这些问题的根源不是某个人的操作失误,而是软件运行环境和代码本身没有打包在一起。代码是代码,环境是环境,中间靠“人工部署”这条脆弱链条连接。
Docker 解决这个问题的思路很朴素:把代码和运行环境一起打包成一个可以分发和运行的“包裹”,这个包裹就是镜像。别人拿到这个镜像,不需要再关心环境怎么配,直接运行起来就是一致的。同一个镜像无论跑在开发笔记本、测试服务器还是生产机器上,内部看到的操作系统依赖、库文件、环境变量完全一致。
从工程角度看,Docker 真正改变的是协作边界。以前开发说“我这边是好的”,运维说“我这边起不来”,两边互相拉扯。现在开发负责把镜像构建好,运维负责把镜像跑起来,双方基于同一个镜像进行协作,扯皮空间被大幅压缩。就算出了问题,问题也出在镜像构建或运行配置上,能够准确定位和回溯,而不是“环境玄学”。
需要说明的是,Docker 不等于虚拟机。虚拟机是把整个操作系统虚拟化,重量级、启动慢、资源占用高;容器则是共享宿主机内核,只隔离进程、文件系统、网络等资源,启动速度和资源占用都轻量得多。这也是很多人第一次用 Docker 时感到“快得离谱”的原因。
对个人开发者来说,Docker 更像一台“万能环境恢复机”。你不需要在电脑上安装各种版本冲突的软件,需要 MySQL 就拉一个 MySQL 镜像,用完删掉也不污染系统。需要测试某个开源项目,跑一个容器就行,不用担心卸载不干净。
2. Docker 镜像与容器的核心概念
要真正理解 Docker 的使用,必须先建立两个核心概念。
2.1 镜像(Image)是模板,容器(Container)是运行实例
镜像是一个只读的模板文件,里面包含了运行某个应用所需要的完整操作系统文件系统、代码、依赖库、环境变量、默认命令等。你可以把它理解成一个“装箱清单 + 完整快照”,它本身不运行,只负责定义容器应该长什么样。
容器是镜像运行起来后产生的进程实例。同一个镜像可以同时启动多个容器,每个容器之间相互隔离,运行状态彼此独立。容器可以被启动、停止、删除,也可以在里面执行命令、写入文件。容器的写入层是临时的,一旦容器被删除,容器内产生的数据默认也会丢失,这个特性后面操作数据卷时会重点讲。
理解二者的关系,最常用的一个类比是:
- 镜像 = 类(Class),容器 = 对象(Instance)
- 镜像 = 安装光盘,容器 = 安装完成后运行起来的系统
- 镜像 = 菜谱,容器 = 按这个菜谱做出的菜
其中“菜谱”这个类比很贴切。菜谱是静态的文本,你可以反复用同一份菜谱做很多盘菜;每盘菜都是独立的,吃完或者倒掉,不影响菜谱本身。如果菜谱里某一步写错了,你只能修改菜谱后重新做菜,已经做好的菜不会自动变好。对应到 Docker 里,就是:容器运行后如果在里面安装了额外软件或修改了文件,这些修改默认只停留在容器层,不会写回镜像;要永久修改,必须通过 Dockerfile 重新构建镜像。
2.2 镜像分层结构与联合文件系统
镜像还有一个不能忽略的特性:分层存储。Docker 镜像由多个只读层组成,每一层代表 Dockerfile 中的一条指令。构建镜像时,如果基础镜像层没有变化,Docker 会直接复用缓存,这也是为什么很多项目构建第二次时速度会快很多。
层的好处是节约磁盘空间和网络带宽。你拉取一个新镜像时,如果它的某些层本地已经存在,Docker 只需下载不在本地的那部分层。多个镜像可以共享相同的底层基础镜像层,这对本地磁盘占用和私有镜像仓库的带宽优化非常有意义。
但要提醒新手的是,分层结构也带来一个常见坑:每一层都会固化当时的文件状态。如果有人把包含密码的文件写进镜像的某一层,即使后面的层把它删了,它仍然存在于更底层的镜像历史中,通过 docker history 可以翻出来。所以管理和制作镜像时,不要把密钥、密码、token 写进去,这是一个必须从第一天就养成的安全意识。
2.3 镜像仓库(Registry)
镜像建好后需要分发的载体叫镜像仓库。Docker Hub 是官方公共仓库,里面放着大量官方维护的镜像,比如 mysql、nginx、redis、ubuntu、python 等,绝大多数情况下不用自己从零搭建环境,直接基于官方镜像稍作修改即可。实际公司内部也经常会搭建私有镜像仓库,用于存放内部应用镜像,避免直接依赖外网拉取。后面写实战时会演示如何把一个自建镜像打上标签并推送。
3. Docker 安装与环境准备
不同操作系统安装 Docker 的方式不同,这里不写死某个版本的安装包,因为 Docker 迭代较快,安装方式也会更新。关键在于理解你装的到底是哪一类 Docker 环境。
3.1 Linux 环境
在 Linux 服务器上,Docker 以守护进程方式运行,安装后通过 systemctl 管理。绝大多数云服务器和公司测试机都是这种方式。安装步骤一般是:
# 1. 安装依赖 sudo apt-get update sudo apt-get install -y ca-certificates curl # 2. 添加 Docker 官方 GPG 密钥和仓库(以 Debian/Ubuntu 系为例) sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg # 3. 写入软件源 echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # 4. 安装 Docker Engine sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io # 5. 验证 sudo docker run hello-world安装成功后,可以使用 docker version 查看客户端和服务端版本信息。如果显示服务端和客户端都有版本号,说明 Docker 守护进程已经在运行。如果只显示客户端而服务端报错,多半是守护进程没启动,先检查 systemctl status docker 再启动它。
sudo systemctl start docker sudo systemctl enable docker这里有一个实际开发中常见的操作建议:尽量不要让普通用户直接执行 docker 命令时需要 sudo 或者在“是否把用户加入 docker 用户组”之间反复纠结。把用户加入 docker 组确实可以免 sudo 执行 docker 命令,但 Docker 的权限边界很特殊,凡是能执行 docker 命令的用户,本质上就等同于有了宿主机的 root 权限。不要为了图方便在共用服务器上给所有人开放 docker 权限,安全边界比便利更重要。个人开发机可以正常操作,生产服务器请务必做好用户和权限规划。
3.2 Windows 与 macOS 环境
Windows 上过去常被提及的 Docker Toolbox 和基于 Hyper-V 的方案已经逐渐淡出,目前主流是安装 Docker Desktop。Docker Desktop 在 Windows 上依赖 WSL 2 或 Hyper-V,在 macOS 上依赖 HyperKit 或 Apple Virtualization Framework。
安装 Docker Desktop 后,它会提供一个图形化界面,你可以在上面直接管理容器、查看日志、打开终端、查看镜像。Docker Desktop 内置了 docker CLI,安装完成后在 PowerShell 或终端中直接敲 docker 即可。
需要特别提醒的是,Windows 下要检查 Docker Desktop 是否正常运行,右下角托盘里的鲸鱼图标是否常亮。刚启动时 Docker Desktop 需要一个启动时间,如果立即执行 docker ps 会报“Cannot connect to the Docker daemon”,这是正常的,等图标稳定后再操作。WSL 2 模式下,你还可以在 Windows 和 Linux 发行版之间共用 Docker 命令,具体取决于你终端所在的上下文。
从我个人经验来看,新手不要一上来就在 Windows 上折腾原生安装,先装好 Docker Desktop,能让你把精力集中在理解镜像和容器本身,而不是跟 Docker 的环境依赖搏斗。
3.3 验证安装
无论哪个平台,安装完成后跑一次 hello-world 是最稳的验证方式:
docker run hello-world如果能正常输出一段 Hello from Docker! 的提示,说明整个链路已经通了。这个命令背后发生的事情是:Docker 发现在本地没有 hello-world 镜像,于是自动从 Docker Hub 拉取镜像,然后创建并运行容器,容器打印信息后退出了。
4. Docker 镜像的基础操作
安装完成只是第一步。接下来我们把命令分成两个层次,先掌握镜像操作,再掌握容器操作,最后串起来做项目打包。很多人学 Docker 的失败经验就是镜像命令和容器命令混在一起记,最后完全不知道当前在操作哪个对象。
4.1 搜索镜像
在不确认镜像是否存在以及有哪些版本时,可以直接在远程仓库搜索:
docker search nginx搜索结果会列出镜像名称、描述、星数、是否官方。星数是参考项,但不是绝对权威,有些第三方镜像虽然星数很高,维护情况和安全性并不一定可靠。生产环境尽量选择官方镜像或可信组织维护的镜像。
更准确的做法是直接到 Docker Hub 网站上查看镜像的 tag(版本标签)。因为 docker search 不会列出具体版本号,只有知道具体 tag 才能精准拉取。
4.2 拉取镜像
拉取镜像的基本命令:
docker pull nginx:latest其中nginx是镜像名,latest是标签。如果不写 tag,默认拉取 latest。这里从工程角度给一个建议:尽量不要依赖 latest 标签。latest 指向的版本会随时间变化,今天拉的是一个版本,三个月后再拉可能就是另一个大版本,这会给环境复现带来不确定性。更稳妥的做法是指定明确版本,例如固定使用某个常用稳定版本,后面需要升级再明确调整。如果你不清楚镜像有哪些版本,可以在 Docker Hub 的 Tags 页面查看,也可以通过docker image inspect查看已拉取镜像的详细信息。
关于国内网络环境下镜像拉取慢的问题,需要说明一点:不同网络环境下的镜像加速方案差异很大,具体加速地址和配置方式时效性也很强,这里不写死任何加速地址。如果你在拉取镜像时很慢,一个更通用的建议是:先确认基础镜像尽量选择体积小的变体;或者在公司里配置私有镜像仓库作为中转;或者考虑使用更大带宽的网络环境拉取后通过镜像导出再内网导入。不要盲目相信网上流传的加速地址,很多已经失效。
4.3 查看本地镜像
docker images输出包含 REPOSITORY、TAG、IMAGE ID、CREATED、SIZE 几列。IMAGE ID 是镜像的唯一标识,操作镜像时可以直接用 IMAGE ID 的前几位作为简写。SIZE 列反映的是镜像的软件包体积,不代表实际解压后的磁盘占用。
如果想看某个镜像更完整的信息,包括环境变量、端口暴露、挂载点、架构等,可以使用:
docker inspect nginx输出是 JSON 格式,内容很详细。新手可能不适应这种大段 JSON,但排查问题时 inspect 是很有用的工具,比如你想知道镜像默认暴露了哪些端口、入口命令是什么,都可以在这里找到。
4.4 删除镜像
docker rmi nginx:latest如果镜像已经被某个容器使用,需要先删除相关容器再删除镜像,否则会报错。这里的逻辑和操作系统中“文件正被占用无法删除”类似。强制删除可以用 -f 参数,但生产环境不建议默认使用,先理清关联关系再操作更安全。
4.5 镜像导入导出
在没有私有镜像仓库,又需要把镜像从一台机器搬到另一台机器时,可以用导出导入:
# 导出镜像为 tar 文件 docker save -o nginx.tar nginx:latest # 导入镜像 docker load -i nginx.tar还有一种 dump 容器文件系统的方式是docker export,它导出的内容和docker save不一样。save 保存的是镜像的分层结构,可以重新加载为镜像;export 导出的是容器的文件系统快照,导入后不再是完整镜像,通常没有镜像的元数据、分层和历史信息。实际项目打包时优先用 save 和 load。
4.6 镜像操作小结
到这里,镜像这条线的操作闭环已经通了:搜索、拉取、查看、删除、导出、导入。你可能已经发现,这些操作和包管理工具的思路很像,比如 Python 里的 pip、Node 里的 npm,只是镜像管理的粒度更重,携带的是完整运行环境。
5. Docker 容器的生命周期操作
镜像只是“原材料”,真正运行起来产生项目效果的是容器。这一节我们完整走一遍容器的创建、启动、停止、进入、删除。
5.1 从镜像创建并运行容器
先看最常见的命令:
docker run -d --name my-nginx -p 8080:80 nginx:latest把这个命令拆开解释:
docker run:从镜像创建并运行一个新容器。-d:后台运行容器,终端不阻塞,不写这个参数时容器会在前台运行,直接霸占当前终端。--name my-nginx:给容器命名。不命名的话 Docker 会随机生成一个名字,不便于管理。-p 8080:80:端口映射。左边是宿主机端口,右边是容器内端口。这里把宿主机 8080 端口映射到容器内 nginx 的 80 端口。nginx:latest:指定镜像。
运行后,在浏览器访问http://localhost:8080,能看到 nginx 的欢迎页面。这里需要理解一个关键概念:容器是一个隔离的沙箱,容器内的端口默认不会被宿主机直接访问到,必须通过 -p 映射出来才能从外部访问。很多人刚学 Docker 时疑惑“为什么我运行了 MySQL 容器,但程序连不上 MySQL”,多半就是没有做端口映射,或者映射错方向了。
5.2 查看容器列表
# 查看运行中的容器 docker ps # 查看所有容器,包括已经停止的 docker ps -a容器状态里通常会有 Up、Exited、Restarting 等状态。Up 表示正在运行,Exited 表示容器已退出。如果容器启动不到两秒就退出,多半是容器内的主进程退出了,这个问题后面排查时还要提到。
5.3 启动、停止、重启、删除容器
# 停止容器 docker stop my-nginx # 启动已停止的容器 docker start my-nginx # 重启容器 docker restart my-nginx # 强制停止 docker kill my-nginx # 删除容器(只能删除已停止的容器) docker rm my-nginx # 强制删除正在运行的容器 docker rm -f my-nginx这里的重点在于理解 start 和 run 的区别。docker run每次都会创建一个新容器;docker start是启动一个已经存在的、处于停止状态的容器。如果你反复用 docker run 同一个镜像,会产生多个容器实例,名字冲突时会报错。实际项目操作中经常有新手用 docker run -d 启动容器,发现端口冲突,于是又改端口重新 run 一次,结果机器上残留了一堆容器。正确做法是先 docker ps -a 看看到底有哪些容器,再决定 stop、rm 还是重新 run。
5.4 进入容器内部执行命令
排查问题或查看容器内部文件时经常需要进入容器:
# 在运行中的容器内执行一条命令 docker exec my-nginx ls /etc/nginx # 以交互模式进入容器并打开 bash 终端 docker exec -it my-nginx bash-i表示保持标准输入打开,-t表示分配一个伪终端。两者配合才能得到一个可以交互操作的 bash 终端。如果容器内部没有 bash(极简镜像可能只有 sh),可以尝试:
docker exec -it my-nginx sh进入容器后可以查看日志文件、检查进程、验证配置等。要注意的是,你在容器内做的修改只对当前容器有效,不会影响镜像,也不会影响由同一镜像创建的其他容器。如果你希望修改后重新生成一个新容器也能保留效果,正确的做法是改 Dockerfile 重新构建镜像,或者用数据卷挂载外部文件。
5.5 查看容器日志
项目部署后第一件事往往就是看日志:
# 查看全部日志 docker logs my-nginx # 实时跟踪日志输出,类似 tail -f docker logs -f my-nginx # 查看最后 100 行 docker logs --tail 100 my-nginx如果容器启动后立即退出,docker logs 是最有效的排查手段。比如你发现自己写的一个应用容器运行后立刻 Exited,运行 docker logs 容器名,看到的具体报错信息就是下一步排查的依据。
5.6 容器文件拷贝
需要从容器里拷贝文件到宿主机,或者往容器里拷文件时:
# 从容器拷贝文件到宿主机 docker cp my-nginx:/etc/nginx/nginx.conf ./nginx.conf # 从宿主机拷贝文件到容器 docker cp ./nginx.conf my-nginx:/etc/nginx/nginx.confdocker cp 适合临时操作,不适合作为常规配置管理手段。因为容器一旦被删除重建,之前往容器里拷贝的内容会全部消失。
6. 项目环境打包实战:从 Dockerfile 到可分发镜像
前面几节你可以理解为准备工作,这一节进入主线目标:把手里的项目连同一个完整运行环境打包成镜像,让别人一条命令就能跑起来。
这里以最常见的 Java Spring Boot 项目为例,也可以换成一个 Node 项目或 Python 项目,核心思路是一样的:先选择一个合适的基础镜像,再把项目文件放进去,最后声明启动命令。
6.1 编写 Dockerfile
Dockerfile 是描述镜像构建过程的文本文件,Docker 会按文件里的指令逐行构建镜像。下面是一个 Spring Boot 项目的 Dockerfile 示例:
# 文件路径:项目根目录/Dockerfile # 第一阶段:使用 Maven 镜像构建项目 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 第二阶段:使用精简 JRE 镜像运行项目 FROM openjdk:11-jre-slim WORKDIR /app COPY --from=builder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里使用了多阶段构建,作用是:第一阶段用大而全的 Maven 镜像编译项目,第二阶段只把编译好的 jar 包放进精简运行镜像。这样做的好处是最终镜像体积小很多,不包含编译工具链,攻击面也更小。
如果不使用多阶段构建,直接把源码、Maven 和运行时都塞进一个镜像,镜像体积会非常庞大。从实际经验看,一个精简的多阶段 Spring Boot 镜像可能只有一两百兆,而带完整 Maven JDK 的镜像可能达到几百兆甚至更大。对部署带宽和存储成本都有影响。
Dockerfile 里几个关键指令的含义:
FROM:指定基础镜像。所有镜像都必须有基础镜像,可以是官方镜像,也可以是自建镜像。WORKDIR:设置工作目录。后续命令默认在这个目录下执行。COPY:把文件从宿主机复制到镜像内。RUN:在构建阶段执行命令,常用于安装依赖、编译项目等。EXPOSE:声明容器运行时监听的端口。它更像文档说明,真正暴露端口仍要靠运行容器时的 -p 参数。ENTRYPOINT:定义容器启动时执行的命令。
如果是一个 Python Flask 项目,Dockerfile 类似这样:
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 5000 CMD ["python", "app.py"]如果是 Node.js 项目:
FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "start"]无论哪种技术栈,背后的逻辑都是:装依赖、放代码、写启动命令。
6.2 构建镜像
在 Dockerfile 所在目录执行:
docker build -t my-demo:1.0 .-t my-demo:1.0:给镜像命名并打标签。镜像名通常是小写字母,建议包含项目含义,标签按版本号管理。- 最后的
.:构建上下文路径。Docker 会把当前目录下的文件作为构建上下文发送给 Docker 守护进程,Dockerfile 里的 COPY 指令会从这个上下文中找文件。
有一点很重要:.dockerignore文件。它和 .gitignore 类似,声明哪些文件不要发送给 Docker 构建上下文,比如本地 target 目录、node_modules、.git 等。不写 .dockerignore 的话,构建上下文会变得非常大,构建速度也会明显变慢。
示例 .dockerignore:
target/ node_modules/ .git/ .idea/ *.log6.3 运行自建镜像
docker run -d --name my-demo-app -p 8080:8080 my-demo:1.0运行后访问http://localhost:8080检查项目是否启动成功。
6.4 分发镜像的常用方式
自建镜像要在团队内部分发,通常有三种做法:
- 推送到镜像仓库:这是最正规的方式。先给镜像打上仓库地址和命名空间:
docker tag my-demo:1.0 registry.example.com/team01/my-demo:1.0 docker push registry.example.com/team01/my-demo:1.0其他同事在自己的机器上执行 docker pull registry.example.com/team01/my-demo:1.0 即可获取镜像。公司内部一般会搭建私有镜像仓库,避免依赖外网且方便权限控制。如果你使用云服务商提供的镜像仓库,操作流程也是类似,先登录再推送。
导出镜像文件:在没有仓库的情况下,用前面讲过的 docker save 导出成 tar 文件,拷贝给目标机器再 docker load 导入。这种方式适合小范围交付,但文件较大时不方便传输,而且缺少集中管理能力。
通过 Docker Compose 编排多容器项目:如果项目不止一个容器,比如一个应用要同时依赖 MySQL 和 Redis,用 docker run 两条三条命令去启动会非常零散。这时可以使用 docker-compose.yml 定义整个环境的服务列表、网络、依赖关系、数据卷,然后一条命令启动全部服务。这是实际项目中最接近“项目环境打包”的完整形态,因为环境不只是一个镜像,而是多个组件协同运行。
一个示例 docker-compose.yml:
version: "3.8" services: app: build: . ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/demo?useUnicode=true&characterEncoding=utf8 SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root123 depends_on: - db db: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: demo ports: - "3306:3306" volumes: - db_data:/var/lib/mysql volumes: db_data:这里要强调一个安全规范:这是为了演示服务间连通性而展示的配置,实际项目中绝对不要把数据库密码硬编码在 docker-compose.yml 文件里,尤其是不能提交到代码仓库。生产环境推荐使用环境变量注入、Docker 密钥管理或者配置中心等专门机制管理敏感信息。
在这个编排文件中,app 服务的数据库地址写成 db,而不是 localhost,是因为 Compose 会为服务创建一个默认网络,服务之间通过服务名互相解析。当 app 容器访问 db 这个主机名时,实际上访问的是 db 服务对应的容器。如果你写成 localhost,应用会尝试连接 app 容器自己的 3306 端口,而那里并没有 MySQL,这个报错是新手最容易遇到的问题之一。
在 docker-compose.yml 所在目录执行:
# 构建并启动 docker-compose up -d # 查看服务状态 docker-compose ps # 查看日志 docker-compose logs -f app # 停止并移除容器,保留数据卷 docker-compose down # 停止并移除容器、网络、数据卷(慎用,会删数据) docker-compose down -v如果你使用的是新版 Docker 插件版 Compose,命令可能是docker compose(中间没有横线),执行方式基本一致。
7. 数据卷:让容器里的数据“活”起来
前面反复提到一个让人不安的问题:容器删了就什么都没了。对无状态应用来说这不是问题,但数据库、上传文件、日志等场景必须把数据持久化保存。Docker 的解决方案是数据卷(Volume)。
# 创建一个数据卷 docker volume create mydata # 挂载数据卷运行容器 docker run -d --name mysql-demo \ -v mydata:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=root123 \ mysql:8.0-v mydata:/var/lib/mysql表示把名为 mydata 的数据卷挂载到容器内的 /var/lib/mysql 目录。这样 MySQL 写入的数据实际上保存在宿主机的数据卷中,即使删除容器,数据卷还在;重新用同一数据卷启动新容器,数据就回来了。
除了命名数据卷,还可以直接挂载宿主机目录,这在开发调试时很常用:
# 把宿主机当前目录下的 nginx.conf 挂载到容器内 docker run -d --name my-nginx -p 8080:80 -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro nginx:latest:ro表示只读挂载,避免容器内修改宿主机文件。开发场景下,可以直接把本地代码目录挂载进容器,改代码后服务自动加载,不用重新构建镜像,这也是很多前端 Node 容器开发方案的核心思路。
关于数据卷,需要掌握两个判断:
- 什么时候该用数据卷?有状态服务(数据库、消息队列)一定要用;应用产生需要保留的文件(上传文件、日志)一定要用;其他无状态服务可以不挂载。
- 数据卷会不会泄露?数据卷里的数据独立于容器生命周期。用 docker-compose down -v 或 docker volume rm 会删掉数据,执行前必须确认备份。
还要补充一个容器文件系统写入层和挂载卷的区别:容器内未挂载卷的路径修改都发生在可写容器层,容器删除即丢失;挂载卷的路径直接对应宿主机路径,删除容器不丢数据。判断一个容器是否有数据丢失风险,第一看它是否有状态,第二看状态写在哪个路径。
8. 常见问题与排查思路
以下问题是我在实际使用和帮助同事排查时遇到比较多的场景,按出现频率排列。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 执行 docker 命令报 Cannot connect to the Docker daemon | Docker 守护进程未启动 | systemctl status docker 或查看 Docker Desktop 图标状态 | 启动守护进程或 Docker Desktop |
| 拉取镜像超时或非常慢 | 网络环境限制、镜像源问题 | 查看拉取时的错误信息 | 配置镜像加速、使用公司镜像仓库、换网络环境 |
| 容器启动后立即退出(Exited) | 容器内主进程启动失败或直接结束 | docker logs 容器名 | 根据日志修复应用配置、依赖、启动命令 |
| 端口无法访问 | 端口映射错误或容器未运行 | docker ps 看端口映射,宿主机制试防火墙 | 检查 -p 参数方向,开放宿主机防火墙端口 |
| 容器之间无法互通 | 未加入同一网络或主机名写错 | docker inspect 查看网络配置 | 使用 docker network 创建网络,服务名访问 |
| 端口被占用 | 宿主机的端口已被其他进程或容器占用 | docker ps 或 lsof -i:端口 | 停掉冲突进程或修改 -p 映射端口 |
| 删除镜像报错 image is being used by container | 镜像仍被容器引用 | docker ps -a 找到对应容器 | 删除或停止对应容器后再删除镜像 |
重点讲两个最容易卡住新手的场景。
第一个是容器秒退。很多新手用 docker run 启动一个自己写的应用镜像,发现 docker ps 看不到容器,以为没启动成功。实际上容器启动后,如果容器内主进程正常执行并退出,容器状态就变成 Exited。这种情况要区分“异常退出”和“任务完成”——比如跑一个打印日志就结束的脚本,容器自然退出是正常的;但启动一个 web 服务却秒退,就需要用 docker logs 查看日志找启动失败原因。判断方法:如果你的应用应该常驻运行,但容器秒退,大概率是应用启动异常。
第二个是容器之间访问 localhost 的误区。在 docker-compose 里,app 容器内访问数据库不能用 localhost,因为 localhost 指向的是 app 容器自己。必须用服务名 db。如果不用 Compose,只用 docker run 启动多个容器,它们默认不在同一网络,需要通过 docker network create 自定义网络并让容器加入这个网络,才能用容器名互相访问。任何时候遇到“容器内连不上另一个容器”,第一反应应该是检查网络连接和主机名,而不是怀疑数据库配置错了。
9. 最佳实践与工程建议
到这里,Docker 的主线操作已经全部走通。下面是一些在实际项目和团队协作中验证过有效的经验,建议收藏后在真实项目中逐步实践。
第一,镜像构建要追求“可重复”。Dockerfile 里的基础镜像标签不要用 latest,写明确版本;依赖锁定文件要提交到仓库,比如 Java 项目 lock 住 pom.xml 或 gradle 版本,Node 项目锁定 package-lock.json;Cold 构建缓存虽然能提速,但要保证不依赖缓存时也能完整构建出同一结果。
第二,运行容器要遵循最小权限。不要默认用 root 用户跑应用容器。Dockerfile 中可以通过创建专用用户来降低逃逸风险:
FROM openjdk:11-jre-slim RUN groupadd -r app && useradd -r -g app app WORKDIR /app COPY --from=builder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar USER app EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]容器内以普通用户运行的好处是,即使容器被攻破,攻击者获得的权限也受限于容器内普通用户,而不是 root。这个方法在基线安全和生产环境部署要求中非常常见。
第三,配置和密钥不进镜像。镜像会被分发到不同环境甚至不同团队,一旦包含密钥就相当于把密码公开。正确做法是通过环境变量、配置文件挂载、密钥管理服务等方式在运行时注入。判断一个值该不该写进镜像的标准是:如果这个值在不同环境(开发、测试、生产)中不一样,就不该写在镜像里。
第四,日志要输出到标准输出。Docker 容器里不要用文件方式写日志,而是输出到 stdout/stderr,由 Docker 日志机制统一收集。这样可以使用 docker logs 查看,接入日志采集系统也会方便很多。如果应用框架默认写文件,需要配置成输出到控制台。
第五,容器是无状态的。尽量把应用做成无状态,需要保存的数据放到数据卷或外部存储。这样容器可以随时删除重建,可以水平扩容。如果某个容器还需要小心翼翼地保存它的“现场”,说明架构上可能还有优化空间。
第六,镜像体积需要控制。基础镜像优先选择 slim、alpine 等变体;多阶段构建只保留运行产物;及时清理构建缓存的中间镜像和不再使用的悬空镜像。镜像越小,拉取越快,部署越快,暴露的攻击面也越小。
第七,给镜像和容器一个清晰的命名规范。镜像名建议格式为 项目名:版本号 或 仓库地址/项目名:版本号,容器名用简短且包含项目含义的单词,不要用 docker run 后随机生成的怪异名字用于正式环境。规范命名能避免团队协作时和运维确认问题说不清楚操作对象。
第八,生产环境不要使用 docker-compose 作为唯一交付方案。Compose 非常适合开发环境和测试环境,以及单机多容器的简单场景。但到生产环境,需要有更完善的容器编排方案,比如 Kubernetes 一类平台来管理容器数量、健康检查、滚动升级、配置管理、存储、服务发现等能力。Docker 是底座,生产落地还需要往前再走一步。
10. 写在最后
回到最初的问题:为什么项目环境打包这个事,值得每个人认真掌握 Docker?因为环境一致性不是靠口头约定保证的,而是靠把运行环境固化进镜像保证的。镜像一旦构建完成,它在任何一台装有 Docker 的机器上表现都一致。这种“一次构建,到处运行”的确定性,是 Docker 对开发、测试、运维协作最大的价值。
如果你是从零开始的新手,建议你按下面这个顺序做一次完整练习:
- 安装 Docker,跑通 hello-world。
- 拉取一个官方 nginx 镜像,用 -p 做端口映射,浏览器访问成功。
- 修改宿主机上的一个静态页面,用数据卷挂载进容器,让 nginx 提供新页面的内容。
- 选一个你正在写的小项目,编写 Dockerfile 构建镜像。
- 用 docker-compose 把一个应用和一个数据库组合起来,实现全栈启动。
- 把镜像打标签、导出、在另一台机器上导入运行,感受一下“环境打包分发”带来的交付体验。
这个练习覆盖了镜像、容器、数据卷、构建、编排、分发,是你后续学习 K8s、CI/CD、云原生概念的基础。Docker 的命令在熟练后会形成肌肉记忆,但真正有价值的不是记住命令,而是理解镜像和容器的边界、数据如何持久化、服务如何网络互通,以及如何构建出可分发、可复现、安全可控的项目环境交付物。
如果你的项目里还没有引入 Docker,现在就可以从最小场景开始:把你本地的数据库换成容器来跑,感受一下“用完即走、不污染系统”的干净;再把一个项目用 Dockerfile 构建成镜像发给同事试运行。这条路走通之后,你会回来收藏这篇文章的。
以上是本次实战的完整内容。如果你在打包项目时遇到了具体报错,建议先把 docker logs 和 docker inspect 的输出贴出来,按上面的排查表逐项对照,大多数问题都能定位到原因。建议收藏备用,也欢迎在评论区留下你遇到的奇葩环境问题。