Skip to content

容器与部署

容器的价值不是"能跑起来",而是把运行环境变成一份可版本化、可复制、可回滚的制品。本文从容器与虚拟机的本质差异讲起,落到 Dockerfile 的分层与瘦身、Kubernetes 的声明式对象模型,最后重点讲两个真正决定线上体感的细节:优雅退出发布策略

一、容器到底是什么

虚拟机容器
隔离层Hypervisor 虚拟硬件 + 独立内核共享宿主机内核,靠 Linux 内核机制隔离
启动分钟级秒级
体积GB 级(含完整 OS)MB 级(只含应用与依赖)
开销每个 VM 一份 OS共享内核,密度高
隔离强度强(内核级)较弱(内核共享),需额外加固

容器依赖三个内核能力:

  • Namespace:隔离视图——PID(进程树)、NET(网络栈)、MNT(挂载点)、UTS(主机名)、IPC、USER(用户/组映射);
  • Cgroup:限制资源——CPU 配额、内存上限、IO 权重,这是 resources.limits 的落地机制;
  • UnionFS:镜像分层——每一层只读,容器启动时叠加一个可写层,多个容器共享基础层。

镜像分层直接决定了构建优化方向:变化频率低的层放前面,变化频率高的放后面,中间任何一层变了,它之后的层缓存全部失效。

二、Dockerfile:写得对,构建快十倍

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

yaml
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

yaml
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):

text
===== 【默认退出】不注册任何信号处理器 =====
+ 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=0
js
const 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,避免"预发独有配置"
密钥轮换支持不重启生效(挂载卷会自动更新),并有轮换演练
禁止入 GitSecret 用 Sealed Secrets / External Secrets / 云厂商 KMS,CI 有扫描门禁
最小权限容器只读文件系统、runAsNonRoot、按需授予 RBAC

八、发布策略怎么选

策略做法优点代价
重建先停舊再起新最简单有停机
滚动更新逐批替换无停机、资源友好回滚较慢,新旧版本短暂共存
蓝绿两套环境整体切换秒级回滚、验证充分需要双倍资源
金丝雀先放 5% 流量,看指标再放量风险最小需要流量切分与指标对比能力
影子流量复制真实流量到新版本,不返回给用户用真实流量验证,零用户影响需要流量复制与写操作去重

选择依据:资源成本 vs 回滚速度 vs 验证强度。绝大多数业务从"滚动更新 + 就绪探针"起步即可;涉及资金、核心链路的服务再上金丝雀,并配置基于错误率/延迟的自动回滚。

九、CI/CD 流水线

text
提交 → 静态检查(lint/type) → 单元测试 → 构建镜像(打 commit sha 标签)
     → 镜像扫描(漏洞/依赖) → 推镜像仓库 → 部署到预发 → 冒烟/契约测试
     → 人工或自动卡点 → 灰度发布 → 指标观察 → 全量 / 自动回滚
  • 镜像 tag 用 git sha,让"线上跑的是哪个提交"可追溯;
  • 部署清单与代码同仓(GitOps 则单独仓库 + Argo CD 自动同步);
  • 数据库迁移与代码发布解耦:先发布向后兼容的迁移,再发布代码,避免新旧版本同时对表结构有要求。

十、检查清单

  1. 镜像多阶段构建,只含运行时依赖,非 root 运行;
  2. CMD 使用 exec 形式,应用是 PID 1,能收到 SIGTERM;
  3. 应用处理 SIGTERM:停止接收新请求 → 排空进行中请求 → 关闭空闲连接 → 退出;
  4. terminationGracePeriodSecondspreStop 已配置,且大于最长请求耗时;
  5. 三类探针按职责分开配置,livenessProbe 不依赖外部组件;
  6. 资源 requestslimits 均已设置,HPA 有明确指标;
  7. 配置走 ConfigMap/Secret,密钥不进镜像、不进 Git;
  8. 镜像 tag 固定为版本或 commit sha,禁用 latest
  9. 有健康检查端点(/healthz/live/healthz/ready),且就绪状态真实反映依赖;
  10. 发布有灰度与自动回滚机制,数据库变更向后兼容。
  11. 镜像有漏洞扫描,基础镜像定期更新。

十一、常见坑速查

  1. CMD npm start 导致收不到信号:npm 作为 PID 1 不转发 SIGTERM,优雅退出形同虚设;
  2. 不处理 SIGTERM:发布时用户请求被掐断,表现为"每次发版都有一波 502"(实测见 §六);
  3. preStop 缺失:Pod 已开始关闭,Ingress 仍在转发新请求 → 发布期 502;
  4. 宽限期太短terminationGracePeriodSeconds 小于排空耗时,请求照样被 SIGKILL;
  5. keep-alive 空闲连接不清理:进程迟迟不退出,拖满宽限期才被强杀(实测见 §六);
  6. livenessProbe 依赖下游:DB 抖动 → 所有 Pod 被判定不健康 → 集体重启,故障放大;
  7. 没设 resources:调度无依据,节点资源紧张时 Pod 被 OOMKilled 且难以排查;
  8. 镜像用 latest:无法回滚,也无法确认线上版本;
  9. Secret 当加密用:base64 可一键还原,必须配合加密存储与 RBAC 管控;
  10. 配置打进镜像:改一个参数要走完整构建发布流程,且不同环境镜像不一致;
  11. 滚动更新时 maxUnavailable 过大:高峰期发布直接掉容量;
  12. 数据库迁移与代码同批发布:新旧版本对表结构要求冲突 → 发布即故障。

状态与参考

  • 状态:已收录(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 清单
  • [ ] 写一篇"数据库迁移与滚动发布如何协同"的实操笔记(含向后兼容的迁移三段式)

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