把 Scala 应用交付到 Kubernetes,难点往往不在业务代码,而在「JVM 与容器语义的错配」:堆内存按宿主机而非 limit 计算、探针把慢启动误判为死锁、SIGTERM 之后连接被硬切、日志散落在 stdout 之外。本文按「产物 → 镜像 → 运行时 → 编排 → 运维」的顺序,给出一条可直接落地的 Scala 云原生部署链路。
目录
- 1. 构建可部署产物
- 2. Docker 镜像最佳实践
- 3. JVM 容器感知与资源限制
- 4. Kubernetes 部署清单
- 5. 健康检查与优雅停机
- 6. 配置与密钥管理
- 7. 可观测性建设
- 8. 发布策略与回滚
- 9. 生产运维与成本优化
- 10. 速查表与一句话记忆
- 延伸阅读
1. 构建可部署产物
Scala 的产物形态决定了后续镜像与发布方式。fat jar 最通用,native image 启动最快,分层镜像兼顾缓存与体积,三者可以共存于同一条流水线,按服务的启动敏感度分别选用。
- sbt-assembly:单 jar 交付,核心难点是 META-INF 合并冲突与 reference.conf 合并
- sbt-native-packager:JavaAppPackaging 生成 bin/ 启动脚本 + lib/ 依赖,DockerPlugin 可直接出镜像
- GraalVM native image:毫秒级启动、低内存,但反射与资源需配置,构建耗时长
- 分层镜像:依赖与业务代码分层,代码变更只重建最上层,推送体积下降一个数量级
- 版本号:镜像 tag 使用 git sha 或语义化版本,禁止只用 latest
// project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.4")
// build.sbt:合并策略是 fat jar 成败的关键
ThisBuild / version := sys.env.getOrElse("GIT_SHA", "0.0.1-SNAPSHOT")
assembly / assemblyJarName := "scala-api.jar"
assembly / assemblyMergeStrategy := {
case PathList("META-INF", "services", xs @ _*) => MergeStrategy.concat
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case "reference.conf" => MergeStrategy.concat
case _ => MergeStrategy.first
}
工程要点:MergeStrategy.discard 会丢掉 SPI 注册文件,导致 JDBC 驱动、序列化框架在运行时找不到实现,所以 META-INF/services 必须用 concat;reference.conf 同理,多个 Akka/Pekko 模块的默认配置必须合并而非覆盖。若服务启动时间敏感(如 Serverless 场景),应优先考虑 native image 而非 fat jar。
2. Docker 镜像最佳实践
镜像既是交付物也是攻击面,目标很明确:体积小、层数少、非 root、可缓存。多阶段构建是达成这四点的基本盘。
- 多阶段构建:构建阶段用 JDK,运行阶段只留 JRE,镜像通常可省 300MB 以上
- 基础镜像:eclipse-temurin:21-jre 稳妥;alpine 更小但 musl 对 glibc 依赖不友好,慎用
- jlink 裁剪:只保留应用实际用到的模块,运行时镜像可压到 80MB 以内
- 非 root:创建普通用户后 USER 切换,降低容器逃逸后的权限
- 层缓存:先 COPY 依赖描述文件跑一次解析,再 COPY 源码,改代码不重下依赖
#--- 构建阶段 ---
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /src
COPY build.sbt ./
COPY project ./project
RUN sbt -batch update
COPY src ./src
RUN sbt -batch assembly
#--- 运行时阶段 ---
FROM eclipse-temurin:21-jre AS runtime
RUN groupadd -r app && useradd -r -g app -d /app app
WORKDIR /app
COPY --from=builder /src/target/scala-3.3.3/scala-api.jar /app/app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]
工程要点:COPY --from=builder 只搬运 jar,构建阶段的 JDK 与 sbt 缓存不会进入运行时镜像。若追求极致体积,可在 builder 阶段用 jlink --add-modules java.base,java.logging,java.naming,jdk.crypto.ec --strip-debug --no-header-files 生成定制 JRE,再把整个 JRE 目录 COPY 进 runtime 阶段。注意 COPY src ./src 之前必须完成依赖解析,否则任何源码改动都会让依赖层缓存失效。
3. JVM 容器感知与资源限制
JDK 10 之后默认开启 UseContainerSupport,但「默认正确」不等于「默认最优」,堆外内存与 CPU 配额仍需显式治理。
- UseContainerSupport:默认开启,JVM 读取 cgroup 内存上限而非宿主机物理内存
- MaxRAMPercentage:按 limit 的百分比设堆,比硬编码 Xmx 更适合弹性规格
- 堆外开销:Metaspace、直接内存、线程栈、JIT 代码缓存合计常占 20%~30%
- cgroup v2:Kubernetes 1.25+ 普遍启用,需 JDK 11.0.16+ 或 17.0.4+ 才正确识别
- CPU 限制:limit 过低会触发 CFS throttling,可用 ActiveProcessorCount 校正并行度
- GC 选择:堆小于 4G 且追求低延迟用 G1;大堆低延迟用 ZGC;单核小堆 Serial 反而更省
java \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+ExitOnOutOfMemoryError \
-XX:NativeMemoryTracking=summary \
-jar /app/app.jar
工程要点:requests 与 limits 的内存若设为相等,JVM 看到的 limit 就是稳定值,堆大小不会随节点负载漂移;反之若只设 requests,JVM 可能按节点总内存计算,最终被 OOMKilled。容器内 CPU 并行度默认取 limit,ForkJoinPool 与 Akka 调度器会据此初始化,limit 偏低时务必显式设置 ActiveProcessorCount,否则默认并行度会把 CPU 配额吃满并触发限流。
4. Kubernetes 部署清单
一份生产级 Deployment 至少要交代清楚副本数、资源、探针、优雅停机窗口与拓扑分布,再叠加 HPA 与 PDB 才算完整。
- resources:requests 决定调度,limits 决定上限,内存两者相等最稳
- 探针三件套:startup 保护慢启动,readiness 控制流量,liveness 负责重启
- terminationGracePeriodSeconds:必须大于应用排空连接所需时间
- topologySpreadConstraints:按 zone 打散,避免单可用区故障全量不可用
- PDB:配合节点排空,保证滚动维护时仍有可用副本
- HPA:基于 CPU 或自定义指标扩缩,需与 requests 联动才有意义
apiVersion: apps/v1
kind: Deployment
metadata:
name: scala-api
spec:
replicas: 3
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: scala-api
template:
metadata:
labels:
app: scala-api
spec:
terminationGracePeriodSeconds: 45
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: scala-api
containers:
- name: api
image: registry.example.com/scala-api:1.4.2
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "1Gi"
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75"
startupProbe:
httpGet: { path: /health/live, port: 8080 }
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
failureThreshold: 3
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: scala-api
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: scala-api }
minReplicas: 3
maxReplicas: 12
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 65 }
工程要点:CPU 的 limits 建议留空或设得较宽,仅用 requests 参与调度,避免 CFS 限流把延迟抖动放大到 P99;内存则必须设 limits,因为堆外内存无法被 GC 回收。HPA 的 averageUtilization 是相对 requests 计算的,requests 设得过小会让扩容阈值形同虚设。还需补一份 PodDisruptionBudget(minAvailable: 2),否则节点排空时 3 个副本可能被一次性驱逐干净。
5. 健康检查与优雅停机
三类探针的语义完全不同,混用是生产事故的高发区:把业务依赖检查塞进 liveness,会让数据库抖动直接演变成全量重启。
- liveness:进程是否还活着,失败即重启容器,绝不能检查下游依赖
- readiness:是否可以接收流量,失败仅从 Service Endpoints 摘除
- startup:启动是否完成,成功前不执行 liveness/readiness,专治慢启动
- 排空语义:收到 SIGTERM 后先置 ready=false,等待 Endpoint 摘除,再关闭监听
- 关闭顺序:停止接收新请求 → 排空在途请求 → 关闭连接池 → 退出进程
import cats.effect.*
import org.http4s.*
import org.http4s.dsl.io.*
// http4s 风格的探针路由:live 只证明进程存活,ready 反映真实可用性
def healthRoutes(readiness: Ref[IO, Boolean]): HttpRoutes[IO] = HttpRoutes.of[IO] {
case GET -> Root / "health" / "live" =>
Ok("ok")
case GET -> Root / "health" / "ready" =>
readiness.get.flatMap {
case true => Ok("ready")
case false => ServiceUnavailable("draining")
}
}
import akka.actor.CoordinatedShutdown
// Akka HTTP:先摘流量,再在解绑监听前完成连接排空
CoordinatedShutdown(system).addTask(
CoordinatedShutdown.PhaseBeforeServiceUnbind, "drain"
) { () => readyFlag.set(false); binding.flatMap(_.terminate(30.seconds)).map(_ => Done) }
工程要点:Kubernetes 在容器进入 Terminating 的同时就会从 Endpoints 摘除,但 kube-proxy 与 Ingress 的规则同步存在延迟,因此应用侧应额外等待 5~10 秒再真正关闭监听,否则会出现少量 502。preStop 钩子执行 sleep 8 是最省事的做法,但要确认它与 terminationGracePeriodSeconds 的总时长匹配。
6. 配置与密钥管理
十二要素应用要求配置与代码分离。Scala 生态常用 Typesafe Config / PureConfig 读取外部化配置,容器里则以环境变量与挂载文件两种方式注入。
- 优先级:命令行参数 > 环境变量 > 挂载配置文件 > 打包内默认值
- ConfigMap:非敏感配置,可整目录挂载,更新后文件同步但需应用自行 reload
- Secret:base64 编码并非加密,务必开启 etcd 静态加密与 RBAC 最小权限
- 外部化:数据库、下游地址全部走配置,镜像做到「一次构建,多环境运行」
- 版本化:配置纳入 Git 管理,变更走 PR 与灰度,而非直接 kubectl edit
apiVersion: v1
kind: ConfigMap
metadata:
name: scala-api-config
data:
application.conf: |
http.port = 8080
db.pool.size = 16
feature.new-pipeline = false
---
apiVersion: v1
kind: Secret
metadata:
name: scala-api-secret
type: Opaque
stringData:
db.password: change-me-in-vault
import pureconfig.*
import pureconfig.generic.derivation.default.*
case class DbConfig(url: String, user: String, password: String, poolSize: Int)
derives ConfigReader
// 启动即解析,失败直接终止,避免带着半截配置对外服务
val db: DbConfig = ConfigSource.default.at("db").loadOrThrow[DbConfig]
工程要点:ConfigMap 以 volume 方式挂载时,kubelet 会周期同步更新文件,但应用不会自动感知,需要监听文件变化或暴露 /actuator/refresh 类的重载端点;若用 envFrom 注入环境变量,则更新后必须重启 Pod 才生效。Secret 的生产实践是接入 Vault 或 External Secrets Operator,让密文只以内存态存在。
7. 可观测性建设
云原生环境里没有 SSH 可登,日志、指标、链路是唯一的排障入口,三者必须在镜像里就绪。
- 日志:JSON 结构化输出到 stdout,由采集器统一收走,禁止写文件
- 指标:Prometheus 端点暴露 JVM、HTTP、连接池与业务指标
- 链路:OpenTelemetry javaagent 零代码侵入,W3C traceparent 贯穿上下游
- 关联:日志中带上 traceId,实现「指标发现异常 → 链路定位 → 日志下钻」
- 基数控制:标签不要用用户 ID、请求 ID 等高基数字段
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:MaxRAMPercentage=75
-Xlog:gc*:stdout:time,uptime,level,tags
- name: OTEL_SERVICE_NAME
value: scala-api
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector:4317
工程要点:-Xlog:gc*:stdout 把 GC 日志并入容器标准输出,采集器可自动附加 pod 与 namespace 标签,省去单独挂载文件。OTel javaagent 需在 ENTRYPOINT 中以 -javaagent:/otel/opentelemetry-javaagent.jar 方式挂载,采样率在高峰期应下调到 10% 以内,避免 trace 后端被打爆。
8. 发布策略与回滚
发布策略的核心是「把爆炸半径控制在一小部分流量内」,同时保证数据库变更与代码版本解耦。
- 滚动更新:默认策略,maxUnavailable=0 配合 readiness 可做到零中断
- 蓝绿:两套完整环境切换 Service,回滚最快,但资源成本翻倍
- 金丝雀:按流量比例逐步放量,配合指标断言自动回滚
- 数据库兼容:expand-contract 三步走,先加列、双写、再删旧列
- 回滚前提:镜像 tag 不可变,配置可回退,迁移脚本必须向前兼容
- 版本可观测:请求头或日志中带上版本号,便于按版本切分指标
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: scala-api
spec:
replicas: 6
strategy:
canary:
steps:
- setWeight: 10
- pause: { duration: 5m }
- analysis: { templates: [{ templateName: error-rate }] }
- setWeight: 50
- pause: { duration: 10m }
工程要点:数据库迁移与代码发布必须解耦,绝不能让新代码依赖尚未执行的 DDL。回滚时如果旧版本无法识别新列,就说明迁移违反了 expand-contract 原则,只能前滚不能后退。金丝雀的自动分析要绑定明确的 SLI,例如 5xx 比例与 P99 延迟,否则「自动回滚」只是摆设。
9. 生产运维与成本优化
上线只是开始,真正的功夫在于把反复出现的故障模式固化成排查手册。
- 启动慢:多为 JIT 预热与大量反射扫描,可用 CDS/AppCDS 或 native image 改善
- OOMKilled:先看堆外内存与堆大小之和是否超过 limit,再看是否存在泄漏
- 连接泄漏:数据库与 HTTP 客户端连接池必须有上限与超时
- 混沌演练:定期注入 Pod 删除、网络分区、依赖超时,验证降级与自愈
- 成本优化:按实际用量调 requests、开启 HPA、低峰缩容到最小副本
- Serverless:Knative 需配合 scale-to-zero,冷启动敏感的 Scala 服务更适配 native image
| 症状 | 常见根因 | 对策 |
|---|---|---|
| Pod 反复 OOMKilled | 堆外内存挤占,堆接近 limit | 降 MaxRAMPercentage 至 70,内存 requests 与 limits 相等 |
| P99 延迟毛刺 | CPU limits 触发 CFS 限流 | 移除 CPU limits,仅保留 requests |
| 滚动更新期间 502 | 摘除 Endpoint 与关闭监听存在竞态 | preStop 加 sleep,应用侧延迟关闭监听 |
| 启动被 liveness 杀死 | 探针未区分启动与运行 | 引入 startupProbe,failureThreshold 放宽 |
| 日志查不到 traceId | 未注入 MDC 或采样率为零 | OTel agent 挂载正确,采样率大于零 |
| 扩缩容不生效 | HPA 阈值相对 requests 计算 | 校准 requests,接入自定义指标 |
工程要点:成本优化的第一步是把 requests 从「拍脑袋」改成基于历史用量的 P95;第二步是让 HPA 覆盖日常峰谷;第三步才考虑混部与抢占式实例。省下的钱如果换来频繁 OOM 与延迟抖动,就是负收益。
10. 速查表与一句话记忆
| 场景 | 关键动作 |
|---|---|
| 打包产物 | sbt-assembly 合并 reference.conf,native image 用于启动敏感场景 |
| 镜像瘦身 | 多阶段构建 + jlink 裁剪 + 非 root 用户 + .dockerignore |
| 内存设置 | requests 等于 limits,MaxRAMPercentage 取 70~75 |
| CPU 设置 | 只设 requests 不设 limits,必要时显式 ActiveProcessorCount |
| 探针 | startup 保护慢启动,liveness 不查依赖,readiness 反映真实可用性 |
| 优雅停机 | 先置 ready=false,等摘除,再排空,最后退出 |
| 配置密钥 | 外部化 + 强类型 fail-fast,Secret 接入 Vault |
| 可观测性 | JSON 日志到 stdout,Prometheus 指标,OTel 链路,GC 日志并入 stdout |
| 发布回滚 | 滚动更新零中断,金丝雀绑定 SLI,迁移走 expand-contract |
| 故障排查 | OOMKilled 查堆外,毛刺查限流,502 查停机时序 |
一句话记忆:让 JVM 读懂 cgroup,让探针分清死活,让停机先摘流量再关连接——这三件事做对,Scala 服务的云原生部署就成功了大半。
延伸阅读
- GraalVM native image 与 Scala Native,启动优化与 Serverless 部署
- sbt 构建、打包插件与 CI 流水线组织
- 结构化日志、指标与分布式链路追踪
- 外部化配置、多环境与特性开关
- 微服务拆分、服务治理与部署实践
- JVM 内存模型、GC 调优与性能剖析
- Akka 集群、分片与协调关闭
- 测试策略与契约测试,保障发布质量
- Kubernetes 专题 — 编排、控制器与运行时安全
- Docker 专题 — 镜像构建、网络与存储
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。