如果你也被“在我电脑上是好的”这句话折磨过,那迟早会和 Docker 打上交道。容器技术解决的最核心问题,不是“让服务器更轻”,而是把整套运行环境——系统依赖、库文件、配置、代码——一起打包成一个标准件,拿到哪台机器上结果都一样。这篇内容适合刚接触容器、还没系统理清概念的人,我会把镜像、容器、数据卷、网络、Compose 这些绕不开的基础知识,按照实际使用时的逻辑重新讲一遍,也会分享一些踩过之后才知道的细节。
1. 为什么 Docker 值得你花时间:环境不一致的终极解法
先不急着敲命令,得先把“为什么要用”这件事想明白。否则你只会觉得 Docker 是个比虚拟机轻一点的工具,用起来还得学一堆新概念,性价比不高。实际上 Docker 能火起来,核心原因是它把“部署应用”这件事从“在机器上装环境”变成了“把环境一起带走”。
以前部署一个 Web 服务,流程大概是:买一台服务器,装操作系统,装语言运行时,装数据库,手动改配置文件,再跑一堆初始化脚本。哪个环节版本不对、路径不对、权限不对,都能让服务起不来。最麻烦的是这些问题往往不是立刻暴露,而是等流量上来之后才出各种怪问题。后来有人尝试用虚拟机解决,把整个系统快照下来,到哪都能跑,但虚拟机太重了,动辄几个 GB,启动要几分钟,性能损耗也明显。
Docker 换了个思路:不虚拟整套硬件和操作系统,而是共享宿主机的操作系统内核,只把“应用运行时需要的文件、依赖、配置”打包进一个镜像。启动容器相当于直接跑一个进程,秒级完成,磁盘占用也只有几十到几百 MB。我见过很多团队从虚拟机迁到容器之后,最直观的感受不是省了多少资源,而是“新环境从搭建到跑通”的时间从两三天缩短到了几分钟。
1.1 虚拟机 vs 容器:两种“隔离”思路
虚拟机和容器都叫“隔离”,但隔离的东西不太一样。
虚拟机隔离的是“整个系统”:每台虚拟机里有完整的操作系统,有自己的内核,Hypervisor 负责分配硬件资源。容器隔离的是“进程和文件系统”:所有容器共用宿主机的内核,但在文件系统、进程、网络、用户等方面做了隔离,容器里的进程以为自己在独立的系统里跑。
可以用一个不太严谨但好懂的类比:虚拟机像是你租了一整套房子,里面水管、电线、家具全是你自己的,和邻居完全隔开;容器像是住在一栋统一管理的大楼里,墙和公共管线是共用的,但房间钥匙、内部装修是你的,你想装成什么样就装成什么样。
这个差异直接决定了两个特性。第一,容器的启动速度远快于虚拟机,因为它不需要引导操作系统,只需要启动一个进程。第二,容器的资源开销远小于虚拟机,因为不需要为每个实例维护一整套操作系统内核和系统服务。代价是,容器里的进程直接跑在宿主机内核上,隔离强度不如虚拟机,所以生产环境通常会在命名空间、权限、安全策略上再做加固。
1.2 镜像分层与共享:为什么拉取快、占用小
很多人第一次用 Docker 时会有一个疑问:我明明只拉了一个应用镜像,为什么输出里会显示十几层“Pull complete”?这些层就是镜像分层结构——每个镜像不是一大块数据,而是由一系列只读层叠加而成,每一层对应 Dockerfile 里一条指令产生的变更。
这个设计的最大好处是共享。宿主机上存着多个镜像时,相同的基础层只存一份。比如你同时拉了基于某个 Linux 发行版的多个应用镜像,它们底层的系统库层是同一份数据,不需要重复下载、重复占磁盘。实际使用中你会发现,拉一个常见基础镜像可能只要几秒钟,很多层是本机已经存在的。
分层还带来另一个实用特性:增量拉取。如果某个镜像更新了应用层,但是底层基础镜像没变,你重新拉取时只需要下载变化的那几层。这一点在镜像更新频繁、带宽又不太充裕的场景里特别管用。理解了这一点,后面看 Dockerfile 时,你就会明白为什么调整指令顺序会影响构建缓存命中率。
2. 镜像和容器:先分清这两个最容易混淆的概念
Docker 里出现频率最高的两个词就是镜像和容器,但新手往往把它们当成一个东西。我自己的经验是,一旦把这两者的关系真正想明白,后面学什么命令都很快。
镜像(Image)是一个只读的、静态的文件包,里面包含了运行某个应用所需的全部内容:代码、运行时、系统库、配置、环境变量等。它本身不运行,是“死”的。容器(Container)是镜像被加载到内存并启动进程之后形成的运行实例,是“活”的。同一个镜像可以同时启动出多个容器,它们彼此独立、互不影响。
这和面向对象里的“类与实例”很像:镜像是类,定义好了模板;容器是根据模板创建出来的对象,每个对象有自己的状态。你改一个对象的状态,不会影响类本身,也不会影响其他对象。
2.1 一个关于“可写层”的关键细节
镜像本身是只读的,那容器运行时产生的文件写到哪去了?
答案是在镜像之上叠加了一层“可写层”。镜像所有层都是只读的,启动容器时,Docker 在最上面添加一个临时可写层。容器里的所有写操作——创建文件、修改配置、安装软件——都发生在这个可写层里。容器被删除时,这一层跟着消失,而底层镜像完全不受影响。
换句话说,容器运行期间折腾出来的所有东西都是“临时”的。你可以在容器里安装一堆调试工具,也能跑得很欢,但容器一删,这些改动统统没了。这正是很多人踩过的坑:在容器里手动改完配置、装完数据,以为一切保存好了,结果容器一重启或一删除,发现什么都没了。
所以,任何需要持久化的东西都不能只留在容器可写层里,要么在镜像构建时就固化好,要么挂载数据卷,把数据写到宿主机上。这个我在后面的数据卷部分会详细展开。
2.2 容器的生命周期:怎么创建、怎么停止、怎么删除
理解容器生命周期,命令就很好记了。整个过程大概是:创建容器、启动进程、运行中、停止、删除。
docker run是核心入口,它做的事可以拆成几步:先去本地找镜像,没有就自动从仓库拉取;然后基于镜像创建一个新容器;最后在该容器里启动指定的进程。所以docker run本质上等于“拉取 + 创建 + 启动”的组合。
运行中的容器用docker ps查看,加上-a参数可以连已停止的容器一起列出。停止容器用docker stop,它会给容器主进程发送停止信号,让进程优雅退出;强制停止用docker kill,信号发送过去之后基本是直接终止。删除容器用docker rm,已经停止的容器才能干净删除,如果你不想先停再删,可以用docker rm -f直接强制删除。
我建议新手记住一个原则:容器是很容易重建的东西。真正宝贵的是镜像、数据卷和配置文件,容器本身只是这些要素的“运行组合体”。所以不要心疼删容器,多删几次,你对编排的理解会深很多。
3. 每天都要用的 Docker 命令清单:照着敲就行
基础命令不需要背,多用几次自然就记住了。但有几个高频场景的命令我会放到一起说,方便你快速查阅:镜像管理、容器运行、进入容器与看日志、系统清理。
3.1 镜像管理:拉取、查看、删除
从仓库拉取镜像用docker pull,后面跟镜像名和标签:
docker pull nginx:latest标签(tag)用来区分同一个镜像的不同版本,latest是最常见的默认标签,但在生产环境里我建议尽量指定明确版本,比如改用具体的版本号标签,而不是依赖latest,否则某天更新可能带来不兼容变化。
查看本地已有的镜像:
docker images删除镜像用docker rmi,可以跟镜像 ID 或镜像名加标签。如果这个镜像还有容器在使用,删除会报错,需要先停掉并删除相关容器,或者用-f强制删除。清理无用的悬空镜像(没有任何标签且没有被容器使用的镜像)可以用:
docker image prune3.2 容器运行:run 的参数到底该怎么给
docker run的参数非常多,刚开始不需要全记,先把这几个高频的用好:
docker run -d -p 8080:80 --name web nginx:latest-d:后台运行,不占用当前终端。调试时可以去掉-d换成前台运行,日志直接打在终端里。-p:端口映射,格式是“宿主机端口:容器端口”。这里把宿主机的 8080 端口映射到容器里的 80 端口,访问宿主机 IP 的 8080 就能访问容器里的 Web 服务。--name:给容器起个名字。不写的话 Docker 会随机生成一个名字,虽然能用,但在管理多个容器时非常痛苦。--rm:容器停止后自动删除,适合临时测试,避免堆积垃圾容器。--restart:设置重启策略,比如unless-stopped表示除非手动停止,否则容器退出后会自动重启。线上服务通常会设置这个策略。
进入正在运行的容器,最常用的是:
docker exec -it web /bin/bash-i保持标准输入打开,-t分配一个终端。进入容器后,你就可以像在一台精简版 Linux 机器里一样查看文件、跑命令。不过基础镜像通常很精简,可能没有vim、ping这些工具,需要先安装或直接通过日志排查。
3.3 日志与系统清理:排障和防止磁盘膨胀
看容器日志:
docker logs -f web-f表示持续跟踪新日志,退出按 Ctrl + C。排障时这是第一利器,90% 的“容器怎么起不来”问题都可以靠日志定位。
系统清理方面,Docker 用久了磁盘会越来越大,尤其是日志文件和悬空镜像。查看占用情况:
docker system df一把清理所有未使用资源:
docker system prune -a这会删除所有未被使用的镜像、停止的容器、网络和构建缓存。注意它是把“没用到的”都清理掉,如果某些镜像你有保留需求,先确认它们正在被使用,否则不要随便加-a。
4. Dockerfile:把“环境”固化成一段可读的配方
命令行只是第一步,真正让项目可复现的关键是 Dockerfile。它是一份声明式的环境配置文档,规定了用什么基础镜像、拷贝哪些文件、安装什么依赖、启动什么命令。有了它,任何一个人拿到项目之后执行一次构建,得到的镜像基本是一致的。
4.1 Dockerfile 的骨架:FROM、WORKDIR、COPY、RUN、CMD
一个最简单的 Node.js 服务 Dockerfile 大概长这样:
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["node", "server.js"]逐条拆开讲讲为什么这么写。
FROM指定基础镜像。选镜像的关键是“够用就好”,不需要什么都装。node:20-alpine是基于 Alpine Linux 的精简镜像,体积比完整版小很多,适合装完依赖直接跑。
WORKDIR设置工作目录。之后的命令都会在这个目录下执行,如果目录不存在,Docker 会自动创建。
COPY把宿主机上的文件复制进镜像。注意这里有个顺序问题:我先复制了package.json和package-lock.json,再运行npm install,最后才复制项目源码。这是有意为之。因为 Docker 构建时会缓存每一层的结果,如果先把整个项目的文件都复制进去,再执行npm install,那么任何源码改动都会导致依赖安装那一层缓存失效,每次构建都要重新下载依赖。先复制依赖清单、安装依赖、再复制源码,源码改动时只有最后两层需要重建,速度会快很多。
CMD是容器启动时要执行的命令。这里用数组形式书写,表示直接执行可执行文件,没有经过 shell 包装。这个细节有讲究,后面讲坑的时候会说。
4.2 多阶段构建:为什么镜像能瘦一大圈
构建某些项目时,编译阶段需要完整的编译工具链,但运行阶段根本不需要。多阶段构建就是利用 Dockerfile 里多个FROM,把构建和运行分开。
看一个 Go 项目的经典例子:
FROM golang:1.22-alpine AS builder WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o app . FROM alpine:latest WORKDIR /app COPY --from=builder /src/app /usr/local/bin/app CMD ["app"]第一阶段用带 Go 编译器的镜像编译出二进制文件,第二阶段直接用精简的 Alpine 镜像把编译产物复制进去。最后得到的镜像里只有二进制文件,不包含任何编译工具链。这种方式对很多语言项目都适用,最终镜像体积能减小一个数量级,推送和拉取都快很多。
4.3 搭镜像的几条实用原则
第一,基础镜像尽量选官方镜像和精简版本,不要什么都往里面装。镜像体积越大,构建、拉取、启动的速度都受影响。第二,把“不常变的内容”放在 Dockerfile 前面,“频繁变的内容”放后面,充分利用构建缓存。第三,安全相关的更新放在基础镜像层面解决,而不是每次在容器里手动打补丁。
我现在写 Dockerfile 的习惯是:先保证能跑通,再考虑瘦身,最后才加各种优化。不要一开始就追求最薄的镜像,那会让你把大量时间花在折腾底层依赖上。
5. 数据持久化:容器删了,数据还在
前面提到,容器可写层是临时的,容器一删数据就没了。那数据库、上传文件、日志这类要长期保存的数据怎么办?答案是数据卷(Volume)和挂载(Mount)。
5.1 为什么不能直接往容器里写文件
容器本质上是一堆可执行文件和运行配置的组合,它应该是“可替换的”。今天跑一个容器,明天因为镜像更新或者配置调整,你可能需要删掉它再起一个新的。如果把数据放在容器可写层里,更新版本就意味着丢数据。
所以,凡是需要长期保留的内容,都应该写到宿主机文件系统上,再通过挂载的方式“借给”容器使用。这样容器本身可以随时重建,数据却保留在宿主机上。
5.2 bind mount 和 named volume:选哪种
Docker 提供两种主要的持久化方式:绑定挂载和命名卷。
| 对比项 | 绑定挂载(bind mount) | 命名卷(named volume) |
|---|---|---|
| 宿主机目录位置 | 由你指定,如/data/app | Docker 统一管理,不需要你指定路径 |
| 适合场景 | 本地开发调试,代码改完立即生效 | 生产环境数据持久化、多容器共享 |
| 维护方式 | 直接操作目录文件 | 用docker volume命令管理,可用docker volume prune清理 |
| 数据一致性 | 目录权限由宿主机决定 | Docker 创建卷时可以按镜像要求初始化目录权限 |
绑定挂载的典型写法:
docker run -d -p 3000:3000 -v /home/user/app:/app your-app-image把宿主机/home/user/app目录挂载到容器里的/app,两边的数据是实时同步的。开发时改代码不需要重新构建镜像,容器里的进程运行着新代码(前提是服务支持热加载或者你手动重启容器内进程)。
命名卷的典型写法:
docker volume create appdata docker run -d -v appdata:/var/lib/data your-app-image数据实际存在 Docker 管理的路径里,但你不必关心具体在哪。备份时用docker run --rm -v appdata:/data alpine tar -czf /data-backup/data.tar.gz -C /data .这类方式处理。
这里有个我见过很多次的误区:以为-v后面写宿主机路径就一定是绑定挂载。实际上如果冒号左边写的是纯名称(不含/),Docker 会把它当成命名卷处理;如果写的是以/开头的绝对路径,才是绑定挂载。两者的管理方式差异很大,很容易搞混,建议刚开始用时多看几眼命令写法。
6. 网络基础:端口映射与容器互访
容器之间、容器和宿主机之间怎么通信,是另一个高频困惑点。默认情况下,容器只有自己的网络命名空间,外部并不能直接访问它的端口,需要通过端口映射把容器的端口“暴露”到宿主机上。
6.1 -p 到底做了什么
-p 8080:80的含义是:访问宿主机的 8080 端口时,流量会被转发到容器的 80 端口。实际操作中,你可以把宿主机端口换成任意没有被占用的端口,比如-p 8080:80、-p 3000:3000。
反向理解也很有用:如果你发现某个端口访问不了,检查两件事——容器里进程是否真的监听在容器端口上,以及宿主机端口映射是否写对。很多时候问题是服务监听在容器内的127.0.0.1上,只对容器内部可见,外部映射完全进不去,这时候要让进程监听0.0.0.0。
6.2 自定义网络:让容器用服务名互相访问
当有多个容器需要协作时,比如一个 Web 服务要连数据库容器,新手最容易想到的办法是“用数据库容器的 IP 地址来访问”。这能用,但容器重建后 IP 可能会变,配置里 IP 就得跟着改,非常脆弱。
更好的做法是创建一个自定义 bridge 网络,让容器加入同一个网络后,直接用容器名互相访问。Docker 内置的 DNS 服务会自动把容器名解析成对应 IP。
实际操作是:
docker network create mynet docker run -d --network mynet --name db mysql-image docker run -d --network mynet --name app your-app-image之后在app容器里,直接用db这个主机名就能访问数据库容器,比如数据库连接串里写db而不是 IP。容器重建多少次,只要名字还叫db,连接关系就不会断。
这个模式在本地开发和单机多容器编排时尤其好用,也是后面 Compose 能简化多容器管理的基础。
7. Docker Compose:一份 YAML 管理整套服务
单机跑两三个容器时,手动敲命令还能忍。一旦服务变多,你会发现启动顺序、网络、数据卷、环境变量都要手动管理,命令也越来越长。Docker Compose 就是用来解决这个问题的:用一份 YAML 文件描述整套服务,一条命令全部启动。
7.1 为什么推荐 Compose:一套环境一个文件搞定
Compose 的核心价值是“把环境配置代码化”。项目里提交一份docker-compose.yml,任何同事拉下来后执行docker compose up -d,就能在当前机器上跑起完全一致的环境。这比在文档里写“先装这个、再改那个配置”要可靠得多。
而且 Compose 会自动创建项目专属网络,容器之间直接用服务名互访,数据卷也在同一份文件里声明。整个环境的状态一目了然。
7.2 一个可运行的 Compose 示例:Web 服务加数据库
以 Node.js 服务连 Redis 为例,配置文件内容大致如下:
services: app: build: . ports: - "3000:3000" environment: - REDIS_HOST=redis depends_on: - redis redis: image: redis:7-alpineapp服务用当前目录的 Dockerfile 构建;redis服务直接用公共仓库里的 Redis 镜像。app通过environment里的REDIS_HOST=redis拿到 Redis 服务名,在代码里连接redis这个主机名就能访问数据库容器。depends_on表示启动顺序:先启动redis,再启动app,省去不少手动等待的时间。
7.3 常用的 Compose 命令
docker compose up -d # 构建并后台启动所有服务 docker compose down # 停止并删除所有容器和默认网络 docker compose logs -f # 查看所有服务日志 docker compose exec app bash # 进入某个服务容器需要注意新旧版本命令差异:早期版本命令是docker-compose,现在主流是docker compose(中间有空格)。如果你用的是老版本,记得把命令里的空格换成-。配置文件里有变更时,重新执行docker compose up -d会按变更重建相关服务,日常更新代码后基本靠这一个命令完成重新部署。
8. 先用起来,再慢慢深入:几个绕不开的坑和我的建议
最后聊几个新手最容易踩、但文档里不会专门强调的点。
8.1 容器里的进程和日志,藏着两个隐患
第一个坑是容器主进程的退出信号问题。如果 Dockerfile 里写的是CMD ["sh", "-c", "node server.js"],那容器的主进程其实是sh而不是node。当你执行docker stop时,Docker 给主进程发退出信号,sh不一定会把信号转交给node,结果容器迟迟不退出,最后 Docker 只能等超时后强制杀。写 Dockerfile 时尽量让最终命令直接执行目标进程,不要多包一层 shell。
第二个坑是日志文件无限增长。容器里的进程如果一直往标准输出写日志,Docker 会收集这些日志并写到宿主机上的日志文件里。日志文件越滚越大,慢慢把磁盘占满。可以在 Docker 配置文件里加上日志轮转限制,比如单个日志文件最大 10 MB、最多保留 3 个文件,具体配置可以查你所用环境的 Docker 日志配置说明。
8.2 镜像拉取慢:先检查网络和加速配置
很多新手第一次拉镜像就卡在下载阶段,于是开始怀疑是不是哪里操作有问题。实际上公共镜像仓库的下载速度受网络环境影响,不同网络环境下差异很大。如果你也遇到拉取特别慢的情况,比较常见的解决办法是给 Docker 配置一个合适的镜像加速地址:在 Docker 配置文件中添加registry-mirrors字段,然后重启 Docker 服务。
{ "registry-mirrors": ["https://registry.example.com"] }地址需要填写你所在网络环境实际可用的加速服务。配置完成后再拉一次,速度通常会有明显提升。另外,拉取镜像时尽量选择精简基础镜像,数据量小,自然拉得快。
8.3 一条更适合新手的路径
不要试图一次把所有概念都学完。先只做一个容器,跑一个简单的服务,折腾明白docker run、docker ps、docker logs;然后写一份 Dockerfile,把项目容器化;再学习数据卷,把数据留在宿主机上;最后才上 Compose 管理多个服务。每一步都在真实场景里用过之后,再去研究镜像瘦身、存储驱动、容器编排这些进阶内容。
我自己到现在还会偶尔用docker system df看看磁盘占用情况,也会定期清理多年前的旧镜像。Docker 的学习曲线并不陡,真正的门槛在于是否理解“镜像只读、容器临时、数据要挂载”这三个基本心智模型。想明白这三点,你碰到的大部分问题都能自己推断出原因了。