
要是你最近也跟我一样被同一个问题反复折腾——新拉下来的代码在本地怎么都跑不起来在同事电脑上一秒启动部署到测试环境又冒出一堆新报错那这篇记录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 --frombuilder /root/.local /root/.local COPY . . CMD [python, app.py]这种写法在实际项目中能有效压缩镜像体积。不过有一点要注意COPY --frombuilder引用的路径要和第一阶段真实生产路径一致否则拷贝会失败。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_HOSTdb db: image: postgres:16-alpine environment: - POSTGRES_USERapp - POSTGRES_PASSWORDsecret volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:这个文件里最关键的两个点一是depends_on它声明了Web服务依赖数据库启动时Docker会按照依赖顺序先启动db再启动web。二是环境变量的传递web容器里可以通过DB_HOSTdb这个环境变量知道数据库的地址而这个值恰好是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带来的改变并不是某条命令有多酷而是让“环境”变成了可以写进版本控制里的资产让应用在不同环境之间迁移成了一件非常平凡的事情。刚开始用的时候我也被各种概念和参数绕晕过但坚持用一段时间、跑几个真实项目后你会发现它已经成为一种基础设施级别的习惯。希望这篇分享能帮你把第一块拼图拼上后面越用越顺手。