从「我的机器能跑」到容器化 — 生产准备、Docker 与 Compose
从「我的机器能跑」到容器化 — 生产准备、Docker 与 Compose
这一章讲什么: 应用做完只是原型——只跑在 localhost 上的东西还称不上 产品。这一章回答「上线到底意味着什么」:先把应用改造成生产的形状 (配置快速失败、健康检查、优雅关停、结构化日志),再把它打包成 容器镜像(在哪跑都一个样),最后在本地用 Docker Compose 把「前端 + API + 数据库」三件套完整彩排一遍。 读完你能解释:为什么 1.2 GB 的镜像是耻辱,以及
docker compose up为什么是在给生产做预演。
1. 生产 mindset:九行差距表
开发的活法:tsx 热重载、Vite dev server、错误堆栈直接糊在脸上、 单用户、localhost。生产的活法:凌晨 3 点崩了得自己爬起来;用户看到 的是脱敏错误,堆栈进内部日志;一个连接变成 500 个;浏览器没 HTTPS 就罢工;出事了系统要自己恢复1。原书把差距列成一张九行对照表: 错误处理、环境变量、数据库连接、资源服务、进程管理、可观测性、 故障处理、HTTPS——每一行都是真用户到场后迟早要付的账1。
部署是一条流水线,而且每一段失败的姿势都不同:本地过的测试在 CI 里 因缺环境变量而挂;构建成功但镜像因 Node 版本错而崩;部署顺利但应用连 不上库(连接串错了)。没有一个是假想——这就是要自动化的理由2。
路线选择:FTP 传文件是老黄历;SSH+PM2 的传统 VPS 正被容器取代; Serverless 是另一种心智模型;学习路径的最优解:Docker 打包 + PaaS(Railway/Render/Fly.io)部署——Docker 是跨平台的通用技能, PaaS 是这个体量的项目最实际的路3。
2. 生产三件套:配置、健康检查、优雅关停
快速失败的环境配置
src/config.ts 在启动时就校验必需环境变量:DATABASE_URL 缺失
→ 打印明确错误、立即退出。听着激进,想想反面:应用起来了、看着健康,
第一个用户登录时才炸出一段莫名其妙的数据库错误——带清晰消息的早死,
永远胜过看似健康的晚死。PORT 同时做类型和范围校验(1–65535),
并且用 Number() 不用 parseInt()——后者对 "3000abc" 会宽容地
吐出 3000,把畸形输入藏起来4。配套 .env.example 只记录变量名
不给真值;真 .env 永不进 git4。
健康检查:活着 ≠ 能用
GET /healthz → liveness:进程还能响应吗?永远回 200
GET /readyz → readiness:现在真能服务用户吗?
办法:prisma.$queryRaw`SELECT 1`
库挂了 → 503 → 平台把流量挪走
命名来自 Kubernetes 社区,已成行业标准。为什么要两个:进程活着但数据库连不上时,healthz 说一切正常,
readyz 说此实例别接客。健康端点要注册在认证中间件之前——
否则负载均衡器自己都验不了;响应保持最小化,不含任何秘密5。
优雅关停:部署不能吞掉正在提交的表单
平台部署新版时,给旧进程发 SIGTERM——操作系统的「请你关机」。
正确应答:server.close()(不再收新连接、等在途请求处理完)→
prisma.$disconnect()(关连接池)→ 干净退出。这就是零停机部署
的全部秘密:旧进程伺候完手头请求,新进程已经开始接客6。
三个工程细节。server.closeIdleConnections()(Node 18.2+)立即断开
闲着的 keep-alive 连接,免得它们拖住关停;shuttingDown 旗标防重复
信号跑两遍逻辑;10 秒强退超时是兜底——宁可强杀,不挂到天荒地老
(超时按你最长的请求调)6。
结构化日志:pino 与「敢不敢上公屏」测试
生产没人盯着终端看,日志是给解析、检索、聚合的机器吃的——所以输出
JSON 行({"method":"GET","path":"/api/users","status":200,"duration":45})
而不是给人看的散文7。pino 用流式写,比 winston 快得多;
redact: ['req.headers.authorization', 'req.headers.cookie', '*.password']
把「别记敏感信息」从自律变成代码强制——就算哪行日志不小心带了令牌,
落盘前已被替换 [Redacted]。自检口诀:这行日志敢不敢出现在公共
仪表盘上?7
构建产物
后端:tsc 把 TS 编成 dist/(生产用 node dist/index.js 直接跑,
不要 tsx——它每文件现编译,有开销);module: "NodeNext" 正确建模
Node 的 ESM 解析(含导入路径要带 .js 后缀);前端项目则相反,用
"moduleResolution": "bundler"——前后端共用一个 tsconfig 是
经典踩坑8。前端 VITE_ 变量是构建时烘焙进产物的:
staging 和生产的 API 地址不同?那就每个环境各构建一次(或改用运行时
拉 /config.json 的 workaround)8。
3. 主线例子:镜像减肥,1.2 GB → 150 MB
naive Dockerfile 六行就能跑,但 docker images 一看 1.2 GB——
「开卡车去杂货店买两条面包」。里面全是不该上生产的东西:开发依赖、
TypeScript 源码、测试文件、完整的 Debian 底9。
多阶段版逐刀解剖:
① FROM node:24-alpine(两阶段都用)
Alpine ≈ 5 MB 底 vs Debian ≈ 200 MB;镜像小=下载快、攻击面小
⚠ Alpine 用 musl 而非 glibc,原生模块(如 Prisma)可能抗议——
出「query engine / binary target」错,先换 node:24-slim 再查别的
② 多阶段构建:builder 阶段装全套编译工具(tsc、dev deps),
production 阶段从零开始,只 COPY 需要的(dist + prisma + 生产依赖)
→ 构建工具、源码、dev 依赖全部丢弃
③ npm ci --omit=dev && npm cache clean --force
→ 镜像里没有 vitest/typescript/tsx,也没有缓存残渣
④ COPY package*.json → RUN npm ci → COPY 源码(顺序即缓存策略)
→ 依赖没变只改代码时,Docker 整层跳过 npm install,构建从分钟变秒
⑤ USER node → 容器里不再以 root 跑;应用被攻破,攻击者没有 root
⑥ .dockerignore(node_modules、.env、dist、.git…)
→ 没它,Docker 会把整个目录(包括好几万文件的 node_modules)
塞进构建上下文成果:1.2 GB → 150–180 MB9。前端是另一条路:React 构建产物只是
静态文件,不需要 Node 运行时——builder 阶段构建,产物 COPY 进
nginx:stable-alpine(约 25 MB)。VITE_API_URL 靠
ARG/ENV 在构建时注入(烘焙特性决定的)10。
nginx.conf 里的三件宝:try_files $uri $uri/ /index.html——SPA 的
命门,用户直接访问 /dashboard 时回落到 index.html 交给 React Router;
/assets/ 一年缓存(Vite 文件名带内容哈希,内容变哈希变);
安全头加 always(错误响应也带上)10。
4. 调试与日常命令
构建失败三板斧:--progress=plain --no-cache 看完整日志;
--target builder 构建到中间阶段再 docker run -it sh 进去
「看看文件到底在哪」;本地成功 CI 失败九成是构建上下文——
.dockerignore 把文件排除后 COPY 看不见它(「本地能跑 CI 崩」
的经典原因);用临时目录 fresh git clone 复现 CI 环境11。
高频命令:build / run -p --env-file / ps / logs / exec -it sh / stop / images,Podman 逐字替换12。
5. Docker Compose:三服务一台戏
为什么学部署却花时间学一个「本地工具」?因为 Compose 让你在自家机器上 验证整个类生产编排——Dockerfile、环境变量、healthcheck、网络, 这里配的和生产用的是同一套;它是部署前的彩排13。
三服务:db(postgres:18-alpine + 命名卷——**没有卷,容器一停数据
就没了)、api、frontend。四个要点:
- 服务名即主机名:API 的
DATABASE_URL写db:5432,不写 localhost——Compose 网络内按服务名互达; depends_on: condition: service_healthy:不靠「容器启动了」 靠「真的健康了」。没这个,PostgreSQL 容器 running 好几秒后才真接客, API 抢跑会在首个查询崩掉——healthcheck 消灭这个启动竞态;- API 的 healthcheck 用 Node 内置 fetch 打自己的
/healthz, 让 frontend 等它真就绪; - 生产不暴露 5432(本地为方便才映射)13。
迁移在容器里怎么跑:启动脚本 start.sh = prisma migrate deploy
exec node dist/index.js。exec不是洁癖——它让 Node 取代 shell 成为 PID 1,SIGTERM 才能直达应用,优雅关停代码才轮得到执行; 没有 exec,shell 占着 PID 1,部署信号永远到不了你的程序14。
6. 作者的判断与证据
- 「学习路径 = Docker 打包 + PaaS 部署」是明确的教学选型建议3。
- 「快速失败优于带病运行」是贯穿配置设计的原则4。
- 「Alpine musl 问题先换 slim 再查别的」是经验之谈,优先级判断9。
- 「Compose 是生产的预演而非目标形态」给工具定了正确的位置13。
7. 边界与局限
- 零停机只在单实例轮换时成立:多实例滚动部署、数据库迁移与代码 版本兼容(expand/contract)等更硬的话题未展开;
- 镜像安全(扫描漏洞、签名、最小权限)点到为止;
- secrets 管理只说「平台托管、不进 git」,KMS/Vault 级方案未涉及;
- Docker/Podman、Node 24 LTS 等版本口径为 2026 年中时点。
判断(我们的,不是书里的): 本章最高明的教法是故意从 naive Dockerfile 开始——让你亲手称出 1.2 GB,再逐刀讲每刀省了什么、 为什么。抽象清单(「用多阶段构建」「用 alpine」)背完即忘; 「卡车买面包」的称重视觉 + 逐刀归因,记得牢。 如果错,会错在: 「先跑通再优化」的顺序对学习最优,但对有安全 合规要求的团队是反模式——那些团队的第一版 Dockerfile 就该有 USER 非 root 和漏洞扫描;本书的渐进式是为教学设计的,不是为合规。
8. 可带走的
- 生产的差距是九行表:错误、配置、连接、静态资源、进程、可观测、自愈、HTTPS。
- 部署是流水线,每段失败姿势不同 → 自动化。
- 必需环境变量启动即验、缺了就死;Number 比 parseInt 诚实;
.env不进 git。 - healthz=活着,readyz=能接客(查数据库);注册在认证之前。
- SIGTERM → close(等完在途)→ 断库 → 退出;closeIdleConnections + 强退超时。
- 日志出 JSON;pino 的 redact 把「别记密码」变成强制。
- 后端 NodeNext / 前端 bundler,两本 tsconfig 别混。
- 镜像减肥五刀:alpine、多阶段、omit=dev、COPY 顺序(层缓存)、USER 非 root; .dockerignore 是「本地能跑 CI 崩」的常客。
- SPA 的 nginx 命门:try_files 回落 index.html;资产带哈希可长缓存。
- Compose:服务名当主机名、depends_on 等 healthy(消灭启动竞态); start.sh 用 exec 让 Node 当 PID 1,信号才达得到。
9. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 开发 vs 生产九行表 | Chapter 13: Deploying Full-Stack Applications | text/125-ch13-chapter-13-deploying-full-stack-applications.txt:115(搜「Error handling」;表跨 25–45 段) |
| 部署流水线六段 | What does "deployment" actually involve? | text/126-fm-what-does-deployment-actually-involve.txt:7(搜「pipeline with multiple stages」) |
| 路线选择 | The modern deployment landscape | text/127-fm-the-modern-deployment-landscape.txt:9(搜「PaaS (Platform as a Service)」) · text/127-fm-the-modern-deployment-landscape.txt:55(搜「Docker as the packaging」) |
| 快速失败配置 | Environment configuration | text/128-fm-environment-configuration.txt:52(搜「crashes immediately」) · text/128-fm-environment-configuration.txt:76(搜「parseInt()」) |
| healthz/readyz | Health check endpoints | text/129-fm-health-check-endpoints.txt:51(搜「liveness」) · text/129-fm-health-check-endpoints.txt:115(搜「// Health checks first, before any auth 」) |
| 优雅关停 | Graceful shutdown | text/130-fm-graceful-shutdown.txt:79(搜「This is the key to zero-downtime deploym」) · text/130-fm-graceful-shutdown.txt:61(搜「server.closeIdleConnections();」) |
| pino 与 redact | Production logging | text/131-fm-production-logging.txt:39(搜「public dashboard」) · text/131-fm-production-logging.txt:90(搜「Redacted」) |
| 两本 tsconfig | Building for production | text/132-fm-building-for-production.txt:55(搜「moduleResolution: "bundler"」) |
| 1.2 GB 与逐刀解剖 | Dockerizing the Express API | text/134-fm-dockerizing-the-express-api.txt:44(搜「, you will see it is around 1.2 GB.」) · text/134-fm-dockerizing-the-express-api.txt:72(搜「Replace the Dockerfile with this multi-s」) · text/134-fm-dockerizing-the-express-api.txt:327(搜「layer caching」) |
| Alpine musl 警告 | Dockerizing the Express API | text/134-fm-dockerizing-the-express-api.txt:56(搜「musl」) |
| nginx 三件套 | Dockerizing the React frontend | text/135-fm-dockerizing-the-react-frontend.txt:134(搜「try_files $uri $uri/ /index.html;」) · text/135-fm-dockerizing-the-react-frontend.txt:235(搜「content hashes」) |
| 构建调试三板斧 | Debugging failed builds | text/136-fm-debugging-failed-builds.txt:17(搜「--progress=plain」) · text/136-fm-debugging-failed-builds.txt:114(搜「git clone」) |
| Compose 即预演 | Running the stack | text/137-fm-essential-container-commands.txt:65(搜「rehearsal」) |
| 服务名即主机名与健康检查 | Running the stack | text/138-fm-running-the-stack.txt:33(搜「db」);healthcheck 段见 137 文件(text/137-fm-essential-container-commands.txt:87 搜「pg_isready」) |
| start.sh 与 exec PID 1 | Running Prisma migrations in Docker | text/139-fm-running-prisma-migrations-in-docker.txt:22(搜「exec node dist/index.js」) · text/139-fm-running-prisma-migrations-in-docker.txt:28(搜「command replaces the shell process with 」) |
Footnotes
-
出处:「Chapter 13: Deploying Full-Stack Applications」第 25–55 段(
text/125-ch13-chapter-13-deploying-full-stack-applications.txt:95,搜「completely different world」)。 ↩ ↩2 -
出处:「What does "deployment" actually involve?」第 5–31 段(
text/126-fm-what-does-deployment-actually-involve.txt:25,搜「fail differently」;:31,搜「automate」)。 ↩ -
出处:「The modern deployment landscape」第 37–59 段(
text/127-fm-the-modern-deployment-landscape.txt:55,搜「Docker as the packaging」)。 ↩ ↩2 -
出处:「Environment configuration」第 11–47 段(
text/128-fm-environment-configuration.txt:52,搜「crashes immediately」;:33,搜「parseInt("3000abc")」;:45,搜「Never commit」)。 ↩ ↩2 ↩3 -
出处:「Health check endpoints」第 13–49 段(
text/129-fm-health-check-endpoints.txt:51,搜「liveness」;:27,搜「before any auth」;:43,搜「Kubernetes community」)。 ↩ -
出处:「Graceful shutdown」第 9–45 段(
text/130-fm-graceful-shutdown.txt:79,搜「This is the key to zero-downtime deploym」;:39,搜「closeIdleConnections」;:45,搜「safety net」)。 ↩ ↩2 -
出处:「Production logging」第 11–39 段(
text/131-fm-production-logging.txt:39,搜「public dashboard」;:35,搜「Redacted」)。 ↩ ↩2 -
出处:「Building for production」第 21–63 段(
text/132-fm-building-for-production.txt:55,搜「moduleResolution: "bundler"」;:47,搜「baked into the build」)。 ↩ ↩2 -
出处:「Dockerizing the Express API」第 13–51 段与 93–105 段(
text/134-fm-dockerizing-the-express-api.txt:44,搜「, you will see it is around 1.2 GB.」;:31,搜「musl」;:45,搜「multi-stage」;:99,搜「layer caching」)。 ↩ ↩2 ↩3 -
出处:「Dockerizing the React frontend」第 27–65 段(
text/135-fm-dockerizing-the-react-frontend.txt:93,搜「ARG」;:45,搜「try_files」;:61,搜「content hashes」)。 ↩ ↩2 -
出处:「Debugging failed builds」第 7–41 段(
text/136-fm-debugging-failed-builds.txt:17,搜「--progress=plain」;:37,搜「clean checkout」)。 ↩ -
出处:「Essential container commands」第 3–29 段(
text/137-fm-essential-container-commands.txt:8,搜「docker build -t myapp-api .」)。 ↩ -
出处:「Essential container commands」第 31–55 段(编排动机与 Compose 定位,
text/137-fm-essential-container-commands.txt:65,搜「rehearsal」)。 ↩ ↩2 ↩3 -
出处:「Running Prisma migrations in Docker」第 9–21 段(
text/139-fm-running-prisma-migrations-in-docker.txt:22,搜「exec node dist/index.js」;:17,搜「SIGTERM」)。 ↩