ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker零基础实战:镜像、容器到Compose编排的避坑指南

Docker零基础实战:镜像、容器到Compose编排的避坑指南

Docker这东西,刚接触的人容易把它想复杂,觉得又是一个需要啃很久的新技术。其实本质上它解决的就两件事:环境一致性和交付标准化。你不想再听到"我本机跑得好好的啊",你不想为了部署一个应用写一长串的环境准备文档,你不想把生产环境搞得跟玄学一样,那Docker就是现阶段最务实的答案。这篇笔记是给零基础同学的,但也不是把官方文档念一遍,核心是把我自己从"知道Docker"到"能利索地用Docker"之间踩过的坑、理顺的思路全部整理出来,你看完能直接上手干活。

平时写完代码,最烦的不是逻辑,而是"怎么让它在别人机器上跑起来"。Docker最迷人的地方在于,你把应用连同它的运行环境一起打包成一个镜像,拿到哪儿都能跑。这份笔记会从镜像、容器、仓库这三个基础概念讲起,带你完成环境安装、镜像拉取、容器启动、数据持久化、网络配置、镜像定制、Compose编排这一整套流程,最后附上我在实际项目里遇到过的高频问题排查方案。无论你是后端开发、前端同学,还是做运维的,只要需要交付和部署,这套东西早晚用得上。

1. 先搞懂Docker到底解决了什么问题

1.1 镜像、容器、仓库这三兄弟

很多人学Docker,第一个被绕晕的就是镜像和容器到底啥关系。我换个说法你就懂了:镜像是一份"只读的打包模板",容器是这份模板"运行起来的实例"。同一个镜像可以同时启动多个容器,每个容器互不干扰。打个比方,镜像就是装系统的光盘,容器就是插了光盘跑起来的那台机器,光盘本身是共享的、只读的,但每台机器上跑出来的状态是独立的。

仓库就更简单,它是存放镜像的地方。你从仓库拉镜像,就好比从网上下载一个安装包。最常用的公共仓库就是Docker Hub,里面有nginx、mysql、redis、python、node这些官方镜像。国内访问可能慢,后面我会专门讲怎么优化拉取速度。

概念层面,你先记住一句话:镜像是文件,容器是进程。你docker pull下来的是镜像,它不占内存、不耗CPU,就是躺在磁盘上的一堆只读文件;你docker run之后才产生容器,它才真正在跑。后面所有操作,理解到这层就不会乱。

1.2 容器和虚拟机为什么不一样

这个坑几乎每个新手都会问:Docker和虚拟机有啥区别?不都是隔离环境吗?区别很大,简单来说:虚拟机是在你的物理机上虚拟出一台完整的"假电脑",每个虚拟机里都有一个独立的操作系统内核;而Docker容器是在你宿主机操作系统之上,通过内核的namespace和cgroup机制做隔离,所有容器共享宿主机内核。

换个生活化的比喻。虚拟机是你从小区里租了一套独立公寓,有独立的房门、独立的水电表、独立的户口(独立内核),房东完全管不到你内部;容器则是合租室友模式,大家住在一个大套间里,共用客厅和厨房(宿主机内核),但每人有自己的卧室(独立进程空间),用水用电有限额(资源限制)。合租显然比整租启动快、开销小,这也是为什么容器能在毫秒级启动,而虚拟机往往要用秒算。

但要注意,共享内核这事儿是有代价的。你可以在Linux上用Docker跑centos和ubuntu容器,没问题,因为它们内核同源。但你在纯Windows上想直接跑Linux容器就费劲,因为Windows自身不是Linux内核,需要通过WSL2或者虚拟机做兼容层。这个认知决定了你装Docker时选对方式,后面环境准备我会重点说。

2. 环境准备:先把Docker跑起来

2.1 安装方式与版本验证

安装这块不同系统差别很大。Linux用户最简单,各大发行版都有包管理器,装好后启动服务即可。macOS用户直接装Docker Desktop,Windows用户也建议装Docker Desktop,它会自动处理WSL2后端的问题。装完之后,不要急着一顿操作,先做两个验证:

docker version docker info

这两条命令能看到客户端和服务端的版本信息。如果你发现服务端没起来,说明Docker引擎没启动。Linux上执行sudo systemctl enable --now docker,macOS和Windows则直接在桌面端打开软件。我见过太多人装完Docker,一敲命令报错"cannot connect to the Docker daemon",第一反应是重装,其实只是服务没拉起来。

验证安装的经典一步是跑hello-world:

