Docker 到底解决了什么?一次 Node 版本冲突引发的容器化思考
一、从一个让我当场卡住的问题说起
有一个场景:
你到公司接手一个 n 年前 Vue 2 的项目,要求用 Node 16 + npm 8 进行重构。你电脑上装的是 Node 22,这个项目根本跑不起来。
这种事我其实早就遇到过。之前跟着教程装最新版 Node,跑某个老脚手架直接报一堆语法错误,当时只知道重装 Node、切 nvm,折腾一晚上。老师这一句话把我拉回那个晚上——所以这节课真正要回答的问题是:
软件本身没坏,是它依赖的运行环境对不上。能不能把"环境"也打包带走?
Docker 就是来干这件事的。
二、Docker 到底是什么?课堂定义的两层意思
Docker 是一个应用容器化工具,解决应用在不同机器上运行结果不一致的问题。 Docker 解决的是软件运行环境不一致的问题。
两句话其实是同一个意思的两种说法:容器化是手段,解决运行环境不一致是目的——根因都是"环境差异"。
为什么一个软件会这么挑环境?因为一个软件可能依赖一堆东西:
- redis
- next
- react
- mysql
- node
每一个都有自己的版本要求。少一个、错一个版本,整个项目就跑不起来。Docker 干的事情,就是把这些"应用 + 一整套运行环境"打包成一个整体容器,让它在不同机器上能一致地运行。
Agent = LLM + Harness(tool + MCP + RAG + Skill + ...) Docker = 应用 + 运行环境
这个类比对我很有用,但两边的"+号"含义不一样:Agent 里 LLM 和 Harness 是"大脑+手脚"的协作关系——LLM 决定做什么,Harness 帮它执行;Docker 里应用和运行环境是"演员+舞台"的支撑关系——应用本身已经包含完整逻辑,运行环境是让它能跑起来的底层(OS、库、依赖版本)。少了舞台,演员再会演也没地方演。
至于 Docker 怎么做到隔离的——docker 使用容器化技术(namespaces + cgroups),把各个依赖隔离安装。细节后面再补,本节课的重点是把它用起来。
三、image 和 container:光盘与 DVD 机的比喻
这两个概念是整节课的地基:
- image 就相当于光盘:包含应用程序 + 环境,是隔离的。可以像
git pull一样从远处拉取一个 image。 - container 是 DVD 机:把 image 放到 container 里,就可以运行。
image(镜像):Docker 里的只读模板,里面打包了应用代码和它需要的全部运行环境。你可以把它理解成一张"已经刻好、不能修改的光盘"。
container(容器):image 的运行实例。同一张 image 可以同时放进多个 container 里跑,互不干扰。就像同一张光盘可以塞进多台 DVD 机同时播放。
这个比喻解决了我一开始的困惑:为什么 image 和 container 要分开? 因为 image 是模板,container 是实例。模板不变,实例可以随便造、随便删。这跟"类 vs 对象"是一个套路。
四、七个命令:Docker 的最小操作面
笔记里列了七个命令,我原样贴出来:
docker run <镜像> # 根据镜像 创建并启动 一个新容器
docker images # 列出本地所有 镜像
docker ps -a # 列出所有 容器( -a 包括已停止的)
docker pull <镜像名> # 从仓库(如 Docker Hub)拉取镜像
docker rmi <镜像ID> # 删除镜像(必须先删掉用它的容器)
docker stop <容器ID> # 停止一个运行中的容器
docker rm <容器ID> # 删除容器(必须先停止)
我把它分成三组记忆:
| 分组 | 命令 | 关键约束 |
|---|---|---|
| 镜像 | pull / images / rmi | 删镜像前要先删容器 |
| 容器 | run / ps -a / stop / rm | 删容器前要先 stop |
| 生命周期 | run 创建+启动,stop 停,rm 删 | 顺序不能跳 |
两个"必须先"是考点:
- 删镜像 → 必须先删掉用它的容器
- 删容器 → 必须先停止
这点容易忘。我之前脑补"docker rm 应该会顺手停一下",但笔记明确写了"必须先停止",那就不该赌,老老实实 stop 再 rm。
五、Demo:一个跑在 1314 端口的 http server
为了把后面的 Nginx 讲清楚,先给了一个极简的 Node 服务。代码就八行:
// node 早期的 commonjs 规范
const http = require('http');
const server = http.createServer((req, res) => {
res.end('caohui so handsome');
});
server.listen(1314, '0.0.0.0', () => {
console.log('server is running at http://localhost:1314');
});
对应的 package.json 也极简,但有一行很关键:
{
"name": "demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}
CommonJS:Node.js 早期的模块规范,用
require引入模块、module.exports导出。package.json里的"type": "commonjs"就是在告诉 Node:这个项目按 CommonJS 规范来解析.js文件。后来 ES6 出了import/export(ESM),Node 才开始支持,但老项目大量还是 CommonJS。
这段代码我要特别注意两个细节:
第一,端口是 1314,不是 80。 这不是随便选的。http://localhost:1314/ 在笔记里被专门标了出来。1314 是这个 Node 服务自己的监听端口,跟"默认端口 80"是两码事。
第二,监听地址是 0.0.0.0。
0.0.0.0:不是一个具体的网卡地址,而是"监听本机所有网络接口"。如果写
127.0.0.1,那只有本机能访问;写0.0.0.0,Docker 容器、局域网其他机器才能把请求打进来。这在容器化场景下几乎是必须的。
如果这里写 127.0.0.1,后面 Nginx 转发过来根本接不住——因为 Nginx 是从 Docker 网桥过来的请求,不算"本机 loopback"。
六、为什么需要 Nginx?80 端口与高并发代理
Node 服务跑在 1314 了,但用户浏览器默认访问的是 http://localhost,连端口号都不带——因为 80 是 HTTP 的默认端口号。
用户输
http://localhost≈ 用户输http://localhost:80。
那问题来了:用户打 80,服务在 1314,中间怎么连起来?
笔记里写得很直白:
服务器软件把所有在 80 端口号产生的请求帮我们代理给 3000(其他)端口,并为它提供服务。
这个"服务器软件"就是 Nginx。老师讲它的动机是:
要满足高并发,就需要代理转发,需要 nginx。
高并发:同一时刻有大量请求涌进来。直接让业务代码扛,一个请求崩了可能拖死整台机器;用 Nginx 在前面挡一层,它能同时接住成千上万个连接,再按节奏分发到后端。
Nginx:一个高性能的 HTTP 服务器 / 反向代理软件。默认监听 80 端口,通过配置文件决定"80 收到的请求往哪个后端端口转"。
所以 Nginx 在这个 demo 里的角色很清楚:80 端口的门卫,把进来的请求转给 1314 的 Node 服务。
这里还藏着 Nginx 用 80 端口的另一个动机——隐藏后端端口号:
- 体验上:用户访问
http://localhost不用输端口号,省去记忆成本。如果后端直接暴露 1314,用户得输http://localhost:1314,别扭。 - 安全上:后端端口(1314)不直接对外暴露,外部看不到也连不上。但要注意——"隐藏"本身不是真正的防护,端口仍可能被扫描到;真正的保护来自 Nginx 作为前置网关可以做的事:访问控制、限流、过滤恶意请求。所以准确说法是"Nginx 把后端端口挡在身后,再叠加一层防护",而不是"藏起来就安全了"。
七、docker run 四参数:把 Nginx 跑起来的完整命令
下面这条命令是本节课的"中央代码",我原样贴出来:
docker run --name my-nginx-demo -p 80:80 -v "E:/docker/demo/nginx.conf:/etc/nginx/nginx.conf" -d nginx
老师把每个参数都拆开讲了,我也原样记下来:
docker run:启动一个镜像,让它成为可运行的容器。--name my-nginx-demo:容器的名字。起个名字,后面stop/rm才好喊它。-p 80:80:端口映射。- 左边的 80:本机的 80 端口。
- 右边的 80:Docker 容器启动后的 80 端口(Nginx 默认监听的就是 80)。
- 用户的浏览器输
http://localhost:80,请求先到本机 80,再被映射到 container 的 80。
-v:卷映射(挂载)。- 左边:本机的
nginx.conf配置文件路径。 - 右边:容器内的
/etc/nginx/nginx.conf。 - 作用:用本机的配置文件,覆盖容器里的配置文件。这样我们改本机这个文件,容器里的 Nginx 就按新配置走。
- 左边:本机的
-d nginx:-d是后台运行(daemon),nginx是镜像名。
端口映射(-p):把宿主机的一个端口和容器内的一个端口连起来。格式
宿主端口:容器端口。没有这一步,容器里的服务再怎么跑,外部也访问不到。卷挂载(-v):把宿主机的一个文件/目录"挂"到容器里的某个路径上。格式
宿主路径:容器路径。常用来把配置文件、数据目录挂进容器,这样改宿主机文件等于改容器内文件,不用重新打镜像。-d / daemon:后台运行。不加
-d,这个 Nginx 会占住当前终端,Ctrl+C 就停了;加了-d,容器在后台跑,终端关了它也继续。
这条命令我读了好几遍才理顺。最容易混的是 -p 80:80——为什么两边都是 80?因为 Nginx 镜像默认监听 80,我也想让本机用 80 访问,所以刚好都是 80。如果我想本机用 8080 访问,那就是 -p 8080:80。
八、nginx.conf:八行配置完成反向代理
光跑起 Nginx 镜像没用,得告诉它"80 收到的请求往哪儿转"。这就是 nginx.conf 的作用:
# nginx.conf 配置文件
events {}
http {
server {
listen 80;
location / {
proxy_pass http://host.docker.internal:1314;
proxy_set_header Host $host;
}
}
}
八行配置,三处关键:
1. listen 80; —— 这个 server 块监听 80 端口,跟 docker 命令里 -p 80:80 的右边那个 80 对上。
2. proxy_pass http://host.docker.internal:1314; —— 把请求转发到 1314。但这里有个陌生地址 host.docker.internal,必须搞懂。
host.docker.internal:Docker 提供的一个特殊域名,指向宿主机(也就是跑 Docker 的那台电脑)。容器内部不能直接写
localhost——因为容器有自己的网络空间,容器里的localhost是容器自己,不是宿主机。要让容器访问宿主机上的 Node 服务(跑在宿主机的 1314),必须用host.docker.internal。
这一行是整份配置的灵魂。我一开始看的时候在想:为什么不直接写 localhost:1314?答案就在上面——容器里的 localhost 不是宿主机的 localhost。这个域名是 Docker 给的"回到宿主机"的桥。
3. proxy_set_header Host $host; —— 把原始请求的 Host 头透传给后端。
proxy_pass:Nginx 的反向代理指令,把匹配到的请求转发到指定地址。
proxy_set_header:在转发请求时,往请求头里塞/改字段。
Host $host是把浏览器原始发的 Host 头(比如localhost)保留下来传给后端;如果不设这一行,Nginx 会把 Host 改成上游地址localhost:1314,后端拿到的就是被改过的值,可能导致重定向 URL、日志、虚拟主机路由出错。
理顺之后,整份配置的意思一句话能讲清:Nginx 在容器里的 80 端口接请求,转给宿主机的 1314 端口(Node 服务所在的地方),顺便把 Host 头透传过去。
九、运维考点:正向代理 vs 反向代理
关于代理:
- nginx 反向代理
- nginx 代理 1314 端口
- nginx 在 80 端口上使用 nginx(nginx.conf 代理端口服务):80 <- 1314 端口服务
- 直接访问 localhost 我们不知道后端具体在哪个端口上运行的
最后一行是反向代理最直观的特征:用户只知道 localhost,根本不知道后面是 1314 还是 3000,是 Node 还是 Java。 代理对象是"服务器这一侧",所以叫"反向"。
那"正向"是什么?笔记里那张链路图给出了答案:
用户(上网 intent)
-> 使用 browser (chrome)(正向代理 http 请求)
-> localhost:80
-> docker -p(ort):container(80)
-> -v 映射配置文件(localhost:/etc/nginx/nginx.conf)
-> -d(后台运行 docker nginx 服务) nginx(image)
-> nginx(nginx.conf 代理端口服务):80 <- :1314 端口服务
这张图我读了好几遍才理顺。从左到右是请求流向,最关键的是开头那一段:用户用浏览器上网,浏览器本身就是一个"正向代理"——它代理的是用户这一侧,把用户的上网意图翻译成 HTTP 请求发出去。
正向代理:代理的是客户端。用户 → 代理 → 服务器。服务器不知道真实用户是谁,只看到代理。典型例子:浏览器、翻墙工具。
反向代理:代理的是服务端。用户 → 服务器(其实是代理)→ 真实后端。用户不知道真实后端是谁,只看到代理。典型例子:Nginx、负载均衡器。
老师在这段笔记末尾留了一句话:"为什么用户上网都是代理"。我现在能回答了——因为浏览器替用户发请求,是正向代理;Nginx 替后端接请求,是反向代理。一次上网请求,至少经过两层代理,只是我们平时感觉不到。
十、我现在的理解
把整节课串起来,我对 Docker 的认知从"容器化工具"变成了三层:
- 它解决的是环境问题,不是代码问题。 Node 22 跑不动 Vue2 老项目,不是代码错了,是环境错了。Docker 把环境打包带走,让代码在哪都能跑。
- image 和 container 是"模板 vs 实例"的关系。 image 是只读光盘,container 是 DVD 机。同一张光盘可以塞进多台 DVD 机。
- Docker 自身不直接面向用户。 真实场景里,前面通常站着一个 Nginx 做反向代理,把 80 端口的请求转给容器里的服务。用户看到的是
localhost,看不到后面是 1314 还是 3000。
而 Nginx 那一段最让我有收获的是 host.docker.internal 这个细节。容器里的 localhost 不是宿主机的 localhost——这句话我现在能背下来,但真要理解它,得自己再起一个容器、exec 进去 curl localhost 试一下,看看拿到的是容器自己还是宿主机。这是下一步要补的实操。
课上还埋了一个问题没展开:为什么用户上网都是代理? 我现在的回答是"浏览器是正向代理,Nginx 是反向代理,两层代理夹着一次请求"。但这句话我自己也没完全想透——浏览器到底算不算严格意义的"正向代理",还是只是"客户端程序"?这个等下一节课再问老师。
术语速查
- 虚拟化技术:在一台物理机上通过软件隔离出多套独立运行环境的技术。Docker 用的是操作系统级虚拟化(容器化),比传统虚拟机轻量,共享宿主机内核,启动快、占用小。
- Docker Hub:Docker 官方的公共镜像仓库,类似 GitHub 但装的是镜像。
docker pull默认从这里拉。 - 镜像仓库(Registry):存放 image 的地方。Docker Hub 是公共仓库,公司内部通常会搭私有 Registry(如 Harbor)。
- daemon(守护进程):长期在后台运行的进程。
dockerd是 Docker 的守护进程,-d参数让容器也以 daemon 形式后台跑。 - 高并发:同一时刻大量请求涌入。Nginx 的事件驱动模型能扛住几万到几十万的并发连接,所以常被放在业务服务前面当门卫。
- loopback / 127.0.0.1:本机回环地址,只能被本机自己访问。容器里的
127.0.0.1指向容器自己,不指向宿主机——这就是为什么要用host.docker.internal。