很多新手把第一篇看完,装好了环境,敲出了第一个 hello-world,结果到了真正要用 Docker 的时候反而懵住了:镜像、容器、仓库到底是什么关系?为什么同一个命令别人能跑起来,自己一运行容器就秒退?为什么容器删了之后,之前在里头改的东西全没了?
这些问题卡住的不是少数人。网上讲 Docker 的教程非常多,但大部分要么直接甩命令,要么一上来就贴一堆底层原理,中间缺了最关键的一层——概念和实操之间的桥。这篇作为教程的第二篇,我不打算再重复安装和 hello-world 那点事,而是把镜像、容器、仓库、数据卷、Dockerfile 这些绕不开的核心概念,配合一组一组真实场景下的命令讲清楚。适合刚看完第一篇、还没正式跑起自己服务的朋友,或者是被那句"容器就是轻量级虚拟机"误导过的人。
先说明一点:这一篇讲的东西很基础,但基础不等于简单。把这几块弄明白,后面无论是用 Docker 部署 Web 服务、跑数据库,还是自己写 Dockerfile 做镜像,都不会再觉得别扭。
1. 镜像、容器、仓库:三个最容易搞混的角色,一次性讲清楚
1.1 生活化类比:镜像是一张"出生光盘",容器是一台"正在运行的电脑"
镜像和容器这两个词,是 Docker 新手第一道坎。很多人以为它俩是一回事,其实区别非常直观。
想象一下你买了一张游戏光盘。光盘本身是只读的,里面刻着完整的游戏数据,这张光盘就是镜像。你把光盘塞进游戏机,游戏跑起来了,你可以新建存档、调整设置、打补丁,这是容器。游戏机可以同时插两张同款光盘,两个存档互不干扰,这就是一个镜像可以运行出多个容器。
更贴切一点说,镜像是一个模板,它包含了程序运行所需的完整文件系统、依赖库、配置文件和启动命令。容器是基于这个模板创建出来的运行实例,它有自己独立的可写空间。镜像本身一旦构建完成就不该再改动,容器则随时可以被创建、启动、停止、删除。
很多老教程喜欢说"容器是轻量级虚拟机",这个类比方便归方便,但会误导人。虚拟机里跑的是完整的客户机操作系统,有自己独立的内核;容器和宿主机共享内核,只是通过命名空间做了隔离,所以启动才能做到秒级,资源占用才那么低。你把它理解成"同一台电脑上开了几个互相看不见的沙箱"会更准确。
1.2 分层存储:为什么镜像仓库看似很大,但拉取往往很快
镜像还有一个让新手困惑的特性:分层。
镜像不是一块完整的大硬盘,而是由一层一层的只读文件系统叠加组成的。每一层只记录相对于上一层的改动。拉镜像的时候,Docker 会逐层下载,如果本机已经有了某个底层,这层就直接复用,不再重复下载。
这个设计带来的直接体验就是:你第一次拉一个 ubuntu 基础镜像可能要下几百兆,但之后再拉基于同一个底层的其他镜像,比如 nginx、mysql 的官方镜像,速度会快很多,因为基础层已经在了,实际只需要下载各自的上层差异。很多人在这一步误以为"这个镜像真小",其实不是小,是分层复用的功劳。
分层还有一个好处是磁盘占用。十个镜像如果都基于同一个 Debian 基础层,底层文件在磁盘上只有一份,大家共享。这也是为什么 Docker 用久了,docker system df里看到的镜像体积总和,通常比实际占用小不少。
1.3 可写层与容器的"临时性":容器不是虚拟机,别在里面慢慢装
镜像本身只读,那容器运行时产生的文件写到哪去?
Docker 会给每个容器分配一个可写层。容器里所有文件改动,都记录在这个可写层里。这层是容器私有的,容器一删除,可写层也跟着销毁,你之前在容器里安装的软件、改的配置、落盘的临时数据,全部连锅端。
这就是容器和虚拟机最本质的一个区别:虚拟机里装的软件,关机重启后还在,磁盘是虚拟硬盘,数据持久化。容器不是这样,容器天然是"临时的"。正确用法应该是,需要持久化的数据放到数据卷里(后面会细讲),需要固化的环境写进镜像里,而不是把容器当作一台小虚拟机,启动后慢慢往里面装环境,装完觉得万事大吉。
把这个心智模型建立起来,后面所有操作都不会再觉得别扭。镜像负责"环境长什么样",容器负责"环境跑起来",仓库负责"镜像从哪来"。
2. 新手必背的一组命令:从拉取到跑起来的完整操作链
2.1 跑起第一个服务:docker run 的常用参数拆解
概念说再多,不如实际操作一遍。我拿一个最简单的场景举例:在本地启动一个 nginx 服务,访问它看到一个网页。
第一步,从公共镜像仓库把镜像拉到本地:
docker pull nginx:1.25-alpine这里我刻意指定了标签1.25-alpine。alpine是一个体积很小的基础系统,nginx 官方基于它做的镜像只有几十兆,比默认的nginx:latest小很多。新手第一次拉镜像建议直接选 alpine 标签,速度快、后面构建自定义镜像也方便。
然后启动一个容器:
docker run --name myweb -d -p 8080:80 nginx:1.25-alpine跑完之后浏览器访问http://localhost:8080,如果看到 nginx 的欢迎页,这条命令就算彻底跑通了。
我们来拆解一下参数,这几个参数你以后会天天用:
--name myweb:给容器起个名字,后面docker stop myweb直接引用名字,比记一串随机 ID 省心太多。-d:后台运行。不加这个参数的话,容器会在前台运行,终端直接挂住,日志不断刷屏,你只能 Ctrl+C 退出,退出一瞬间容器也就停了。Web 服务、数据库这类长驻进程,基本都用-d。-p 8080:80:端口映射,宿主机 8080 端口转发到容器 80 端口。容器本身有自己独立的网络命名空间,不映射的话,宿主机访问不到容器里的服务。
对了,-p左侧是宿主机端口,右侧是容器端口。我见过不止一次有人把两边写反,然后到处问为什么访问不了。
2.2 生命周期管理:从 docker ps 到 docker rm 的心智模型
容器跑起来之后,会有几个非常高频的场景。
查看正在运行的容器:
docker ps注意,docker ps默认只显示运行中的容器。一个容器启动时如果是因为某种原因退出了,它不会出现在这个列表里,而是出现在:
docker ps -a-a表示 all,所有的容器,包括正在跑的和已经退出的。很多新手容器"跑丢"了,急得不行,其实多半是docker ps看不到它就以为没了,其实用docker ps -a一眼就能找到。
停止、启动、重启:
docker stop myweb docker start myweb docker restart myweb删除容器:
docker rm myweb如果容器还在运行,直接删会报错,需要先docker stop再删,或者用docker rm -f myweb强制删。容器彻底不需要了,就删掉,别留着堆内存和磁盘。
清理所有已经退出的容器,可以一条命令搞定:
docker container prune这个命令会删除所有处于 exited 状态的容器,执行前它会问你要不要继续,不用慌。整体心智模型就一句话:stop是关机,rm是丢掉这台机器,删容器不等于删镜像,镜像还在本地docker images里躺着。
2.3 调试两件宝:docker logs 和 docker exec
容器是隔离的,启动之后你没法像看虚拟机界面那样直接看里面发生了什么,这时候两个命令是救命稻草。
查看容器日志:
docker logs -f myweb-f是 follow,跟踪输出,类似tail -f。容器启动失败、服务报错、访问日志,全都可以从这里看到。排查问题时第一件事永远是docker logs,别瞎猜。
进入容器内部执行命令:
docker exec -it myweb bash-it其实是两个参数,-i保持标准输入打开,-t分配一个伪终端,组合起来的效果就是你可以像 ssh 到一台机器上一样,在容器里敲命令。nginx 镜像里自带 bash,可以直接进;有些极简镜像不带 bash 只有 sh,就把命令换成sh。
注意,进入容器只是调试用的,不是让你进去装东西的。我在 1.3 节强调过,容器是临时的,你此刻在容器里装的任何东西,容器一删全没。把docker exec当作查看现场的手段,而不是运维入口。
为了方便记忆,我把这组命令整理成一个快查表:
| 命令 | 作用 | 备注 |
|---|---|---|
| docker pull 镜像名 | 拉取镜像 | 建议带版本标签 |
| docker images | 查看本地镜像 | 构建镜像的原材料 |
| docker run -d --name xxx -p 端口:端口 镜像 | 启动容器 | 参数按需增减 |
| docker ps / docker ps -a | 查看运行中/所有容器 | 排查先看 -a |
| docker logs -f 容器名 | 跟踪日志 | 排错第一抓手 |
| docker exec -it 容器名 bash | 进入容器 | 临时调试用 |
| docker stop / start / restart 容器名 | 生命周期控制 | 类似关机和开机 |
| docker rm 容器名 | 删除容器 | -f 强制删除 |
3. 环境固化的核心:写一份自己的 Dockerfile
3.1 第一个 Dockerfile:目标要足够简单,才能看清原理
用现成的镜像解决不了所有问题。大部分真实场景是:代码是你自己的,依赖是你自己的,配置也是你的,你得把"自己的东西"和"别人的镜像基础"组合成一个新镜像。这时候就要写 Dockerfile。
Dockerfile 本质上是一份构建说明书,告诉 Docker 从哪开始、执行哪些命令、拷贝哪些文件、最终启动时跑什么。它最大的价值是"环境即代码":你同事拉走你的镜像,或者照着你的 Dockerfile 重新构建,得到的环境和你本地一模一样,再也不会有"我机器上明明好好的"这种问题。
我用一个最简示例来拆解。假设你在本地有一个index.html,内容随意,你想做一个镜像,把 nginx 跑起来并展示这个页面。Dockerfile 这样写:
FROM nginx:1.25-alpine COPY index.html /usr/share/nginx/html/index.html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]每一行都讲清楚:
FROM:指定基础镜像,这是构建的起点。这里的 nginx 镜像已经把 nginx 装好了,文件结构也是现成的,你只需要做增量修改。COPY:把当前目录下的 index.html 拷贝到镜像里的 nginx 网页根目录。这里有个前提:index.html和 Dockerfile 必须放在同一个目录下,这个目录叫构建上下文。EXPOSE:声明容器对外提供服务的端口。注意它只是一个声明,真正生效的还是运行时-p映射,但写清楚能让人一眼看懂这个镜像开的是哪个口。CMD:容器启动时执行的命令。nginx 默认的官方镜像其实已经内置了合适的 CMD,这里写出来是为了解释概念——容器启动后必须有一个前台进程占住,否则会被系统判定为"没事干了",直接结束进程退出容器。
在 Dockerfile 所在目录执行构建:
docker build -t myweb:v1 .-t是给镜像打标签,格式是名字:版本。末尾那个点表示构建上下文为当前目录。构建完成后docker images就能看到myweb:v1,然后docker run --name myweb-test -d -p 8080:80 myweb:v1跑起来,浏览器访问看看页面是不是换成了你自己的 index.html。
3.2 一次 RUN 一个层:构建过程与体积控制的关键
写 Dockerfile 有个容易忽视的原理:每一条会改变镜像内容的指令(比如RUN、COPY)都会产生一个新的镜像层,最终镜像由这些层叠加而成。
这个特性带来的直接影响是体积膨胀。举个例子,如果你想在镜像里安装一些工具,不要一个工具写一条RUN:
RUN apt-get update RUN apt-get install -y curl vim这样会留下三层,其中apt-get update产生的临时缓存也会被完整留在这一层里,白白占空间。更合理的写法是合并成一条:
RUN apt-get update && apt-get install -y curl vim用&&串起来,构建时只产生一层,apt-get update的缓存也能在同一层里被清理掉,镜像体积小不少。
但是注意,合并命令别走火入魔。有些人为了追求层数少,把所有命令用一大堆&&串起来,一条指令几百个字符,构建失败后定位问题都费劲。我的建议是:相关命令合并,比如update和install必须合并,因为不合并会留下过期的索引缓存;无关命令分开写,保证可读性。层数和可读性的平衡,比单纯追求"层越少越好"更重要。
另外补一个很多新手不知道的细节:docker build会把整个构建上下文打包发给 Docker 引擎,如果目录里有大量无关文件,比如 node_modules、.git、临时文件,每次构建都会做一次巨量传输。这时候在目录里建一个.dockerignore文件,语法类似.gitignore,把不需要的文件排除掉:
node_modules .git *.log这玩意能直接改变构建速度,项目越大体感越明显。
3.3 ENTRYPOINT 和 CMD:一张表理清启动命令
Dockerfile 里负责定义启动行为的指令有两个:ENTRYPOINT和CMD。新手经常搞混,我直接上结论。
CMD:提供默认启动命令或参数。Docker run 启动容器时,如果没有额外指定命令,就执行 CMD;如果指定了,CMD 会被覆盖。ENTRYPOINT:容器启动时真正要执行的命令,它不会因为你在 docker run 后面追加参数而被覆盖,追加的参数会作为它后面的参数传进去。
两者配合最常见的方式是:ENTRYPOINT固定写主程序,CMD写默认参数。比如:
ENTRYPOINT ["nginx"] CMD ["-g", "daemon off;"]这样 docker run 时如果你追加参数,本质上是替换掉默认参数,主程序永远不会变。
| 指令 | 是否会被 docker run 追加命令覆盖 | 典型用法 |
|---|---|---|
| CMD | 会被覆盖 | 提供默认参数 |
| ENTRYPOINT | 不会被覆盖 | 固定主程序/入口脚本 |
| 两者都用 | CMD 作为 ENTRYPOINT 的默认参数 | 既要固定主程序又要可调参数 |
看完这张表再看CMD ["nginx", "-g", "daemon off;"]这个写法就很清晰了:方括号里是 JSON 数组格式,表示不加 shell 直接执行命令;daemon off;是让 nginx 前台运行的关键参数,因为容器里不能跑后台守护进程,必须有一个前台进程把容器"撑住",否则容器启动后立刻退出。
4. 容器删了数据还在:数据卷和端口映射的正确打开方式
4.1 容器删除后的一切归零:为什么需要数据卷
前面反复强调容器删除后数据会没,用一句话解释就是:数据写在了容器的可写层里。这一层随着容器销毁而销毁。
你可能会想:那我不用 Docker 存数据不就行了?问题是很多服务天然要存数据,比如数据库、文件存储、日志。举一个很现实的例子:你用 Docker 跑了一个 MySQL,往里面写了业务数据,某天你想升级 MySQL 镜像版本,停掉旧容器、删掉它、用新镜像再跑一个。你会发现数据全没了,数据库空空如也。谁遇到谁知道,那一刻真能急出一身汗。
解决思路不是"别删容器",而是把数据放到容器外部。Docker 专门为这个场景提供了数据卷(volume),卷的生命周期独立于容器,容器删了卷还在,数据就还在。
创建一个数据卷:
docker volume create mydata运行容器时挂载它:
docker run -d --name mysql-test -v mydata:/var/lib/mysql mysql:8这里的-v mydata:/var/lib/mysql,斜杠左边是卷名,右边是容器内的挂载路径。MySQL 会把数据文件写到/var/lib/mysql,而 Docr 在底层已经把它重定向到mydata这个卷里,容器删除再重建同一个命令,数据自动恢复。生产环境里数据库容器几乎都这么跑。
4.2 数据卷与挂载:宿主机与容器共享文件的正确姿势
数据卷之外还有一种更实际的方式:宿主机目录挂载。语法和卷类似,但左侧从卷名变成宿主机绝对路径:
docker run -d --name nginx-custom -p 8080:80 -v /home/you/website:/usr/share/nginx/html nginx效果是宿主机/home/you/website目录和容器内网页根目录共享同一份文件,你改宿主机里的页面文件,容器立刻就能服务新内容,不用重新构建镜像。日志场景也一样,nginx 默认写日志的路径挂到宿主机,日志文件就落在本地磁盘上,后续接日志收集工具非常方便。
这个模式对开发调试尤其好用:代码在宿主机编辑,容器跑服务,两边实时同步,改完代码容器里立即可见,不用反复docker build。很多前面写的"本机开发改代码要重新打镜像"的焦虑,到这里自然就解开了。
还有一组经常用到的命令:
docker cp 宿主机路径 容器名:容器路径 # 文件拷进容器 docker cp 容器名:容器路径 宿主机路径 # 文件拷出容器docker cp适合临时传文件,但它是一次性拷贝,不是同步。想保持同步,老老实实用-v挂载。
4.3 端口映射:-p 8080:80 里哪个是哪个,别再搞错
端口映射是新手最容易晕的地方。我再把这个规则用大白话讲一次。
容器有自己独立的网络命名空间,相当于一台没有独立 IP 对外可见的"内网主机"。想让外面访问它,必须做一个"门牌号"映射:宿主机开一个口,把流量转发到容器里的端口。
命令写法:
docker run -p 宿主机端口:容器端口 镜像名所以-p 8080:80就是把宿主机 8080 端口收到的请求,全部转发给容器里的 80 端口。你访问localhost:8080,实际上是容器里的 nginx 在响应。容器内的服务如果想换端口,比如 nginx 在容器里监听了 8080,那就写-p 8080:8080或者-p 9090:8080,完全没有限制。
补充一句:-p后面可以不写宿主机端口,比如-p 80,Docker 会随机分配一个宿主机空闲端口,然后用docker port 容器名查看实际被分配了哪个端口。脚本化、自动化的场景偶尔用到,刚开始可以不深究。
关于网络模式,新手现阶段只需要知道默认的 bridge 模式足够用。还有一个 host 模式,容器直接共享宿主机的网络栈,端口就不用映射了,但会带来端口冲突的风险,不如 bridge 干净。
5. 真实翻车现场:破解容器秒退、时区错乱与镜像体积焦虑
5.1 容器总是"秒退",或者启动后立即结束
这个现象太经典了。启动容器命令敲下去,docker ps一看什么都没有,百思不得其解。
别急,先执行docker ps -a看所有容器。你会看到那个容器的状态栏写着Exited (0)或者类似的样子。后面括号里的数字是退出码,它已经在告诉你原因了。
最常见的原因是:容器里没有一个前台进程在运行。Docker 容器的生命周期绑定于它启动时启动的那个主进程,这个进程一旦结束,容器就认为"任务完成",立刻退出。
典型错误写法,比如有人用基础系统镜像个启动就跑一个后台服务:
docker run -d -it ubuntu service nginx startservice nginx start启动完会立刻返回,主进程结束,容器随之退出。这不是 Docker 的 Bug,而是它的设计逻辑:容器必须有一个长期运行的进程占住前台。真正的启动方式应该是让主进程以前台模式运行,比如nginx -g 'daemon off;',或者用tail -f /dev/null这种命令硬占住前台——后者只是调试临时用,别当正经方案。
退码的含义也可以记两个:Exited (0)表示主进程自己正常结束了,最可能就是后台命令秒退;Exited (127)多半是命令不存在,比如 CMD 里写了个根本没安装的程序。
5.2 时区、软件源和基础镜像选择:三个隐形坑
还有一个特别容易踩的坑:时区。官方镜像的基本时区基本都是 UTC,比我们日常时间慢 8 个小时。你在容器里跑日志任务,发现打印出来的时间怎么都对不上,查了半天最后发现是容器时区问题。
解决办法很简单,运行容器时传环境变量:
docker run -e TZ=Asia/Shanghai ...或者在 Dockerfile 里提前设好。
还有一个坑是软件源。有些基础镜像自带的软件源可能因为各种原因不稳定,构建镜像时执行apt-get update慢得像蜗牛。处理思路是换成可用的镜像源再执行安装。但要注意:不同发行版的源写法完全不同,Debian/Ubuntu 系用的是 apt,Alpine 用的是 apk,文件路径和仓库格式都不一样,对着网上教程换源前一定先确认自己装的是哪一类系统。
基础镜像选择也是个隐形的坑。Alpine 系镜像小,层也少,但因为它阉割了一些东西,有些二进制程序跑起来会有兼容性问题,如果遇到编译类工具,缺依赖率更高。Debian/Ubuntu 系镜像体积大,但环境完整,省心很多。我的选择标准是:自己玩、对体积敏感的用 Alpine;跑正经服务、不想跟依赖纠缠的用 Debian slim 或 Ubuntu 的基础镜像。
5.3 镜像体积焦虑:瘦身有方法,但不是越瘦越好
网上关于"镜像瘦身"的文章特别多,动不动就是"从 1G 减到 50M"。看多了容易产生一种错觉:镜像体积越小就越牛。我实际用下来觉得这个目标需要理性看待。
体积小确实有好处:拉取快、部署快、磁盘省。但代价往往也很实在:构建过程复杂、调试不方便、某些官方镜像基于不同系统裁剪后缺这缺那。有些镜像为了小,把调试工具、curl、vim、ping 全删了,真出了故障进容器排查,发现连ps命令都没有,那种绝望谁试谁知道。
我的建议是:初次优化把目标放在"消除明显浪费"上。比如合并 RUN 命令清除缓存、用对体积友好的基础镜像、写.dockerignore防止把巨型目录打进去、用多阶段构建把最终镜像里的编译工具链卸掉。这些动作做下来,体积通常已经比原始版本小一大截,够用且干净。至于再往下压的那几十兆,除非是部署在频繁拉取大规模镜像的生产环境,否则投入产出比很一般。
镜像体积是"构建速度、可维护性、运行效率"综合权衡中的一个因子,而不是唯一的 KPI。追求合适,别追求极限。
说到这,还得再强调一下学习路线:镜像决定环境长什么样,容器让环境跑起来,Dockerfile 让环境可复制,数据卷让数据不随容器消亡。这四个点串起来,Docker 的主干就算通了。我平时给同事讲的时候,最常说的一句话是——把容器当成一次性的临时执行环境,该持久化的数据挂出来,该固化的环境写进镜像,别想着去“养”一个容器。带着这个思路去操作,踩坑次数能少一半。