docker run hello-world

如果看到一段解释"你的Docker工作正常"的提示,就说明整个链路通了。这一步同时也是你第一次体会"拉取镜像到运行容器"的完整过程,虽然它只是个打印日志的小家伙。

2.2 拉镜像太慢怎么办

这是国内环境下的刚需痛点。你能拉得到hello-world这么小的镜像,等你拉mysql、redis这种几百兆甚至上GB的镜像时,速度能慢到怀疑人生。解决方案是配置镜像加速器,也就是给Docker配置registry-mirrors。好多云厂商都提供免费的镜像加速地址,你只需要在Docker的配置文件里把它们加进去就行。

Linux下改/etc/docker/daemon.json,macOS和Windows可以在Docker Desktop的Settings里直接配置,核心内容如下:

{ "registry-mirrors": [ "https://自己的加速地址" ] }

改完重启Docker服务,再用docker info看一下Registry Mirrors一栏有没有生效。我要提醒一句:加速器只对公共仓库的镜像生效,如果你拉的是私有仓库的镜像,那还得走网络路径本身,这个不要混淆。实测下来这个配置能快上好几倍,属于入门必做动作。

3. 镜像管理:从仓库拉到你本地

3.1 拉取镜像与版本选择

日常用得最多的就是docker pull。这里有个非常容易踩的习惯坑:直接写docker pull nginx或者docker pull mysql,不带版本号。这样拉下来的是latest最新版标签。最新版看着香,但它的Bug、行为变更、配置项变动都可能在你不知情的情况下发生,长期用latest等于把镜像的稳定性交给上游随机掷骰子。

真实项目里,我强烈要求大家拉取时明确锁定版本:

docker pull nginx:1.25.3 docker pull mysql:8.0 docker pull redis:7.0

主版本号定型,比如mysql:8.0代表8.0系列的最新补丁版,生产环境如果要更严格,可以精确到具体小版本。版本确定的好处不仅是稳定,还方便团队协作,大家拉同一个tag,行为才一致。这也是"快速入门"里最应该尽早养成的习惯。

3.2 查看、删除与善用inspect

拉完镜像,docker images或docker image ls查看本地已有的镜像列表。你会看到列出的仓库名、标签、镜像ID、创建时间和大小。镜像ID是完整的sha256哈希的前12位,够日常区分使用。

觉得本地镜像太杂想清理,用docker rmi删除。删除时注意,如果某个镜像正被容器使用,会报错。你得先删容器,再删镜像。还有一种批量清理操作用的很少,因为我总是担心误删,反而是docker image prune这种命令在开发机上比较稳妥,它会把"没有对应运行容器"的悬挂镜像清理掉。

平时排查问题或者想做精细配置时,docker inspect值得记住。比如你想看某个镜像的环境变量、入口命令、暴露端口,一条docker inspect 镜像名就能把底层配置全部翻出来,有些官方文档没写明白的细节,inspect里都有答案。

4. 容器操作:日常命令一条龙

4.1 docker run参数逐个拆解

进入实操核心环节。一条docker run命令是整个Docker日常操作里参数最多、信息量最大的:

docker run -d --name web01 -p 8080:80 -v /opt/html:/usr/share/nginx/html nginx:1.25.3

逐个拆解:-d表示后台运行,不加它,容器在前台运行,日志会把你终端霸占,你一关终端容器就跟着关了;--name给容器起个名字,不然后续操作你要依赖一长串的容器ID;-p 8080:80做端口映射,左边是宿主机端口,右边是容器内服务监听的端口,访问宿主的8080就会进到容器的80;-v做目录挂载,等于是把宿主机目录映射进容器,改宿主机文件等于改容器文件,前面提过数据持久化的事,后面会展开。

实际试一下,用nginx启动一个站点:

docker run -d --name web01 -p 8080:80 nginx:1.25.3

启动后用浏览器访问http://localhost:8080,看到nginx欢迎页就成功了。如果页面打不开,优先排查端口是否冲突,换一个比如-p 8081:80再试。这条命令你反复用几次,Docker就算入门一半了。

4.2 容器生命周期管理

容器启动之后,高频操作就是查状态、停启、看日志、进容器:

  • docker ps:查看运行中的容器
  • docker ps -a:查看所有容器,包括已退出的
  • docker stop 容器名:优雅停止
  • docker start 容器名:再次启动已存在的容器
  • docker restart 容器名:重启,等同于停+启
  • docker logs -f 容器名:追踪容器日志,排障神器
  • docker exec -it 容器名 bash:进入容器内部执行命令

