容器与部署
容器的价值不是"能跑起来",而是把运行环境变成一份可版本化、可复制、可回滚的制品。本文从容器与虚拟机的本质差异讲起,落到 Dockerfile 的分层与瘦身、Kubernetes 的声明式对象模型,最后重点讲两个真正决定线上体感的细节:优雅退出与发布策略。
一、容器到底是什么
| 虚拟机 | 容器 | |
|---|---|---|
| 隔离层 | Hypervisor 虚拟硬件 + 独立内核 | 共享宿主机内核,靠 Linux 内核机制隔离 |
| 启动 | 分钟级 | 秒级 |
| 体积 | GB 级(含完整 OS) | MB 级(只含应用与依赖) |
| 开销 | 每个 VM 一份 OS | 共享内核,密度高 |
| 隔离强度 | 强(内核级) | 较弱(内核共享),需额外加固 |
容器依赖三个内核能力:
- Namespace:隔离视图——PID(进程树)、NET(网络栈)、MNT(挂载点)、UTS(主机名)、IPC、USER(用户/组映射);
- Cgroup:限制资源——CPU 配额、内存上限、IO 权重,这是
resources.limits的落地机制; - UnionFS:镜像分层——每一层只读,容器启动时叠加一个可写层,多个容器共享基础层。
镜像分层直接决定了构建优化方向:变化频率低的层放前面,变化频率高的放后面,中间任何一层变了,它之后的层缓存全部失效。
二、Dockerfile:写得对,构建快十倍
# ---------- 阶段一:构建 ----------
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./ # 依赖清单先拷:命中缓存的关键
RUN npm ci --omit=dev # 锁定版本、可复现,比 npm install 更适合 CI
COPY . .
RUN npm run build
# ---------- 阶段二:运行 ----------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER node # 非 root 运行,缩小被攻破后的影响面
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/main.js"] # exec 形式:直接作为 PID 1,能收到信号| 实践 | 原因 |
|---|---|
| 多阶段构建 | 构建工具链不进运行镜像,体积与安全面同时下降 |
| 先拷依赖清单再装依赖 | 只改业务代码时,npm ci 那一层命中缓存 |
npm ci 而非 npm install | 严格按 lockfile 安装,避免"我这里能跑" |
.dockerignore 排除 node_modules .git *.log | 减少构建上下文,避免本地 node_modules 覆盖镜像内依赖 |
| 非 root 用户 | 容器逃逸/文件写入的影响被限制 |
CMD 用 exec 形式(["node", ...]) | shell 形式会让 shell 成为 PID 1,信号无法传递给应用,导致无法优雅退出 |
| 精简基础镜像(alpine / distroless / slim) | 更小的攻击面与更快的拉取速度 |
| 不在镜像里放密钥 | 镜像会被分发、缓存、扫描(见 §七) |
CMD ["node", "dist/main.js"]而不是CMD npm start:后者会额外起一个 shell/npm 进程,不仅多占内存,还会让 SIGTERM 传不到 Node 进程——这正是"容器里优雅退出失效"的第一大原因。
三、本地编排:Compose
services:
app:
build: .
ports: ["3000:3000"]
environment:
- DATABASE_URL=postgres://app:app@db:5432/app
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy # 等数据库真正可用,而不只是容器起来了
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
retries: 5
start_period: 10s
db:
image: postgres:17-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
- POSTGRES_DB=app
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
cache:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
pgdata:
depends_on默认只等容器启动、不等服务就绪;用condition: service_healthy配合健康检查,才能避免"应用启动时连不上数据库而崩溃重启"的启动竞态。
四、Kubernetes:声明式与核心对象
K8s 的核心思维是声明期望状态,控制器负责把现实拉向期望(reconcile 循环)。你提交 YAML,控制器不断对比并收敛。
| 对象 | 作用 | 要点 |
|---|---|---|
| Pod | 最小调度单位,一个或多个容器 | 一般不直接创建,由控制器管理 |
| Deployment | 管理无状态 Pod 副本数与滚动更新 | 扩缩容、回滚的入口 |
| StatefulSet | 有状态服务(稳定网络标识与存储) | 数据库、消息队列 |
| Service | 稳定的访问入口(ClusterIP/NodePort/LoadBalancer) | 靠 label selector 关联 Pod |
| Ingress / Gateway API | 七层路由(域名、路径、TLS) | 需 Ingress Controller 才生效 |
| ConfigMap / Secret | 配置与密钥 | Secret 只是 base64,不是加密,需配合加密存储 |
| PVC / PV | 持久化存储 | 有状态服务必需 |
| HPA | 按指标自动扩缩副本 | 依赖 metrics-server 与资源 requests |
| PodDisruptionBudget | 自愿中断时最少可用副本数 | 保证节点腾挪时不全部中断 |
五、一份能上生产的 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } # 更新过程中始终不缺副本
selector:
matchLabels: { app: order-api }
template:
metadata:
labels: { app: order-api }
spec:
terminationGracePeriodSeconds: 40 # 留给优雅退出的时间窗
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: api
image: registry.example.com/order-api:1.8.3 # 固定 tag,禁止用 latest
ports: [{ containerPort: 3000 }]
env:
- name: NODE_ENV
value: production
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: order-api-secret, key: database-url } }
resources:
requests: { cpu: 200m, memory: 256Mi } # 调度依据,必须设置
limits: { cpu: "1", memory: 512Mi } # 上限,防失控影响邻居
readinessProbe: # 没就绪 → 从 Service 摘除
httpGet: { path: /healthz/ready, port: 3000 }
periodSeconds: 5
failureThreshold: 3
livenessProbe: # 卡死 → 重启容器
httpGet: { path: /healthz/live, port: 3000 }
periodSeconds: 10
failureThreshold: 6
startupProbe: # 慢启动保护,避免被 liveness 误杀
httpGet: { path: /healthz/ready, port: 3000 }
failureThreshold: 30
periodSeconds: 2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"] # 等端点传播,避免继续收新流量
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-api
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: order-api }
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }要点:
- 探针三分工:
startupProbe保护慢启动,readinessProbe控制是否接流量,livenessProbe只在真正卡死时重启——把 liveness 配成"依赖 DB 失败就重启",会在数据库抖动时把全部 Pod 重启一遍,把小故障放大成全站不可用; - 资源 requests 必填:没有 requests,调度器无据可依,HPA 的利用率也没有分母;
- 镜像 tag 固定:
latest会让"当前跑的是什么版本"变成玄学,回滚也无法进行。
六、优雅退出:实测对比
容器被删除时,K8s 的流程是:发 SIGTERM → 等待 terminationGracePeriodSeconds → 超时发 SIGKILL。应用若不处理 SIGTERM,进行中的请求会被直接掐断。
Node v22.22.1 实测(服务有一个耗时 1000ms 的接口,请求发出 300ms 后发送 SIGTERM):
===== 【默认退出】不注册任何信号处理器 =====
+ 603ms [test] 发送 SIGTERM(慢请求尚未返回)
+ 616ms [client] 慢请求中断: TypeError UND_ERR_SOCKET ← 客户端直接报错
+ 616ms [child] 进程退出,code=null ← 被信号杀死
===== 【优雅退出】注册 SIGTERM + server.close() =====
+1176ms [test] 发送 SIGTERM(慢请求尚未返回)
+1176ms [child] SIGTERM received → stop accepting new connections
+1890ms [client] 慢请求正常返回: done ← 进行中的请求被保住
+4894ms [child] all connections drained → exit 0
+4901ms [child] 进程退出,code=0const server = app.listen(PORT)
process.on('SIGTERM', () => {
console.log('SIGTERM received → stop accepting new connections')
server.close(() => { // 停止接收新连接,等待现存请求结束
console.log('all connections drained → exit 0')
process.exit(0)
})
server.closeIdleConnections?.() // 关键:否则 keep-alive 空闲连接会拖到最后
setTimeout(() => process.exit(1), 5000) // 兜底:超过宽限期立即退出,避免被 SIGKILL
})实测里有个容易被忽略的细节:请求在 +1890ms 就返回了,进程却拖到 +4894ms 才退出——因为 fetch 默认 keep-alive,连接空闲后仍占着,server.close() 会一直等。生产必须处理掉这些空闲连接(Node 18+ 的 closeIdleConnections(),或设置 server.keepAliveTimeout),否则每次发布都要耗满宽限期。
配套的 K8s 侧配置:
terminationGracePeriodSeconds要大于"最长请求耗时 + 排空时间";preStop: sleep 5:K8s 发 SIGTERM 与端点从 Service 摘除是并行的,sleep 一下让 kube-proxy/Ingress 完成规则下发,避免 Pod 已停止接收、上游还在把新请求转过来(这正是"发布时偶发 502"的经典成因)。
七、配置与密钥
| 原则 | 做法 |
|---|---|
| 配置外置 | ConfigMap / Secret / 配置中心,不进镜像(12-Factor) |
| 环境差异最小化 | 只区分少数环境,配置用同一套 key,避免"预发独有配置" |
| 密钥轮换 | 支持不重启生效(挂载卷会自动更新),并有轮换演练 |
| 禁止入 Git | Secret 用 Sealed Secrets / External Secrets / 云厂商 KMS,CI 有扫描门禁 |
| 最小权限 | 容器只读文件系统、runAsNonRoot、按需授予 RBAC |
八、发布策略怎么选
| 策略 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 重建 | 先停舊再起新 | 最简单 | 有停机 |
| 滚动更新 | 逐批替换 | 无停机、资源友好 | 回滚较慢,新旧版本短暂共存 |
| 蓝绿 | 两套环境整体切换 | 秒级回滚、验证充分 | 需要双倍资源 |
| 金丝雀 | 先放 5% 流量,看指标再放量 | 风险最小 | 需要流量切分与指标对比能力 |
| 影子流量 | 复制真实流量到新版本,不返回给用户 | 用真实流量验证,零用户影响 | 需要流量复制与写操作去重 |
选择依据:资源成本 vs 回滚速度 vs 验证强度。绝大多数业务从"滚动更新 + 就绪探针"起步即可;涉及资金、核心链路的服务再上金丝雀,并配置基于错误率/延迟的自动回滚。
九、CI/CD 流水线
提交 → 静态检查(lint/type) → 单元测试 → 构建镜像(打 commit sha 标签)
→ 镜像扫描(漏洞/依赖) → 推镜像仓库 → 部署到预发 → 冒烟/契约测试
→ 人工或自动卡点 → 灰度发布 → 指标观察 → 全量 / 自动回滚- 镜像 tag 用
git sha,让"线上跑的是哪个提交"可追溯; - 部署清单与代码同仓(GitOps 则单独仓库 + Argo CD 自动同步);
- 数据库迁移与代码发布解耦:先发布向后兼容的迁移,再发布代码,避免新旧版本同时对表结构有要求。
十、检查清单
- 镜像多阶段构建,只含运行时依赖,非 root 运行;
CMD使用 exec 形式,应用是 PID 1,能收到 SIGTERM;- 应用处理 SIGTERM:停止接收新请求 → 排空进行中请求 → 关闭空闲连接 → 退出;
terminationGracePeriodSeconds与preStop已配置,且大于最长请求耗时;- 三类探针按职责分开配置,
livenessProbe不依赖外部组件; - 资源
requests与limits均已设置,HPA 有明确指标; - 配置走 ConfigMap/Secret,密钥不进镜像、不进 Git;
- 镜像 tag 固定为版本或 commit sha,禁用
latest; - 有健康检查端点(
/healthz/live、/healthz/ready),且就绪状态真实反映依赖; - 发布有灰度与自动回滚机制,数据库变更向后兼容。
- 镜像有漏洞扫描,基础镜像定期更新。
十一、常见坑速查
- 用
CMD npm start导致收不到信号:npm 作为 PID 1 不转发 SIGTERM,优雅退出形同虚设; - 不处理 SIGTERM:发布时用户请求被掐断,表现为"每次发版都有一波 502"(实测见 §六);
preStop缺失:Pod 已开始关闭,Ingress 仍在转发新请求 → 发布期 502;- 宽限期太短:
terminationGracePeriodSeconds小于排空耗时,请求照样被 SIGKILL; - keep-alive 空闲连接不清理:进程迟迟不退出,拖满宽限期才被强杀(实测见 §六);
- livenessProbe 依赖下游:DB 抖动 → 所有 Pod 被判定不健康 → 集体重启,故障放大;
- 没设 resources:调度无依据,节点资源紧张时 Pod 被 OOMKilled 且难以排查;
- 镜像用
latest:无法回滚,也无法确认线上版本; - Secret 当加密用:base64 可一键还原,必须配合加密存储与 RBAC 管控;
- 配置打进镜像:改一个参数要走完整构建发布流程,且不同环境镜像不一致;
- 滚动更新时
maxUnavailable过大:高峰期发布直接掉容量; - 数据库迁移与代码同批发布:新旧版本对表结构要求冲突 → 发布即故障。
状态与参考
- 状态:已收录(2026-09-02,由后端领域规划清单「容器与部署|Docker、Kubernetes 基础」新建)。
- 版本:优雅退出为 Node v22.22.1 在本机实测(输出见 §六,可直接复现);Dockerfile、Compose 与 K8s 清单为标准写法——本机装有 Docker CLI 与 kubectl v1.27.2 客户端,但 Docker daemon 未运行、无可用集群,容器与集群部分未做实机验证,请以运行环境实测为准。
- 参考:Dockerfile 最佳实践、Kubernetes 文档、K8s Pod 生命周期与优雅退出、配置存活/就绪/启动探针、12-Factor App。
- 阅读联动:发布策略与灰度配合的熔断限流见 微服务;多副本下的会话与缓存见 缓存;容量与副本数估算见 系统设计。
下一步
- [ ] 启动 Docker daemon,实测多阶段构建前后的镜像体积差异并补入本文
- [ ] 用 kind/minikube 起一个本地集群,跑通本文的 Deployment + HPA 清单
- [ ] 写一篇"数据库迁移与滚动发布如何协同"的实操笔记(含向后兼容的迁移三段式)
写作规范请参阅领域概览。