容器化与多副本
容器里的后端直接跑生产环境(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 ci 加 npm run build,Caddy 阶段托管 dist 并套用 web/Caddyfile。
起全栈
先复制环境变量样板并填好三项必填值,docker-compose.yml 里这三项没有默认值,缺一项 docker compose up 会在解析阶段直接报错,不会起任何容器:
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)。起来后:
open http://localhost:8080 # 前端
curl http://127.0.0.1:8081/health/ready # 后端调试口,只绑回环,原因见下文
docker compose logs app # 首启信息app 跑的是 ASPNETCORE_ENVIRONMENT=Production,这是刻意的。生产有三道硬门槛。JWT 密钥必须显式给,不给就落进开发密钥模式,每个副本各签各的,随机 401。空库首次上生产必须显式允许建表,因为内核默认不自动 ALTER 生产库。上传根必须挪出 wwwroot。docker-compose.yml 把这三项都写成了环境变量,照抄改成你自己的值就行。少配一条会怎样?你会拿到一条点名到配置项的启动错误,读得懂。这比「进程照常起、直到第一次写库才炸在驱动层」好排查得多。这三道门槛,连同升级时的建表、补列细节,部署概览里讲全。
首次登录的超管账号是 superAdmin,密码就是你在 .env 里填的 SMART_ADMIN_PASSWORD。compose 里这一行没有默认值:
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.yml 里 app-data、upload-data 都是具名卷。 |
镜像里没有 HEALTHCHECK | aspnet 运行时镜像既没有 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 里真跑的那套:
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.yml里app2就显式给了SmartAdmin__Id__WorkerId: "1",与app的0不同。 - k8s:用 StatefulSet,从 Pod 名字的序号注入,比如
app-0、app-1。Deployment 的随机 Pod 名给不了稳定序号。
多副本必须共享同一把 DataProtection:Key
不显式配置 SmartAdmin:Security:DataProtection:Key,内核在生产环境要么直接拒绝启动(开了 TOTP 或 Cookie 会话时),要么退回一把进程内临时密钥,不落盘,重启或者换一个副本就是另一把。TOTP 种子、AI 厂商 API Key,凡经 ISecretProtector 加密落库的东西,写在副本 A、读在副本 B 就是两把不同的密钥,解密直接抛 CryptographicException。AI 厂商保存 Key 时会查这把密钥是否临时,是就拒绝落库(49030),但这道守卫只防得住「自己重启后读不回自己」,防不住「每个副本各配各的」。
显式配一把主密钥,所有副本共用同一个值:
openssl rand -base64 32SmartAdmin__Security__DataProtection__Key: "上面命令的输出,每个副本原样粘贴同一份"反代之后必须配 ForwardedHeaders
app 服务已经配了:
SmartAdmin__Api__ForwardedHeaders__Enabled: "true"
SmartAdmin__Api__ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12这套 ForwardedHeaders 配置和路线 B:反向代理是同一件事,只是受信来源换成了 Docker 桥接网段。为什么要配、不配会有什么后果,那页已经讲透,这里不重复。多副本要每个副本都配,否则大家看到的都只是负载均衡器那一个 IP。app 的端口映射因此只绑 127.0.0.1,理由同样在那页:
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 先把库建好,再放开副本。
上传目录必须是共享可写卷
LocalFileStorage、ChunkStorage 写的是本地盘。compose 里两个副本共享同一个 upload-data 具名卷,天然没问题。可 k8s 上就不一定了。如果每个 Pod 用独立 PVC,A 传的文件在 B 上会直接 404。分片上传更是必然 ChunkMissing,因为分片散落在不同 Pod,合并必然缺片。多副本要么给上传根挂一个 RWX(ReadWriteMany)共享卷,要么前置替换 IFileStorage,改成对象存储,比如 S3、OSS。
不想上容器的话,部署概览还给了单体、反向代理、真跨源三条托管路线,上线后的健康检查与自检清单也在那里。