跑了3个Docker容器后,我终于搞懂镜像和容器到底啥关系

前段时间在摸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。
  • 易错提醒:不要在容器里写localhost127.0.0.1去访问宿主机服务,那指向的是容器自身,会连接失败。

我目前理解到的程度大概就是这些,毕竟才刚跑通hello-world级别,后面Dockerfile怎么写镜像、docker-compose怎么编排多容器、数据卷网络这些东西还得接着啃。有哪里理解有偏差的,或者有什么学Docker的好办法,可以评论区唠唠。

相关推荐
东风破_2 小时前
Docker 基础:为什么需要容器?
前端·后端·nginx
东风破_2 小时前
Docker 实战:使用 Nginx 作为容器入口代理 Node 服务
前端·后端·nginx
Lyyaoo.2 小时前
Spring Boot Validation 声明式参数校验
spring boot·后端·mysql
剩下了什么3 小时前
float32 与 float64 精度陷阱:如何在 Go 中避免错误使用
开发语言·后端·golang
Rain的Java大神之路3 小时前
介绍一下分布式事务
java·分布式·后端·spring·spring cloud·架构·springcloud
tryCbest5 小时前
Docker 从零到精通:知识与跨平台使用指南
docker·容器
IT_陈寒7 小时前
Vue的响应式什么时候会失灵?这个坑我踩了
前端·人工智能·后端
Patrick_Wilson7 小时前
Web 认证方案技术指南
前端·后端·面试
与驴OO7 小时前
设计 Skill 系统,这 3 个坑我替你踩过了
后端·aigc