[{"content":"这篇文章记录一次 IIoT 接入链路部署故障的排查与修复过程。目标是在云端通过 Podman Rootless 容器运行 EMQX 5.8.9，完成设备 MQTT 接入、HTTP 动态鉴权、Webhook 转发和时序数据落库。\n实际推进中，链路先后暴露出四类问题：emqx ctl 在容器内无法稳定连接主节点、预写 cluster.hocon 后 EMQX 启动阶段因 Schema 校验失败而崩溃、外部设备连接持续超时，以及后端对 EMQX 发起的 M2M HTTP 请求返回 403 Forbidden。这些现象分散在运行时、配置、网络和安全中间件四个层面，若只盯住单个报错，很容易误判为某一处配置写错。\n说明：本文中的项目名、服务名、域名、IP、路径、账号、表名、请求头、日志片段和命令输出均已脱敏、抽象或重写，仅保留与排查链路相关的技术信息。\n背景与目标 容器运行时：Podman Rootless Broker：EMQX 5.8.9 鉴权方式：HTTP AuthN 数据链路：MQTT -\u0026gt; Rule Engine -\u0026gt; Webhook -\u0026gt; TimescaleDB 后端安全：浏览器侧 CSRF 防护 + M2M Token 校验 目标并不复杂：设备通过 MQTT 接入，EMQX 调后端完成动态认证，再把遥测数据通过 Webhook 转发给后端，由后端落入时序库。\n故障摘要 这次故障最终确认并不是单点问题，而是四条故障链叠加：\nPodman Rootless 的隔离机制导致 Erlang EPMD IPC 不稳定，emqx ctl 无法作为可靠的热加载入口 EMQX 5.8.9 对 Webhook Bridge 配置执行更严格的 HOCON Schema 校验，历史参数触发 unknown_fields 云安全组未放通 1883 入站流量，外部 MQTT 连接直接被阻断 后端全局 CSRF 中间件默认保护所有 POST 请求，误伤了 EMQX 发起的 M2M AuthN/Webhook 调用 最终修复也对应这四条链路：\n放弃依赖 emqx ctl 的动态热加载，改为在 Entrypoint 前置阶段渲染完整 cluster.hocon 删除不再兼容的 resource_opts 历史参数 放通安全组 1883 对 /api/v1/iot/* 路径绕过 CSRF，同时对 /iot/ingest 增加 X-EMQX-Token 常量时间校验 排查时间线 第一阶段：先确认动态配置为什么不可靠 最初部署方案是在 EMQX 主进程启动后，通过脚本执行：\nemqx ctl conf load --merge\n把 HTTP AuthN 和 Webhook 规则动态注入 Broker。实际运行时，容器内持续出现：\nNode emqx@\u0026lt;service\u0026gt;-emqx not responding to pings 这一步的关键结论是：问题暂时还不在业务配置，而在运行时控制面。只要 emqx ctl 不能稳定通过 Erlang 分布式节点协议连回主节点，后续动态配置方案就没有可靠性基础。\n假设 A：节点 Hostname 绑定错误 第一反应是节点名 emqx@\u0026lt;service\u0026gt;-emqx 解析异常，因此在 Compose 中显式声明 hostname。这一步修正后，节点命名确实更一致，但 emqx ctl 仍然时好时坏，说明 Hostname 只是干扰项，不是根因。\n假设 B：sed 占位符替换被 Compose 提前展开 另一个已确认的问题是命令里的 ${VAR} 被 podman-compose 提前展开，导致 sed 替换目标被吃掉，Token 注入失效。把目标改为静态占位符字符串后，Token 替换恢复正常，但 emqx ctl 仍然不可靠。这一步说明模板替换有缺陷，但它仍不是导致整个链路失败的主因。\n第二阶段：绕开 emqx ctl，改为启动前写配置 既然问题集中在运行时 IPC，排查方向自然收敛到“是否能在主进程启动前就完成配置注入”。后续做法是把渲染后的配置直接写入：\n/opt/emqx/data/configs/cluster.hocon\n再由 Entrypoint exec 启动 EMQX。这个方向绕开了 emqx ctl，但马上暴露出第二个阻塞点：EMQX 启动即崩溃。\n日志中的关键信息为：\n[error] failed_to_check_schema: emqx_conf_schema [error] #{reason =\u0026gt; unknown_fields, path =\u0026gt; \u0026#34;bridges.webhook.\u0026lt;service\u0026gt;_backend_ingest_bridge.resource_opts\u0026#34;, unknown =\u0026gt; \u0026#34;pool_size,buffer_type,max_retries,...\u0026#34;} 这一步的结论也很明确：预写配置本身是正确方向，但模板里仍带有旧版本可接受、当前版本已不兼容的历史字段。\n假设 C：只精简部分 resource_opts 字段即可恢复 初始处理是逐步删减 buffer_type、max_retries 等字段，保留看起来仍然合理的 pool_size。结果 EMQX 依旧在 Schema 校验阶段失败，说明这里不是“个别字段值不合法”，而是整个 resource_opts 子树已经不再适配当前版本。最终把这一块整体移除后，EMQX 才恢复正常启动。\n同时发现的配置覆盖风险 这一阶段还暴露出另一个隐患：cluster.hocon 改为覆盖写入后，如果模板只包含 Webhook 规则，就可能把先前通过 Dashboard 配置的 HTTP AuthN 一并覆盖掉，导致 Broker 虽然能启动，但设备接入鉴权链路反而断开。\n因此最终模板调整为一次性声明三类配置：\nHTTP AuthN Rule Engine 规则 Webhook Bridge 这一步不是性能或可维护性优化，而是保证 Broker 启动配置具备原子性，避免不同入口写同一份生效配置时互相覆盖。\n第三阶段：Broker 启动后，外部设备仍然连不上 当 EMQX 已能正常启动，并且容器日志确认：\nListener tcp:default on 0.0.0.0:1883 started 外部客户端连接仍然持续超时。此时排查对象已经从 Broker 内部转移到外围网络层。\n本地使用 mosquitto_pub 直接测试 \u0026lt;IP\u0026gt;:1883 后依然超时，这一步将问题范围缩小到 IaaS 层。最终确认云安全组没有放通 1883 入站流量。补齐规则后，设备到 Broker 的 MQTT 连通性恢复。\n这一步比较典型：容器内端口监听正常，不代表外部访问路径已经打通。只要安全组或外层防火墙未放行，应用侧日志会呈现“服务正常、客户端超时”的假象。\n第四阶段：M2M HTTP 请求被后端安全中间件拦截 MQTT 连接打通后，EMQX 发起的 HTTP AuthN 与 Webhook 请求又返回 403 Forbidden。继续看后端逻辑后，问题定位到全局 CSRF 中间件。\n后端原本面向浏览器流量启用了严格的 Origin / Referer 校验，这对 Web 管理端是合理的，但对 EMQX 发起的机器对机器请求并不适用。问题不在于这类请求“不能携带”相关头，而在于它们并不处于浏览器信任模型内，无法稳定满足基于 Origin / Referer 的 CSRF 判定前提，因此会被误拦截。\n这里的处理不是简单关掉 CSRF，而是分流：\n/api/v1/iot/* 前缀绕过 CSRF 数据接收端点 /iot/ingest 额外校验 X-EMQX-Token Token 比对使用 crypto/hmac.Equal，避免时序侧信道 这样处理后，Web 浏览器端仍保留原有 CSRF 防护，而 IoT M2M 链路改用更适合机器调用场景的预共享密钥校验。\n根因分析 1. Podman Rootless 与 Erlang EPMD 的运行时阻抗 emqx ctl 本质上依赖 Erlang Distribution Protocol 与 EPMD 进行节点发现和控制。在 Rootless 场景下，User Namespace 与虚拟网络栈组合改变了容器内回环与节点通信的行为，导致这类控制面 IPC 无法稳定建立。因此问题不在于 emqx ctl 命令本身写错，而在于它不适合作为当前运行环境中的核心配置入口。\n2. EMQX 5.8.x 对历史 HOCON 字段收紧 旧版本 Bridge 配置里常见的 resource_opts.pool_size、buffer_type、max_retries 等字段，在 5.8.x 中已不再按原路径生效。当前版本在启动阶段就会对未知字段执行强校验，因此这类历史配置不会被忽略，而是直接阻断 Broker 启动。\n3. 外围网络策略与应用日志之间存在错位 EMQX 容器内监听 1883 正常，只能证明 Broker 完成了本地绑定；它并不能证明外部设备访问链路已经连通。外部客户端超时而服务内部无异常日志，通常意味着还需要继续检查安全组、宿主机防火墙或入口转发规则。\n4. 浏览器安全策略不能直接套用到 M2M 链路 CSRF 是针对浏览器上下文设计的防护模型，而 EMQX 与后端之间的 AuthN/Webhook 调用属于 M2M 通信。若直接对这类请求复用浏览器安全策略，就会把“不满足浏览器来源校验前提”的机器调用误判成异常请求。因此正确做法不是关掉安全，而是将 Web 与 M2M 两类入口使用不同校验机制。\n修复方案 最终落地方案包含四个部分：\n1. 启动前渲染完整 cluster.hocon 放弃运行时热加载，在容器 Entrypoint 前置阶段渲染并写入完整配置，再启动 EMQX 主进程。这样做的目的是把配置生效时机前移到 Broker 启动前，避免继续依赖不稳定的 Erlang IPC。\n2. 合并 AuthN、Rule Engine 和 Webhook 模板 最终模板统一包含：\nHTTP AuthN 遥测转发规则 Webhook Bridge 同时完全删除不再兼容的 resource_opts 块。\n3. 打通外部网络入口 在确认 EMQX 已监听 0.0.0.0:1883 后，补齐云安全组入站规则，恢复设备到 Broker 的外部访问路径。\n4. 重构 M2M 安全策略 对 /api/v1/iot/* 绕过浏览器导向的 CSRF 校验，同时在 /iot/ingest 上增加共享密钥校验。最终形成两套并行防护：\n浏览器端：继续使用 CSRF IoT M2M 端：使用 X-EMQX-Token 这让后端安全边界更清晰，也避免后续把浏览器流量和设备流量混用同一套校验逻辑。\n验证结果 修复后，验证分三层完成：\n1. 后端中间件验证 针对 CSRF 和 Token 中间件的测试确认：\n普通 Web/API 请求在缺失或伪造 Origin / Referer 时仍返回 403 /api/v1/iot/* 前缀请求可绕过 CSRF /iot/ingest 在缺失或错误 Token 时被拒绝 2. MQTT 端到端验证 使用外部客户端执行发布测试：\nmosquitto_pub -d -h \u0026lt;Domain\u0026gt; -p 1883 \\ -i \u0026#34;\u0026lt;Device_SN\u0026gt;\u0026#34; -u \u0026#34;\u0026lt;Device_SN\u0026gt;\u0026#34; -P \u0026#34;\u0026lt;Token\u0026gt;\u0026#34; \\ -t \u0026#34;telemetry/fms/\u0026lt;Device_SN\u0026gt;\u0026#34; \\ -m \u0026#39;{\u0026#34;fcs_speed_rpm\u0026#34;: 7766, \u0026#34;fcs_soc\u0026#34;: 88.9, \u0026#34;fms_fault_code\u0026#34;: 0}\u0026#39; 验证结果表明：\n设备连接成功 HTTP AuthN 返回通过 消息被 Broker 接收并进入 Rule Engine 3. 数据落库验证 时序库查询确认数据写入成功，_iot 数据面角色权限与控制面角色保持隔离，链路闭环成立。\n经验与后续 这次排查沉淀出几条比较明确的工程经验：\n在 Rootless Podman 下，凡是可以通过声明式配置解决的问题，优先不要依赖启动后的 Erlang IPC 控制链路 中间件跨版本升级时，高级调优参数比基础功能配置更容易失效，配置应尽量从最小集开始验证 “服务已监听”不等于“外部可达”，网络连通性必须分层验证到安全组或防火墙 浏览器安全机制与 M2M 安全机制应明确分流，避免互相误伤 从当前阶段看，单节点模板渲染方案已经满足 MVP 验证需求，但如果继续演进到生产高可用架构，后续仍需要关注：\nBroker 集群化与配置中心化 Webhook 异步化，避免后端被同步写流量放大 从共享密钥逐步演进到双向 TLS 或更强的设备身份体系 这次修复的价值不在于解决了某一条启动报错，而在于把原本分散在运行时、配置、网络与安全层的故障链路，重新整理成了一条可解释、可验证、可重复执行的接入链路。\n","permalink":"https://intent.me/zh-cn/blog/tech/iiot-emqx-rootless-deployment-postmortem/","summary":"记录一次 IIoT 接入链路部署故障的完整排查：在 Podman Rootless 环境下，EMQX 5.8 同时暴露出 Erlang IPC 失效、HOCON Schema 校验失败、安全组未放通和 M2M 请求被 CSRF 误拦截等问题，并给出最终修复方案与验证结果。","title":"IIoT 接入链路排查与修复记录：Podman Rootless 下 EMQX 5.8 部署故障复盘"},{"content":"在一次 100ms 轮询场景的性能排查中，TrafficMonitor 所在进程的 CPU 使用率长期稳定在 3.6%~4.2%。除资源占用偏高外，界面在高频收发期间也会出现可感知的刷新迟滞和滚动不跟手。对比两个版本实现后，优化版本最终将该指标降到 0.1%~0.6%，同时高频轮询下的 UI 卡顿现象明显缓解。\n这次收益并非来自单点热点函数优化，而是一次典型的工程化收缩：减少高频路径上的中间层、状态机和无效唤醒，让系统回到更符合其职责边界的实现方式。\n本文复盘的优化对象来自开源工具 Modbus-Tools。如果需要先了解工具的整体定位与核心能力，可先阅读该介绍文章。\n背景与结论 这次优化的结论可以概括为两点：\nTrafficMonitor 回归轻量流量展示组件，不再承担复杂日志系统职责 工作线程在无事件时真正阻塞，而不是以固定时间片持续轮询 对应到代码层，主要改动集中在四个方向：\nTrafficMonitorWidget 从事件模型回退到直接文本追加 ModbusTcpView 与 ModbusRtuView 删除轮询汇总和抑制逻辑 ModbusClient 从 wait_for + 5ms slice 改为 wait_until SerialChannel 与 TcpChannel 移除高频路径上的线程诊断日志 关键改动 1. UI 日志链路收缩 复杂版本中，TrafficMonitor 引入了完整事件抽象，日志写入路径为：\n调用方 -\u0026gt; 构造事件对象 -\u0026gt; 组件判定级别/模式 -\u0026gt; 渲染文本 -\u0026gt; 写入列表\n优化版本删除这套中间层，保留 appendTx()、appendRx()、appendInfo()、appendWarning() 等直接入口，路径收缩为：\n调用方 -\u0026gt; 直接格式化文本 -\u0026gt; 追加到 QListWidget\n这一步减少了对象构造、事件分类、二次渲染和状态同步开销，是本次 CPU 下降的主要来源之一。\n2. 组件实现从 Model/View 回退到直接列表 复杂版本的 TrafficMonitorWidget 不只是显示控件，还维护了完整的数据层，包括：\nQListView + QAbstractListModel pendingEvents_ eventHistory_ flushTimer_ rebuildScheduled_ pausedEventCount_ 这类设计在功能上更完整，但在 100ms 高频刷新下，会持续放大以下成本：\n事件入队与批量刷新 文本格式化与过滤判断 历史重建与滚动同步 优化版本改回 QListWidget 直接追加，本质上是删除一层常驻的数据管理系统。对高频日志场景，这种收缩带来的收益非常直接。\n3. 删除高频附加能力与轮询状态机 复杂版本围绕“更强可观察性”增加了多项能力，包括：\nPause View Raw Frames Level Filter 暂停计数 可见列表重建 Poll Summary 汇总状态机 这些能力单独看都合理，但在高频场景下，会把每条日志都变成“需要判断和调度的事件”，而不是“直接展示的文本”。\n尤其是 ModbusTcpView 与 ModbusRtuView 中的 Poll Summary 逻辑，虽然减少了可见日志条数，但并未减少系统总工作量。每次轮询仍需更新计数、维护状态、判断窗口并格式化摘要。优化版本删除这套逻辑后，链路明显缩短。\n4. 等待模型改为真正阻塞 除 UI 外，另一处关键改动在 ModbusClient 的等待策略。\n复杂版本使用 cv_.wait_for(lock, 5ms, ...) 配合事件泵机制，意味着线程即使没有真实事件，也会以 5ms 周期被唤醒一次。对 100ms 轮询场景，这会制造大量无效 wakeup。\n优化版本统一改为：\ncv_.wait_until(lock, deadline, predicate)\n改造后的效果是：\n无事件时线程真正休眠 仅在超时或条件满足时唤醒 不再周期性处理当前线程事件 这部分改动直接降低了工作线程空转，是本次优化的另一主要来源。\n5. 清理高频调试输出 SerialChannel.cpp 和 TcpChannel.cpp 中，复杂版本保留了线程上下文诊断逻辑，例如：\nthreadToken(...) logThreadContextOnce(...) open()、onReadyRead()、onConnected() 中的线程日志 单条日志成本不高，但它们位于高频收发路径，累计后会放大格式化和分支判断开销。优化版本将其移除，是一次典型的高频路径减负。\n收益为什么明显 这次优化的价值，不在于把某个函数从 10ms 优化到 2ms，而在于删除了高频链路上的多项乘法项。\n复杂版本的一次轮询收发，通常会经历：\nIO 回调 事件对象构造 级别与模式判断 队列操作 定时器唤醒 批量渲染 过滤判断 历史重建 自动滚动 优化版本则更接近：\nIO 回调 文本格式化 直接追加到列表 当调用频率固定为 100ms 时，链路长度差异会持续放大，最终直接反映为 CPU 使用率从 3.6%~4.2% 降到 0.1%~0.6%。由于主线程不再持续承受事件分发、批量刷新和列表重建压力，界面交互也恢复到更稳定的状态。\n工程经验 这次复盘沉淀出几条比较明确的工程经验：\n高频场景下，功能复杂度本身就是性能成本 GUI 性能问题很多时候不在绘制，而在绘制前的事件系统和状态机 对等待模型来说，能阻塞就不要轮询，能按条件唤醒就不要按时间片唤醒 日志组件职责越清晰，实现越容易稳定，性能也越可控 从结果看，这次优化并不是“做了很多技巧性调优”，而是重新收缩了系统边界。当 TrafficMonitor 回归轻量视图、ModbusClient 回到真正阻塞等待后，系统不再为低频附加能力支付高频成本，整体负载自然回落。\n如需进一步了解工具能力全貌，可参考 Modbus-Tools 介绍文章。项目源码见 GitHub 仓库：mingyucheng692/Modbus-Tools。\n","permalink":"https://intent.me/zh-cn/blog/tech/trafficmonitor-cpu-optimization-postmortem/","summary":"记录一次高频轮询场景下的 CPU 优化复盘：通过收缩 UI 日志链路、删除高频状态机、改造线程等待模型，将进程 CPU 使用率从 3.6%~4.2% 降到 0.1%~0.6%。","title":"TrafficMonitor CPU 占用优化复盘：从 4% 降到 0.6% 的工程化收缩"},{"content":"这篇复盘记录一次容器恢复链路失效后的排查与修复过程。在一套由 Rootless Podman 托管的业务服务中，数据库容器异常退出后没有按预期被自动拉起，随后又陆续暴露出启动超时、容器在线但服务 inactive、以及频率限制锁死等问题。麻烦之处不在单次退出，而在于同类故障在不同时间可能表现为自动恢复、反复重启后锁死，或容器仍在运行但托管状态已经脱节。\n说明：本文中的项目名、服务名、路径、域名、用户名、主机标识、时间点、PID、命令输出与日志片段均已做脱敏、抽象或重写，仅保留与排障链路相关的技术信息，不对应任何真实生产标识。\n运行时：Rootless Podman 4.9.x 进程托管：Systemd User Units 业务组件：TimescaleDB、Redis、Go Backend、Nginx Frontend 目标能力：容器异常退出后由 Systemd 自动恢复 事故摘要 触发背景：数据库容器异常退出后，现场最先表现为数据库对应 Unit inactive，其余服务对应 Unit 仍为 active 过程表现：进入修复后，数据库服务先恢复为 active，但其余服务一度未被 Systemd 正确接管，出现启动超时、容器状态与 Unit 状态不一致，以及部分服务因频率限制被锁死 已确认的致因因素：主 Unit 曾被脚本通过 sed -i 直接改写，并在 PowerShell 转义截断后被写坏；Compose 与 Systemd 并存导致控制权分离；自动生成的 Unit 仍需后处理；恢复脚本对依赖清理和失败恢复覆盖不足 修复方向：停止用 sed -i 直接改主 Unit，改为重新生成 .service 后由 Python 脚本修补，并显式把运行期控制权移交给 Systemd，再补齐状态轮询与最终健康检查 当前结果：在当前环境中，恢复路径已能按固定顺序执行，失败时也有明确的诊断入口；但 readiness 判定和监控采集仍待完善 现象与影响 最初现场并不是“所有服务一起掉线”，而是数据库对应 Unit 先变成 inactive，其余服务仍保持 active。真正把问题放大的，是进入修复和接管阶段后又暴露出另一组现象：\n有些服务在 systemctl --user status 中显示超时重启 有些容器已经 Up，但对应的 Systemd 服务却仍是 inactive 有些服务因为频繁失败被 Systemd 直接锁死，不再继续重试 这说明问题不是某一个容器单点异常，而是“容器实际状态、Systemd 托管状态、部署脚本默认假设”三者之间已经出现偏差。本文聚焦“恢复链路为什么失效、脚本后来怎样调整”，不展开数据库掉线本身的业务触发原因。\n排查思路：先确认谁真正拥有进程控制权 这次排查没有从单个报错入手，而是先回答一个基础问题：容器退出后，究竟是谁负责发现它、判断它失败并把它重新拉起来。沿着这条线索，最终定位到四类相互叠加的问题：\n问题一：主 Unit 被直接修改，最终损坏 最先暴露的问题，是数据库对应的主 Unit 被破坏成了空文件：\nsystemd[\u0026lt;pid\u0026gt;]: /home/\u0026lt;deploy-user\u0026gt;/.config/systemd/user/container-\u0026lt;db-service\u0026gt;.service:1: Missing \u0026#39;=\u0026#39;. 进一步检查文件本体时，可以直接看到它已经被截断为 0 字节：\nls -l /home/\u0026lt;deploy-user\u0026gt;/.config/systemd/user/container-\u0026lt;db-service\u0026gt;.service # -rw-rw-r-- 1 \u0026lt;deploy-user\u0026gt; \u0026lt;deploy-user\u0026gt; 0 \u0026lt;timestamp\u0026gt; container-\u0026lt;db-service\u0026gt;.service file /home/\u0026lt;deploy-user\u0026gt;/.config/systemd/user/container-\u0026lt;db-service\u0026gt;.service # container-\u0026lt;db-service\u0026gt;.service: empty 这次对触发链已经能说得更具体：旧版脚本确实会直接用 sed -i 修改主 Unit；而在通过 PowerShell 下发这类命令时，转义处理存在偏差，导致目标文件没有被正确写回，最终出现主 Unit 被截断为 0 字节、Systemd 无法继续解析的结果。这里的问题不是单纯的“脚本里用了 sed -i”，而是“把主 Unit 当作可原地改写对象，再叠加跨 Shell 转义差异”，共同放大了损坏风险。\n旧版脚本中的关键路径大致如下：\n# 旧版：直接修改主 Unit sed -i \u0026#39;s/Restart=always/Restart=no/g\u0026#39; \u0026#34;$service\u0026#34; 问题已经足够明确：主服务文件同时承担“运行定义”和“运维开关”两种角色，本身就是高风险设计。一旦主 Unit 损坏，解析、启动和重启策略会一起失效。\n后来的调整比较直接：\n主服务文件只保留生成产物属性，不再由 sed -i 直接修改 重启策略等开关下沉到 .service.d/watchdog.conf 生成后的 .service 兼容性修补交给 Python 处理，Drop-in 则由脚本覆盖写入 当前脚本里，Drop-in 的写入方式类似下面这样：\ncat \u0026gt; \u0026#34;$DROP_IN_DIR/watchdog.conf\u0026#34; \u0026lt;\u0026lt; EOF [Service] Restart=always RestartSec=10s ExecStartPre=-/usr/bin/podman rm -f \u0026lt;db-service\u0026gt; EOF 这样做至少把边界拆清楚了：主 Unit 视为可重建产物，运行期开关统一由 Drop-in 管理。就当前脚本和后续现场结果看，也没有再复现“因原地修改主 Unit 而导致服务文件损坏”的问题。\n问题二：Systemd 认定失败，但容器实际已经启动 第二类问题更隐蔽。故障阶段，Systemd 会把某些服务标记为启动超时：\nActive: activating (auto-restart) (Result: timeout) Main PID: \u0026lt;pid\u0026gt; (code=exited, status=0/SUCCESS) 但同时 podman ps 又能看到容器确实已经起来了，本质上是进程契约不一致。\npodman generate systemd 生成的 Unit 默认使用 Type=notify。这意味着 Systemd 不会仅凭进程存在就判定服务启动成功，而是要求运行中的容器向宿主发送 sd_notify 就绪信号。\n故障阶段未完成修补时，生成产物中的关键字段如下：\nType=notify NotifyAccess=all 恢复后重新采集当前环境时，重新生成的产物仍然保持这个默认值；而成功上线后的生效 Unit 已经被修补为 Type=simple：\n# 故障期对应的默认生成结果 Type=notify # 修复上线后的生效 Unit Type=simple 这次现场能确认的现象是：在服务尚未完成稳定托管、生成产物仍保持 Type=notify 的阶段，容器已经 Up，但对应 Unit 仍会在超时后被 Systemd 判为失败并重启。后来的修复做法，是把生成产物中的 Type=notify 改成 Type=simple，把状态认定切回到“由 Systemd 直接追踪前台进程”。\n这里需要说明边界：这并不等于已经证明 Rootless 场景下 notify 一定不可用，而是当前这套环境里，notify 没有表现出稳定、可依赖的就绪语义。对这类长驻前台进程来说，Type=simple 是更保守、也更容易排障的选择。\n问题三：一部分容器并不在 Systemd 的控制内 更准确地说，这个危险状态出现在修复过程里，而不是最初故障现场：数据库对应 Unit 已恢复为 active，但其余服务对应 Unit 仍显示 inactive (dead)。\n当时现场的状态大致如下：\npodman ps # ... 部分业务容器可能已经重新起来 systemctl --user list-units \u0026#39;container-*.service\u0026#39; # ... container-\u0026lt;db-service\u0026gt;.service 为 active # ... 其余服务对应 Unit 为 inactive (dead) 原因是这些容器并不是由 Systemd 拉起，而是早先通过 podman-compose up -d 直接启动的。对于这类非托管容器，即使容器进程已经存在，Systemd 也没有进程控制权，自然谈不上稳定接管、失败感知和自动恢复。\n这件事暴露出一个容易被忽略的前提：托管能力不仅取决于 Unit 文件是否存在，更取决于进程是不是由 Systemd 持有。如果运行期仍然由 Compose 持有进程控制权，那么配置虽然在，恢复能力并没有真正建立起来。\n这里也需要说明实现细节：当前部署脚本并不是完全不用 Compose。它仍会先用 podman-compose 完成构建和冷启动，再调用 watchdog-enable.sh 生成并修补 Unit，随后显式停止这些 compose 容器，最后由 systemctl --user start 按顺序接管。问题不在于“脚本里出现过 podman-compose”，而在于运行期是否仍由 Compose 持有进程控制权。后来的调整，就是把这一步接管固定下来：\n停止现有非托管容器，重新由 systemctl --user start 接管 在接管前后做状态检查，避免再次留下“容器在跑但 Systemd 不知情”的状态 问题四：自动生成与恢复脚本还需要补齐细节 1）自动生成的 Unit 仍然需要后处理 重新使用 podman generate systemd --new 后，至少暴露出三类问题：\n通过 podman-compose 等工具初始化的参数也会带来干扰。例如，compose 生成的容器可能带有 -d 参数，podman generate systemd --new 会将其原样提取。这会导致 Systemd 执行 podman run -d 时进程立即退出，无法追踪容器主进程 另一个典型案例是 Redis 的配置。若原本通过空参数禁用危险命令（如 rename-command \u0026quot;\u0026quot;），自动生成工具会静默剥离空字符串，导致最终启动命令变为 redis-server --rename-command FLUSHALL，引发启动参数报错 Type=notify 默认值仍会再次引入 Rootless 场景的超时问题 Redis 的问题在原始容器参数和生成结果之间可以直接对照出来：\n# 原始容器参数 command: - --rename-command - \u0026#34;FLUSHALL\u0026#34; - \u0026#34;\u0026#34; # 未修补的 generate 结果 redis-server --rename-command FLUSHALL 恢复后重新执行 podman generate systemd --new --name \u0026lt;redis-service\u0026gt;，仍能看到生成结果里空字符串被吞掉；而当前生效 Unit 中已经补回：\n# 恢复后重新生成的结果 --rename-command FLUSHALL # 修复上线后的生效 Unit --rename-command FLUSHALL \u0026#34;\u0026#34; -d 也是同类问题。恢复后对照生成产物与生效 Unit，可以直接看到默认生成结果仍包含 -d，而接管用的 Unit 已经将其移除：\n# 恢复后重新生成的结果 ExecStart=/usr/bin/podman run \\ ... -d \\ --sdnotify=conmon \\ # 修复上线后的生效 Unit ExecStart=/usr/bin/podman run \\ ... --sdnotify=conmon \\ watchdog-enable.sh 当前的做法，是先重新执行 podman generate systemd --new --name ...，再用 Python 脚本修补生成产物。实际落地的处理包括三件事：\n把 Type=notify 改成 Type=simple 移除 ExecStart 中可能残留的 -d 对 Redis 的 rename-command \u0026quot;\u0026quot; 做回填，避免空参数在生成阶段丢失 这里使用 Python 的原因也很具体：它更适合处理由 \\ 续行的多行命令，能让生成产物的修补行为更可控。\n2）依赖关系会影响失败容器的清理 数据库服务恢复前，需要先执行 podman rm -f 清理旧容器；但由于上游服务仍持有依赖引用，删除动作返回 exit 125。这说明单容器恢复不是孤立操作，依赖图会反向影响清理过程。\n直接报错类似这样：\nProcess: ExecStartPre=/usr/bin/podman rm -f \u0026lt;db-service\u0026gt; (code=exited, status=125) 当前脚本的处理方式，是把依赖清理逻辑写进 Drop-in：\n数据库服务在 ExecStartPre 中会先停掉依赖它的上层服务，再执行 podman rm -f \u0026lt;db-service\u0026gt; 其他几个服务只清理各自容器 这不是一套通用模板，只是针对这次这组服务依赖关系做的脚本化处理。\n3）恢复链路不能只依赖固定 sleep 早期脚本里确实存在靠固定 sleep 等待依赖的做法。\n当前部署脚本在 Compose 冷启动阶段仍保留了少量固定等待；但在控制权移交给 Systemd 之后，已经改成按依赖顺序逐个 systemctl --user start，并配合 wait_for_service() 轮询 is-active / is-failed，在失败或超时时输出对应 journalctl。这比盲等更容易定位失败点。\n因此最终把启动顺序改成显式编排：\n\u0026lt;db-service\u0026gt; -\u0026gt; \u0026lt;redis-service\u0026gt; -\u0026gt; \u0026lt;backend-service\u0026gt; -\u0026gt; \u0026lt;frontend-service\u0026gt; 另外，脚本在全部服务接管完成后还会追加一次应用级健康检查，用来确认“服务 active”已经进一步接近“对外可用”。这一步是对 Type=simple 取舍的补充，不是每个服务启动时都做的深度 readiness 检查。\n交接给 Systemd 后，等待逻辑大致如下：\nsystemctl --user start container-\u0026lt;db-service\u0026gt;.service for i in $(seq 1 60); do systemctl --user is-active --quiet container-\u0026lt;db-service\u0026gt;.service \u0026amp;\u0026amp; break systemctl --user is-failed --quiet container-\u0026lt;db-service\u0026gt;.service \u0026amp;\u0026amp; exit 1 sleep 1 done 4）Systemd 的频率限制会把服务锁死 在依赖尚未就绪时（例如 Nginx 在启动时会强行解析 upstream 的 \u0026lt;backend-service\u0026gt; 域名），Frontend 会反复失败重启，随后触发 StartLimitBurst 限制（例如在 300 秒内重启超过 10 次）。此时即使后续依赖恢复正常，Systemd 也不会再自动拉起该服务，必须显式执行：\ncontainer-\u0026lt;frontend-service\u0026gt;.service: Start request repeated too quickly. container-\u0026lt;frontend-service\u0026gt;.service: Failed with result \u0026#39;exit-code\u0026#39;. 这类失败在 Nginx 侧通常还能看到更直接的应用级证据：\n[emerg] 1#1: host not found in upstream \u0026#34;\u0026lt;backend-service\u0026gt;\u0026#34; in default.conf:31 systemctl --user reset-failed container-\u0026lt;frontend-service\u0026gt;.service deploy-all.sh 当前也已经在拉起 Frontend 之前补了一次 reset-failed，用来清掉此前可能残留的锁死状态。\n本次故障中已确认的致因因素 这次故障表面上看是数据库异常退出后，现场状态与接管状态不断分裂；回头看，本质上是几类问题叠加：\n主 Unit 直接被脚本改写，服务定义本身有被破坏的风险。 podman-compose 直接启动和 Systemd 托管并存，容器状态与托管状态会分离。 podman generate systemd 的产物不能直接拿来用，还需要补齐 Type、-d 和参数修补。 恢复脚本对依赖清理、频控恢复和最终健康检查最初覆盖得不够完整。 这不是某一个开关写错导致的单点问题，而是恢复链路从定义、接管到执行都存在断点。对应的修复思路也不是只处理某一条报错，而是把运行期控制权、生成后的修补动作和失败后的诊断入口一起补齐。\n已落地的修复动作 结合 watchdog-enable.sh 和 deploy-all.sh，这次实际落地的动作大致如下：\n部署脚本先校验执行用户、HOME、XDG_RUNTIME_DIR 和 DBUS_SESSION_BUS_ADDRESS，减少 Rootless User Units 因运行环境不一致而失效的概率 部署阶段仍使用 podman-compose 完成构建和冷启动，但在交接阶段会显式停掉 compose 容器，再由 Systemd 顺序接管 watchdog-enable.sh 会先备份旧 Unit，再重新生成 .service 生成后的 .service 改由 Python 脚本做三项修补：改 Type=simple、删 -d、补 Redis 空参数，同时避开继续用 sed -i 直接改主 Unit 时的 PowerShell 转义截断问题 Drop-in 中统一写入 Restart=always、RestartSec、StartLimit* 和各服务对应的 ExecStartPre 交接给 Systemd 后，脚本按 \u0026lt;db-service\u0026gt; -\u0026gt; \u0026lt;redis-service\u0026gt; -\u0026gt; \u0026lt;backend-service\u0026gt; -\u0026gt; \u0026lt;frontend-service\u0026gt; 顺序启动，并通过 wait_for_service() 检查 is-active / is-failed；若失败或超时，直接输出对应 journalctl 在拉起 Frontend 前补一次 reset-failed，避免残留的频率限制状态阻断恢复 全部服务接管完成后，再追加一次应用级健康检查，确认系统已经从“服务 active”进一步接近“对外可用” 这套做法仍然是单机、Rootless、User Units 这组约束下的工程化实现，解决的是“谁接管进程、怎样恢复、失败后怎样排查”这些具体问题，不等于更广义的高可用方案。\n修复后如何验证 接管前后对照 podman ps 与 systemctl --user list-units，确认不再出现“容器在跑但对应 Unit 为 inactive”的状态 通过 wait_for_service() 轮询 is-active / is-failed，让服务在启动阶段就暴露失败点，而不是交给固定 sleep 若服务失败或超时，立即输出对应 journalctl，把排查入口固定到具体 Unit 全部服务接管完成后追加一次应用级 /health 检查，用来验证外部可用性不再只依赖 Type=simple 的进程在线语义 恢复后再次核对时，四个容器与四个 User Unit 已经重新对齐：\npodman ps \u0026lt;db-service\u0026gt; / \u0026lt;redis-service\u0026gt; / \u0026lt;backend-service\u0026gt; / \u0026lt;frontend-service\u0026gt; 均为 Up systemctl --user list-units \u0026#39;container-*.service\u0026#39; 对应四个 Unit 均为 active (running) 这些验证主要覆盖“是否由 Systemd 持有进程”“失败时是否能及时暴露”“接管完成后是否对外可用”，还没有扩展到更细的 readiness 信号和监控采集。\n这次排障后明确的约束 主 Unit 只作为生成产物保留，运行期开关统一放到 Drop-in。这样做不是为了形式上的规范，而是为了减少主文件损坏时同时打断解析、启动和重启策略的风险。 podman generate systemd 的产物不能直接视为可运行结果，生成、修补、重载应被看作同一个步骤。就这次脚本而言，Type=notify、-d 和空参数问题都属于生成后必须立即处理的兼容性项。 部署期和运行期的控制面需要明确分开。部署阶段仍可使用 Compose 做构建和冷启动，但进入运行期后，进程控制权必须显式交回 Systemd，否则“已经写了重启配置”和“恢复能力真的生效”会继续混在一起。 恢复链路不能只写 Restart=always 就结束，还需要覆盖依赖清理、频控恢复、状态轮询和最终健康检查。这样做的目的不是堆功能，而是让故障后的排查和恢复路径可重复。 后续演进方向 这次修复解决了恢复链路中的几个确定问题，但仍有几类风险没有被完全覆盖，长期维护上还需要继续推进：\nType=simple 配合 is-active / is-failed 和最终 /health 检查，解决了“进程在线”和“最终可用”的判断问题，但还没有覆盖更细粒度的 readiness 信号；如果服务启动后短时间内可见为 active、随后才暴露内部初始化失败，当前脚本仍可能滞后发现。 当前方案仍依赖 podman generate systemd 加脚本修补；只要生成产物的格式、参数展开方式或 Podman 版本行为发生变化，现有补丁逻辑就可能失效，因此是否迁移到 Quadlet 或其他声明式方式，仍需要单独评估。 目前状态验证仍偏脚本内诊断；失败次数、退出码、容器状态、频率限制命中情况还没有接入采集、上报和告警。这意味着脚本外仍缺少持续观测能力，类似问题更可能在故障发生后才被动暴露。 这次排障带来的直接改进 进程由谁启动、谁负责重启，边界比之前清楚了 自动生成的 Unit 是否可直接使用，有了明确判断和后处理步骤 接管失败时该看哪一层日志、在哪一步退出，脚本里有了稳定入口 服务 active 与对外可用之间的差异，被补上了一层应用级验证 这些改动并不意味着这套方案已经成为更广义的高可用设计，但至少把“重启配置存在却无法稳定恢复”的状态改成了一条更容易验证、也更容易重复执行的恢复链路。\n结语 这次排查最后留下的结论很直接：配置上写了 Restart=always，并不代表恢复链路就已经完整。主 Unit 是否稳定、容器是不是由 Systemd 启动、生成产物有没有修补、依赖和频率限制有没有被脚本覆盖，这些细节都会影响最终结果。\n至少在这次这套单机 Rootless Podman 场景里，把这些断点补进脚本之后，进程控制权、排查入口和故障处置路径都比之前清楚了很多。这未必意味着方案已经“完美”，但已经把一次原本依赖经验判断的故障恢复，整理成了一条更可解释、也更容易验证的工程链路。\n附录：关于 Linger 机制的补充说明 Rootless Podman 如果配合 systemd --user 托管服务，通常需要先为目标用户开启 linger。否则一旦该用户退出登录，用户级 Systemd 实例可能被回收，相关服务也无法在“无人登录”的情况下持续运行。本次事故发生时环境已经满足 Linger=yes，因此 linger 不是本次故障根因，这里仅作为前置检查项补充。\n# 检查状态 loginctl show-user \u0026lt;deploy-user\u0026gt; --property=Linger # 若未启用，由 sudo 用户执行： sudo loginctl enable-linger \u0026lt;deploy-user\u0026gt; ","permalink":"https://intent.me/zh-cn/blog/tech/rootless-podman-systemd-watchdog-postmortem/","summary":"一次 Rootless Podman + Systemd 托管失效的复盘：从主 Unit 损坏、启动状态判定偏差到部署脚本显式移交 Systemd 接管，梳理已确认的问题、修复动作、验证方式与未覆盖风险。","title":"Rootless Podman + Systemd 托管失效复盘：一次恢复链路排查与修复记录"},{"content":"这篇复盘记录一次 JWT 双票（AT/RT）治理的真实过程。我们要解决的问题很直接：如果刷新令牌在异常场景下被重复使用，系统能不能及时发现并收口。\n说明：本文涉及的域名、IP、账号标识、日志字段、会话标识均为脱敏示例，不对应任何真实生产信息。\n系统边界如下：\n前端：Web SPA（AT 仅存放在内存） 后端：Go + Gin（鉴权与刷新） 会话层：Redis（令牌状态索引） 网关：HTTPS 反向代理 现象与触发条件 在储能云平台的日常安全演练中，我们针对海量边缘网关与前端看板的认证链路进行了渗透测试，其中重点模拟了“旧 RT 被拦截并重复提交”的极端情况。\n旧方案虽然有 AT/RT 分层，但在刷新链路上缺少状态约束，导致旧 RT 在短时间窗口内仍可能被并发利用。\n脱敏后的事件样式如下：\n{ \u0026#34;event\u0026#34;: \u0026#34;auth.refresh.reuse_detected\u0026#34;, \u0026#34;user_id_masked\u0026#34;: \u0026#34;u-***39\u0026#34;, \u0026#34;session_id\u0026#34;: \u0026#34;s-***b1\u0026#34;, \u0026#34;ip_masked\u0026#34;: \u0026#34;10.**.**.21\u0026#34;, \u0026#34;action\u0026#34;: \u0026#34;session_revoked\u0026#34; } 排查与改造策略：保留 AT 无状态，强化 RT 治理 我们没有改变双票结构，而是在 RT 刷新路径补齐状态管理，核心目标是“可撤销、可审计、可回收”。\n第一层：明确令牌职责 AT 负责高频访问，走 Authorization: Bearer RT 只通过 HttpOnly Cookie 传输 AT 校验仍保持无状态，确保吞吐和扩展性 这样做的好处是：把控制成本留在 RT 通道，不影响 AT 的轻量访问特性。\n第二层：将 RT 刷新改为单次可追踪流程 刷新链路采用“校验旧 RT -\u0026gt; 签发新 RT -\u0026gt; 更新状态 -\u0026gt; 失效旧 RT”的顺序，避免并发刷新出现双活。\nRedis 键模型（脱敏示例）：\nrt:active:{jti} -\u0026gt; { user_id, session_id, exp } // 核心状态，TTL 跟随 RT 过期时间 rt:session:{session_id} -\u0026gt; Set\u0026lt;jti\u0026gt; // 冗余索引，用于会话级一键清理 rt:deny:{jti} -\u0026gt; 1 (TTL=剩余有效期) // 黑名单，拦截被重放的旧票据 user:sessions:{user_id} -\u0026gt; Set\u0026lt;session_id\u0026gt; // 用户级管控，支持“踢出其他设备” 对应约束：\n同一 RT 只允许成功刷新一次 新 RT 生效后，旧 RT 立即进入 deny 列表 刷新过程加短时锁，避免并发竞态 并发防抖设计（结果复用窗口） 现代前端 SPA 往往会并发发起多个请求，这很容易导致旧 RT 同时触发多次刷新，进而把合法请求误判为重放。为此，我们设计了 5 秒复用窗口：\n第一个请求拿到锁并完成 RT 轮换 轮换得到的 AT/RT 对会缓存 5 秒 相同 key 的并发请求在 5 秒内直接复用这组 AT/RT，不再触发二次轮换 超过窗口后恢复严格单次轮换策略 第三层：异常重放后的处置策略 一旦检测到 RT 重放，系统会触发会话级失效并记录审计事件，再由前端回到登录流程。\n目标不是“继续尝试修补会话”，而是尽快结束风险会话。\n第四层：把登出和高危操作接入服务端吊销 以下操作统一接入会话失效流程：\n用户主动退出登录 管理员重置密码 用户状态调整为锁定或禁用 这一步把“只删浏览器 Cookie”升级为“服务端状态真实失效”。\n根因总结 这次问题本质上是治理深度不够，而不是单点代码缺陷：\n早期实现关注“签发与验签”，对令牌生命周期管理覆盖不足。 会话状态能力已有技术预留，但未完整接入刷新主链路。 改造后的收益 这次治理后，链路上最明显的变化有三点：\n会话可以在服务端快速撤销 RT 重放可以被识别并审计 安全事件可按用户与会话维度快速定位 对业务体验的影响可控：正常请求保持无感刷新，异常场景统一回到重新登录。\n过程改进与行动项 1）把令牌生命周期校验纳入发版检查 上线前新增固定检查项：\nRT 单次使用验证 刷新并发一致性验证 登出/重置密码后的会话失效验证 2）统一安全审计字段 审计日志统一使用脱敏字段：user_id_masked、session_id、jti_prefix、ip_masked、ua_hash、risk_level。\n3）固定开展重放演练 按月执行一次“RT 重放”演练，持续验证告警、失效和回收链路是否生效。\nPhase 2 演进路线（Roadmap）：从会话可控到上下文零信任 经过本次改造，我们已经完成 Phase 1 的核心目标：构建了 Redis 会话撤销链路与并发防重放机制。Refresh 流程会实时校验用户状态，账号进入锁定或禁用状态时会即时阻断新票据签发。\n针对更高阶的 Token 劫持场景，Phase 2 规划继续向上下文零信任演进：\n上下文感知风控：计划引入环境指纹（IP 段 + UA Hash）对比，当 RT 出现异常地域跳变时，自动执行 AT/RT 全量失效，并联动账号冻结与三方二次身份核验。 误杀率治理：考虑到工业现场可能出现 4G/5G 频繁切网、出口漂移等合法抖动，相关策略将先完成风控模型调优，再按灰度方式切入主链路。 结语 认证安全真正考验的，不是签发速度，而是异常发生后的收口能力。\n这次改造最大的价值，是让 AT/RT 架构从“能用”走向“可治理”。\n","permalink":"https://intent.me/zh-cn/blog/tech/jwt-at-rt-redis-hardening-postmortem/","summary":"一次针对 JWT AT/RT 架构的安全加固复盘：将 Redis 预留能力视为已落地，实现 RT 轮换、重放检测与会话可撤销。","title":"JWT 双票体系加固复盘：从无状态刷新到 Redis 可撤销会话"},{"content":"近期在推进核心业务线基础架构安全演进时，我们将全局 HTTP 协议升级为 HTTPS。Nginx 网关层完成 SSL 配置并开启 443 强转后，回归测试稳定复现登录接口 403 Forbidden。\n返回报文如下：\n{\u0026#34;code\u0026#34;:403,\u0026#34;msg\u0026#34;:\u0026#34;forbidden: invalid origin\u0026#34;} 系统架构：\n前端：Vue 后端：Go + Gin 网关：Nginx 反向代理 运行时：Podman（Rootless 模式） 排查策略：自顶向下，逐层剥离 本次排查遵循“边界清晰、逐层验证”的原则，依次穿透网关层、应用层和容器运行时层。\n第一层：网关层（Nginx） 第一反应是 Nginx 未正确透传请求头。检查配置后确认 Origin 等关键头部已正常透传。\n调试过程中发现，只要在网关中强行注入：\nproxy_set_header Origin \u0026#34;http://业务域名\u0026#34;; 接口就能“恢复正常”。\n但这个方案本质是在伪造请求来源，用 HTTP 值去冒充 HTTPS 请求，直接绕过后端 Origin 校验，等价于削弱 CSRF 防线。该 workaround 被明确废弃，继续向应用层深挖。\n第二层：应用层（CORS 与 CSRF 的错位） 抓包确认响应头中已经返回：\nAccess-Control-Allow-Origin: https://业务域名 说明 CORS 中间件对 HTTPS 来源已经放行。问题在于：\nCORS 负责“浏览器能否读取跨域响应” CSRF 负责“后端是否信任请求来源” 两者并行但独立。\n最终在代码中定位到核心遗漏：协议升级时只更新了 CORS 白名单，CSRF 中间件仍保留旧的 http:// 信任列表。请求通过了 CORS，却被 CSRF 拦截并返回 invalid origin。\n修复动作：同步补齐 CSRF 中间件中的 https:// 域名白名单。\n第三层：运行时层（Podman Rootless 隔离） 代码修复并触发构建后，线上现象仍未消失。进一步通过 podman inspect 对比镜像哈希，发现了一个工程化陷阱：\n生产服务运行在 deploy 账号的 Rootless 命名空间 部分镜像曾在 Root 命名空间构建 Rootless 隔离下，deploy 无法感知 Root 空间里的新镜像 结果是部署脚本反复重启旧镜像，形成“代码已修复、环境未生效”的假象。\n为从机制上杜绝该类问题，我们在生产机落地了强约束：通过 /usr/local/bin/podman 与 /usr/local/bin/podman-compose 包装脚本，检测到 uid=0 直接拒绝执行并退出；which podman 指向包装器后，即使 root 误操作也会立即失败，避免再次写入错误命名空间镜像。\n根因总结 这次故障不是单点 Bug，而是两类系统性遗漏叠加：\n架构规范缺口：CORS 与 CSRF 白名单分散配置，协议升级时缺乏统一治理入口。 工程闭环缺失：生产镜像构建权限未完全收敛到标准化 CI/CD，导致跨命名空间脏状态。 过程改进与行动项 1）完善安全发版 SOP 与 CheckList 将“CORS 与 CSRF 策略一致性校验”纳入强制发布检查项，新项目上线和协议升级必须过卡点。\n2）收敛生产环境运维准入 明确应用容器仅允许通过 CI/CD 管道构建与发布，禁止人工跨权限命名空间干预线上服务。\n3）推进可观测性规范 将中间件加载与路由注册顺序纳入架构规范，确保日志层前置于安全拦截层，避免“被拦截但无日志”的幽灵请求。\n结语 这次 403 排查的价值不在于“把接口调通”，而在于暴露了安全配置治理和交付链路上的真实薄弱点。技术修复解决的是当下，流程与制度收敛解决的是下一次不再重演。\n","permalink":"https://intent.me/zh-cn/blog/tech/https-upgrade-403-postmortem/","summary":"一次 HTTPS 协议升级后稳定复现 403 的排障复盘，最终定位为 CSRF 白名单遗漏与 Podman Rootless 镜像隔离叠加问题。","title":"记一次 HTTPS 升级引发的 403 排查：从安全中间件到容器隔离机制的深度复盘"},{"content":"在 Modbus-Tools 的迭代过程中，遇到过一次典型的并发问题：窗口已关闭，但进程未退出，调试会话也无法正常结束。后续修复中，又暴露出通信超时误判与重入竞争问题。本文记录排查过程与最终方案。\n现象一：退出阶段卡住 主线程销毁 MainWindow 时会等待工作线程退出 工作线程可能仍阻塞于串口或网络读写流程 UI 已消失，但进程保持存活 原因一：线程依附关系不一致 QSerialPort 与 QTcpSocket 的事件处理依赖其所属线程事件循环。若对象创建于主线程，但调用路径在工作线程，退出阶段容易出现互相等待。\n处理一：明确对象归属线程 在通道创建后显式迁移至工作线程：\nstack.thread = std::make_shared\u0026lt;QThread\u0026gt;(); stack.channel-\u0026gt;moveToThread(stack.thread.get()); 同时保证对象创建时不绑定 parent，避免迁移失败。\n现象二：连接后偶发 Timeout 监控抓包可见设备有响应 业务层仍返回超时 原因二：阻塞等待饿死事件循环 请求发送后使用 condition_variable::wait_until 阻塞等待，导致承载 IO 的线程无法及时处理 readyRead 事件，回包回调被延后。\n处理二：改为可让渡事件的等待循环 while (true) { QCoreApplication::processEvents(QEventLoop::AllEvents); if (signalReceived) break; if (timeout) return Error; std::this_thread::sleep_for(std::chrono::milliseconds(1)); } 该方案在可控范围内维持响应性，同时避免忙等占满 CPU。\n现象三：高频操作下偶发自锁 引入 processEvents 后，等待窗口期内可能触发同线程重入，导致同一把互斥锁被重复申请。\n原因三：锁模型与重入路径不匹配 原有 std::mutex 无法支持同线程重复进入临界区。\n处理三：改用递归锁保护请求序列 std::recursive_mutex requestMutex_; 复盘结论 IO 对象所属线程必须在设计阶段固定并贯穿生命周期管理。 IO 线程中应避免长时间阻塞等待，必要时采用可处理中断事件的等待策略。 引入事件让渡后，要同步评估重入路径与锁策略。 这次修复不仅解决了退出挂起，也提升了 Modbus 通信超时判定的一致性。对工业软件而言，稳定退出和时序可解释性与功能本身同等重要。\n","permalink":"https://intent.me/zh-cn/blog/tech/modbus-deadlock-fix-notes/","summary":"记录一次 Qt 多线程 Modbus 场景中的退出死锁与超时误判问题，以及对应的工程化修复方案。","title":"踩坑日记：Modbus 多线程死锁修复记录"},{"content":"Modbus-Tools C++20 | Qt6 | Industrial Protocols\n本文基于 Modbus-Tools 最新版本编写，更新于 2026-04-21\n在工业自动化和嵌入式开发中，Modbus 协议几乎是日常工作的一部分。真正进入现场联调后，时间往往消耗在重复动作上：手动组帧、反复核对日志、逐条做倍率换算。\n开发 Modbus-Tools 的目标很直接：把“发帧、看日志、解帧”三件高频操作做顺手。它不追求功能堆叠，而是优先保证调试效率和可用性。实现上采用 channel / transport / session / parser 分层设计，并配套 CI/CD、多语言与自动更新能力，方便工具持续迭代。\n下面按实际使用顺序，快速介绍这款工具的核心用法。\n1. 快速连接与报文构建 打开软件后，无论是走 Modbus RTU（串口）还是 Modbus TCP（网络），连接参数都在左侧面板，一目了然。\n在调试阶段，最需要的是“快速试错”。Modbus-Tools 将参数输入与功能码操作拆分得非常清晰：\n一键发送常用功能码：填好 从机地址 (Slave ID)、起始地址 和 数量/数据，点击 01/02/03/04/05/06/0F/10 等按钮即可发送。底层自动组帧并计算 CRC/LRC。功能码覆盖 0x01–0x04（读）、0x05–0x06（单写）、0x0F–0x10（多写）。 HEX / DEC 智能识别：Slave ID 与 起始地址 支持 HEX（如 0x10、10H）与 DEC（如 16）两种格式输入，由 parseSmartInt() 统一解析并做范围校验。 写入数据格式可切换（HEX / DEC / Binary）：在 Write Data 旁可通过 Format 下拉框切换 Hex、Decimal 或 Binary，输入习惯可以按项目场景自由调整。 Raw 模式增强：需要发送自定义 Hex 报文时，可直接切换 Raw 输入并发送，适合非标场景或异常流程验证。Raw 模式内置两个辅助按钮： Append CRC (RTU)：自动计算并追加 CRC16 校验值至输入框 Add MBAP (TCP)：自动封装 Modbus TCP 主站报头（Transaction ID / Protocol ID / Length / Unit ID）至输入框 线圈 (Coils) 二进制下发交互 针对位操作场景，工具提供了直观的 Binary 输入模式：\nBinary 输入：支持直接输入比特串（如 1 0 1 1），系统自动编码并配合 0x05（单线圈写入）或 0x0F（多线圈写入）功能码下发。 位级读取：配合 0x01/0x02 读取指令，实现对远程设备线圈与离散输入状态的高效验证。 2. 盯日志与一键复制 (Traffic Monitor) 联调阶段的核心是快速定位问题。Traffic Monitor 重点优化了日志可读性和复用效率。\n收发分离显示：TX（发送）与 RX（接收）采用不同高亮，并附毫秒级时间戳，便于还原交互顺序。 一键复制：可直接复制关键报文用于缺陷复现、团队沟通或测试记录。 按方向过滤：支持仅显示 TX 或 RX，在高频轮询场景下更容易聚焦关键数据。 日志可保存：支持将当前通信记录导出保存，便于归档现场问题与形成测试留痕。 3. 帧解析器 (Frame Analyzer)：告别计算器 Frame Analyzer 是日常使用频率最高的模块之一。将 Hex 报文粘贴后点击解析，即可自动拆解报文结构并输出可读表格。\n对现场调试最有价值的，是以下几个能力：\n解码模式切换 (Unsigned / Signed) 解析器顶部提供 Decode Mode，可在 Unsigned 与 Signed 间切换。切换后会立即按新模式重新解析，十进制、Hex、二进制和换算值会同步更新，查看有符号量（如负温度、反向功率）更直观。\n倍率换算 (Multiplier Scaling) 很多设备会将浮点量按倍率放大后再上送（例如 220.5V -\u0026gt; 2205）。在解析表格中可按寄存器设置 Scale（如 0.1、0.01），系统会实时展示换算后的工程值（显示 2205 -\u0026gt; 220.5V ）。\n多维字节序分析 (Byte Order) 不同厂商的 PLC 和仪表可能采用不同的数据排列方式。解析器支持 四种字节/字序模式，适配各类设备：\nABCD (Big Endian)：大端模式，高位在前 CDAB (Little Endian Byte Swap)：小端字节交换 BADC (Big Endian Byte Swap)：大端字节交换 DCBA (Little Endian)：小端模式，低位在前 切换字节序后，寄存器值会重新计算并展示。\n寄存器功能注释 (Description) 支持为寄存器地址添加描述（如\u0026quot;A 相电压\u0026quot;\u0026ldquo;电机转速\u0026rdquo;）。解析结果与注释并排展示，减少来回翻表的时间。\n配置持久化与模板 (JSON / CSV 导入导出) 配置可自动保持，并支持导入/导出 JSON / CSV 模板。\n自动保存：保留最近使用的倍率与描述配置。 模板复用：按设备或项目保存独立模板，切换调试对象时可直接导入。 其他实用细节 Format Hex 按钮：可一键清洗并规范 Hex 输入格式，粘贴长报文后更易读。 响应起始地址可配置：针对响应帧解析可设置 Start Address，让地址映射和点位表保持一致。 协议识别可选：支持 Auto Detect / Modbus TCP / Modbus RTU，便于混合抓包场景快速判别。 强制解析 (Force Parse) 现场抓包时，报文可能因截断、中间设备修改等原因导致校验不通过。当用户在 Protocol 下拉框中手动指定 TCP 或 RTU（而非 Auto Detect）时，解析器进入强制模式：\nRTU 强制模式：CRC 不匹配时不再直接报错终止，而是标记 checksumValid = false 并在 warnings 中记录 \u0026quot;CRC Mismatch (Forced)\u0026quot;，同时继续解析 PDU 数据字段。 TCP 强制模式：MBAP length 字段异常或帧尾有多余字节时，记录对应 warning 后仍按实际字节长度提取 PDU。 此机制适用于分析被网关/中继修改过、或从串口抓包工具截取的不完整报文。Auto Detect 模式下校验严格，适合正常通信场景的精确验证。\nLink to Analyzer (实时联动) 除手动粘贴 Hex 报文外，Frame Analyzer 还支持从 Traffic Monitor 接收实时数据：\n自动推送：在 Modbus TCP / RTU 视图中开启 Linkage 开关后，RX 响应报文的 PDU 会自动送入解析器，无需手动拷贝。 暂停 / 恢复：点击 Pause Refresh 可驻留当前帧，方便编辑 Scale 或 Description；再次点击 Resume Refresh 恢复自动刷新。 停止联动：点击 Stop Link 断开数据流，解析器恢复为手动模式。 异步执行：解析逻辑运行于 QThread 后台线程，不阻塞 Traffic Monitor 的列表滚动。 如果你同时维护多个型号设备，按设备或项目保存独立模板后，切换调试对象时可直接导入，减少重复配置。\n4. 顺手的辅助小工具 除 Modbus 核心流程外，工具也提供了两个轻量能力，便于处理临时调试任务：\nTCP Client：用于快速验证自定义网络报文。 Serial Port：用于基础串口收发测试（ASCII/Hex）。 5. 工程质量与测试保障 作为持续迭代的开源工具，Modbus-Tools 在代码质量方面投入了相当精力：\n自动化测试 项目采用 Google Test (GTest) 与 Google Mock (GMock) 框架进行自动化质量检测，覆盖以下核心模块：\n会话管理：连接/断开逻辑、请求超时重试及异常状态恢复 协议传输：TCP/RTU 报文封装、解包、校验和计算及完整性验证 解析逻辑：针对多种有效指令及畸形报文的鲁棒性验证 数据处理：字节序转换、工程量缩放及格式化算法的计算准确性 目前全量 42 个自动化测试用例（TEST + TEST_F），覆盖会话管理、协议传输、解析逻辑、数据处理及格式化等模块，每次 Release 发布均执行回归测试。\nCI/CD 集成 GitHub Actions 流水线集成 MSVC AddressSanitizer (ASan)，用于内存损坏与泄漏的自动化监测 支持自动构建、测试及 Release 解析包分发 自动更新 (OTA) 工具集成了基于 GitHub Releases 的自动更新机制：启动时静默检测新版本 + 菜单栏手动检查。更新包通过 SHA256 校验后执行替换，支持 UpdateOnly（增量）和 Full Package 两种模式。\n写在最后 Modbus-Tools 的设计目标是将高频调试操作（发帧、看日志、解帧）封装为可复用的工作流，使开发者能更专注于业务逻辑和问题定位。\n功能概要：\n快速组帧：HEX/DEC 智能识别（parseSmartInt）+ Raw 模式 CRC/MBAP 辅助计算 实时联动：Link to Analyzer 支持 RX 报文自动推送至解析器 深度分析：倍率换算（Scale Factor）+ 四种字节序（ABCD/BADC/CDAB/DCBA）+ 寄存器描述 位级控制：线圈 Binary 输入模式，支持 0x05/0x0F 功能码 质量保障：42 个自动化测试 + CI/CD 集成 MSVC AddressSanitizer 如果你也在做嵌入式或上位机开发，欢迎前往 GitHub 仓库 体验并反馈建议。\n","permalink":"https://intent.me/zh-cn/blog/tech/modbus-tools-intro/","summary":"面向嵌入式开发与现场联调的轻量级 Modbus 工具。介绍快速发帧、日志查看、帧解析（倍率换算、寄存器注释、JSON/CSV 配置持久化）、Link to Analyzer 实时联动分析等功能的实际用法。","title":"Modbus-Tools 深度解析：高效轻量的工业协议调试利器"},{"content":"package main import \u0026#34;fmt\u0026#34; func main() { fmt.Println(\u0026#34;你好，Intent\u0026#34;) } 关于我 我是一名游走在 嵌入式系统 与 云原生 技术边界的软件工程师。\n拥有 电力系统 (EMS/PCS) 行业背景，致力于构建连接物理世界与数字世界的可靠系统。\n核心: C/C++, Golang, Qt, Modbus, CAN, MQTT, IEC-104, IEC-61850, 协议转换 关注: 物联网协议, 实时控制, 分布式架构 \u0026ldquo;Stay hungry, stay foolish.\u0026rdquo;\n这里是我记录技术探索与开源项目的数字花园。\n","permalink":"https://intent.me/zh-cn/blog/hello-world/","summary":"fmt.Println(\u0026ldquo;你好，Intent\u0026rdquo;)","title":"Hello World"},{"content":" 明裕成 嵌入式 / 上位机软件工程师 ｜ 3年经验 ｜ 聚焦储能系统、工业通信与云边协同\n学历：本科 ｜ 城市：中山（意向广州/深圳） ｜ 邮箱：点击获取 ｜ GitHub：mingyucheng692\n核心能力 编程语言：C / C++ ｜ Golang ｜ Python ｜ Shell 工业协议：Modbus RTU / TCP ｜ MQTT ｜ IEC-104 ｜ CAN 框架与工具：Qt6 ｜ CMake ｜ Docker ｜ Redis ｜ PostgreSQL ｜ Git 工作经历 泓慧能源·南方总部 / 广东睿来华控科技有限公司 软件工程师 ｜ 2025.06 - 至今 ｜ 广东中山\n▸ 所在公司为泓慧能源（飞轮储能头部企业）全资子公司，承担南方研发与量产基地核心系统开发\n参与南方基地储能软件体系（端-边-云）从 0 到 1 建设，负责衔接集团技术规范，交付的软件模块直接服务于本地产线设备的整机测试与现场出厂交付。 深度对接硬件与电气规约团队，解决复杂工业现场的通信拥塞与信号抖动等痛点，保障底层电力电子设备与上层系统的可靠交互。 协助推行团队 Git 协同与代码审查机制；针对跨部门设备联调耗时长的痛点，主导开发多套通用联调工具链，以可视化解析取代手工查表与拼接，显著缩短现场排障周期。 代表项目 飞轮储能监控系统上位机（FMS） 核心开发 ｜ C++ / Qt6 / Modbus-TCP / CAN / SQLite / IOCP ｜ 2025.06 - 至今\n重构 Modbus-TCP 通信链路，引入 Windows IOCP 与连接池机制，解决高频数据采集导致的界面阻塞问题，CPU 占用率降低约 15%。 开发底层 DSP 控制板专属调试模块（基于 ZLG CAN SDK），实现 IEEE 754 浮点数与 HEX 报文的双向动态解析，支持多字节序（CDAB/ABCD）自动转换与寄存器语义映射，替代 CANTest 手动抓包换算流程，显著提升软硬件联调效率。 设计滑动窗口算法实现报警信号防抖；基于 SQLite WAL 模式实现高频报文落盘，支持现场历史数据的高效本地检索与追溯。 推进 CMake 模块化构建，并集成 Crash Dump 异常捕获机制，将全量编译耗时从 5 分钟压缩至 50 秒内，显著提升日常开发迭代与现场排障效率。 飞轮储能边缘通信网关 核心开发 ｜ C / STM32F407 / FreeRTOS / MQTT / Modbus / IEC-104 ｜ 2025.12 - 至今\n负责基于 STM32F407 与 FreeRTOS 的边缘网关核心业务逻辑，向下通过 Modbus/RS485 轮询底层设备，向上通过 MQTT 建立与云端的时序数据通道。 实现 Modbus、IEC-104 与 MQTT 之间的协议解析与映射转换，打通设备端、电力规约侧与平台侧的数据交互闭环。 针对现场弱网工况，设计并实现时序数据缓存与断点续传机制，保障网络波动下的数据完整性。 飞轮储能智慧云平台 后端开发 ｜ Golang / Docker / Podman / Redpanda / PostgreSQL / Redis ｜ 2025.06 - 至今\n参与储能云平台（基于 Alibaba Cloud Linux）核心后端服务开发，承接设备数据的高频接入、协议解析与时序入库。 主导部署环境配置，采用 Docker 兼顾本地开发，生产环境应用 Podman rootless 结合 Shell 脚本，实现Rootless账号的安全隔离部署。 引入 Redpanda 消息队列解耦数据采集与存储层，平抑并发写入峰值；独立设计认证中间件，基于 JWT (AT/RT) 与 Redis 维护安全的会话状态。 Modbus-Tools（个人开源项目） 独立开发者 ｜ C++20 / Qt6 / CMake / CI-CD ｜ 2025.12 - 至今\n采用 channel / transport / session / parser 分层架构研发跨平台调试工具，内置可视化报文构建器，彻底免除繁琐的手动查表与十六进制帧拼接。 开发 Frame Analyzer 核心解析器，支持协议自动识别、自定义倍率换算与寄存器语义标注；支持 JSON / CSV 配置导入导出，将现场排障耗时从分钟级压缩至秒级，联调提效 5 倍以上。 基于 GitHub Actions 跑通全自动 CI/CD 流水线，实现代码推送后的自动构建与 Release 发布；客户端内建多语言切换与自动更新（Auto-Updater）机制，保障工具在现场的敏捷迭代。 教育背景 重庆对外经贸学院 ｜ 物联网工程 ｜ 本科 / 工学学士\n","permalink":"https://intent.me/zh-cn/resume/","summary":"明裕成 - 嵌入式 / 上位机工程师，聚焦储能系统、工业通信与云边协同","title":"简历"},{"content":"🏗️ 开源项目 (Open Source) 这里展示了我个人开发或参与的开源项目。对于我在企业工作期间负责的商业项目（如储能监控系统、工业云平台等），请移步至 简历 查看详细介绍。\n项目名称 简介 技术栈 传送门 Modbus-Tools 一个面向嵌入式联调的轻量级 Modbus 调试工具，支持 RTU/TCP、可视化发帧、Frame Analyzer 解析，以及 JSON / CSV 模板配置。 C++20 Qt6 Industrial Protocols 📖 深度解析\n🏗 源码仓库 更多个人实验性项目代码请访问我的 GitHub 主页.\n","permalink":"https://intent.me/zh-cn/projects/","summary":"个人开源项目与技术实践","title":"项目"}]