打包部署与可观测(Docker + CI/CD)
这一课你将能做到
- 写出多阶段 Dockerfile,把 API 与判题 Worker 打成小体积镜像,并写一键启动全平台的
docker-compose.yml
- 搭 GitHub Actions 流水线:构建 → 测试 → 推镜像 → 部署
- 配好结构化日志(ILogger/Serilog)、健康检查与 Prometheus 指标,对照 actuator/Logback 完成可观测三件套映射
为什么要学
代码写完只算 70 分,剩下 30 分在「能可靠地跑在生产并被人看懂」。Spring Boot 的肌肉记忆是 fat jar + actuator + Logback + CI/CD;.NET 侧对应 dotnet publish + 健康检查中间件 + ILogger/Serilog + 同一套 CI/CD。差异最大的是镜像构建,原理相同但细节值得逐行看。
先建立图像
部署形态对照:
| 环节 | Spring Boot | ASP.NET Core |
|---|
| 产物 | Boot fat jar(内嵌 Tomcat) | dotnet publish 输出 + Kestrel |
| 运行时 | 宿主装 JDK 或 jlink 裁剪 | 基础镜像 aspnet:8.0 或 self-contained |
| 健康端点 | /actuator/health | /healthz(自己 Map) |
| 日志门面 | SLF4J + Logback | ILogger + Provider(Serilog/NLog) |
| 指标 | Micrometer + Prometheus | OpenTelemetry / prometheus-net |
| CI | GitHub Actions / Jenkins | 同(构建命令不同) |
跟着做
步骤 1:多阶段 Dockerfile(API 与 Worker 通用模板)
# 阶段 1:构建(SDK 镜像大,只用于编译)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.sln .
COPY Oj.Api/*.csproj Oj.Api/
COPY Oj.Worker/*.csproj Oj.Worker/
RUN dotnet restore # 先只拷 csproj:依赖层缓存,源码改动不触发重装包
COPY . .
RUN dotnet publish Oj.Api -c Release -o /app/publish /p:UseAppHost=false
# 阶段 2:运行(只带运行时,体积 ~100MB 级)
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
USER app # 非 root 运行,安全基线
ENTRYPOINT ["dotnet", "Oj.Api.dll"]
判题 Worker 同理,入口换 Oj.Worker.dll;需要编译器/沙箱(g++、isolate)就在运行阶段 apt-get install。
步骤 2:docker-compose 一键起全平台
services:
api:
build: .
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__Default=Host=db;Database=oj;Username=oj;Password=oj123 # 双下划线=层级配置
- ConnectionStrings__Redis=redis:6379
- RabbitMq__Host=rabbitmq
depends_on:
db: { condition: service_healthy }
ports: ["8080:8080"]
worker:
build:
context: .
dockerfile: Dockerfile.worker
environment:
- ConnectionStrings__Default=Host=db;Database=oj;Username=oj;Password=oj123
- RabbitMq__Host=rabbitmq
deploy:
replicas: 2 # 判题能力横向扩容就改这个数字
db:
image: postgres:16
environment:
- POSTGRES_DB=oj
- POSTGRES_USER=oj
- POSTGRES_PASSWORD=oj123
healthcheck:
test: ["CMD-SHELL", "pg_isready -U oj"]
interval: 5s
redis:
image: redis:7
rabbitmq:
image: rabbitmq:3-management
ports: ["15672:15672"] # 管理界面
环境变量 ConnectionStrings__Default 覆盖 appsettings.json 同名层级——容器化配置注入的标准姿势,等价 Spring 的 SPRING_DATASOURCE_URL 宽松绑定。
步骤 3:GitHub Actions 流水线
name: ci
on:
push: { branches: [main] }
jobs:
build-test-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: "8.0.x" }
- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build --collect:"XPlat Code Coverage"
- name: Build & push image
run: |
docker build -t ${{ secrets.REGISTRY }}/oj-api:${{ github.sha }} .
docker push ${{ secrets.REGISTRY }}/oj-api:${{ github.sha }}
- name: Deploy
run: |
kubectl set image deploy/oj-api oj-api=${{ secrets.REGISTRY }}/oj-api:${{ github.sha }}
kubectl rollout status deploy/oj-api --timeout=120s
对比 Spring 项目:只有构建命令从 ./mvnw package 换成 dotnet publish,流水线骨架(构建→测试→镜像→部署→健康确认)一致。
步骤 4:结构化日志(ILogger 惯例 + Serilog 落 JSON)
builder.Logging.ClearProviders();
builder.Services.AddSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter())); // 生产 JSON 行 → Loki/ELK 直接吃
// 用模板占位而非字符串插值:结构化字段,可在日志平台按 submissionId 检索
logger.LogInformation("提交 {SubmissionId} 判题完成,结果 {Verdict},耗时 {ElapsedMs}ms",
submissionId, verdict, sw.ElapsedMilliseconds);
这与 SLF4J 的 log.info("... {}", arg) 惯例同源:占位符保住结构,插值($"...")会破坏它。MDC 对应物是 LogContext.PushProperty("UserId", id)。
步骤 5:指标与健康检查落地
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore
dotnet add package AspNetCore.HealthChecks.NpgSql
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddAspNetCoreInstrumentation() // 内置 HTTP QPS/耗时
.AddRuntimeInstrumentation() // GC/线程池(对应 JVM metrics)
.AddPrometheusExporter());
app.MapPrometheusScrapingEndpoint("/metrics");
app.MapHealthChecks("/healthz");
livenessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 10 }
readinessProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 5 }
resources: { requests: { cpu: 250m, memory: 256Mi }, limits: { cpu: "1", memory: 512Mi } }
步骤 6:发布策略(滚动更新验证)
dotnet publish -c Release -r linux-x64 --self-contained false # 依赖框架发布
kubectl rollout restart deploy/oj-api # 触发滚动替换
kubectl rollout undo deploy/oj-api # 一条命令回滚
就绪探针未就绪的 Pod 不会接流量,配合 EF 迁移在启动任务(Job)里执行,实现零停机发布。
讲清楚要点
- 多阶段构建的经济学:SDK 镜像 ~700MB 只存在于构建期;运行镜像只含运行时与产物。依赖层缓存(先 COPY csproj 再 restore)让改代码的镜像构建从分钟级降到秒级。
- 配置分层优先级:命令行 > 环境变量 > appsettings.{Env}.json > appsettings.json。容器里靠环境变量改行为;敏感信息进 K8s Secret。
- 日志三原则:结构化(模板占位)、带上下文(traceId/userId 属性)、分级克制(Information 业务事件、Warning 可自愈、Error 需人工)。日志是给机器检索的,不是给人翻的。
- 可观测三支柱在 .NET 的落位:日志(Serilog → Loki/ELK)、指标(OpenTelemetry → Prometheus)、追踪(OTel trace → Jaeger),与 Java 侧 Micrometer + SLF4J 逐项对应。
你可能会懵的地方
-
self-contained 和 framework-dependent 怎么选?
前者把运行时打进产物(80MB+,宿主零依赖);后者小但要镜像版本匹配。容器化部署一律 framework-dependent + 官方 aspnet:8.0 镜像。
-
镜像为什么不用 alpine?
aspnet:8.0-alpine 存在,但 glibc/musl 差异会让依赖原生库的包出问题;判题 Worker 还要装编译器与沙箱工具,用 Debian 系(默认 aspnet:8.0)更省心。
-
EF 迁移什么时候执行?启动时自动跑吗?
生产惯例:迁移作为独立 Job 在部署管线执行,应用启动只校验不改动;启动时自动 Migrate() 多副本会竞争迁移锁,仅适合单人小项目。
-
GC/线程池要不要调参?
容器默认值就不错;高吞吐场景用 DOTNET_GCHeapHardLimit 限制堆避免 OOMKilled。对应 JVM 的 -Xmx 调优,但默认更省心。
-
证书/TLS 在容器里怎么处理?
Kestrel 容器内通常只听 HTTP,TLS 交给 ingress/Nginx 终结。需要端到端加密时挂载证书并配置 Kestrel__Certificates__* 环境变量。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 单阶段 Dockerfile(SDK 当运行镜像) | 镜像 700MB+,攻击面大 | 多阶段,运行阶段用 aspnet:8.0 |
| 镜像里打 appsettings.Production.json 密码 | 密钥进仓库/进镜像层 | 环境变量/K8s Secret 注入 |
| 容器里跑 root | 安全基线违规 | Dockerfile 末尾 USER 非 root 账户 |
日志用 $"提交{ id}完成" 插值 | 结构丢失,无法按字段检索 | 模板占位 logger.LogInformation("提交 {Id}...", id) |
| 就绪探针指向依赖重启中的外部服务 | 依赖抖动导致全量重启 | 区分 liveness(进程)/readiness(依赖),依赖检查独立降级 |
卡点急救
- 容器时区不对(日志时间差 8 小时):设置
TZ=Asia/Shanghai 并在镜像里 apt-get install tzdata;或统一 UTC。
dotnet restore 每次都全量拉包:Dockerfile 未按「先 csproj 后源码」分层,或 .dockerignore 缺失把 bin/obj 拷进去。
- OOMKilled(exit 137):内存限额低于实际需求。设
DOTNET_GCHeapHardLimit(十六进制字节数)略低于 limit,或调高 limit。
面试常问
-
多阶段 Dockerfile 解决什么问题?.NET 侧关键细节?
分离构建环境与运行环境:SDK 层编译,运行层只拷贝产物,体积与攻击面骤降。关键细节:先 COPY csproj 再 restore 利用层缓存;/p:UseAppHost=false 免去生成可执行文件;USER 非 root;ASPNETCORE_URLS 统一监听端口。
-
零停机发布的完整链路?
构建镜像 → 迁移 Job 先行(向后兼容)→ 滚动替换 Pod(readiness 拦截未就绪实例)→ rollout status 验证 → 异常 rollout undo 回滚。前提:接口向后兼容,删字段分两次发布——与 Spring 滚动发布约束一致。
-
生产日志怎么设计才能高效排障?
结构化 JSON + traceId 贯穿全链路 + 统一采集(Loki/ELK);代码纪律是模板占位、分级克制、敏感信息脱敏。排障路径:traceId 串链路 → 指标定时段 → 堆栈定代码。
自测三问
- 为什么 Dockerfile 先 COPY csproj 再 COPY 源码?
- liveness 和 readiness 探针失败分别触发什么?
ConnectionStrings__Default 里的双下划线是什么机制?
小结一句话
部署 = 多阶段镜像 + 环境变量配置注入 + CI/CD 一条龙;可观测 = ILogger 结构化日志 + OpenTelemetry 指标 + 健康探针——与 Spring Boot 的 fat jar/actuator/Micrometer 逐项同构,换的是命令与包名。
延伸