1. 内容整体设计与思路拆解
1.1 为什么第一课必须从HelloWorld开始
很多人学Docker,一上来就想跑个Nginx、装个MySQL,结果镜像拉不下来,端口映射搞不明白,Volume挂载又出问题,半天过去连Docker在干什么都没搞懂。我见过不少初学者卡在这里,最后得出结论是“Docker太难了”。
其实Docker本身不难,难的是你还没建立起“镜像”和“容器”这两个核心概念,就直接去操作复杂应用。写这篇HelloWorld入门,就是想用最小的代价把这两个概念讲清楚:先用一个几KB的镜像,把整个Docker的运行链路跑通,再逐步叠加复杂度。
选hello-world这个镜像作为第一个实验对象,原因有三个。第一,它体积极小,本地没有也能快速拉取,几乎不会因为网络问题卡住。第二,它不需要任何配置,不需要映射端口、不需要挂载数据卷,连环境变量都不用设置。第三,它运行完就退出,不会像Nginx那样一直占着前台进程,对新手来说“跑完就结束”反而是好事——你能清清楚楚看到容器的生命周期。
这个思路和学编程时先写“print(‘Hello World’)”是一个道理:不是为了打印那行字,而是为了验证整个工具链是通的。编译器装了没有、环境变量配了没有、代码能不能编译运行,一行Hello World全暴露出来。Docker的HelloWorld也一样,它能帮你一次性验证Docker引擎是否安装正确、镜像仓库能否访问、镜像能不能下载、容器能不能创建和运行、日志能不能正常输出。任何一个环节出问题,都跑不出那行经典输出。
1.2 HelloWorld背后隐藏的完整流程
把docker run hello-world这条命令拆开看,它背后其实经历了五步完整流程,每一步都对应着Docker的核心组件。
第一步,Docker客户端(CLI)接收到你的命令,把它打包成HTTP请求发送给Docker守护进程(Docker daemon)。这个守护进程才是真正干活的角色,它一直监听在后台,负责管理镜像、容器、网络和存储。理解了客户端和服务端的分离架构,你以后用Portainer这类可视化工具时就不会困惑——那些工具本质上是换了一个“客户端”来和守护进程通信。
第二步,守护进程检查本地镜像缓存中是否存在hello-world镜像。如果不存在,它会去配置好的镜像仓库(默认是Docker Hub)拉取。这里有个关键点:Docker用的是“先查本地、没有再拉”的策略,和Git先查本地仓库再拉远程很像。所以你在生产环境优化部署速度时,一个核心思路就是把常用镜像提前拉好、打标签管理,而不是每次都现拉。
第三步,镜像下载完成后,守护进程依据镜像里的配置信息创建一个可写容器层,并准备运行环境。这里涉及两个重要概念——镜像(Image)和容器(Container)。你可以把镜像理解成一个“模板”或者“安装包”,里面打包好了程序代码、运行时、依赖库和启动命令;容器则是这个模板跑起来之后的“实例”,是真正运行进程、读写数据的地方。镜像本身是只读的,容器启动时才在镜像之上加一层可写层。
第四步,容器启动后执行镜像里预设的入口命令(EntryPoint或CMD)。hello-world镜像的入口命令是一个编译好的二进制程序,它做的就是打印一段欢迎信息。
第五步,程序执行完毕,容器退出,状态变为Exited。这时候容器并没有被删除,它还留在你的机器上,只是不再运行了。很多新手第一次跑完docker run hello-world后,用docker ps想看容器,结果什么都看不到,就是因为默认只显示运行中的容器,需要加-a参数才能看到已经退出的。
这五步走完,你的Docker环境就验证通过了。后面跑任何复杂镜像,本质上都是在这五步的基础上增加额外的配置项而已。
2. 环境准备:先让Docker跑起来
2.1 各操作系统下的安装姿势
在运行HelloWorld之前,先把Docker环境装好。不同操作系统的安装方式差异挺大,但核心目标一致:让docker version和docker info两条命令能正常输出。
如果你用的是Linux,安装相对最直接。以Ubuntu为例,可以用官方仓库或者用apt装docker.io,但我个人更推荐用官方脚本方式安装,方便后续管理。安装完成后记得把当前用户加入docker组,命令是sudo usermod -aG docker $USER,然后重新登录让组权限生效。这一步不做的后果是:每次执行docker命令都得加sudo,而sudo环境下Docker又和当前用户的环境变量、认证信息隔离开,排查问题时很容易被带偏。
Windows系统分两种情况。如果你的系统是Windows 10/11专业版或企业版,且BIOS里开启了虚拟化,建议用WSL2后端模式安装Docker Desktop,这是现在最顺畅的路径。装好之后在Docker Desktop的设置里确认WSL集成已经打开。如果你用的是Windows家庭版,没有Hyper-V,那WSL2更是唯一顺畅的选项——Docker Desktop会通过WSL2里内置的轻量虚拟机来运行Linux容器。这里要提醒一句,装完之后如果docker run报错说“no matching manifest for windows/amd64”,说明Docker还在用Windows容器模式,右键托盘图标切到Linux containers即可。
macOS用户相对省心,直接下载Docker Desktop的dmg安装包就行。Apple Silicon芯片的机器注意选对架构版本,因为arm64和amd64的镜像不通用——虽然Docker Desktop会做一层翻译兼容,但遇到某些对架构敏感的镜像时还是会出问题。
装完后打开终端,输入docker --version能看到版本号,再输入docker info确认Server信息里没有报错,环境就绪。
2.2 镜像源配置:跳过新手第一道坎
环境装好后,第一次跑HelloWorld最可能卡在镜像拉取这一步。Docker默认的镜像仓库是Docker Hub,它在国内的访问速度时快时慢,有时候一条几百KB的镜像能拉到超时。
解决办法是配置镜像加速器。在国内云厂商的容器镜像服务控制台里,一般能找到专属的加速地址,就是“https://+一串字符”的格式。以Linux上通过修改/etc/docker/daemon.json为例,内容大概是:
{ "registry-mirrors": [ "https://你的加速地址" ] }改完之后执行sudo systemctl daemon-reload重启Docker服务,再用docker info查看Registry Mirrors一栏是否已经生效。一个很容易被忽略的细节是,Windows和macOS上的Docker Desktop改了配置后,必须在Docker Desktop的界面里点一下Apply & Restart,光改文件不重启是不会生效的。
如果加速器配了还是拉不动,多半是网络层面的问题,可以换一个镜像源试试,或者检查一下代理设置是否影响了Docker的流量。这里要特别说明:不要用任何特殊网络工具来解决问题,治理思路应该是优先用合规的镜像源,其次调整Docker的DNS配置,比如在daemon.json里加上自定义DNS服务器,大部分拉取问题都能通过合法手段解决。
3. 第一个镜像:hello-world的运行实验
3.1 逐行拆解docker run命令
环境准备好后,打开终端,输入下面这条命令:
docker run hello-world第一次执行时,如果本地没有hello-world镜像,终端会显示类似这样的输出:
Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world ... Status: Downloaded newer image for hello-world:latest Hello from Docker! This message shows that your installation appears to be working correctly.看到“Hello from Docker!”这段输出,你的Docker环境就验证通过了。但别急着关终端,把这条命令拆开理解清楚。
docker run是由两个部分组成的复合命令:docker是客户端程序,run是它的子命令,表示“创建并运行一个容器”。hello-world是镜像的名字,默认会去仓库拉取标签为latest的版本。命令的完整写法其实是docker run hello-world:latest,当你没有写明标签时,Docker会自动补上latest。
很多人会问,这条命令为什么没有指定端口、没有指定数据卷、也没有设置环境变量?因为hello-world这个镜像太简单了,它的全部职责就是打印一段文字然后退出。它不需要对外提供服务,所以不用-p参数映射端口;不需要持久化数据,所以不用-v参数挂载目录;也没有可配置的行为,所以不用-e参数注入环境变量。如果你一上来就跑Nginx,这些参数一个都少不了,概念一多就容易乱。先跑通这个极简案例,再逐步加参数,学习曲线会平滑很多。
docker run和docker start有什么区别?这也是新手高频疑问。docker run总是“从镜像创建全新容器并启动”,相当于“照着图纸搭一座新房子再搬进去住”;docker start则是“启动一个已经存在的容器”,相当于“回到一座已经搭好的房子继续住”。第一次跑hello-world时用docker run,因为它还不存在;跑完之后它成了一个Exited状态的容器,下次想再运行同一个容器,可以用docker start,这样就不会再创建一个新的了。验证方法:连续执行两次docker run hello-world,再用docker ps -a看看,会看到两个独立的容器记录。
3.2 用docker pull单独拉取镜像
docker run在本地没有镜像时会自动拉取,但你也可以手动单独拉取镜像到本地,命令是:
docker pull hello-world拉完之后用docker images查看,能看到一条hello-world的镜像记录,大小通常在10KB左右。这一步多做一次的好处是:你会在docker images的输出里直观感受到“镜像体积原来可以这么小”。很多初学者被“镜像包含完整操作系统”的说法误导,以为任何镜像都有几百MB,看到hello-world只有10KB时会很疑惑。
解释一下这个疑惑:hello-world镜像确实不包含完整的操作系统,它只包含一个编译好的静态二进制文件和对应的Manifest配置。Docker镜像的哲学是“只打包应用及其依赖”,而不是把整个OS都塞进去。基础镜像才会把最小化的系统库打包进来,比如ubuntu镜像有几十MB,alpine只有几MB。hello-world的体积之所以那么极端,是因为它连系统库都没依赖,直接静态链接了。
顺便说说docker pull和docker run的配合逻辑。在生产环境里,你完全可以在部署前先docker pull把镜像拉好,再docker run启动,这样可以把“拉取镜像”和“启动容器”拆成两步,分别观察耗时和结果。我之前部署某内部工具时,就是先写个脚本批量pull镜像到本地仓库,再统一启动多个容器,极大减少了同时拉取的等待时间。对于规模再大一点的场景,还可以在局域网红外部署镜像仓库,把公共仓库的镜像同步到内网仓库,这样每台机器启动容器时拉取的都是内网流量,速度快得多。
3.3 查看容器的运行记录
跑完hello-world之后,想看它留下了什么,需要一条关键命令:
docker ps -a这里有个知识点:docker ps默认只显示正在运行的容器,而hello-world容器运行完就退出了,所以必须加-a(all的缩写)才能看到全部容器,包括运行中的和已退出的。输出表格里会包含容器ID、使用的镜像、启动命令、创建时间、状态和端口信息等字段。
第一次看到输出时,你可能对Container ID那一长串随机字符感到困惑,那其实是容器的唯一标识,由守护进程生成。不用记全,前几位就够用。比如想查看某个已退出容器的完整日志,可以用:
docker logs <容器ID或容器名>hello-world的日志就是它打印的那段欢迎信息。注意这里用的是容器ID而不是镜像名,因为一个是“模板”,一个是“实例”,模板可以派生出无数实例,而日志是实例专属的数据。如果同一台机器上跑了多个不同的容器,docker logs后面跟随的对象必须是具体的容器标识,这一点在实际运维中非常容易记混。
还有一个经常被忽视的操作细节:容器退出后不会自动清理。你每跑一次docker run hello-world,系统里就会多一个处于Exited状态的容器,虽然它不占CPU不占内存,但会占用一点磁盘空间记录元数据。在反复测试的环境中,容器会越积越多,定期清理就用:
docker rm <容器ID>清理完再用docker ps -a确认记录已被移除。同理,验证完HelloWorld之后,我习惯把测试产生的容器随手删掉,保持环境干净。
4. 镜像与容器的核心差异
4.1 模板与实例的关系
把镜像和容器用“程序和进程”来类比非常好理解。程序是保存在磁盘上的文件,比如你桌面上有个微信的安装目录,你没打开它时,它就是一堆静态文件;你双击运行它,系统就为它创建一个进程,这个进程有PID、有内存占用、有自己的生命周期。镜像对应“安装目录”,容器对应“运行中的进程”。
但用“类和对象”来类比更贴近Docker的设计思维。镜像像是一个类,里面规定了类的属性和方法;容器则是一个实例化的对象,每个对象有自己独立的状态。同一个类可以new出多个对象,互不干扰;同一个镜像可以启动多个容器,每个容器的文件系统修改彼此隔离。
这个思想是容器技术的基石之一。哪怕两个容器都是由同一个镜像启动的,它们在运行期间产生的文件修改也只会写在自己的可写层里,不会污染镜像层,也不会影响其他容器。这也是Docker之所以高效的重要原因:几百个容器副本可以共享同一个镜像层,你物理磁盘上其实只存了一份镜像数据,容器之间通过写时复制机制在各自的可写层上做修改。用“刻录光盘”来想象更直观:镜像就是那张母盘,容器就是从母盘复制出来的刻录盘。你在刻录盘上记录数据,母盘始终保持原样;母盘还在,就能再刻任意多张新盘。
4.2 镜像层的缓存机制和写时复制
hello-world虽然简单,但它的镜像结构麻雀虽小五脏俱全。Docker镜像不是一个大文件,而是由多个只读层叠加组成的,Dockerfile里的每一行指令(如FROM、COPY、RUN)都会生成一个新层。下载镜像时,你看到的那种一层一层“Pulling fs layer”的进度条,就是在拉取这些层。
每层之间是独立的,Docker会为每一层计算一个内容哈希值。拉取镜像时,如果某层的哈希在本地已经存在,Docker会直接复用,而不重新下载。这就是镜像层缓存机制。所以当你在一个基础镜像之上构建自己的应用镜像时,只要基础镜像没变,所有基础层都可以复用,只有新增的层需要下载,节省的时间和带宽非常可观。
容器运行时,Docker会在镜像层之上创建一个可写的容器层。进程的写操作都发生在这一层,删除文件也只会往这一层写入一条“删除标记”,原始镜像层的内容还在。这种设计叫写时复制,它带来的直接好处是启动容器几乎零成本——不需要复制镜像文件,只需要叠上一层可写层。我经常看到有人误以为docker run会像VM那样复制一个几十GB的虚拟磁盘文件,恰恰相反,容器启动通常只要几百毫秒,根源就在这里。
用hello-world做一次验证:连跑三个hello-world容器,然后查看docker ps -a的输出,再用docker system df看看镜像部分占用的总空间。你会发现,无论跑多少个容器,镜像层的总占用都不会明显增长。这一刻你就能直观感受到为什么容器比虚拟机“轻”。
4.3 镜像tag和版本管理
在学习HelloWorld时你用的是latest标签,但在生产实践中,latest是个危险标签。它指向的镜像内容会随着上游维护者更新而漂移,你这次拉的是v1版本,下次拉的可能已经是v2甚至已经不兼容的版本。镜像不具备可重复性,这是部署稳定性的大忌。
用固定的tag或摘要值才能锁定版本。比如:
docker pull hello-world@sha256:<具体摘要值>这是用镜像摘要来锁定内容,只要内容没变,摘要就不会变。在用docker pull命令时,如果仓库返回的镜像摘要与Manifest里的不一致,Docker会拒绝拉取,这从机制上防止了镜像被篡改。对于正式项目,建议统一管理镜像的tag规范,比如用版本号加时间戳组合,至少保证每次发布都能精确定位到哪个镜像。
对于任何生产环境的容器,都要执行一条非常实用的规范:镜像tag禁止使用latest,推送镜像时必须打上明确的版本号。这一点,用好HelloWorld之后就尽快养成习惯,后面部署自己的应用时能少踩许多隐含的坑。
5. 实操中的高频踩坑与排查技巧
5.1 常见问题速查表
把初学者跑Docker时最常遇到的几个问题整理成表,以后遇到类似报错可以快速定位。
| 报错/现象 | 可能原因 | 常用排查方法 |
|---|---|---|
| Unable to find image... / pull access denied | 镜像名写错,仓库不存在 | 检查拼写,去Docker Hub确认名称 |
| no matching manifest for windows/amd64 | Docker为Windows容器模式,或架构不匹配 | 切换到Linux containers模式,或加--platform参数 |
| Cannot connect to the Docker daemon | Docker服务未启动,或权限不足 | 检查服务状态,把用户加入docker组 |
| Image pulled but container exits immediately | 镜像设计为执行后退出,hello-world就很典型 | 用docker ps -a(不是docker ps)查看状态 |
| 拉取速度极慢或一直Wait | 镜像源网络问题 | 配置合规镜像加速源,或换源再试 |
| port is already allocated | 端口被其他容器或进程占用 | 用netstat或docker ps检查端口占用,换端口 |
| Init found in path, but docker was not found | Docker环境变量问题 | 重新安装并确认PATH路径 |
表里最容易被误判的是“镜像拉取超时”。这类报错通常不是代码问题,要用系统化方式排查。先ping搭建镜像源的域名,确认基础网络通不通;再检查daemon.json里的加速地址是否以https开头且可访问;最后看看系统DNS是否正常解析加速域名。按这个顺序排查,大部分问题都是网络抖动或配置笔误,动几行配置就能解决。
5.2 排查技巧实例:连带容器一起清理
有些时候,不光是hello-world,你可能会连续创建好几个退出状态的容器,时间一久docker ps -a的输出就很长,影响查找目标容器。这时候你可以用过滤参数:
docker ps -a --filter status=exited只显示已退出的容器,看得清爽得多。如果决定一部分容器彻底删除,还可以用一条组合命令批量清理:
docker rm $(docker ps -a -q)这条命令的原理是:先用docker ps -a -q拿到所有容器的ID列表,再交给docker rm批量删除。它不会识别运行中的容器是否在跑,执行前最好先仔细确认列表内容,别误删了正在运行的容器。有一个更安全的范围可控写法:
docker ps -a -q --filter status=exited | xargs docker rm这里用了管道和xargs,把已退出容器的ID逐个传给docker rm。因为传过去的都只是已退出的容器,即使批量删掉也不会影响活跃容器。生产环境建议用这个版本,至少能避免误操作。再进一步,如果你确认要一并清理未使用的镜像、网络和构建缓存,可以用:
docker system prune它会问你确认,然后清理掉所有停止运行的容器、所有未被容器使用的网络、所有悬挂镜像和构建缓存。这个命令能释放大量磁盘空间,但在正式服务器上使用时务必要先看清将被删除的清单。
5.3 最隐蔽的一个坑:容器退出码
hello-world运行一次就退出,很多人会把它当作“容器都只能运行一次”的证据,这其实是个危险错觉。容器退出与否,只取决于它启动的进程是否还活着。hello-world的启动命令打印完文字就返回,进程结束了,容器自然退出。而Nginx容器启动后,进程会一直运行等待连接,容器就一直处于Up状态。
判断容器是不是“成功退出”的方法,是看退出码。docker ps -a输出的STATUS字段会显示Exited (0),括号里的0就是退出码。0代表正常退出,非0则代表异常退出。例如126表示命令无法执行,137表示容器被强制杀死,139则是段错误。用docker inspect再看详细信息,可以拿到更详细的退出日志。
docker inspect是一个非常强大的命令,它输出的JSON里几乎包含了容器的全部配置信息:镜像ID、挂载项、网络设置、环境变量、重启策略、退出时间等等。当容器行为不符合预期时,第一时间用它检查配置,比盲目折腾参数高效得多。比如你想确认一个容器的启动命令到底是什么,用:
docker inspect --format='{{.Config.Cmd}}' <容器名>当你对Docker渐渐熟悉后,docker inspect会成为你调试问题时最信任的朋友之一。hello-world虽然简单,但它是你学习用docker inspect检查容器全貌的第一个好素材。
6. 把HelloWorld的知识延伸到真实项目
6.1 从一条命令到一个部署流程
跑通了hello-world,你的第一步就算迈出去了。但如果就此打住,你和Docker之间还只算“认识”,不算“熟悉”。我建议下一步做两件事:第一,用docker run -it ubuntu bash进到一个交互式容器里,亲手在容器里执行ls、apt update这些命令,体会一下“在一个干净环境里操作”是什么感觉;第二,把你写过的最简单的服务,比如一个返回JSON的Python Flask应用,写成一个Dockerfile,再用docker build把它做成镜像,最后用docker run跑起来。
这两步做完,你会自然遇到端口映射、镜像构建、容器日志等真实场景,届时再回来翻这篇HelloWorld,很多概念会豁然开朗。
HelloWorld训练的价值不在那条命令本身,而在于它帮你验证了整个工具链、帮你建立了镜像与容器的思维模型、帮你养成了用docker ps -a查看状态的排查习惯。接下来所有复杂的Docker操作,都是在这个基础上加参数、加配置。如果后面的学习卡壳了,回头自查一下:是不是在忘了某个基础概念,而不是Docker本身有问题。
6.2 后续可以尝试的小实验清单
根据我开始的介绍,你可以做一个循序渐进的实验清单来巩固基础:
- 运行docker run --name demo hello-world,给容器起名字,再用docker rm demo删除,理清“命名”与“清理”的关系。
- 运行docker run -d -p 8080:80 nginx,拉起一个Nginx容器,访问本地8080端口看到欢迎页面,体会端口映射和守护式运行。
- 用docker exec -it <容器名> bash进入正在运行的Nginx容器,观察容器内部的文件结构,然后退出。
- 基于Dockerfile构建一个自己的Python或Node应用镜像,加入标签,推送仓库之前先docker build -t myapp:1.0 .打好版本号。
这组练习做完,你的Docker基础就已经超过不少只停留在“会拉镜像”阶段的人了。尤其要强调第2个实验,当你看到浏览器真的通过8080端口访问到容器里的Nginx时,那种“原来容器在真实网络里这么好用”的实感,比任何文档都更有说服力。
依我的经验,大多数人学Docker的瓶颈从来不是命令记不住,而是头脑中没有“镜像、容器、层、守护进程”这一套完整概念模型。概念通了,任何命令都只是工具的细节,用时查一下即可。HelloWorld恰好就是先把这套模型种下去的最短路径。现在,跑通你的第一个容器吧。