Skip to content

容器化与多副本

容器里的后端直接跑生产环境(ASPNETCORE_ENVIRONMENT=Production),开发期 dotnet run 默默替你兜底的那几项,到这里全要求显式给出。所以一条 docker compose up,等于把上线首启预演了一遍。

这套 compose 是给谁用的

仓库根的 Dockerfile 是从源码构建示例宿主 MinimalHost,给内核自己的 CI 用。你要是 NuGet 消费方,那用另一份。dotnet new smart-app 生成的目录里已经带了一份 Dockerfile,它从 NuGet 装内核、构建你自己的 host,直接用就行,下面的步骤照样适用。

前端也一样。web/Dockerfile 从源码构建本仓库的 web/ 工作区,内核包和模板一起编。degit 出来的模板不带容器文件,照它的两段自己写一份:node 阶段 npm cinpm run build,Caddy 阶段托管 dist 并套用 web/Caddyfile

起全栈

先复制环境变量样板并填好三项必填值,docker-compose.yml 里这三项没有默认值,缺一项 docker compose up 会在解析阶段直接报错,不会起任何容器:

bash
cp .env.example .env
# 编辑 .env,填好 SMART_JWT_SECRET、SMART_DB_PASSWORD、SMART_ADMIN_PASSWORD 三项
docker compose up -d --build

这一条命令拉起四个服务,定义在仓库根 docker-compose.yml 里:db(MySQL 8.0)、redis(Redis 7)、app(后端)、web(Caddy,托管前端静态产物并反代 /api)。起来后:

bash
open http://localhost:8080                 # 前端
curl http://127.0.0.1:8081/health/ready    # 后端调试口,只绑回环,原因见下文
docker compose logs app                    # 首启信息

app 跑的是 ASPNETCORE_ENVIRONMENT=Production,这是刻意的。生产有三道硬门槛。JWT 密钥必须显式给,不给就落进开发密钥模式,每个副本各签各的,随机 401。空库首次上生产必须显式允许建表,因为内核默认不自动 ALTER 生产库。上传根必须挪出 wwwrootdocker-compose.yml 把这三项都写成了环境变量,照抄改成你自己的值就行。少配一条会怎样?你会拿到一条点名到配置项的启动错误,读得懂。这比「进程照常起、直到第一次写库才炸在驱动层」好排查得多。这三道门槛,连同升级时的建表、补列细节,部署概览里讲全。

首次登录的超管账号是 superAdmin,密码就是你在 .env 里填的 SMART_ADMIN_PASSWORD。compose 里这一行没有默认值:

yaml
SmartAdmin__Seed__AdminPassword: ${SMART_ADMIN_PASSWORD:?请先按 .env.example 建一份 .env,或在环境里给出 SMART_ADMIN_PASSWORD}

:? 是必填校验写法:变量没设或是空串,直接拒绝启动并报出冒号后面的提示,不会像 :- 那样垫一个默认密码。零配置随机密码那条路径在这套 compose 里走不通,要用它就换成本地 dotnet run(见快速开始)。

.env 装的是真实密钥,不能进版本库

.env 已被 .gitignore 排除,从 .env.example 复制出来的这份不会进版本库。三个必填值里,SMART_JWT_SECRET 换掉等于让所有已签发的令牌当场失效(全员重新登录);SMART_DB_PASSWORD 首次 up 时写进数据卷,之后再改这里不会同步改库里的密码,要改先 docker compose down -v(会删数据)。正式部署用部署平台的密钥管理也一样可以,不必非用 .env 文件。

前端那个 web 服务跑的是 Caddy。把 web/Caddyfile 的站点标签从 :8080 换成你的域名,再删掉 auto_https off,它就会自动申请并续期 Let's Encrypt 证书,自托管省掉整套 TLS 手工活。想用 nginx 也行,把 web/Dockerfile 的运行阶段换成 nginx:alpine,配置用仓库里的 web/nginx.conf。完整的 nginx、Caddy 反代配置在路线 B:反向代理

容器化里几个不写出来就会踩的点

为什么
具名卷,不要 bind mount镜像里跑的是非 root 用户。具名卷首次挂载会从镜像目录继承属主,容器写得进去;bind mount 会用宿主属主覆盖,应用直接写不了 SQLite / 上传目录。docker-compose.ymlapp-dataupload-data 都是具名卷。
镜像里没有 HEALTHCHECKaspnet 运行时镜像既没有 curl 也没有 wget,写了健康检查指令只会恒失败。健康检查交给编排层探 /health(存活)与 /health/ready(DB + 缓存)。
.dockerignore 是安全项开发机的 data/ 里可能躺着真实的 SmartAdmin.db 和开发期自动生成的 JWT 签名密钥(dev-jwt.key)。仓库根的 .dockerignore 把它排除掉。没有它,一次 COPY . . 就能把签名密钥烤进镜像层,镜像一推,谁都能伪造超管令牌。
多副本改 WorkerId每实例 0–63 必须各不相同,否则同毫秒发号撞主键;配了 Redis 却没显式给还会当场拒绝启动。详见下面「多副本与 WorkerId」。
多副本共享 DataProtection:Key不显式配置时各副本各生成一把进程内临时密钥,互不相认;TOTP 种子、AI 厂商 API Key 等经 ISecretProtector 加密的信封换个副本就读不出来。详见下面「多副本必须共享同一把 DataProtection:Key」。

多副本与 WorkerId

起第二个副本之前,下面几条一条都不能少。少了大多不会报错,只会开始悄悄做错事。唯一会当场拦你的是 WorkerId。仓库里有现成的双副本叠加层,也是 CI 里真跑的那套:

bash
docker compose -f docker-compose.yml -f docker-compose.scale.yml up -d --build
bash scripts/smoke-multi-replica.sh http://localhost:8080   # 逐条验证下面这些保证

