如何对接 EasyPublish 和 Docker 快速发布系统

把 EasyDocker 的镜像构建能力和 EasyPublish 的分发执行能力串成一条流水线:开发机双击一次,代码编译、镜像构建、推送私有库、批量下发到多台服务器并重启服务全自动完成。含 MCP 对接口径、个人令牌鉴权、远程中止的实现细节与踩坑记录。

如何对接 EasyPublish 和 Docker 快速发布系统

手上这套发布系统的原始痛点很朴素:公司内网不能直连生产机,改一行代码走堡垒机手动发布要十几分钟。手工步骤是「本地 dotnet publish → 打 tar 包 → 堡垒机上传 → 逐台 scp → 逐台解压 → 逐台 systemctl restart」,任何一步漏了一台机器,就是一台机器跑着旧版本没人发现。

现在这套流程是:开发机双击一个 .bat,或者改完 .dmk 直接 EasyDocker.exe --dmk xx.dmk --pipeline,剩下的交给机器。

本文讲两个项目怎么分工、怎么串起来,以及里面几个绕不过去的坑。

一、两个项目各自负责什么

先划清边界,否则对接会很乱。

EasyDocker EasyPublish
形态 WPF 桌面工具(Windows 开发机) 自托管 Web 平台(Server + Agent)
解决的问题 「怎么把产物变成镜像」 「怎么把东西发到几十台机器上并执行」
核心动作 生成发布命令 / Dockerfile / compose,调 docker build 制品管理、批量下发、执行 shell、收执行结果
网络方向 开发机 → Docker(本机或远端 Agent) Agent 主动连 Server(出站,穿透堡垒机)
边界 到「镜像进了私有仓库」为止 从「从私有仓库拉镜像」开始

一句话概括:

EasyDocker 把代码变成镜像并推到私有库;EasyPublish 把指令批量送到每台服务器,在那上面执行 docker 命令把新镜像跑起来。

关键设计是 EasyPublish 的 Agent 主动连 Server(出站长轮询 + 心跳),所以生产机不需要开任何入站端口,也不需要堡垒机做反向转发。这是整条链路能在内网跑通的前提。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
开发机                              Server                     多台 Linux 生产机
┌──────────────────┐              ┌──────────────┐            ┌────────────────┐
│ EasyDocker (WPF) │              │ EasyPublish  │            │ easylp-agent   │
│  1 dotnet publish│              │  .Server     │  ◄──心跳──  │  (AOT 单文件)  │
│  2 docker build  │              │  FreeSql+    │  ◄─长轮询─  │                │
│  3 docker push   │              │  SQLite      │  ──下发──► │  docker pull   │
│     ↑ EasyAgent  │              │  JWT + Web UI│            │  docker compose│
│  (远端构建)       │              └──────┬───────┘            │  systemctl ... │
└──────────────────┘                     │                    └────────────────┘
        │  4 curl -X POST /api/releases/trigger  │
        └────────────────────────────────────────┘

二、EasyPublish 侧:怎么接

2.1 三个概念先搞清

  • 流程(Flow):一次发布的任务编排,绑定了默认目标主机。绑哪些机器,就发布到哪些机器——这本身就是灰度手段。
  • 步骤(Step):流程里的一个动作,两种类型:
    • command:在目标机跑 shell,可配超时和失败策略;
    • download:把制品管理里某个版本下发到目标目录(版本填「最新」按语义版本取最高),可勾选自动解压。
  • 步骤编码(code):同一流程内唯一的短名,如 build、restart-api。外部系统只认编码,不认 Id。 步骤删掉重建(Id 变了)只要编码不变,调用方一行代码都不用改。

步骤编码只能用字母、数字、_ - .,最长 64 个字符。新建时不填会自动取步骤 Id(一串数字)——别偷懒,起个有意义的名字,否则半年后没人知道 12 是什么。