这里有个我踩过的坑,docker exec -it进去之后,某些精简镜像(比如alpine版本)里没有bash,只有sh,你直接敲bash会报"bash not found"。解决方式是先用sh试,或者命令写成docker exec -it 容器名 sh。另外,进程在容器里做的修改,容器一删就全没了,这个特性初期很容易让人误以为容器是永久的,实际恰恰相反,它是"一次性的临时进程"。

4.3 容器内外的文件拷贝与清理

有时候你会想把宿主机上的文件塞进容器,或者把容器里生成的产物拷出来。docker cp就是干这个的:

docker cp ./config.json web01:/etc/nginx/conf.d/

反过来也一样。这个操作在调试阶段非常常用。比如你用nginx容器跑前端项目,直接从容器里看文件对不对,可以先把构建产物docker cp进去验证,确认没问题再上正式的持久化方案。

清理容器用docker rm 容器名,同样的,删容器不等于删镜像,两者别搞混。开发机上我定期执行docker container prune清理已停止的容器,docker image prune清理悬挂镜像。刚开始用的时候别太心疼数据,容器本来就是"临时进程"心态,重要的数据一定要走持久化,而不是指望容器本身长生不老。

5. 数据持久化:容器没了,数据还在

5.1 先理解为什么数据会丢

很多新手第一次用Docker跑了个mysql,往里存了点数据,然后不小心docker rm了容器,再启动一个新容器,发现数据全没了,那个崩溃的瞬间我经历过。根本原因在于容器销毁时,它内部的可写层一起被销毁了,它只是个"临时进程",你给进程里写的数据,进程没了就是没了。

解决办法就是使用持久化方案,把"容器内部的可变状态"放到"宿主机上的独立存储空间"。业务数据不该跟容器共存亡,必须在外部独立保存。理解了这个逻辑,后面所有卷和挂载的用法你都不用死记,自然知道哪里该挂。

5.2 绑定挂载与命名卷,怎么选

Docker提供两种主流持久化方式。第一种是绑定挂载(bind mount),把宿主机上的绝对路径映射到容器路径:

docker run -d -p 8080:80 -v /home/user/html:/usr/share/nginx/html nginx

这种方式的好处是,你直接在宿主机改文件,容器里立刻生效,非常适合开发调试。比如前端项目用nginx镜像托着,改完代码直接刷新浏览器就看到效果,不用重新构建镜像。缺点是宿主机和容器共用目录,权限和路径绑死,生产环境迁移麻烦一点。

第二种是命名卷(named volume),由Docker管理存储位置:

docker volume create mysql_data docker run -d -p 3306:3306 -v mysql_data:/var/lib/mysql mysql:8.0

数据跟容器解耦,换容器不丢数据,生产环境我更推荐这种方式。两种方式在docker run里的写法区别就是:绑定挂载的-v后面跟的是宿主机绝对路径,命名卷的-v后面跟的是卷名。判断标准一句话:开发调试用绑定挂载,数据存储用命名卷。

注意:-v /path和-v name的区别,就看第一个字符是不是/。如果是/开头的绝对路径,就是绑定挂载;否则Docker会把它当作命名卷来创建。

5.3 持久化里的权限与路径坑

持久化最容易踩的坑是权限问题。宿主机目录的属主和属组,跟容器内的用户不一定一致,尤其数据库镜像。mysql容器默认用mysql用户跑,但宿主机挂载的目录属主可能是root,结果容器启动时没权限写数据,直接闪退。解决方式比较粗暴也比较常用:先给挂载目录开权限,比如chmod -R 777或者chown改成容器期望的UID,但别图省事一刀切777,安全隐患大,可以通过docker inspect看镜像声明的用户,再对应设置属主。

路径方面,新手还容易栽在相对路径上。-v后面永远写绝对路径,否则Docker会把它理解成卷名,行为跟你预期完全不一样。观察它到底挂载在哪儿,用docker inspect 容器名里的"Mounts"字段,一清二楚。

6. 网络通信:容器之间怎么互访

6.1 端口映射的本质

你运行一个容器,容器里有服务监听在某个端口。但容器自己是一个"虚拟子网IP",宿主机之外的机器没法直接访问这个IP。-p参数干的事就是"端口转发":把宿主机某个端口上的流量转给容器内端口。规则是"左外右内",这一点我反复占很多人会反,-p 8080:80就是宿主8080进容器80,反过来就错了。

