要是你最近也跟我一样,被同一个问题反复折腾——新拉下来的代码在本地怎么都跑不起来,在同事电脑上一秒启动,部署到测试环境又冒出一堆新报错,那这篇记录Docker用法的文章你应该能看完。我早些年做项目部署时没少被这种“环境不一致”的问题折磨,后来把项目服务全部容器化之后,才意识到很多所谓的玄学故障其实都可以从根上避免。这份笔记不是教科书式的目录复述,而是我把Docker真正用进日常开发、测试和上线流程之后,沉淀下来的一套核心用法、取舍思路和排坑经验,希望帮到正在入门或者刚准备把这些套路落地到项目里的朋友。
1. 先搞清楚:Docker到底帮你解决了什么
1.1 让“在我机器上能跑”成为历史
先说一个很多人对Docker的误解:以为它是个虚拟机,用来在里面装一个完整的操作系统。其实Docker做的事情更轻、更直接,它把“你的程序”以及“程序运行所需要的一切环境”打包成一个标准单元。这个单元里可以包含代码、运行时、系统库、配置文件、环境变量,甚至你那个版本刁钻的依赖。只要有Docker引擎的机器,就能用几乎相同的方式把这个单元跑起来。
传统部署方式的问题在哪?代码从开发机传到测试机、再到生产机,每一台机器的操作系统版本、依赖库、环境变量都可能不一样。某个库的版本在本地是A,测试环境却是B,问题就来了。而Docker用镜像这个载体,把“代码”和“运行环境”一起固化下来。我再也不用花半天时间在服务器上一个个装依赖,也不用担心“测试环境明明好的,上线就崩”,因为上线跑的还是同一个镜像。
1.2 镜像、容器、仓库,三个概念足够入门
很多教程一上来就让记一堆命令,容易劝退。我觉得最开始只需要理解三个词:镜像、容器、仓库。
镜像(Image)是一个只读的模板,包含了运行某个应用所需的一切。你可以把镜像理解成“安装光盘”或者“类”,里面什么都有,但它本身不运行。容器(Container)是镜像运行起来后的实例,相当于“装好系统并开机了的电脑”,有独立的文件系统、网络、进程空间。仓库(Registry)则是存放镜像的地方,最常用的就是Docker Hub这种公共仓库,也有一些团队自己搭的私有仓库。
实际工作中,绝大多数场景就是三条路:从仓库拉一个现成镜像来跑,或者基于一个基础镜像自己往里面装东西做成新镜像,再就是把做好的镜像推到仓库让别处使用。只要理清这三个对象,后面命令再多也只是围绕它们打转。
2. 从零跑起第一个容器:镜像与容器的日常操作
2.1 拉镜像、跑容器、进容器,三步上手
第一次上手不用搞太复杂,找一个轻量级镜像跑一下,把链路跑通就有感觉了。以某个通用的Web服务镜像为例,我当时的操作是这样的:
# 1. 从远程仓库拉取指定版本的镜像 docker pull nginx:1.25-alpine # 2. 查看本地已有镜像 docker images # 3. 基于该镜像启动一个容器,映射端口,并给容器起名字 docker run -d --name web-demo -p 8080:80 nginx:1.25-alpine # 4. 查看容器运行状态 docker ps # 5. 进入正在运行的容器内部,看看里面的文件结构 docker exec -it web-demo /bin/sh这里有个新手容易懵的点:-p 8080:80到底是什么意思。冒号左边是宿主机端口,冒号右边是容器内部端口。容器内部有自己的网络命名空间,默认情况下外部访问不到。你要把宿主机的8080端口“映射”到容器的80端口,这样浏览器访问这台机器的8080端口时,请求才会被转发到容器内部的80端口上。很多初学的人忘了映射,容器明明在跑,但宿主机死活访问不通,十有八九就是这个原因。
容器启动后,完全没有头绪的时候,最直接的办法就是进容器里看。docker exec是所有排查工具里最高频的一个。进去之后看看进程在不在、环境变量对不对、配置文件有没有生效,思路会清晰很多。不过要注意,容器是一个精简环境,很多基础工具可能没有装,不一定能像操作虚拟机一样随心所欲。
2.2 理解容器的生命周期:为什么我的容器一跑就退
用Docker最常见的翻车现场:容器启动后,用docker ps一看,根本没在运行;或者启动了但过一会儿就自动退出。遇到这种情况不用慌,几乎都是同一个原因——容器里的主进程结束了,容器就跟着结束了。
Docker容器不是用来跑一个“空系统”的,它的生命周期绑定在PID 1(主进程)上。如果主进程是交互式命令行,前台进程一旦退出,容器就终止。所以很多官方镜像要搭配nginx -g "daemon off;"这种前台运行方式,就是为了让主进程一直占着。本地调试时,如果你想“进去看看”,可以临时指定命令覆盖默认启动:
docker run -it --rm --name debug-box nginx:1.25-alpine /bin/sh-it表示交互式终端,/bin/sh会覆盖镜像原本的启动命令,直接给你一个shell界面。这个用法在学习阶段非常管用,你可以慢慢在容器里折腾,退出之后加--rm参数能让容器自动清理,不残留垃圾。
2.3 操作容器的“后悔药”:停止、删除、提交
容器用久了,难免会把环境弄乱,或者启动参数不理想想重来。这里整理几条我常用的命令:
# 停止容器 docker stop web-demo # 启动已停止的容器 docker start web-demo # 删除容器(需要先停止) docker rm web-demo # 查看所有容器,包括已停止的 docker ps -a还有一个比较有意思的命令是docker commit,它可以把当前容器的修改保存成一个新的镜像。早期我调试某个服务时,喜欢进容器手动改配置,折腾到能用之后直接commit成一个新镜像作为基线。这么做非常直观,但我不建议把它当成常规操作。真实项目中,镜像的构建应该通过Dockerfile实现,也就是下面要说的做法,这样才能保证可追溯、可复现。commit出来的镜像往往说不清改了哪些文件,后续维护时会很被动。
3. 自定义镜像的核心:Dockerfile的编写思路
3.1 为什么推荐用Dockerfile而不是手动改容器
如果你只是临时拉个镜像跑跑,那还谈不上真正把Docker用好。真正的分水岭在于:你能不能用一个文本文件,把一套环境的构建过程精确地固化下来。Dockerfile就是这个文本文件,它里面是一条条指令,每条指令都会生成一个镜像层。任何人在任何机器上执行构建,产出的镜像都应该是一致的。
手动在容器里改配置,改完commit,容器怎么修改的就不可追溯了。而Dockerfile是代码,可以放进代码仓库里做版本管理。一次构建,到处运行,从源头保证环境一致。我见过一些团队,部署手册写了好几十页,结果每次部署还是出幺蛾子,后来把所有依赖和启动步骤写成Dockerfile,部署变成了一条命令,效率完全不一样。
3.2 一个最小可用的Dockerfile示例
假设你有一个用Python写的Web服务,依赖都记录在requirements.txt里,启动命令是python app.py。最简单的Dockerfile可以这样写:
# 选择基础镜像,alpine是轻量版本,适合跑小型应用 FROM python:3.11-alpine # 设置工作目录,后续操作都基于这个目录 WORKDIR /app # 把本地的依赖清单拷贝到容器 COPY requirements.txt . # 安装依赖 RUN pip install --no-cache-dir -r requirements.txt # 把当前目录下的代码全部拷贝进容器 COPY . . # 声明容器运行时监听的端口,主要起文档作用 EXPOSE 5000 # 容器启动时执行的命令 CMD ["python", "app.py"]就这几行,已经覆盖了绝大多数小型Web服务的需求。这里我想多说两句容易被忽略的点。COPY requirements.txt .单独放在安装依赖之前,是为了利用Docker的层缓存机制。Docker构建时会缓存每一层的结果,如果你的依赖清单没变,后面几步的缓存可以复用,构建速度会快很多。如果你先把整个代码目录Copy进去再装依赖,那每次改代码都会触发依赖重新安装,实际体验会原地变慢。
CMD和RUN虽然长得像,但作用完全不同。RUN是构建阶段执行的命令,会把结果写进镜像;CMD是容器启动时执行的命令,用来指定默认的启动行为。一个Dockerfile里可以有多个RUN,但只能有一个CMD生效。理解了这些,你就能看懂别人的Dockerfile到底在做什么了。
3.3 构建镜像的实操与镜像瘦身
写好Dockerfile后,在同一个目录下执行:
docker build -t my-web-app:1.0 .-t是给镜像打标签,1.0是版本号,末尾的.表示构建上下文目录。很多人在这里会忽略一个细节:docker build会把整个上下文目录打包发送给Docker引擎,如果你的目录里有个几十G的数据集,构建会卡到怀疑人生。所以项目里一定要配置一个.dockerignore文件,把node_modules、__pycache__、.git这些不需要进镜像的目录全部排除掉,不然镜像会被弄得很臃肿,构建速度也会被拖垮。
镜像瘦身是个长期话题,我具体展开一下。早期我图省事,基于一个带完整系统工具的镜像,装了一堆排查用的包,结果镜像超过2G,每次分发都苦不堪言。后来调整思路,优先选体积小的基础镜像,比如python:3.11-alpine、node:20-alpine,再配合多阶段构建,把编译阶段和运行阶段分开。怎么理解多阶段构建?打个比方,你为了做一道菜,先得借一个大厨房,把所有工具食材摊开处理,处理完只需要把最终的成品端到一个小餐桌上,大厨房里的锅碗瓢盆统统不带过去。Docker多阶段构建就是这样一个思路:在第一个阶段里安装编译器、下载依赖、编译出产物,第二个阶段再基于一个干净轻量的基础镜像,只拷贝产物过去。这样最终镜像体积会小一个数量级。
# 第一阶段:编译打包 FROM python:3.11 as builder WORKDIR /build COPY requirements.txt . RUN pip install --user -r requirements.txt # 第二阶段:只留运行环境 FROM python:3.11-alpine WORKDIR /app COPY --from=builder /root/.local /root/.local COPY . . CMD ["python", "app.py"]这种写法在实际项目中能有效压缩镜像体积。不过有一点要注意,COPY --from=builder引用的路径要和第一阶段真实生产路径一致,否则拷贝会失败。
4. 让数据持久起来:数据卷与网络配置
4.1 容器删除后数据去哪了
默认情况下,容器里产生的数据是“临时”的。一旦容器被删除,容器内部的文件系统就随之消失了。这对很多刚开始接触容器的人来说是个大坑:明明在容器里写了个文件,重启容器之后可能还在,但容器一删,数据全没了。
解决办法是使用数据卷(Volume)。数据卷是宿主机上的一个目录,通过挂载的方式和容器里的目录建立关联。容器写入这个目录的数据,会实际落在宿主机上。即使容器被删除,数据依然保留。我日常开发中最常用的有两种挂载方式:
# 方式一:命名卷,由Docker管理存储路径 docker run -d --name db-data -v mydata:/var/lib/data my-service # 方式二:直接挂载宿主机某个绝对路径,方便直接查看文件 docker run -d --name db-data -v /home/user/data:/var/lib/data my-service第一种方式的好处是路径不用自己操心,Docker会帮你管理好,适合数据库这类不想跟宿主机细节打交道的场景。第二种方式适合开发调试,因为你直接打开宿主机目录就能看到容器生成的文件。为了数据安全,数据库容器务必要配置数据卷,很多事故都是从“没挂载卷”开始的。
4.2 端口映射和跨容器通信
前面提到过,容器之间的网络是隔离的,外部要用-p映射才能访问。那多个容器之间要怎么通信?比如一个前端容器要访问后端容器。最早期的做法是拿到后端的IP地址,再写进前端配置。这种手工维护很脆弱,因为IP会因为容器重建而改变。更好的做法是使用Docker自带的用户自定义网络(network),在同一个网络下的容器可以直接用容器名互相访问。
创建一个自定义网络:
docker network create app-net启动两个容器时都指定加入这个网络:
docker run -d --name backend --network app-net my-backend docker run -d --name frontend --network app-net -p 8080:80 my-frontend之后在frontend容器内部,直接请求http://backend:8000就能访问到后端。这个机制的底层是Docker内置的DNS服务,自动把容器名解析成对应IP。这比固定IP方案优雅得多,容器重启、重建、迁移,应用代码完全不用改。
4.3 用compose管理多个容器的日常协作
容器一多,用docker run一条条敲命令就太累了。而且每次重启都要记住端口、网络、挂载卷,极易出错。我现在的项目基本都会配一个docker-compose.yml,把所有容器的配置集中写在一起。下面是一个典型的Web服务加数据库的组合:
version: "3.8" services: web: build: . ports: - "8080:5000" depends_on: - db environment: - DB_HOST=db db: image: postgres:16-alpine environment: - POSTGRES_USER=app - POSTGRES_PASSWORD=secret volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:这个文件里最关键的两个点:一是depends_on,它声明了Web服务依赖数据库,启动时Docker会按照依赖顺序先启动db再启动web。二是环境变量的传递,web容器里可以通过DB_HOST=db这个环境变量知道数据库的地址,而这个值恰好是db服务在Compose网络里的主机名。这样就实现了一种很干净的服务发现方式。
启动整个环境只需要:
docker compose up -d查看日志、停止服务、重建环境,分别对应docker compose logs、docker compose down、docker compose up -d --build。这套命令几乎成了我日常开发的主旋律。个人体会是,Compose的学习成本很低,但收益非常大,它让“本地整套环境一键起停”成为可能。
5. 排查问题与避坑经验:真实场景里踩过的坑
5.1 日志、进程、资源,三类排查手段
容器跑起来之后,最怕的就是“黑盒运行”,看不到里面发生了什么。好在Docker提供了几个非常直观的排查手段:
# 实时查看容器日志,最能暴露问题 docker logs -f web-demo # 查看容器内部进程 docker top web-demo # 查看容器详细配置,包括端口映射、挂载卷、环境变量 docker inspect web-demodocker logs是遇到故障时我第一个会看的命令。如果容器启动失败,报错通常就打在日志里,比如端口被占用、数据库连不上、缺配置文件,这些都能从日志中快速判断出来。有一个小技巧是把宿主机时间与时区也映射给容器,否则日志时间会和正常时间差好几个小时。
docker stats也是一个很实用的命令:它会展示容器的CPU、内存、网络IO使用情况,类似任务管理器。当生产环境出现性能问题时,我能第一时间判断是哪个容器异常占用了资源,再对症下药。
5.2 常见问题速查
把我在不同项目里遇到的高频问题整理成一张表,方便你直接对着排查:
| 问题现象 | 常见原因 | 解决方向 |
|---|---|---|
| 容器启动后立即退出 | 主进程结束或启动命令异常 | 查看日志确认原因,改用-it进入容器手动运行启动命令 |
| 宿主机访问不到容器服务 | 忘记做端口映射或者端口冲突 | 检查-p参数,确认端口未被占用 |
| 拉取镜像超时或失败 | 网络问题 | 稳定网络条件下重试,或配置可用的镜像加速源 |
| 容器内数据丢失 | 没有挂载数据卷 | 重建容器时挂载命名卷或宿主机目录 |
| 容器时间不对 | 时区未设置 | 启动时通过环境变量或挂载/etc/localtime设置时区 |
| 构建镜像时一直重复装依赖 | 未利用层缓存 | 调整Dockerfile顺序,先拷贝依赖清单再拷贝代码 |
| 容器里curl不到其他服务 | 不在同一个自定义网络 | 创建自定义网络并让容器加入 |
| 升级镜像后启动失败 | 版本兼容问题 | 回滚到上一个可用镜像,逐步对比配置 |
5.3 我的几条独家原则
最后分享几个我坚持了很长时间的原则,不一定适用于所有场景,但至少能帮你少走很多弯路。
第一,容器尽量保持“单进程、单职责”。很多人喜欢在一个容器里塞SSH、塞多个服务,这种做法会让容器的生命周期管理变得混乱。我的做法是一个容器只跑一个主进程,需要多个服务联动时用Compose把它们组合起来。这样伸缩、日志、权限都容易控制。
第二,用标签管理镜像版本,不要依赖latest。镜像的latest标签是会漂移的,今天拉的和下个月拉的很可能不是同一个。部署时指定明确的版本号,能保证你在任何时间点部署出来的环境是一致的。这也是回滚能起作用的前提。
第三,容器里出现问题,先去读日志,不要盲目重启。很多时候连续重启只会让日志被淹没,反而不利于定位。先确认日志内容,再决定是改配置还是重建容器,效率会高很多。遇到存储相关的数据不在时,先确认挂载卷配置,数据卷一旦丢失,恢复难度极大。
6. 后续还能往哪些方向扩展
如果你把上面这些内容都消化得差不多了,Docker的日常使用基本就够用了。再往后,你可能会接触到容器编排层面的问题:多台机器上怎么调度容器、流量怎么负载均衡、服务怎么弹性扩缩容。这个阶段的解决方案通常是容器编排平台,比如Kubernetes。它的核心思路是把容器管理进一步抽象化,让集群像一台巨大的逻辑机器。不过Kubernetes的学习曲线比较陡,我比较建议先把Docker本身的使用习惯练扎实,再往编排方向走,这样理解起来会平滑很多。
还有一种扩展方向是CI/CD流水线里整合Docker。把测试、构建镜像、推送仓库、部署到服务器这几个步骤串起来,每次代码合并都自动触发。我自己实践下来,这种流程一旦跑通,交付节奏会变得很顺畅。至少你不会再把时间花在“为什么测试和本地环境不一样”这种问题上。
我自己的体会是,Docker带来的改变并不是某条命令有多酷,而是让“环境”变成了可以写进版本控制里的资产,让应用在不同环境之间迁移成了一件非常平凡的事情。刚开始用的时候,我也被各种概念和参数绕晕过,但坚持用一段时间、跑几个真实项目后,你会发现它已经成为一种基础设施级别的习惯。希望这篇分享能帮你把第一块拼图拼上,后面越用越顺手。