主机绑定是两级的,这点容易踩:

  • 流程绑的主机 = 默认目标,也是灰度手段;
  • 步骤绑的主机(可选)= 该步骤只在它绑的机器上跑,没绑则回落到流程绑的主机。

典型用法:构建步骤只绑那台 docker builder,发布步骤不绑、跟着流程在各业务机上跑。

⚠️ 例外:按 stepCodes 触发时,每个被打的步骤都必须自己绑了主机,不会回落。否则「只触发构建步骤」会悄悄变成「在流程绑定的全部机器上跑这一步」。

2.2 鉴权:只用个人令牌,别用项目令牌

对接的第一个决定点是认证方式。EasyPublish 里有两套令牌,外部系统一律用个人令牌:

个人令牌 项目发布令牌
前缀 ep_ 项目级
能否追溯人 ✅ 执行记录「触发人」列显示令牌所属用户 ❌ 认不出人
限项目 可限定 天然限单项目
有效期 30/90/180/365 天或永久 —
能否用于触发 ✅ 唯一支持 ❌ 已拒绝(trigger 直接返回 401)

为什么不用项目令牌——执行记录里会出现「不知道是谁触发的」。发布这种事出了问题要能定位到人和变更,匿名触发是运维事故的温床。

令牌的安全设计值得抄一遍:

  • 明文只在创建时显示一次,库里只存 SHA-256 哈希,服务端也拿不回明文,忘了只能重建;
  • 权限 = 用户自身权限 ∩ 令牌限定的项目,令牌只能缩小权限、不能放大;
  • 令牌不能用来创建新令牌,避免一个泄露的令牌不断续期。

调用时二选一:放请求体 token 字段,或放 Authorization: Bearer ep_xxx 头。

2.3 触发接口

1
2
3
4
5
6
7
8
9
# 触发整条流程
curl -X POST 'http://your-server:13344/api/releases/trigger' \
     -H 'Content-Type: application/json' \
     -d '{"projectId":1,"flowId":2,"token":"ep_xxx"}'

# 只触发某几个步骤(按步骤编码,执行顺序仍按流程编排)
curl -X POST 'http://your-server:13344/api/releases/trigger' \
     -H 'Content-Type: application/json' \
     -d '{"projectId":1,"flowId":2,"stepCodes":["build","restart-api"],"token":"ep_xxx"}'

参数口径:

  • flowId 必填。权限与目标主机都挂在流程上,不给它定位不到东西。
  • stepCodes 可选。不传 = 跑整条流程的所有启用步骤。
  • projectId 可选。传了就校验目标确实属于该项目,传错当场报错,不会跑到别的项目上。建议传。
  • 响应即本次执行记录(executionId),拿它去查结果。

触发方式会被记进执行记录的 TriggerType:manual(页面手动)/ webapi(接口)/ cron(定时)/ ai(MCP)。定时任务没有「人」这一层,TriggerBy = 0,显示为系统。

Web 界面里现成的 curl 可以直接复制:发布管理页流程/步骤上的「调用代码」、主机管理页的「调用代码」都有。

2.4 查结果:三件套

CI 里接了触发只完成一半,还得知道成没成。

1
2
3
4
5
6
7
8
# 某次执行「步骤 × 主机」的结果汇总(失败任务带 stderr 末尾)
GET /api/releases/executions/{id}

# 某流程最近的执行记录列表(先从这里拿 executionId)
GET /api/releases/flows/{flowId}/executions

# 增量拉行级实时输出
GET /api/releases/executions/{id}/lines

另外几个运维接口,建议接进监控:

用途 位置
健康检查(数据库可读 + 制品目录可写) GET /healthz
OpenAPI 文档 GET /openapi/v1.json
结构化日志 容器 stdout + data/logs/easypublish-*.log
操作审计 GET /api/audits(管理员)
制品占用 GET /api/artifacts/usage

/healthz 检查的是数据库可读 + 制品目录可写,不是简单的「进程活着」——这两个才是发布平台真正会静默失效的地方(磁盘满导致制品写不进去,接口却一切正常)。

