前段时间在摸NestJS,想正儿八经写写后端接口。写了个hello world级别的接口跑在本地,一切正常。然后问题来了------我想搭个MySQL存点数据,还想用Nginx做反向代理,本地装MySQL版本老对不上,Nginx改配置还得重启服务,搞了一上午环境没搭好,同事路过甩了句"用Docker啊"。
说实话,"Docker"这个词我听了不下一百遍了,但一直停留在"好像是个装东西的容器"这个模糊认知上。每次看别人教程里一溜docker run、docker-compose,我就头大,感觉那是运维的活儿,跟我一个切图的关系不大。但这次环境把我卡得难受,我决定硬着头皮从头捋一遍。
敲完第一行docker run我愣住了
跟着安装教程装完Docker Desktop,所有教程的第一步都是同一个命令:
bash
docker run hello-world
我敲了,回车。然后屏幕上滚出来一堆东西:

"Unable to find image locally",然后开始Pulling,一层层下载,最后蹦出来一句"Hello from Docker!"。当时我没太当回事,觉得就是个安装成功的提示嘛,跟你装完Node打个node -v看到版本号一个道理。但我手贱往上翻了翻,发现"Hello from Docker!"下面还有一大段英文,我之前居然没注意到:
那段话写得明明白白,Docker干了四件事:客户端联系Docker守护进程→守护进程从Docker Hub拉取镜像→守护进程用镜像创建了一个新容器来运行可执行文件→守护进程把输出流式传回客户端显示在我终端上。
我盯着这四步看了半分钟。原来我敲一行命令,背后走了这么一套流程。而且这里面提到了两个关键东西:镜像(image)和容器(container)。这俩词我一直混着用,现在看它们明明是两个步骤------先拉镜像,再用镜像创建容器。
镜像和容器的关系,我用JS原型链给整明白了
带着这个疑惑,我跑了几个命令想看看这俩东西到底长啥样。
bash
docker images

本地已经有hello-world:latest了,25.9kB,小得离谱。
bash
docker ps -a

这里多了个东西,一个叫hopeful_lamport的容器,基于hello-world镜像,状态是Exited (0)。
我当时第一反应是:我不是只run了一下hello-world吗,怎么出来两个东西?一个镜像一个容器?
捋到这我突然想通了。我平时写JS,class Person {} 和 const p = new Person() 的关系不就是这样吗?镜像就是那个class,是个模板、图纸,里面打包好了运行环境和代码;容器就是new出来的实例,是镜像跑起来后的那个进程。你可以用同一个镜像new出无数个容器,各跑各的互不干扰。
后来我找了个更贴切的类比:镜像就像你npm install下来的包,在node_modules里躺着,占着磁盘不动;容器就像你require('http').createServer()之后那个真正监听端口的server实例,它是活的,能接收请求、产生数据。包只有一份,但你可以createServer好多次,每次都是独立的实例。
所以docker run hello-world做了两件事:发现本地没有hello-world镜像就去拉一个(相当于npm install),然后用这个镜像创建并启动了一个容器(相当于new了一个实例出来跑)。跑完了容器就退出了,但镜像还留在本地,容器也留着只是状态变成Exited了。
容器不是用完就没了,它还能再启动
我看到那个Exited状态的hopeful_lamport,以为它死透了,结果试了下:

