Docker 到底解决了什么?一次 Node 版本冲突引发的容器化思考

0 阅读13分钟

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 应该会顺手停一下",但笔记明确写了"必须先停止",那就不该赌,老老实实 stoprm

五、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 的认知从"容器化工具"变成了三层:

  1. 它解决的是环境问题,不是代码问题。 Node 22 跑不动 Vue2 老项目,不是代码错了,是环境错了。Docker 把环境打包带走,让代码在哪都能跑。
  2. image 和 container 是"模板 vs 实例"的关系。 image 是只读光盘,container 是 DVD 机。同一张光盘可以塞进多台 DVD 机。
  3. 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