还有个容易犯迷糊的:为什么docker ps里显示0.0.0.0:8080->80/tcp,你还是访问不了?排查顺序大概是:容器起来没(docker ps)、端口映射对不对(docker ps的PORTS列)、宿主防火墙放没放行、容器内服务绑定的监听地址是不是0.0.0.0。有些容器内的服务默认监听127.0.0.1,那映射了也白搭,流量进来它不认。

6.2 自定义网络里的容器互联

单机场景下,多个容器之间通信有两条路:一是走端口映射,容器A访问宿主IP:映射端口;二是让它们处在同一个自定义网络中,直接通过容器名互访。第二种更优雅也推荐。

先创建网络:

docker network create demo-net

启动两个容器时都加上--network demo-net,比如一个跑后端,一个跑mysql。后端容器里访问mysql,直接用mysql这个容器名当主机名就行:

docker run -d --name backend --network demo-net myapp docker run -d --name mysql --network demo-net -e MYSQL_ROOT_PASSWORD=root mysql:8.0

后端里写数据库连接地址,host直接填mysql,不要写IP。Docker自带的DNS解析会自动把容器名解析成对应IP,而且容器重启换IP了也不影响,写死IP反而一重启就断。这个特性是我最常用的,强烈推荐大家把多个容器放进同一个自定义网络管理。桥接(bridge)是默认网络模式,适合单个容器的端口映射;host模式让容器跟宿主机共用网络,性能好一点但隔离差;none就是无网络,一般场景用不上。

7. 镜像定制:写一个能用的Dockerfile

7.1 最小Dockerfile逐段拆解

跑别人的镜像是入门,能按自己的需求做镜像才算真正掌握Docker。Dockerfile就是一个"如何组装镜像"的配方文件。给你看一份很常见也最实用的Node.js项目镜像:

FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install --registry=https://registry.npmmirror.com COPY . . EXPOSE 3000 CMD ["node", "app.js"]

每一行都讲一下。FROM指定基础镜像,相当于"我要在这个系统上盖房子",alpine标签是精简版Linux,镜像体积小,依赖也干净,推荐优先选它。WORKDIR设定工作目录,后面所有命令都默认在这个目录执行,这行不写,你的COPY和RUN就可能散落在容器根目录里,乱得不行。COPY package*.json ./是先只拷贝依赖描述文件,然后再RUN npm install,这样依赖描述文件没变时,这一层能命中缓存,构建速度会快非常多。COPY . .把项目代码拷进去。EXPOSE只是声明容器内的服务端口,它不自动做映射,映射还是靠-p。CMD指定容器启动时执行的进程命令,注意一个Dockerfile里只能有一个生效的CMD。

7.2 构建镜像的正确姿势与缓存陷阱

在Dockerfile所在目录执行构建:

docker build -t myapp:v1.0 .

-t是给镜像打标签,最后的.表示构建上下文是当前目录。这里引出一个关键概念:构建上下文。COPY只能复制构建上下文目录内的文件,所以别想着COPY .. /path去拷Dockerfile之外的东西,那不在上下文里,构建会直接报错。这就是为什么我建议在项目根目录放Dockerfile,构建时就在根目录执行。

构建加速的核心是理解层缓存。Dockerfile每一行都会生成一个镜像层,如果某层的前置层没变且这一层指令也没变,Docker会复用缓存的旧层。所以把不容易变的内容放在Dockerfile前面,把容易变的代码放在后面。前面我特意先COPY依赖描述再RUN install,就是为了让npm install这层尽量命中缓存,否则每次改一行代码都要把依赖全装一遍,那种痛苦你试过一次就懂。

再说一个实战经验:尽量合并同类命令,减少层数。比如:

RUN apt-get update && apt-get install -y curl vim

不要把两个RUN拆开写。因为每个RUN都会生成一层,层多了不仅镜像体积大,构建也慢。此外,项目根目录放一个.dockerignore文件,把node_modules、.git、dist这些不需要进镜像的目录排除掉,构建速度和镜像大小都会有明显改善。我见过有人忘记写.dockerignore,结果把几个GB的依赖目录全部拷进镜像,镜像体积直接失控。

8. 多人多服务编排:Docker Compose入门

8.1 compose文件长什么样

当你开始同时跑前端、后端、数据库、缓存,一条条docker run命令写下来已经够乱了,重启顺序靠人脑记住更是灾难。Docker Compose就是把一堆容器"配方化",用一个YAML文件声明所有服务、网络、卷,一条命令全部启动。它的价值在于项目级编排,你的一整套环境定义成了代码,别人拿到就能一键复现。