docker-compose.scale.yml 加了一个显式的 app2 服务,没用 docker compose --scale。为什么?--scale 给不了每个副本各自独立的环境变量。而下面「每个副本一个不同的 WorkerId」这条,恰恰要求每个副本都不同。

缓存换成 Redis,这是前提不是优化

单副本留 Memory 能跑,那是进程内缓存。可进程内缓存意味着副本 A 上的失效永远传不到副本 B。后果不是「慢一点」,是安全功能直接失灵,而且失灵窗口是天级:

表现细节
强制下线失灵(最严重)会话缓存的 TTL 是刷新令牌寿命(天级)。A 上强退,DB 随即写了吊销、A 也清了自己的内存,可B 的那份还在,继续判定「活跃」,于是经负载均衡时约一半请求照常放行,一放就是好几天。
撤权后仍有权限权限 / 数据范围缓存默认 20 分钟。被撤权的人在另一副本上照旧有权限;数据范围缓存还喂着 SqlSugar 全局过滤器,于是他继续看得见别的机构的数据
锁定 / 限流阈值翻倍登录失败计数、限流计数各副本各数各的:MaxFailCount=5 两副本就成了 10,认证桶 20/min 成了 40/min。
验证码必失败一次性票据发在 A、验在 B,B 上没有这个键。

配上 SmartAdmin:Cache:Provider=Redis + Cache:RedisConnectionString,以上全部自动修好。失效走的是共享缓存键空间,不是事件总线,业务代码零改动。

每个副本一个不同的 WorkerId

雪花发号器的机器位来自 SmartAdmin:Id:WorkerId,取值 0–63。不配时内核用文件锁在本机抢号,可容器各有各的文件系统,两个副本都会抢到 0。同一毫秒各自发号就会撞主键,这是数据损坏级的事故。内核有两道兜底检查。第一道在启动时:配了 Cache:Provider=Redis,那是明显的多实例意图,却没有显式给出 WorkerId,启动就直接抛错,点名到 SmartAdmin:Id:WorkerId 和 0–63 范围。第二道在数据库:WorkerIdLeaseGuard 按机器号在 sys_worker_lease 表上抢租约并周期续租,同库另有一个活着的实例占着同一个号,后启动的那个当场抛错,不管有没有配 Redis。显式写 0 视为你知情,放行第一道检查;真正躲不开的是第二道——多副本必须各配一个不同的 SmartAdmin:Id:WorkerId

  • compose--scale app=2 给不了各副本不同的环境变量,所以拆成多个显式的 app 服务各配各的。docker-compose.scale.ymlapp2 就显式给了 SmartAdmin__Id__WorkerId: "1",与 app0 不同。
  • k8s:用 StatefulSet,从 Pod 名字的序号注入,比如 app-0app-1。Deployment 的随机 Pod 名给不了稳定序号。

多副本必须共享同一把 DataProtection:Key

不显式配置 SmartAdmin:Security:DataProtection:Key,内核在生产环境要么直接拒绝启动(开了 TOTP 或 Cookie 会话时),要么退回一把进程内临时密钥,不落盘,重启或者换一个副本就是另一把。TOTP 种子、AI 厂商 API Key,凡经 ISecretProtector 加密落库的东西,写在副本 A、读在副本 B 就是两把不同的密钥,解密直接抛 CryptographicException。AI 厂商保存 Key 时会查这把密钥是否临时,是就拒绝落库(49030),但这道守卫只防得住「自己重启后读不回自己」,防不住「每个副本各配各的」。

显式配一把主密钥,所有副本共用同一个值:

bash
openssl rand -base64 32
yaml
SmartAdmin__Security__DataProtection__Key: "上面命令的输出,每个副本原样粘贴同一份"

反代之后必须配 ForwardedHeaders

app 服务已经配了:

yaml
SmartAdmin__Api__ForwardedHeaders__Enabled: "true"
SmartAdmin__Api__ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12

这套 ForwardedHeaders 配置和路线 B:反向代理是同一件事,只是受信来源换成了 Docker 桥接网段。为什么要配、不配会有什么后果,那页已经讲透,这里不重复。多副本要每个副本都配,否则大家看到的都只是负载均衡器那一个 IP。app 的端口映射因此只绑 127.0.0.1,理由同样在那页:

yaml
ports:
  - "127.0.0.1:${SMART_API_PORT:-8081}:8080"

0.0.0.0 会把伪造 IP、绕过限流的能力暴露给整个局域网。正常访问都走前面的 Caddy,它已经反代了 /api/health。生产环境更进一步,建议直接去掉这个端口映射,只留反代入口。

冷启动先起一个副本

CodeFirst 建表加写种子是「检查后插入」,不是原子操作。两个副本同时首启,会有一个撞唯一键崩掉。compose 里给 app2 加了 depends_on: app: condition: service_healthy,等第一个副本把表和种子都写完,再启动第二个,零代码解决。k8s 上换个做法,用 init job 或 migration job 先把库建好,再放开副本。

上传目录必须是共享可写卷

LocalFileStorageChunkStorage 写的是本地盘。compose 里两个副本共享同一个 upload-data 具名卷,天然没问题。可 k8s 上就不一定了。如果每个 Pod 用独立 PVC,A 传的文件在 B 上会直接 404。分片上传更是必然 ChunkMissing,因为分片散落在不同 Pod,合并必然缺片。多副本要么给上传根挂一个 RWX(ReadWriteMany)共享卷,要么前置替换 IFileStorage,改成对象存储,比如 S3、OSS。

不想上容器的话,部署概览还给了单体、反向代理、真跨源三条托管路线,上线后的健康检查与自检清单也在那里。

基于 Apache License 2.0 开源