三、EasyDocker 侧:怎么把镜像造出来

3.1 三种构建路径

EasyDocker 是 WPF 工具,界面三个页签(基本配置 / 命令预览 / 执行)。它支持两种打包类型:

类型 项目文件 构建 基础镜像 启动命令
.NET .csproj dotnet publish → bin/<配置>/<框架>/<运行时>/easypublish mcr.microsoft.com/dotnet/aspnet:X.Y dotnet xxx.dll
前端 package.json npm install && npm run build → dist nginx:latest nginx -g "daemon off;"

产物目录刻意跟 VS2022 右键发布的目录对齐(bin\Release\net8.0\linux-x64\easypublish),但末级用 easypublish 而不是 publish,跟 VS 自己的产物区分开,避免互相覆盖。

开发机不必人人装 Docker Desktop。这是 EasyDocker 最值钱的一个设计:构建方式有两种。

  1. 本机构建:直接调本机 docker;
  2. 远端 EasyAgent:把构建上下文打成 zip 上传给一台装了 Docker 的机器,那边跑 docker build,输出实时回传到「执行」页。

关键点:远端模式下发布仍然在本机执行,build / run / composeUp / composeDown 四个步骤才走 Agent。因为 dotnet publish 需要本机的 SDK 和源码,没必要搬过去。

远端模式有个细节做得很细:整步(pre 钩子 → 步骤命令 → after 钩子)在同一个任务里顺序执行,构建上下文只上传一次。需要留在本机跑的钩子(比如打包前准备本地文件)在行首加 @local :

1
2
@local  copy /y appsettings.Production.json "{publishDir}\appsettings.json"
docker compose -f /home/dzz/test/test.yml up -d

第一条在本机跑,第二条在 Agent 上跑。

3.2 生成的 Dockerfile 长什么样

dotnet publish 的产物直接放进镜像,镜像里不跑构建——构建发生在开发机(或 EasyAgent),镜像构建只做 COPY。好处是镜像构建极快、构建镜像不需要 SDK:

1
2
3
4
5
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY binary .

ENTRYPOINT [ "dotnet", "EasyPublish.Server.dll" ]

前端类型也一个思路,产物固定放进 /app,nginx.conf 不打进镜像、运行时挂载——换配置不用重建镜像:

1
2
3
4
5
6
7
FROM nginx:latest
COPY . /app
RUN rm -f /app/Dockerfile /app/.dockerignore
EXPOSE 80
CMD [ "nginx", "-g", "daemon off;" ]
# nginx 配置不进镜像,运行时挂载:
#   -v ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

踩坑提醒:挂载的 nginx.conf 里 root 必须指向 /app,否则 404。

3.3 钩子 + 占位符:推送私有库就靠它

步骤钩子 pre / after 各 5 个步骤(执行发布 / 构建镜像 / 运行容器 / Compose 启动 / Compose 停止),执行顺序是 pre 命令 → 步骤命令 → after 命令,任一环节退出码非 0 即报错并中止。

支持的占位符:

1
2
{image} {imageLatest} {registry} {name} {version}
{container} {projectDir} {publishDir} {dockerfile} {compose}

推送私有库的标准姿势(勾选了「镜像名带仓库前缀」时 {image} 本身就是全名,省掉 tag):

1
2
3
docker push {image}
docker push {imageLatest}
docker rmi -f {image} {imageLatest} || echo 本地镜像已清理

docker rmi 建议加 -f 和 || echo ...:容器还在运行时(docker run --rm -d)不加 -f 会因镜像被占用而失败,而清理失败没必要让整步报错,把发布搞挂。

3.4 CLI:给脚本用的

GUI 是给人用的,CLI 是给流水线用的:

1
2
3
EasyDocker.exe --dmk EMS.dmk --pipeline                                  # 发布 + 构建(含钩子)
EasyDocker.exe --dmk EMS.dmk --step publish,build --set Version=1.2.3     # 覆盖版本号
EasyDocker.exe --dmk EMS.dmk --step build --silent --log build.log        # CI 场景