默认的配置文件叫docker-compose.yml,长这样:

services: web: image: nginx:1.25.3 ports: - "8080:80" volumes: - ./html:/usr/share/nginx/html depends_on: - db db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: app volumes: - db_data:/var/lib/mysql volumes: db_data:

注意新版Compose已经不需要再写version字段了,你网上看到的老教程里version: "3"那行可以忽略。services下面定义每个服务,ports、volumes、environment都写成清晰的结构化配置。depends_on表示启动顺序上db要先起,但注意它不等待db完全就绪,严格的生产场景要用健康检查才可靠。

8.2 compose常用命令

基本操作就几条:

docker compose up -d # 启动所有服务 docker compose ps # 查看服务状态 docker compose logs -f # 跟踪日志 docker compose exec web sh # 进入某个服务容器 docker compose down # 停止并删除容器

up -d是启动并后台运行,第一次运行会先拉镜像再创建容器。down会把容器和默认网络一起删掉,但命名卷里的数据默认保留,这点设计很合理。如果改了compose文件,想按新配置重建,用docker compose up -d会智能化地重建有变化的服务,不用手动删。

我在实操中最大的体会是:一定不要手动docker run和Compose混着管理同一个项目。Compose有它自己的容器命名规则,你手动创建一个顶着相同名字的容器,下次运行docker compose up就可能报"name conflict"。统一用Compose管项目内服务,手动docker run只用来做临时调试。

9. 常见问题排查与避坑实录

9.1 高频问题速查表

整理一份实际项目里高频出现的Docker故障排查速查表,都是我自己踩过的:

现象常见原因处理方式
端口访问不通端口映射写反或端口被占换-p 8081:80测试,netstat查端口占用
镜像拉取极慢未配置镜像加速器配置registry-mirrors并重启Docker
容器秒退前台进程没保持检查容器内进程,是否缺少-d或进程异常
mysql容器启动后闪退挂载目录权限问题检查挂载目录属主,调整权限和UID
执行docker logs没日志应用缓冲未刷新应用中关闭日志缓冲,或改用stdout/stderr
容器内中文乱码镜像缺中文字符集基础镜像换成带中文字符集的版本
容器时间不对时区未设置启动加-e TZ=Asia/Shanghai
容器被杀掉内存超限docker stats查看资源占用,调高--memory限制

9.2 排查思路与工具链路

很多人遇到容器故障喜欢删了重建,前期调试没问题,但生产环境这么干等于销毁现场证据。正确排查链应该是:先docker ps -a看容器状态,容器已退出的话用docker logs看报错,信息不够再多加docker inspect看配置,最后用docker stats观察资源占用。这套链路把"启动时干了什么"、"运行中发生了什么"、"配置是否合理"全部覆盖了。

最实用的案例是容器秒退。新手第一反应是命令写错了,其实核心是容器里"没有一个能常驻的前台进程"。Docker容器存活的生命线是CMD指定的进程,如果这个进程启动后立刻退出了,容器就跟着退出。比如你写CMD ["nginx"],但镜像里没有可执行文件或者缺依赖,就会报错退出。排查时docker logs看到的最后几行往往就是根因。

还有一个容易忽略的:容器内存限制。一个项目里我跑了个比较重的服务,没加--memory限制,结果宿主机内存被打满,OOM直接杀进程。后来加了限制让它在可控范围内运行,虽然牺牲一些性能,但稳定优先。docker stats可以实时看CPU和内存,出门排查先跑它准没错。

9.3 分享几个提升效率的小习惯

最后分享几个让我日常操作顺手很多的小习惯。第一,给所有常用服务容器都加--restart unless-stopped,宿主机重启后Docker能自动拉起容器,省去手动恢复的麻烦。第二,善用docker inspect --format做查询,比如要查容器的IP,一条命令搞定:docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 容器名。第三,频繁调试小项目时,优先用Compose而不是一堆docker run,配置文件写在项目里,别人接手看一眼就明白整个环境结构。

这些习惯没有一个是复杂的硬核知识,但都来自实际踩坑后的肌肉记忆。Docker入门到熟练的分水岭,不是你背了多少命令,而是你开始理解"容器是临时进程、数据要持久化、环境要声明化"这三条核心原则。后面的路还长,编排集群、容器安全、镜像仓库管理都会慢慢碰到,但把这篇笔记里的命令和思路吃透,日常开发部署你已经能跑得比大多数人顺了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进