bash
docker start db05acffade9
docker stop db05acffade9
还能启动还能停!容器退出之后并没有消失,只是进程停了而已,就像你把Node服务Ctrl+C了,代码文件还在,再node index.js又能跑。docker ps只显示正在运行的容器,加个-a才能看到全部的,包括已经退出的。这个细节让我对"容器就是个进程"这个说法有了实感------它不是什么黑盒子虚拟机,就是个被隔离的进程,有运行和停止两种状态。
docker ps默认只显示运行中的容器,想看所有容器(包括已退出的)要加-a。刚学的时候我经常以为容器丢了,其实就是没加-a。
跑了个nginx容器反代我的Node服务,终于理解-p和-v了
光跑hello-world没什么意思,我得让容器干点正经事。我写了个最朴素的Node服务:
javascript
// 用最原始的http模块,不引任何框架
const http = require('http');
const server = http.createServer((req, res) => {
res.end('hello world');
})
// 这里很容易搞反:0.0.0.0表示监听所有网卡,不能写localhost
server.listen(1314, '0.0.0.0', () => {
console.log('server is running at http://0.0.0.0:1314');
});
然后我想在Docker里跑个Nginx,让它监听80端口,把请求转发到我这个Node服务上。先拉个nginx镜像:
bash
docker pull nginx

注意nginx镜像比hello-world大多了,拉的时候一层一层的(那些Pull complete的行就是layer),这涉及到Docker镜像的分层文件系统,但现在先不展开,先把东西跑起来再说。
关键来了,启动nginx容器:
bash
docker run --name my-nginx-demo -p 80:80 -v D:/workspace/xjl_ai/backend/docker/demo/nginx.conf:/etc/nginx/nginx.conf -d nginx

这行命令参数有点多,我一个个讲。--name就是给容器起个名字,不写的话Docker会随机生成类似hopeful_lamport这种名字,自己起一个好找。-d是让容器在后台跑(detached模式),不然你终端会被容器日志占住啥也干不了。
我想重点说说-p和-v,这俩是日常用Docker最频繁的参数,也是我一开始最懵的。
-p 80:80,端口映射。冒号左边是宿主机(就是我自己电脑)的端口,右边是容器内部的端口。意思是把我本机的80端口映射到容器里Nginx监听的80端口上,这样我访问localhost:80就能打到容器里Nginx监听的80端口上。这个顺序很容易搞反,记住"主机端口在外,容器端口在内",就像你租了个门面(主机端口),后面连着你真正的工作室(容器端口)。
-v是挂载(volume),格式跟-p类似也是"宿主机路径:容器内路径"。我本地写了个nginx.conf,想让容器里的Nginx用我这份配置,而不是它自带的默认配置。挂载的意思就是把我本机的文件"塞"到容器里去,容器读那个路径实际读的是我本地的文件。这样我改本地配置文件,容器里立刻生效,不用进容器改东西。
nginx
# nginx.conf - 极简配置
events {}
http {
server {
listen 80;
location / {
# host.docker.internal是Docker提供的特殊域名
# 让容器能访问宿主机上的服务,这里就是我的Node服务
proxy_pass http://host.docker.internal:1314;
proxy_set_header Host $host;
}
}
}
这里有个点让我卡了好一会儿:Nginx在容器里跑,我的Node服务在宿主机上跑,容器怎么访问宿主机?localhost在容器里指的是容器自己,不是我电脑。Docker Desktop提供了一个特殊的域名host.docker.internal,在容器里访问这个域名就等于访问宿主机,所以我的proxy_pass指向它的1314端口就能打到Node服务了。
跑起来之后浏览器打开localhost:

看到hello world的时候我是真的有点小激动。虽然只是个最简单的反代,但整个链路通了:浏览器→宿主机80端口→容器内Nginx→宿主机1314端口→Node服务→返回hello world。这是我第一次真正用Docker干成了一件"有用的事"。
再来一个MySQL,连数据库也能装在容器里
Nginx跑通了,我胆子大了,试试在容器里跑MySQL。之前本地装MySQL各种版本冲突,听说Docker跑数据库特别方便,我也试试:
bash
docker run -d --name mysql-demo -p 3307:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:8.0