步骤名可简写:p b r up down,all / pipeline = 发布+构建。

退出码有约定:0 成功、1 步骤没有可执行命令、2 参数错误、3 配置文件读取失败,其他值是失败命令的真实退出码。CI 里据此判断。

⚠️ 程序是 WinExe,PowerShell / CMD 不会自动等待,必须用 start /wait 或 Start-Process -Wait:

1
2
3
$p = Start-Process .\EasyDocker.exe -Wait -NoNewWindow -PassThru `
        -ArgumentList '--dmk','EMS.dmk','--step','publish,build','--silent','--log','build.log'
if ($p.ExitCode -ne 0) { throw "构建失败" }

另外一个经常被忽略的点:.dmk 里的路径(CsprojPath / VersionFilePath / DockerfilePath / ComposeFilePath)按 .dmk 所在目录存成相对路径写盘(分隔符统一 /,跨盘符保持绝对路径),读取时还原。所以把 .dmk 和项目一起提交进仓库后,换台机器、换个目录(含 Linux 容器)都能直接用。

四、怎么把两边串起来

到这里素材齐了。对接的核心思路是:

让 EasyDocker 负责「造镜像 + 推私有库」,让 EasyPublish 负责「在目标机下拉镜像 + 重启服务」,两者之间用私有仓库做交接,用 trigger 接口做衔接。

4.1 统一镜像命名

这是对接的第一前提。EasyDocker 的仓库前缀和 EasyPublish 拉镜像时用的地址必须是同一个字符串。

建议在两个项目里用同一套命名规范:

1
2
私有库前缀/镜像名:版本
ks-dockerhub.example.com:9999/app/easypublish.server:1.2.1

EasyDocker 侧配置:

配置项 值
仓库前缀 ks-dockerhub.example.com:9999/app/
镜像名带仓库前缀 ✅ 勾选
同步 tag latest ✅ 勾选
FROM 前缀 ks-dockerhub.example.com:9999/library/

注意 FROM 前缀 会把原镜像的域名去掉再拼前缀:nginx:latest → 前缀/library/nginx:latest、mcr.microsoft.com/dotnet/aspnet:8.0 → 前缀/dotnet/aspnet:8.0。留空则 FROM 保持原样,交给 Docker 的 registry mirror。内网私有库场景建议填上,否则构建时要出公网拉基础镜像。

4.2 一个可用的完整流程

生产机上跑的是 compose,所以 EasyPublish 那边只需两步:拉镜像 + 起服务。

EasyPublish 的流程编排:

顺序 编码 类型 内容 绑主机
1 pull command docker compose -f /apps/myapp/docker-compose.yml pull 不绑(跟流程)
2 up command docker compose -f /apps/myapp/docker-compose.yml up -d 不绑
3 check command docker compose -f /apps/myapp/docker-compose.yml ps 不绑

/apps/myapp/docker-compose.yml(部署机上,由 EasyDocker 的「生成到文件 / 另存为」产出后提交进运维仓库):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
networks:
  myapp:
    external: false
    driver: bridge

services:
  server:
    image: ks-dockerhub.example.com:9999/app/myapp:latest
    container_name: myapp
    environment:
      - TZ=Asia/Shanghai
    restart: always
    networks:
      - myapp
    volumes:
      - "/apps/myapp/data:/app/data"
      - "/apps/myapp/appsettings.json:/app/appsettings.json:ro"
    ports:
      - "13344:13344"
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: "0.5"

⚠️ CPU 限制的键名是 cpus,写成 cpu 新版 Compose 会直接报 additional properties 'cpu' not allowed(那是老的 swarm 写法)。
⚠️ compose 里用 latest 标签才能让部署机 docker compose pull 总拉最新;取消勾选「image 用 latest 标签」就钉死在 镜像:版本号 上,这时必须配合「同步 tag latest」一起用,否则镜像里没有 latest 标签。

EasyDocker 的 after 钩子(构建镜像步骤):

1
2
3
4
5
6
docker push {image}
docker push {imageLatest}
docker rmi -f {image} {imageLatest} || echo 本地镜像已清理
curl -X POST "http://your-server:13344/api/releases/trigger" ^
     -H "Content-Type: application/json" ^
     -d "{\"projectId\":1,\"flowId\":2,\"stepCodes\":[\"pull\",\"up\",\"check\"],\"token\":\"ep_xxx\"}"

这样开发机上执行一次「发布并构建镜像」,整条链路就跑完了:编译 → 打镜像 → 推私有库 → 通知 EasyPublish → 所有目标机拉新镜像并重启 → 检查状态。

4.3 直接用 MCP 对接(更推荐)

如果只是想在发布环节少写脚本,EasyPublish 内置了 MCP 端点 /mcp(Streamable HTTP),接进 Claude Code、VS Code、Copilot 这类客户端就能直接对话式操作。

1
2
3
4
5
6
7
8
9
{
  "mcpServers": {
    "easypublish": {
      "type": "http",
      "url": "http://your-server:13344/mcp",
      "headers": { "Authorization": "Bearer ep_xxxxxxxx" }
    }
  }
}

可用工具:

工具 作用
list_flows 可见流程:Id、项目、绑定主机数、步骤数、定时配置
list_steps 流程的步骤:编码 code、类型、命令/下载目标、绑定主机、超时
list_executions 某流程最近的执行记录(拿 executionId)
get_execution 某次执行「步骤 × 主机」的结果汇总,失败任务带 stderr 末尾
tail_execution_logs 增量拉行级实时输出(游标 after → 返回 maxId)
trigger_release 触发发布(写操作):只传 flowId = 整条流程,传 stepCodes = 只跑这几步

设计上有两条红线值得注意:

  • 只读工具随便调,写操作只放开了「触发发布」。「中止执行」会真的杀掉目标机上正在跑的进程,不开放给 AI。
  • trigger_release 走的是与 HTTP 接口完全相同的校验与下发路径(同一个 ResolveStepsByCodes),触发方式记作 ai。口径统一是为了避免「同一个 stepCodes,接口报 404、AI 那边报 400」这种漂移。

排查发布失败的标准路径:list_flows → list_executions → get_execution(含失败 stderr,通常够定位)→ 需要时 tail_execution_logs(首次 after=0,之后用返回的 maxId 续拉)。

4.4 用生产经验反推的几条纪律

对接完之后踩出来的,直接抄:

  1. 触发必须能追溯到人。 个人令牌 + 限项目 + 设有效期,别用永久令牌。
  2. 耗时步骤单独配超时。 默认 30 分钟,勾「不限时」则一直等。不配的话命令卡死时执行记录会一直挂在「执行中」。
  3. 给每个步骤起有意义的编码。 build / pull / up / check,而不是 12 13。
  4. /healthz 接进监控。 它查的是数据库可读 + 制品目录可写,能提前发现磁盘满。
  5. ./data 定期备份。 数据库 + 制品全在里面,升级镜像前先备份。
  6. 生产环境改 Jwt:Key。 强随机值,别用默认的。
  7. 反向代理给 /mcp 关响应缓冲、放开长连接超时。 否则流式响应会被攒着不发。

五、几个值得记的实现细节

5.1 「结束执行」怎么真的杀掉进程

这是个绕弯的设计,值得单独讲。

痛点:Agent 的 poll 循环是串行的(while(true) 里 await ExecuteTaskAsync(...))。它正卡在某条命令里的时候,根本不会去拉新任务。所以「中止」这条指令不能走任务队列——等它有空去拉的时候,那条命令早就跑完了。

解法是走心跳这条并发通道:

  1. Agent 每次心跳上报 RunningTaskId(当前正在执行的任务 Id,0 = 空闲);
  2. 「结束执行」把该执行下未完成的任务标成 aborted(不是 failed,两者语义不同);
  3. 下一次心跳(间隔 1 秒)时,Server 发现这个 Id 已是 aborted,在回执里返回 abortTaskIds;
  4. Agent 收到后取消该任务的 CancellationTokenSource,下游 ct.Register(...) 立刻杀掉整个进程树;
  5. Agent 照常上报结果,Server 保留 aborted 状态不被迟到的结果改写,并记一条「已被人工结束」的日志。

杀进程树这一点很关键:只 kill 掉 bash 是不够的,它派生的子进程(mvn、dotnet、nginx -s reload 等)会变成孤儿继续跑,既占资源又可能继续改目标机上的文件。

服务端看门狗(TaskTimeoutService)判超时把任务标成 failed 时,走的也是同一条通道——那正是「命令卡住不返回」最该被杀掉的场景。

覆盖不到的情况:主机离线。 指令送不达,那边的进程会按自己的节奏跑完。界面上的确认文案对此有明确提示,这点做得很诚实。

同一套机制也支撑了「强制更新 Agent」:先把该主机的发布任务标成 aborted,目标机上的进程被杀掉、poll 循环腾出来,排在后面的更新命令才能立刻被领走——否则它会一直排在那条可能跑几十分钟的构建后面。

5.2 Agent 身份:不写配置的 HostId

Agent 的身份取自机器的 product_uuid(读不到则回退 /etc/machine-id),配置文件里不需要 HostId / Token:

1
2
3
{
  "ServerUrl": "http://your-server:13344"
}

就这一个必填项。主机行由 Server 在首次注册时按机器标识自动创建/匹配。这么做的好处:同一份安装脚本装到多台机器,product_uuid 不同,各自主册成独立主机,不会互相顶替。用 machine-id 则会在虚拟机克隆后冲突,所以才优先 product_uuid。

5.3 Agent 是自包含单文件,不是 AOT

EasyPublish 和 EasyAgent 都面临同一个选择:目标机要不要装 .NET 运行时。

裁剪单文件(两个项目都用的方案) Native AOT
目标机需装 .NET 否(运行时打包进去) 否
跨操作系统发布 ✅ 可以 ❌ 报 Cross-OS native compilation is not supported.
本机额外工具链 不需要 需要原生链接器(Windows→MSVC,Linux→clang/gcc)
启动速度 / 内存 较好 更好
反射(含 JSON) 受限,需源生成规避 基本不可用

选了裁剪单文件,理由是能在 Windows 上直接出 linux-x64 产物——不需要额外的 C++ 工具链,也不需要在 CI 里搞原生构建环境。代价是必须处理裁剪对反射的限制。

EasyPublish Agent 的关键配置:

1
2
3
4
5
6
7
8
9
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<DebugType>none</DebugType>

裁剪 + 压缩后从 ~39MB 降到 ~14MB。InvariantGlobalization 让二进制不依赖 ICU,跨平台部署免装 ICU 库。

裁剪的硬约束(改代码前必须知道):

  • JSON 全部走源生成:所有请求/响应/输出模型都登记在 Json/AppJsonContext.cs。新增接口出入参时,必须同步加 [JsonSerializable],否则运行期直接抛异常;
  • 已设 JsonSerializerIsReflectionEnabledByDefault=false,反射式 JSON 回退被关闭;
  • 因此不能用匿名类型(new { ... })做响应体,也不能用 Results.Json(obj) 这种带 RequiresDynamicCode 的重载,要用接收 JsonTypeInfo 的重载:Results.Json(dto, AppJsonContext.Default.ErrorResponse)。

EasyAgent 那边踩过同样的坑,README 里专门写了一段「改代码前先看」,说明这个坑是真实踩过的。

5.4 Agent 内存开销的坑

Agent 常驻在业务机上,一次大构建的输出可能是几百 MB。EasyPublish 的 CommandExecutor 做了上限控制:

1
2
/// 单流最多保留的输出字符数(stdout / stderr 各自计算)。
private const int MaxCaptureChars = 256 * 1024;

全量攒在 StringBuilder 里会让 Agent 自己把机器内存吃光(还会连带触发 OOM Killer)。超限后结果里的 stdout/stderr 会标记 Truncated,完整输出仍可在逐行日志里看到——实时日志是逐行上报的,不受这个上限影响。

另外可以用 MemoryMax 给 Agent 加 cgroup v2 硬上限(systemd 244+),超了内核杀掉进程,由 Restart=always 立刻拉起:

1
2
3
4
[Service]
ExecStart=/usr/local/bin/easylp-agent /etc/easylp/agent.json
Restart=always
MemoryMax=50M

⚠️ 别设太小。.NET 自包含单文件自身常驻通常 30~60MB,50 偏紧,建议先观察再留 1.5 倍余量。设太小会让 Agent 频繁被 OOM 杀掉重启,表现为反复掉线——这个症状很容易被误判成网络问题。

5.5 前端产物提交进 git,以及为什么只留 3 个文件

EasyPublish 的前端产物(src/EasyPublish.Server/wwwroot/)是提交进 git 的,目的是克隆后无需先构建即可 dotnet run 看页面。

但产物带内容哈希、每次构建都换一批文件名,如果按默认拆包粒度,一次前端改动会产生几十个文件的增删,diff 噪音无法忍受。所以 vite.config.js 刻意压缩了拆包粒度:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
build: {
  outDir: '../binary/wwwroot',
  emptyOutDir: true,
  cssCodeSplit: false,          // 所有样式合并成单个 css
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes('node_modules')) return 'vendor'   // 第三方依赖统一进 vendor
      },
    },
  },
},

配合「页面不再按路由拆包」(main.js 里静态引入),产物固定只有 3 个文件:

  • assets/index-*.js —— 业务代码
  • assets/vendor-*.js —— 第三方依赖(文件名稳定,可长期缓存)
  • assets/index-*.css —— 全部样式

结果是:只改业务代码时,只动 index.js 和 css 两个文件;vendor 文件名不变,浏览器不用整包重下。代价是首屏会带上其它页面的代码——内部工具,可接受。

镜像构建不受影响:Dockerfile 里由 node:22 阶段从 web/ 源码现构建,且 .dockerignore 排除了仓库里的产物,避免本机残留混进镜像。

5.6 前后端打包进一个 dll

vite.config.js 的 outDir 直接指向 Server 的 wwwroot,所以一次 npm run build 就完成前端往服务端资产的输出,部署只有一个 Server 容器,没有独立的 nginx 容器。

Server 的 csproj 里对应地把 wwwroot/** 标记为 CopyToOutputDirectory:

1
2
3
<ItemGroup>
  <Content Update="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

注意用 Update 而非 Include——Web SDK 已默认把 wwwroot 作为 Content 项,重复 Include 会报 NETSDK1022。

5.7 版本号:两条独立的版本线

EasyPublish 里 Server 和 Agent 的发版节奏不同,刻意不绑在一起:

版本写在哪 影响
Server + Docker 镜像标签 根目录 Directory.Build.props 的 <Version> Server 程序集版本;docker-maker.bat 从这里解析镜像标签
Agent src/EasyPublish.Agent/EasyPublish.Agent.csproj 顶部的 <Version> Agent 程序集版本(运行时 ReadOwnVersion() 读它,用于自更新比对)

为什么不共用:Agent 装在业务主机上、靠自更新升级,Server 却天天改。绑同一个号的话,每次只改 Server 也会让所有主机以为自己该升级,白白重下一次二进制。

实现上不需要任何额外配置——MSBuild 的求值顺序是 Directory.Build.props 先加载、项目自己的 PropertyGroup 后求值,所以 Agent csproj 里那行 <Version> 会自然覆盖根目录的值。

Server 对外宣告的「最新 Agent 版本」取自 agents/easylp-agent.version(Agent 发布时自动写入),与实际分发的二进制严格一致。appsettings.json 的 Agent:LatestVersion 留空即可,只有需要临时钉版本时才填。

⚠️ 改完 Agent 的 <Version> 必须重新 dotnet publish 一次,否则 agents/easylp-agent.version 还是旧值,Server 会宣告错误的版本——然后所有主机会错误地认为自己需要升级(或者该升级的不升)。

5.8 执行日志与审计

  • 写操作自动入库(/api/audits、界面「操作审计」),保留天数由 Audit:RetentionDays 控制(默认 180 天,0 = 永久);
  • Agent 心跳与查询类请求不入库,否则审计表会被心跳写爆;
  • 清空审计日志本身也会被记录;
  • MCP 调用不带 /api/ 前缀,因此不参与 RequestAuditMiddleware 的审计入库——将来开放写工具时,需要在工具内部显式记一条审计。

日志同时输出到容器 stdout 和 data/logs/(按天滚动,默认保留 14 个文件)。容器里 stdout 日志一重启就没了,必须落盘到挂载卷才可能事后排查。用的是 Serilog 核心 + Serilog.Extensions.Logging 接管 ILogger,不引 Serilog.AspNetCore——少一个包,也让 RequestLogging 中间件仍然是自己写的,便于和审计日志合并处理。

六、踩坑清单

按「踩了会疼」的程度排序:

坑 症状 解法
Agent MemoryMax 设太小 Agent 反复掉线,误判成网络问题 .NET 自包含单文件常驻 30~60MB,至少留 1.5 倍余量
按 stepCodes 触发时步骤没绑主机 「只触发构建」变成「在全部机器上跑构建」 按步骤触发时每个步骤必须自己绑主机
改 Agent <Version> 没重新 publish Server 宣告错误版本,主机错误升级 改完必须 dotnet publish 一次
docker rmi 没加 -f 容器还在跑,镜像被占用导致清理失败、整步报错 docker rmi -f ... || echo ...
compose 里写 cpu 而不是 cpus additional properties 'cpu' not allowed 新版 Compose 规范键名是 cpus
挂载的 nginx.conf 里 root 没指 /app 站点 404 前端产物固定放 /app,nginx.conf 里 root /app;
新增 DTO 忘了加 [JsonSerializable] 裁剪后运行期抛异常 所有请求/响应模型登记进 AppJsonContext
用匿名类型做响应体 裁剪后运行期抛异常 用具名 DTO + Results.Json(dto, TypeInfo)
耗时步骤没配超时 命令卡死时执行记录永远挂在「执行中」 编辑步骤单独配执行超时
反向代理没给 /mcp 关缓冲 流式响应被攒着不发 关闭响应缓冲、放开长连接超时
compose 用 latest 但没同步 tag latest 部署机 pull 拉不到镜像 「同步 tag latest」与「image 用 latest」配合使用

七、总结

这套东西的设计取舍值得记住三条:

  1. Agent 主动连 Server。 出站连接穿透堡垒机,生产机不开入站端口,这是内网自动化的前提。
  2. 把「造镜像」和「发东西」拆成两个项目。 构建需要 SDK 和源码,分发需要网络可达和批量能力,绑一起只会两头都做不好。两者用私有仓库做交接,用 HTTP 接口做衔接。
  3. 外部系统一律用个人令牌。 认不出人的触发记录等于没有记录。

EasyDocker 侧的关键是 .dmk 的相对路径(可随仓库分发到 CI)和 --silent --log 的 CLI 模式;EasyPublish 侧的关键是步骤编码(外部系统只看编码不看 Id)和 trigger 接口的 stepCodes 精确触发。两边对齐镜像命名规范,整条链路就通了。


参考资料

comments powered by Disqus
使用 Hugo 构建😊 主题 Stack 由 Jimmy 设计