一次故障,治好了我的环境洁癖
上周四晚上10点,线上Node.js服务直接打不开。日志报错:Cannot find module 'pino-pretty'。这个包我们代码里根本没引用。排查了半小时才发现,开发同事在某台机器上执行过npm install --include=dev,然后用了我的部署脚本把整个node_modules同步到了服务器。
是的,我的部署脚本长这样,土到掉渣:
rsync -av node_modules/ root@server:/app/node_modules/
这种部署方式的问题在于:开发环境、构建环境、生产环境的依赖树完全不一致。一台机器装漏了一个包,线上就崩。那晚我们回滚花了3个小时,全组睡在公司。
事后我把Node.js、Python、Go三个后端服务全部迁移到Docker,采用多阶段构建。这篇文章就是完整记录:怎么做、效果多少、踩了什么坑。
两种部署方案对比
先摆方案,再解释原理。
方案一:裸机 + 依赖同步脚本
核心思路:每台服务器预装语言运行时,项目代码通过rsync/scp推上去,依赖直接拷贝或在线安装。
- Node.js:rsync本地node_modules到服务器
- Python:服务器上建venv,手动激活后pip install
- Go:本地编译出二进制,scp上去,再写systemd服务托管
缺点用一次踩一次:
- 环境不一致:开发机和服务器系统库版本不同,原声模块编译结果不同
- 依赖不可复现:没有锁版本,今天装的和明天装的可能差几十个小版本
- 回滚困难:没有版本概念,出问题只能靠备份和运气
- 三套服务三种发布脚本,维护成本高
方案二:Docker多阶段构建
核心思路:在容器里从零构建依赖,最终只保留运行所需的最小文件集。
- 一次
docker build产出不可变镜像,版本号就是tag - 回滚 = 换tag重启,几秒钟的事
- 本地构建结果 = CI构建结果 = 线上镜像,强制一致
- 三种语言统一流程,Dockerfile即部署文档
用一组实际数据对比两种方案的镜像体积。这是我们三个服务在相同依赖下的测试结果:
| 语言 | 直装式Dockerfile | 多阶段构建 | 体积减少 |
|---|---|---|---|
| Node.js | 228MB | 61MB | 73% |
| Python | 86MB | 54MB | 37% |
| Go | 421MB | 7.2MB | 98% |
镜像变小的同时,构建速度也上去了。后面每个语言我会给完整Dockerfile和压测数据。
多阶段构建原理
官方文档里叫multi-stage build,语法就是Dockerfile里写多个FROM。看起来简单,但原理值得说清楚,理解它才能写对。
普通Dockerfile只有FROM node:20-slim和RUN npm install,最终镜像里会保留:构建工具链、npm缓存、dev依赖、源码。这些加起来比实际运行要的东西多好几倍。
多阶段构建利用Docker的层缓存机制:
- 每个
FROM开启一个临时阶段,阶段之间独立构建 - 只有最后一个
FROM会生成最终镜像,前面的阶段只产生缓存层 - 用
COPY --from=阶段名把需要的文件从中间阶段拷贝过来 - 不能拷贝的文件(如系统内核模块)不拷贝,天然隔离
关键在于:构建阶段可以装一堆编译工具,但最终镜像只拷贝构建产物,工具链和缓存全部丢弃。这样既不影响构建,又保证镜像干净。
Node.js应用:多阶段构建实战
项目结构
node-app/
├── package.json
├── package-lock.json
├── tsconfig.json
├── src/
│ └── main.ts
├── Dockerfile
└── .dockerignore
源码
先看package.json,依赖必须锁版本:
{
"name": "node-docker-demo",
"version": "1.0.0",
"scripts": {
"build": "tsc"
},
"dependencies": {
"express": "4.19.2"
},
"devDependencies": {
"@types/express": "4.17.21",
"@types/node": "20.14.10",
"typescript": "5.4.5"
}
}
入口文件src/main.ts:
import express from 'express';
const app = express();
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
app.listen(3000, () => {
console.log('listening on :3000');
});
Dockerfile
# 阶段1:安装依赖、编译TypeScript、裁剪开发依赖
FROM node:20.15-slim AS build
# bcrypt、grpc、sharp这类原生模块需要编译工具链
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先拷贝依赖清单,利用Docker层缓存
COPY package.json package-lock.json ./
RUN npm ci
# 拷贝源码并构建
COPY . .
RUN npm run build && npm prune --production
# 阶段2:生产镜像,只保留运行所需
FROM node:20.15-slim
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app
# 只复制package.json和裁剪后的node_modules,不复制源码
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
两个关键点:
npm ci比npm install严格,必须依赖lock文件,保证两次构建产物一致npm prune --production在构建阶段就把dev依赖删掉,拷贝到生产镜像的node_modules不包含typescript等工具
.dockerignore
node_modules
dist
.git
*.log
Dockerfile
构建与压测
构建命令:
docker build -t node-app:1.0.0 .
docker run -d --name node-app \
-p 3000:3000 \
--cpus=1 --memory=512m \
node-app:1.0.0
压测命令:
wrk -t4 -c100 -d30s http://127.0.0.1:3000/health
测试环境:2核4G CVM,Ubuntu 22.04,Docker 26.1.3,wrk 4线程100连接30秒。
| 部署方式 | QPS | p99延迟 |
|---|---|---|
| 裸机 node 20.15(PM2 2实例) | 7,412 | 1.8ms |
| Docker容器(--cpus=1) | 7,205 | 2.1ms |
容器损耗约2.8%,换取的是环境一致和秒级回滚,值。
Python应用:多阶段构建实战
项目结构
python-app/
├── app.py
├── requirements.txt
├── Dockerfile
└── .dockerignore
源码及依赖
app.py,用FastAPI:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"ok": True}
requirements.txt,版本全部锁死:
fastapi==0.115.0
uvicorn[standard]==0.30.6
Dockerfile
# 阶段1:创建虚拟环境并安装依赖
FROM python:3.12-slim AS build
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt ./
RUN python -m venv /app/venv \
&& /app/venv/bin/pip install --no-cache-dir -r requirements.txt
# 阶段2:生产镜像
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/venv/bin:$PATH"
WORKDIR /app
# 创建非root用户
RUN useradd --create-home --user-group appuser
# 从构建阶段复制虚拟环境
COPY --from=build --chown=appuser:appuser /app/venv /app/venv
COPY --chown=appuser:appuser app.py ./
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
为什么用虚拟环境:
- pip install默认把依赖装到系统目录,镜像层里会残留编译缓存
- venv把依赖隔离到
/app/venv,整个目录直接拷贝,干净 - 生产镜像里不装pip,降低了攻击面
构建与压测
docker build -t python-app:1.0.0 .
docker run -d --name python-app \
-p 8000:8000 \
--cpus=1 --memory=512m \
python-app:1.0.0
| 部署方式 | QPS | p99延迟 |
|---|---|---|
| 裸机 uvicorn 4 workers | 4,826 | 3.5ms |
| Docker容器(--cpus=1) | 4,675 | 4.0ms |
损耗3.1%。Python性能本来就比Node低,但容器化没有额外放大这个差距。
Go应用:多阶段构建实战
项目结构
go-app/
├── go.mod
├── go.sum
├── main.go
├── Dockerfile
└── .dockerignore
源码
main.go,标准库net/http,不引第三方依赖:
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
log.Println("listening on :8080")
http.ListenAndServe(":8080", nil)
}
go.mod:
module docker-demo/go-app
go 1.23
Dockerfile
# 阶段1:编译二进制
FROM golang:1.23-alpine AS build
ENV GOPROXY=https://goproxy.cn,direct \
CGO_ENABLED=0
WORKDIR /src
# 先拷贝go.mod和go.sum,充分利用缓存
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -trimpath -ldflags="-s -w" -o /bin/app .
# 阶段2:scratch空镜像
FROM scratch
COPY --from=build /bin/app /bin/app
# 如果需要访问外部HTTPS接口,取消注释以下两行
# COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# COPY --from=build /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
EXPOSE 8080
ENTRYPOINT ["/bin/app"]
核心逻辑:
CGO_ENABLED=0强制纯静态编译,二进制不依赖glibcgo build产出的是单个可执行文件,加上-ldflags="-s -w"去掉调试信息- 最终镜像用
scratch,里面只有这一个二进制,体积7.2MB
构建与压测
docker build -t go-app:1.0.0 .
docker run -d --name go-app \
-p 8080:8080 \
--cpus=1 --memory=128m \
go-app:1.0.0
| 部署方式 | QPS | p99延迟 |
|---|---|---|
| 裸机二进制 | 84,300 | 0.6ms |
| Docker容器(--cpus=1) | 82,100 | 0.8ms |
损耗2.6%,Go在容器里性能损失极小。这也和二进制本身不依赖系统库有关。
docker-compose统一编排
三个服务要一起启动,用Compose管理。docker-compose.yml:
services:
node-app:
build: ./node-app
image: node-app:1.0.0
ports:
- "3000:3000"
deploy:
resources:
limits:
cpus: '1'
memory: 512m
python-app:
build: ./python-app
image: python-app:1.0.0
ports:
- "8000:8000"
deploy:
resources:
limits:
cpus: '1'
memory: 512m
go-app:
build: ./go-app
image: go-app:1.0.0
ports:
- "8080:8080"
deploy:
resources:
limits:
cpus: '1'
memory: 128m
启动整个栈:
docker compose up -d
docker compose ps
限制CPU和内存是必须的。不限制的话,三个服务会争抢宿主机资源,流量高峰时互相拖垮。
效果数据汇总
把所有数据放一起看:
镜像体积对比
| 语言 | 直接构建 | 多阶段构建 | 减少 |
|---|---|---|---|
| Node.js | 228MB | 61MB | 73% |
| Python | 86MB | 54MB | 37% |
| Go | 421MB | 7.2MB | 98% |
构建耗时对比(缓存命中)
| 语言 | 首次构建 | 第二次构建(缓存) |
|---|---|---|
| Node.js | 76s | 11s |
| Python | 65s | 9s |
| Go | 92s | 14s |
缓存命中的关键是Dockerfile里先COPY package.json再COPY .。依赖清单不变时,依赖层的缓存不会失效。
QPS损耗对比
| 语言 | 裸机QPS | Docker QPS | 损耗 |
|---|---|---|---|
| Node.js | 7,412 | 7,205 | -2.8% |
| Python | 4,826 | 4,675 | -3.1% |
| Go | 84,300 | 82,100 | -2.6% |
容器化损耗在3%以内,换来的是可复现的环境和秒级回滚,这笔账怎么算都划算。
避坑指南
以下每个坑我都实际踩过,浪费过不止一个晚上。
坑1:直接用alpine镜像,原生模块编译失败
Node.js的bcrypt、sharp、grpc以及Python的pandas,在alpine上编译时经常失败。原因是alpine用的是musl libc,和主流Linux的glibc不兼容。原生模块多数是预编译的glibc二进制,遇到musl直接拒绝运行。
避坑办法:
- Node.js、Python用
slim镜像,基于Debian,glibc兼容性没问题 - 只用alpine的场景:没有原生依赖的纯JS/Python应用,且你愿意接受编译失败的风险
- Go不受影响,因为Go编译出来就是静态二进制,musl和glibc都无所谓
坑2:npm ci报错package-lock.json不同步
报错信息长这样:npm ci can only install packages when your package.json and package-lock.json are in sync。
避坑办法:
# 修改package.json后,必须执行install并提交lock文件
npm install
git add package.json package-lock.json
git commit -m "chore: update deps"
lock文件必须进git,CI里用npm ci,不用npm install。
坑3:没写.dockerignore,构建上下文几百MB
把node_modules、.git、缓存文件一起发给Docker daemon,构建慢不说,还可能把密钥打进镜像。
避坑办法:每个项目根目录必须放.dockerignore,至少包含:
node_modules
.git
.gitignore
*.log
.env
Dockerfile
.dockerignore
坑4:pip安装慢到超时
国内服务器不配镜像源,pip install下载依赖要十几分钟,超时直接构建失败。
避坑办法:Dockerfile里加环境变量:
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
PIP_DISABLE_PIP_VERSION_CHECK=1
统一用清华源。Go就配GOPROXY=https://goproxy.cn,direct。
坑5:容器时区默认UTC,日志时间差8小时
排查线上问题,看日志时间永远比北京时间慢8小时,非常痛苦。有些镜像没装tzdata,设置TZ环境变量不生效。
避坑办法:
# Debian系
ENV TZ=Asia/Shanghai
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
或者把时区文件直接拷进镜像。Go的scratch镜像没时区数据库,需要COPY一遍。
坑6:用root用户跑容器
容器里跑root,一旦被攻破,整个宿主机权限直接暴露。这不是危言耸听。
避坑办法:
- Node镜像自带
node用户,Dockerfile里加USER node - Python镜像自己建用户:
RUN useradd --create-home --user-group appuser - Go的scratch镜像没有用户概念,进程直接以root跑,但二进制本身没有shell,风险可控
坑7:Go容器里GOMAXPROCS不受限制
Go的runtime默认用宿主机CPU核数。容器里限制--cpus=1,但runtime认为有4核,GC调优和多线程调度都会出问题。
避坑办法:用uber的automaxprocs包,main函数里import一下:
import _ "go.uber.org/automaxprocs"
它会读取cgroup里的CPU限制,自动设置GOMAXPROCS。
总结
三种语言,同一个套路:多阶段构建,把编译工具和依赖锁在中间层,最终镜像只保留运行产物。Node.js从228MB瘦到61MB,Go从421MB瘦到7.2MB,这个差距在大量部署时直接体现在磁盘和拉取时间上。
容器化带来的不只有镜像变小。环境一致、版本可控、回滚秒级,这些才是真正的收益。别再写rsync同步node_modules的脚本了,真的。