这里又多了个新参数-e,用来传环境变量。MySQL镜像官方规定了,你必须通过MYSQL_ROOT_PASSWORD这个环境变量来设置root密码,不传的话容器启动不起来。这是Docker镜像的一种约定------通过环境变量来配置容器,而不是让你进去改配置文件。
端口我映射的是3307:3306,因为我本机3306可能已经被别的MySQL占了,所以宿主机用3307,容器内部还是MySQL默认的3306。
容器跑起来之后,我怎么确认MySQL真的能用?docker exec命令可以让我进入正在运行的容器内部:
bash
docker exec -it mysql-demo /bin/bash

-it是两个参数合写:-i保持标准输入打开,-t分配一个终端。合起来就是给你一个交互式的shell,你可以在里面敲命令。
进去之后命令行变成了bash-5.1#,这说明我已经在容器里面了,这是一个独立的Linux环境。然后我直接在里面用mysql客户端连数据库:
bash
mysql -uroot -p123456

连上了。说实话敲到这里的时候我有一种很奇妙的感觉------我没有在本地装MySQL,没有配my.ini,没有处理任何版本兼容问题,一行命令就起来了一个干净的MySQL 8.0实例,用完删容器就行,不留任何垃圾文件。这时候我才真正理解为什么大家说Docker是"环境一致性"神器。
翻完hello-world的输出我愣了,原来Docker是C/S架构
跑了这三个容器,我回头再看hello-world输出的那段话,才真正看懂。Docker是C/S架构的:我在终端敲docker命令是在跟Docker客户端打交道,客户端把请求发给Docker守护进程(daemon),真正干活的是daemon------拉镜像、创建容器、管理网络、挂载卷,全是daemon在做。
我之前以为docker命令就是直接操作容器,就像node直接跑JS一样。但实际上更像你用浏览器访问网站:浏览器(client)发请求,服务器(daemon)处理后返回结果。所以你会发现Docker Desktop是一个一直在后台运行的程序,关掉它docker命令就全挂了,因为daemon没了。
镜像呢,本质上就是一个只读的模板,里面是一层层的文件系统叠加起来的(UnionFS)。容器是在镜像最上面加了一层可读写层,所有运行时的修改都写在这层上。这也是为什么镜像可以很小(因为只读层可以共享),为什么用同一个镜像能跑多个容器(它们共享底下的只读层,各自有自己的可读写层)。
这个分层设计还有个好处就是镜像分发快------只需要下载本地没有的层就行,已有的不用重复下。这也是为什么docker pull的时候会显示一层层的Pull complete。
几个我踩过的小坑
跑这些demo的过程中我踩了几个不大不小的坑,简单提一嘴:
端口映射方向搞反 。一开始我写过-p 80:8080想让容器服务在8080映射到主机80,结果半天访问不到。记住:宿主机端口在前,容器端口在后,-p 主机:容器。
忘记写-d导致终端卡住。第一次跑nginx的时候没加-d,终端直接被日志占住了,Ctrl+C退出容器也停了。跑需要持续运行的服务(Nginx、MySQL、Node)一定记得加-d后台运行。
容器内访问不了宿主机服务 。一开始nginx.conf里proxy_pass写的是http://localhost:1314,结果502。容器里的localhost是容器自己,要访问宿主机在Windows/Mac上用host.docker.internal,Linux上一般用--network=host或者docker0网桥地址。
容器里的localhost不是你电脑的localhost!这是刚学Docker最容易犯的错,因为容器有自己独立的网络栈。从容器访问宿主机服务要用host.docker.internal(Docker Desktop环境)。
容器删了数据就没了 。MySQL容器如果直接docker rm,里面存的数据库数据全没。容器的可读写层是临时的,要持久化数据得用-v挂载数据卷到宿主机。这个我暂时还没深入,下次研究数据卷的时候再记。
说实话我之前对Docker有误解
没动手之前我总觉得Docker是个很"重"的东西,是K8s、微服务、云原生那一挂的,跟我一个前端写页面没关系。实际用下来发现,Docker解决的最核心的问题就是"在我机器上能跑,在你机器上跑不起来"这个环境问题。不管你是跑Nginx、MySQL、Redis,还是跑自己写的Node服务,一行命令就能起一个干净的环境,不用装不用配不用处理版本冲突,用完就删。
当然我也知道我现在玩的都是单容器,docker-compose、Dockerfile、数据卷、网络这些还没涉及,离真正的生产使用还有距离。但至少现在镜像和容器的关系我不再含糊了,-p -v -e这些常用参数也知道是什么意思了,下次看到项目里的Dockerfile和docker-compose.yml不会直接跳过了。
对我来说,Docker最合适的使用场景目前就是:本地需要快速起个数据库/缓存/Nginx等服务的时候,用Docker比本地安装方便太多;团队协作的时候统一开发环境,避免"我这能跑你那跑不了"。但如果只是本地开发一个纯前端项目,webpack/vite dev server跑着挺好,没必要硬套Docker。
顺手捋的核心知识点
写到这顺手把核心的点捋了一遍,我自己回头复盘方便,你们要是懒得翻长文也可以直接看这部分。
1. 镜像与容器的关系
- 核心结论:镜像是只读模板(类/图纸),容器是镜像运行时的实例(对象/成品),一个镜像可以创建多个独立容器。
- 易错提醒:镜像和容器不是一个东西!
docker run会在镜像不存在时自动拉取,但docker pull只拉镜像不创建容器。
2. 端口映射-p
- 核心结论:
-p 宿主机端口:容器端口将宿主机端口映射到容器内端口,让外部能访问容器里的服务。 - 易错提醒:方向是"外:内"不是"内:外",写反了会访问不到。容器有独立网络栈,容器内的localhost不是宿主机。
3. 数据挂载-v
- 核心结论:
-v 宿主机路径:容器路径将宿主机文件/目录挂载进容器,实现配置文件外挂和数据持久化。 - 易错提醒:不挂载的话容器删除后数据全部丢失(容器的可读写层是临时的),存重要数据一定要挂卷。
4. Docker C/S架构
- 核心结论:Docker是客户端-服务端架构,docker命令是客户端,真正干活的是Docker daemon(守护进程)。
- 易错提醒:关掉Docker Desktop后所有docker命令都会失败,因为daemon停了。daemon才是核心,客户端只是个发命令的壳。
5. 容器生命周期
- 核心结论:容器本质是被隔离的进程,有运行(Running)、已退出(Exited)等状态,stop/start控制启停,rm才是删除。
- 易错提醒:
docker ps只看运行中的容器,想看包括已退出的全部容器要加-a。容器退出不等于删除,还可以重新start。
6. 进入容器exec
- 核心结论:
docker exec -it 容器名 /bin/bash进入运行中容器的交互式终端,可以像操作Linux一样操作容器内部。 - 易错提醒:-it是-i(保持输入)和-t(分配终端)的组合,两个都要有才能获得可交互的shell。容器里必须有bash或sh才能进。
7. 环境变量传参-e
- 核心结论:
-e KEY=VALUE给容器传环境变量,是Docker镜像约定的配置方式(如MySQL的MYSQL_ROOT_PASSWORD)。 - 易错提醒:每个镜像要求的环境变量不同,要查对应镜像的官方文档,关键配置(密码、端口、数据库名)通常都通过-e传入。
8. 容器访问宿主机的实现方式
- 核心结论:容器内访问宿主机服务,Docker Desktop环境使用特殊DNS
host.docker.internal,Linux环境使用--add-host或docker0网桥IP。 - 易错提醒:不要在容器里写
localhost或127.0.0.1去访问宿主机服务,那指向的是容器自身,会连接失败。
我目前理解到的程度大概就是这些,毕竟才刚跑通hello-world级别,后面Dockerfile怎么写镜像、docker-compose怎么编排多容器、数据卷网络这些东西还得接着啃。有哪里理解有偏差的,或者有什么学Docker的好办法,可以评论区唠唠。