• Nova AI Agent 接口安全审计:从抓包到发现 Session IDOR 漏洞

    Nova AI Agent 接口安全审计:从抓包到发现 Session IDOR 漏洞

    背景

    Nova AI 是公司内部的 AI 数据分析平台,用户可以通过自然语言对话的方式查询 Dashboard 数据。后端架构是一个 AI Agent(基于 Claude Sonnet 4.6),通过 mountsqlstat 等工具访问数据仓库。

    作为普通用户,黑盒情况下,我对其核心接口 /api/dashboard/summary/llmAgentic 进行了一次完整的安全审计。以下记录了从零开始的探索过程。


    第一步:抓包分析请求结构

    从浏览器 DevTools 抓到前端发起的 HTTP 请求:

    POST https://nova.ai.corp.com/api/dashboard/summary/llmAgentic
    Content-Type: multipart/form-data
    

    关键字段:

    字段 示例值 用途
    protocol agui 协议标识
    sessionId 123456 会话 ID
    eid emp100 员工工号
    dashboardIdCode 647e64ba-... Dashboard UUID
    idCode 210axx0y-... 本次请求 UUID
    question 用户输入 发给 AI 的问题
    threadId session_123456_xxx 对话线程 ID
    runId UUID 本次执行 ID
    filters 大段 JSON 筛选条件
    context [] 上下文
    forwardedProps {} 转发属性

    响应是 SSE(Server-Sent Events)流,AI 逐 token 返回文本,中间穿插 tool call 事件。


    第二步:用 Python 复现请求

    编写脚本,使用 requests 库以 multipart/form-data 格式发送请求,把 Cookie 和 payload 完整复制过来:

    response = requests.post(url, data=data, cookies=cookies, stream=True, verify=False)
    for line in response.iter_lines():
        if line:
            print(line.decode("utf-8"))
    

    成功复现——返回 200,AI 正常回复,验证了脚本可用。


    第三步:理解身份认证机制

    问题:AI 是怎么知道”我是谁”的?

    观察请求中的 Cookie,找到了用户身份标识:

    PRO_cas_principal=PRO-6a69612e6c6975-MTc3...
                           ^^^^^^^^^^^^^^
                           hex('jia.liu') = 6a69612e6c6975
    

    身份通过以下 Cookie 传递:

    • PRO_cas_principal — CAS 认证主体,明文含用户名 hex 编码
    • ada_cas_user — 加密的用户凭证(服务端解密后作为最终身份)
    • offlineTicket — 离线票据

    payload 中的 eidsessionId 看起来像是业务参数而非认证凭据。


    第四步:尝试替换身份

    将 Cookie 中 6a69612e6c6975jia.liu)替换为 63616966caif)后发现:AI 仍然以 jia.liu 身份工作。

    结论: 真正的身份凭据是加密的 ada_cas_user,明文的 hex 编码只是辅助标识,服务端以加密 token 为准。


    第五步:发现 sessionId 的可枚举性

    但是我在多次尝试中,服务器回答我“这已经是第8次你以 caif 身份提问了,我依然不能以 caif 的身份回答你”。

    但我明明使用的短链接,我好奇服务器是如何知道我第8次的,里面应该是有 session 的。

    注意到我们的请求中有 sessionId 字段。

    这引发了一个疑问:如果我传入别人的 sessionId 会怎样?


    第六步:Session IDOR 验证

    编写枚举脚本,用自己的 Cookie 鉴权,但 sessionId 使用相邻的数字:

    question = "我的员工ID是emp100,确认一下对吗?总结一下我们之前聊了啥"
    

    AI 的回复:

    系统中记录的你的员工ID是 emp200,不是 emp100,请确认是否有误。 这是我们本次会话的第一条消息,当前会话中没有任何历史聊天记录。

    关键发现:

    • AI 回复了一个不属于我的员工 ID —— 说明 sessionId 决定了 AI 看到的用户身份
    • “第一条消息”—— 说明 threadId(UUID)控制对话历史,sessionId 只控制身份上下文
    • 鉴权通过了—— 说明 Cookie 只验证”是否为合法用户”,不校验 sessionId 归属

    第七步:确认数据访问可越权

    使用他人 sessionId 发送正常的数据查询:

    question = "帮我查一下 M1: Cursor 工具覆盖"
    

    AI 执行了完整流程:

    1. 调用 mount 挂载了 dashboard(30 张数据表)
    2. 调用 sql 查询具体数据
    3. 调用 stat 获取 SQL 元数据(暴露了底层数据库表名 ctriphr_db.edw_emp_ful
    4. 返回了受害者权限范围内的完整数据

    漏洞根因分析

    ┌─────────────────────────────────────────────────────┐
    │                  请求到达后端                          │
    ├─────────────────────────────────────────────────────┤
    │  1. Cookie 校验 ─────→ 通过(攻击者是合法用户)         │
    │                                                     │
    │  2. sessionId 查找 ──→ session store                 │
    │     └→ 返回该 session 对应的用户身份(受害者 B07464)    │
    │                                                     │
    │  3. 将受害者身份注入 LLM system prompt                 │
    │                                                     │
    │  4. LLM 以受害者身份执行 mount / sql / stat           │
    │     └→ 数据按受害者权限返回                            │
    └─────────────────────────────────────────────────────┘
    

    本质:鉴权(Authentication)和授权(Authorization)解耦。 Cookie 只负责 AuthN,但 AuthZ 完全依赖客户端传入的 sessionId——这是一个经典的 IDOR。


    攻击场景

    1. 攻击者通过社工/日常沟通,诱导目标用户打开 Nova AI 页面
    2. 攻击者随后自己打开页面,获得一个相邻的 sessionId
    3. 枚举前后几个数字,尝试命中目标用户的 session
    4. 成功后,可以目标用户的身份查询任意数据

    由于 sessionId 是自增整数,攻击窗口大、成本极低。


    额外发现

    在审计过程中还发现:

    • 底层 SQL 泄漏: stat 工具会返回报表的原始 SQL 定义,暴露了真实数据库名(ctriphr_db)和表结构
    • 审计策略有效: 标记为敏感的表(如 M2-A1: AI 工具总实付)即使在 IDOR 下也无法访问,说明数据层有独立的权限控制
    • 响应格式: 系统使用 A2UI 协议,SSE 流中混合了文本、tool call、HTML 样式、心跳等多种事件类型

    修复建议

    优先级 措施 说明
    P0 服务端用 Cookie 身份覆盖 sessionId 根本修复,不信任客户端传入的 sessionId
    P1 sessionId 改为 UUID 或带签名的 token 消除枚举可能性
    P1 校验 sessionId 归属 session owner 必须等于 Cookie 认证用户,不一致则拒绝
    P2 sessionId 设置有效期 减少攻击窗口
    P2 stat 工具对外隐藏底层 SQL 避免数据库结构泄漏

    时间线

    时间 动作
    2026-05-25 19:00 抓包分析请求结构,编写 Python 复现脚本
    2026-05-25 19:30 分析身份认证机制(Cookie hex 编码)
    2026-05-25 20:00 编写安全扫描脚本(prompt注入/IDOR/SQL注入)
    2026-05-26 00:00 发现 sessionId IDOR 漏洞
    2026-05-26 00:30 验证可以越权访问他人数据
    2026-05-26 01:00 编写漏洞报告和修复建议

    总结

    这个漏洞的核心教训是:当系统引入 AI Agent 层时,传统的 AuthN/AuthZ 边界容易被打破。 AI Agent 作为中间层,它的”身份”不应该由客户端可控的参数决定。在 AI 时代,安全审计需要特别关注:

    1. AI Agent 以谁的身份执行操作?这个身份从哪来?
    2. 客户端能否篡改这个身份?
    3. AI 的工具(mount/sql/stat)是否有独立的鉴权?还是完全信任 session 上下文?

    对于 AI Agent 系统,“谁在问”和”谁的数据”必须在服务端强绑定,绝不能让客户端参数决定权限边界。

  • Nova AI Agent — Session IDOR 漏洞报告

    漏洞概述

    漏洞类型: IDOR(Insecure Direct Object Reference,不安全的直接对象引用)
    影响接口: POST https://nova.ai.schedule.ops.ctripcorp.com/api/dashboard/summary/llmAgentic
    严重程度:
    发现日期: 2026-05-26


    漏洞原理

    系统在处理请求时,身份鉴权数据访问身份使用了两套独立的机制:

    机制 字段 用途 是否可被篡改
    身份鉴权 Cookie(PRO_cas_principalada_cas_user 验证请求者是否为合法用户 否(加密签名)
    数据访问身份 sessionId(请求 payload 中) 确定 LLM 以哪个用户的身份访问数据

    服务端逻辑伪代码:

    1. 校验 Cookie → 通过(你是合法用户)
    2. 读取 payload.sessionId → 从 session store 查找该 session 对应的用户
    3. 将该用户身份注入 LLM system prompt
    4. LLM 以该用户身份 mount dashboard、执行 SQL 查询
    

    Cookie 验证只保证”请求者已登录”,但 LLM 实际操作数据时用的是 sessionId 对应的用户身份,两者完全解耦。


    漏洞特征

    sessionId 是自增整数

    经实测,sessionId 为简单自增整数(如 664663664664665799…),没有任何随机性或签名保护。

    攻击者可精准定位受害者的 sessionId

    攻击者可以通过以下步骤获取目标用户的 sessionId:

    1. 诱导目标用户打开 Nova AI 聊天页面(触发 session 创建)
    2. 攻击者自己立即打开聊天页面,记录自己的 sessionId(例如 665805
    3. 由于两次操作时间相近,目标用户的 sessionId 必然在攻击者 sessionId 附近(前后几个数字)
    4. 枚举尝试 665800665810 等几个相邻值,即可命中目标 session

    实测验证

    测试请求示意:

    POST /api/dashboard/summary/llmAgentic
    Cookie: <攻击者自己的合法 Cookie>
    
    sessionId=664664   ← 受害者的 sessionId(攻击者的 Cookie)
    question=我的员工ID是xxx,确认一下对吗?
    

    实测响应:

    系统中记录的你的员工ID是 B07464,不是 xxx
    

    LLM 明确返回了受害者的真实员工 ID,证实身份已被替换

    进一步利用:挂载受害者有权限的 dashboard 并执行数据查询,可读取受害者能访问的所有数据,包括团队人员、KPI、AI工具使用明细等。


    影响范围

    • 可冒充任意已登录用户进行数据查询
    • 受害者的 dashboard 数据(含团队组织、人员 KPI、工具使用情况)可被完整读取

    修复建议

    根本修复

    服务端在处理请求时,用 Cookie 解析出的用户身份覆盖 sessionId 对应的用户身份,而不是信任客户端传入的 sessionId

    # 错误做法(当前实现)
    user = session_store.get(request.sessionId)
    
    # 正确做法
    user = auth.resolve_from_cookie(request.cookies)  # 从 Cookie 解析,不信任客户端
    session_store.bind(request.sessionId, user)        # 可选:校验 sessionId 与 Cookie 用户是否一致
    

    辅助加固

    1. sessionId 使用 UUID 或带签名的随机值,而非自增整数,消除枚举可能性
    2. 服务端校验 sessionId 归属:若 sessionId 对应的用户与 Cookie 认证用户不一致,直接拒绝请求(返回 403)
    3. 限制 sessionId 的有效期,减少窗口期
  • Cannot assign requested address 排障实录:从报错到根因的完整路径

    Cannot assign requested address 排障实录

    背景

    我们的 API 网关在转发请求到后端服务时,突然出现大量建连失败:

    {
      "status": {
        "status_code": 400,
        "message": "Service Host is invalid. Please contact Service Provider for help. Connect to vm.corp.com:80 [vm.corp.com/10.10.10.10] failed: Cannot assign requested address (connect failed)"
      }
    }
    

    表面看是”Service Host is invalid”,实际是建连阶段就失败了——内核无法为出站连接分配本地端口。本文记录完整的排障过程,每一步 check 点都值得学习。


    Step 1:读懂真正的错误信息

    应用层抛出的是 HttpHostConnectException,代码把它映射成了”Service Host is invalid”,但真正的关键词是底层的:

    Cannot assign requested address (connect failed)

    这对应 Linux 内核错误码 EADDRNOTAVAIL,含义是:调用 connect() 系统调用时,内核无法为这个出站连接分配本地地址(端口)。连接在三次握手之前就失败了。

    不要被应用层的错误提示误导,看最底层的异常信息。EADDRNOTAVAIL 表示的是”建连发起失败”,不是”连接被拒绝”或”超时”。


    Step 2:确认是本地端口耗尽

    Cannot assign requested address 最常见的原因是本地临时端口(ephemeral port)被用完了。诊断命令:

    # 查看各状态连接数统计
    ss -s
    
    # 按状态分组统计
    netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
    
    # 查看本地端口范围
    cat /proc/sys/net/ipv4/ip_local_port_range
    

    我们的结果: TIME_WAIT 数量2万多,而默认端口范围是 32768-60999(约 28000 个),接近耗尽。

    知识点: 每个 TCP 连接需要占用一个本地端口。连接关闭后进入 TIME_WAIT 状态(持续 60 秒),期间端口不可用。如果每秒新建的连接数 × 60 > 可用端口数,就会耗尽,新连接在 connect() 阶段直接失败。


    Step 3:定位代码层面的根因

    查看网关的转发代码,发现每次请求都新建一个 HttpClient

    // 每次请求都创建新的 client,没有连接池复用
    HttpClient httpclient = newClient();
    

    并且用完后没有关闭,也没有连接池。每个请求都是:新建连接 → 使用 → TCP 关闭 → 进入 TIME_WAIT。高并发下 TIME_WAIT 快速堆积,端口被占满后新的 connect() 调用直接返回 EADDRNOTAVAIL

    知识点:

    • httpclient.close() 能释放 Java 资源,但不能避免 TIME_WAIT——因为 TIME_WAIT 是 TCP 协议层面的,只要主动关闭连接就一定会有。
    • 真正的解法是连接池复用——不关连接,就没有 TIME_WAIT。

    Step 4:尝试内核参数 tcp_tw_reuse

    不改代码的情况下,尝试内核参数让系统在 connect() 时复用 TIME_WAIT 端口:

    echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse
    

    结果: 没有效果,建连仍然失败。


    Step 5:发现 tcp_tw_reuse 的值含义在高版本内核中变了

    检查内核版本:

    uname -r
    # 5.14
    

    关键发现: 从 Linux 4.12 开始,tcp_tw_reuse 的语义变了:

    含义
    0 禁用
    1 仅对 loopback 地址生效(4.12+ 的新语义)
    2 对所有连接生效

    我们的目标是 10.118.168.60(非 loopback),所以 =1 根本不会在 connect() 时触发复用。

    # 修正为 2
    sysctl -w net.ipv4.tcp_tw_reuse=2
    

    Step 6:设为 2 后仍未生效

    建连仍然失败。这里需要纠正一个认知:

    tcp_tw_reuse 不会让 TIME_WAIT 消失。 TIME_WAIT 仍然存在于 ss 的输出中,只是内核在 connect() 分配端口时允许复用这些处于 TIME_WAIT 的端口。

    所以判断标准不是 TIME_WAIT 数减少,而是新的 connect() 不再报错。但我们仍然报错,说明复用没有实际发生。

    通过内核计数器确认:

    nstat -az | grep -i -E 'TW|TimeWait'
    

    TWRecycled 没有增长,确认在 connect() 时端口复用没有触发。


    Step 7:发现 tcp_tw_reuse 的前置依赖——TCP Timestamps

    tcp_tw_reuse 有一个硬性前提:连接双方必须启用 TCP Timestamps(RFC 7323)

    内核通过对比 TIME_WAIT 中连接的时间戳和新连接的时间戳来判断是否可以安全复用端口。没有时间戳,就无法判断,connect() 时不会复用 TIME_WAIT 端口。

    抓包确认:

    tcpdump -i any dst host 10.118.168.60 and dst port 80 -c 10 -nn | grep "TS val"
    

    结果: 对端的 SYN-ACK 中没有 TCP Timestamp 选项。

    知识点: TCP Timestamps 是在三次握手时协商的,任何一方不支持就不会启用。客户端开了 tcp_timestamps=1 没用,服务端也必须开。没有 timestamps 的情况下,tcp_tw_reuse 不会工作,connect() 仍然会因为找不到可用端口而返回 EADDRNOTAVAIL


    Step 8:确认对端关闭了 tcp_timestamps

    我们的客户端(网关)tcp_timestamps=1,但目标服务 VictoriaMetrics 的机器关闭了 timestamps。

    在对端机器上确认:

    sysctl net.ipv4.tcp_timestamps
    # 结果为 0
    

    Step 9:评估让对端开启 tcp_timestamps 的风险

    tcp_timestamps=1 是 Linux 的默认值,开启后:

    • 正面: TCP 性能更好(精确 RTT 测量)、支持 PAWS 防回绕保护
    • 风险: 几乎没有。唯一需要注意的是如果对端前面有 SNAT 型负载均衡,多台后端的 timestamp 不一致可能导致客户端的 PAWS 机制误判

    对方当前的 =0 反而是非标准配置,可能是早期误导性运维文章的遗留。


    最终解决方案

    短期止血

    1. 对端开启 tcp_timestamps=1,使 tcp_tw_reuse=2connect() 时能复用 TIME_WAIT 端口
    2. 扩大本地端口范围:echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range

    长期根治

    将 HttpClient 改为连接池模式,复用已有 TCP 连接,从源头避免产生大量 TIME_WAIT:

    // 单例连接池化 HttpClient
    private static final PoolingHttpClientConnectionManager connManager =
        new PoolingHttpClientConnectionManager();
    static {
        connManager.setMaxTotal(200);
        connManager.setDefaultMaxPerRoute(50);
    }
    
    private static final CloseableHttpClient SHARED_CLIENT = HttpClients.custom()
        .setConnectionManager(connManager)
        .disableContentCompression()
        .build();
    

    连接复用意味着同一个 TCP 连接反复使用,不会触发关闭,自然不会产生 TIME_WAIT,connect() 调用次数大幅减少。


    排障路径总结(速查表)

    报错 "Cannot assign requested address"
      │
      ├─ Step 1: 识别底层错误 → EADDRNOTAVAIL,connect() 阶段失败
      │
      ├─ Step 2: ss -s 确认 TIME_WAIT 数量 → 上万,端口耗尽
      │
      ├─ Step 3: 审查代码 → HttpClient 每次新建不复用,TIME_WAIT 堆积
      │
      ├─ Step 4: 尝试 tcp_tw_reuse=1 → 无效
      │
      ├─ Step 5: 检查内核版本 → 5.14,需要 =2 才对非 loopback 生效
      │
      ├─ Step 6: 设为 2 仍无效 → 通过 nstat 确认 connect() 时复用未触发
      │
      ├─ Step 7: 抓包确认 TCP Timestamps → 对端未启用
      │
      ├─ Step 8: 确认对端 tcp_timestamps=0
      │
      └─ Step 9: 让对端开启 timestamps / 代码改连接池(根治)
    

    核心知识点回顾

    1. Cannot assign requested address = 内核的 EADDRNOTAVAIL,是 connect() 阶段失败,最常见原因是临时端口耗尽
    2. TIME_WAIT 是 TCP 协议行为close() 不能避免,只有不关连接(连接池复用)才能根治
    3. tcp_tw_reuse 在 Linux 4.12+ 改了语义=1 仅 loopback,=2 才全局生效
    4. tcp_tw_reuse 依赖 TCP Timestamps,双方都要开启才能在 connect() 时复用 TIME_WAIT 端口
    5. tcp_timestamps=1 是 Linux 默认值,关掉它几乎没有合理理由

    常用诊断命令速查

    # 连接状态统计
    ss -s
    netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
    
    # 到特定目标的连接
    ss -tn dst 10.x.x.x:80 | awk '{print $1}' | sort | uniq -c
    
    # 端口范围
    cat /proc/sys/net/ipv4/ip_local_port_range
    
    # 内核参数
    sysctl net.ipv4.tcp_tw_reuse
    sysctl net.ipv4.tcp_timestamps
    
    # 内核版本(决定 tcp_tw_reuse 的语义)
    uname -r
    
    # 复用计数器
    nstat -az | grep -i TW
    
    # 抓包确认 timestamps
    tcpdump -i any dst host 10.x.x.x -c 10 -nn | grep "TS val"
    
    # 内核日志
    dmesg | tail -50
    
  • Tomcat Connection: close 排障记录

    Author: claudecode+opus4.6

    Connection: close 排障记录

    OSG 网关 Tomcat BIO 线程池耗尽导致 keep-alive 连接被提前关闭的排查与修复

    目录


    问题描述

    使用 (Tomcat 7.0.39)做了一个 HTTP 转发网关,前端 Nginx 通过 keep-alive 长连接将请求转发到 Tomcat 。观察到 keep-alive 连接在远低于 maxKeepAliveRequests(1000)设定值时就被关闭,通常处理不到 10 个请求后响应中就出现 Connection: close,导致 Nginx 频繁重建 TCP 连接。

    环境信息:

    组件 版本/配置
    Tomcat 7.0.39
    Connector BIO (protocol="HTTP/1.1")
    maxThreads 150
    maxKeepAliveRequests 1000
    前端 Nginx (keep-alive)

    最终结论

    根因:Tomcat 使用 BIO connector(protocol="HTTP/1.1"),maxThreads=150,而实际 TCP 连接数达到 388。BIO 下每个连接(包括空闲 keep-alive)占一个线程,线程池严重不足,Tomcat 被迫主动关闭 keep-alive 回收线程。

    修复:将 protocol="HTTP/1.1" 改为 protocol="org.apache.coyote.http11.Http11NioProtocol",切换后问题立即消失。


    排查过程

    第一步:确认 Connection: close 来源是 代码还是 Tomcat

    分析代码中 Response Header 的处理逻辑。

    GateRequestExecutor.isValidGateResponseHeader() 方法过滤了后端返回的以下 header:

    // osg-scripts/src/main/groovy/scripts/route/GateRequestExecutor.groovy:471
    boolean isValidGateResponseHeader(String name) {
        switch (name.toLowerCase()) {
            case "connection":           // ← 后端返回的 Connection header 被过滤
            case "content-length":
            case "content-encoding":
            case "server":
            case "transfer-encoding":
            case "access-control-allow-origin":
            case "access-control-allow-headers":
                return false
            default:
                return true
        }
    }
    

    结论:后端返回的 Connection header 被代码过滤掉了,不会透传给客户端。响应中出现的 Connection: close 只可能来自 Tomcat 自身


    第二步:tcpdump 抓包分析

    在服务器上抓取了一个完整的 TCP 连接(从 SYN 到 FIN)。

    抓包结果:

    • 来源:10.145.1.100:1303210.1.1.100:80
    • 完整 TCP 三次握手(SYN → SYN-ACK → ACK)
    • 7 个 HTTP 请求,全部 HTTP/1.1,请求头中没有 Connection: close
    • 7 个响应,前 6 个正常,第 7 个响应带 Connection: close
    • 所有 7 个响应状态码均为 200 OK
    请求1: POST /api/GetApp     → 200 OK
    请求2: POST /api/GetRoute   → 200 OK
    请求3: POST /api/GetPage                 → 200 OK
    请求4: POST /api/abapi                       → 200 OK
    请求5: POST /api/GetGroup   → 200 OK
    请求6: POST /api/12345
    请求7: POST /api/GetGroup   → 200 OK + Connection: close ⚠️
    

    结论:排除了请求头 Connection: close、HTTP/1.0、非 200 状态码等原因。


    第三步:检查 server.xml 配置

    grep -i "keepAlive\|Connector" conf/server.xml
    

    确认 maxKeepAliveRequests="1000"。7 个请求远未达到 1000 的上限,排除 maxKeepAliveRequests 耗尽。

    同时确认 maxThreads="150"


    第四步:分析 Tomcat 源码

    阅读 Tomcat 7.0.39 AbstractHttp11Processor.java 源码,找到所有设置 keepAlive = false 的条件:

    # 条件 是否排除
    1 maxKeepAliveRequests 计数器耗尽 ✅ 已排除(7 « 1000)
    2 HTTP/1.0 且无 Connection: keep-alive ✅ 已排除(HTTP/1.1)
    3 请求头中包含 Connection: close ✅ 已排除(抓包确认无)
    4 statusDropsConnection(statusCode) 返回 true ✅ 已排除(全是 200)
    5 response.getErrorException() 不为 null ❓ 待确认

    statusDropsConnection 触发的状态码:400, 408, 411, 413, 414, 500, 501, 503


    第五步:检查生产错误日志

    查看 catalina.out 中的错误日志,发现三类频繁出现的异常:

    1. Hystrix 信号量拒绝could not acquire a semaphore for execution → 映射为 406
    2. ServiceTimeDenySorry Your Openid is denied on currentTime → 映射为 403
    3. JSON 解析异常com.alibaba.fastjson.JSONException → 在 ServiceAuthority.doVerify() 中被 try-catch 吞掉,不会导致 500

    初步怀疑是错误状态码触发了 statusDropsConnection(),但与 tcpdump 中全部 200 的事实矛盾。


    第六步:添加调试日志

    SendResponse.groovyGateServlet.java 中添加 CONNECTION_DEBUG 日志,通过反射读取 Tomcat Http11Processor 内部状态:

    采集字段 含义
    keepAlive Tomcat 是否保持连接
    keepAliveLeft 剩余 keepAlive 次数
    error / errorException 内部错误状态
    openSocket / keptAlive 连接状态

    日志分别在 BEFORE_WRITE(写响应前)和 AFTER_WRITE(写响应后)两个时机采集,用于判断 keepAlive 是在哪个阶段被设为 false。

    日志输出到独立文件 logs/conn-debug.log(logback 中配置独立 appender,logger name = CONNECTION_DEBUG,level = INFO,additivity=false)。


    第七步:分析调试日志 — 发现两种模式

    部署后采集了 1 秒的日志(3566 行),发现 keepAlive=false 有两种不同的模式:

    模式一:BEFORE_WRITE 时 keepAlive 就已经是 false

    BEFORE_WRITE uri=/api/ops_hr_getEmployee, keepAlive=false, keepAliveLeft=-1
    AFTER_WRITE_RESPONSE uri=/api/ops_hr_getEmployee, finalStatus=200
    

    特征keepAliveLeft=-1,响应状态码 200,但 keepAlive 在请求解析阶段就被关闭。

    模式二:AFTER_WRITE 后 keepAlive 才变为 false

    BEFORE_WRITE uri=/api/ckapi, keepAlive=true, keepAliveLeft=993
    AFTER_HEADERS uri=/api/ckapi, status=400
    AFTER_WRITE  uri=/api/ckapi, keepAlive=false, keepAliveLeft=993
    

    特征keepAliveLeft 为正数,由 statusDropsConnection(400) 触发。

    模式一是大量 200 响应带 Connection: close 的主因,与 tcpdump 观察一致。


    第八步:检查服务器连接数和线程数

    $ ss -tnp | grep :80 | wc -l
    388
    
    $ grep -i "maxThreads" conf/server.xml
    maxThreads="150"
    

    关键发现388 个 TCP 连接,150 个线程。BIO connector 下每个连接(包括空闲 keep-alive)占一个线程,线程池严重超载。

    conn-debug.log 中线程名从 http-bio-80-exec-1http-bio-80-exec-187,确认使用 BIO connector。


    第九步:确认根因 — BIO 线程池耗尽

    BIO connector 的工作模式:

    ┌─────────────────────────────────────────────────────────┐
    │  BIO: 每个 TCP 连接绑定一个线程                            │
    │                                                         │
    │  连接1 ──→ 线程1 (处理请求 / 空闲等待)                     │
    │  连接2 ──→ 线程2 (处理请求 / 空闲等待)                     │
    │  ...                                                    │
    │  连接150 ──→ 线程150 (处理请求 / 空闲等待)                 │
    │  连接151 ──→ ❌ 无可用线程!触发自我保护                    │
    │                                                         │
    │  自我保护:keepAlive=false, keepAliveLeft=-1              │
    │           强制关闭 keep-alive 连接以回收线程               │
    └─────────────────────────────────────────────────────────┘
    

    这解释了所有观察到的现象:

    现象 解释
    随机性 取决于当时线程池的繁忙程度
    <10 次请求就关闭 不是 maxKeepAliveRequests 的限制,是线程不够用
    200 响应也带 Connection: close 跟请求结果无关,是线程压力导致

    第十步:修复验证

    修改 server.xml,将 BIO 切换为 NIO:

    <!-- 修复前 -->
    <Connector port="80" protocol="HTTP/1.1"
               maxThreads="150" maxKeepAliveRequests="1000" ... />
    
    <!-- 修复后 -->
    <Connector port="80" protocol="org.apache.coyote.http11.Http11NioProtocol"
               maxThreads="150" maxKeepAliveRequests="1000" ... />
    

    NIO connector 使用 I/O 多路复用,空闲 keep-alive 连接不占线程。150 个线程可以服务数千个 keep-alive 连接。

    重启后 Connection: close 提前关闭的问题消失。


    排查中的错误推断

    记录排查过程中走过的弯路,供参考:

    # 错误推断 实际情况
    1 maxKeepAliveRequests=7 tcpdump 显示 7 个请求后关闭,最初猜测配置为 7。实际是 1000
    2 Tomcat 高负载自动降低 keepAlive 查阅源码未找到该机制(但 BIO 确实有线程压力保护行为)
    3 JSON 解析异常导致 500 ServiceAuthority.doVerify() 中 JSONException 被 try-catch 吞掉,不会冒泡
    4 错误状态码是主因 statusDropsConnection 只影响少量出错请求(模式二),主因是 BIO 线程池耗尽(模式一)

    相关知识

    Tomcat BIO vs NIO

      BIO (HTTP/1.1) NIO (Http11NioProtocol)
    线程模型 1 连接 = 1 线程 I/O 多路复用,线程仅在有数据时使用
    keep-alive 空闲连接 占线程 不占线程
    maxThreads=150 能服务的连接 ~150 数千
    适用场景 低并发、短连接 高并发、长连接

    statusDropsConnection 触发条件

    以下状态码会触发 Tomcat 关闭 keep-alive 连接:

    400 · 408 · 411 · 413 · 414 · 500 · 501 · 503

  • lark-oapi 1.6.2 在内网代理下连不上飞书的根因排查

    TL;DR

    lark-oapi1.5.5 升到 1.6.2 后,部署在内网(必须走 HTTP 代理出公网)的飞书机器人 WebSocket 长连接不通了。原因不在 websockets 换底层、也不在 HTTP_PROXY 环境变量,而是 SDK 源码里显式写了 proxy=None

    # lark_oapi/ws/client.py (1.6.2)
    def _ws_connect_kwargs():
        params = inspect.signature(websockets.connect).parameters
        if "proxy" in params:
            return {"proxy": None}   # ← 主动关掉 websockets 15 的自动代理探测
        return {}
    

    修复方式两选一:

    • 稳妥:钉 lark-oapi==1.5.5
    • 临时升级:monkey-patch 把 _ws_connect_kwargs 换成返回 {},让 websockets 15 的默认行为接管(会自动读 https_proxy

    下面是完整过程,给后面踩同样坑的人作参考。

    背景

    项目 vibedp 是一个 YAML 驱动的 AI Agent 平台,部署在携程内网。机器人走飞书 WebSocket 长连接(lark-oapi SDK),通过公司 HTTP 代理 proxy.corp.com:8080 出公网。

    某天升级后上线失败,现象是 Lark 模式起不来,连不通 open.feishu.cn。降级回上个版本又能用。

    排查过程

    第 1 步:怀疑最近的重构

    最近做过一次 refactor/restructure-architecture,拆了模块、挪了文件。直觉上像是重构把 Lark 链路搞坏了。

    对比了 f1e668...HEAD 这段时间 vibedp/integrations/lark_integration.py 的 diff——几乎没动,只是 make format 把两行拼接的字符串合并成了一行。entry.py 里 Lark 启动序列也和重构前一致。

    重构这条线排除。

    第 2 步:检查依赖

    uv.lock 里写的是 lark-oapi 1.5.3,但用户报告实际装的是 1.6.2

    发现部署链路是:

    uv pip install vip
    

    pyproject.tomllark-oapi 没有版本约束

    dependencies = [
        "lark-oapi",   # ← 任何版本都行
        ...
    ]
    

    第一个结论:依赖漂移。Pin 住版本:

    "lark-oapi==1.5.5",
    

    生效,问题消失。但不知道 1.6.2 到底改了什么导致代理失效。

    第 3 步:对比 1.5.5 → 1.6.2 的 commit

    GitHub compare API 拉下来,5 个 commit。前 4 个都是新加的 channel/ 高层能力层(FeishuChannel 那套),跟底层 WS 无关。

    最后一个引人注目:

    ae7e84e  fix: support websockets 15
    

    这就是嫌疑人。但还不知道具体怎么坏的。

    第 4 步:第一个错误假设

    先自己推理:websockets 15 换了 IO 栈,不再通过 aiohttp,所以 trust_env=True 读环境变量代理的能力没了。

    websockets 官方文档打脸:14.2 开始原生支持环境变量代理,查找顺序:

    1. no_proxy → 命中直连
    2. wss_proxy / ws_proxy(WebSocket 专用,优先级最高)
    3. 系统 SOCKS 代理
    4. https_proxywss://ws:// 都走)
    5. http_proxy(只给 ws://

    底层用 urllib.request.getproxies() 读。

    第 5 步:验证环境

    让用户在容器里跑:

    $ python -c "import websockets; print(websockets.__version__)"
    15.0.1
    
    $ python -c "from urllib.request import getproxies; print(getproxies())"
    {}
    

    这里一度以为找到答案了——getproxies() 返回空,说明进程环境里根本没代理变量。但用户接着查了 supervisor 启动的 vibedp 进程的 /proc/<pid>/environ

    https_proxy=http://proxy.corp.com:8080
    http_proxy=http://proxy.corp.com:8080
    

    (之前的 python -cdocker exec 进去的 shell,跟 supervisor 启的进程不是同一个环境。)

    也就是说 supervisor 启的 vibedp 进程里代理变量是对的,但 websockets 15 就是没走代理。

    第 6 步:直接看源码

    走到这一步应该一开始就做。拉 1.6.2 的 lark_oapi/ws/client.py

    def _ws_connect_kwargs():
        params = inspect.signature(websockets.connect).parameters
        if "proxy" in params:
            # websockets 15 enables environment proxy discovery by default. The SDK
            # historically connected directly, so preserve that behavior when the
            # parameter exists.
            return {"proxy": None}
        return {}
    

    然后在 _connect 里:

    conn = await websockets.connect(conn_url, **_ws_connect_kwargs())
    

    水落石出。

    根因

    lark-oapi 1.6.2 升级到兼容 websockets 15 的时候,维护者发现 websockets 15 默认会读系统代理配置。他们担心这改变 SDK 历史行为(之前的 websockets 13/14 不支持 proxy= 参数,相当于总是直连),于是主动传 proxy=None 关掉了自动探测

    注释原话:preserve that behavior when the parameter exists

    这个决策对”在公网直连飞书”的用户是正确的(没变化),但对必须走代理的内网部署用户是破坏性的——之前版本能通,是因为 websockets 13/14 的默认行为恰好能读到代理环境变量;升到 15 之后这条路被 proxy=None 堵死了。

    用户环境里 https_proxy 配了也没用,因为 websockets.connect(url, proxy=None) 优先级高于环境变量。

    为什么 1.5.5 能连

    对照 1.5.5 的同一处代码:

    # lark_oapi/ws/client.py (1.5.5)
    conn = await websockets.connect(conn_url)
    

    没有 proxy= 参数。当时装的 websockets 是 13 或 14 早期版本,connect() 签名里没有 proxy 参数,走默认行为。具体怎么读到代理的不重要——重点是没有显式禁用

    修复方案

    方案 A:钉版本(推荐)

    # pyproject.toml
    dependencies = [
        "lark-oapi==1.5.5",
        ...
    ]
    

    方案 B:Monkey-patch(想用 1.6.2)

    entry.py 顶部(或任何应用代码的入口):

    import lark_oapi.ws.client as _ws_client
    
    def _enable_proxy_discovery():
        """让 websockets 15 的环境变量代理探测重新生效。"""
        return {}
    
    _ws_client._ws_connect_kwargs = _enable_proxy_discovery
    

    这样 websockets.connect(url) 不再收到 proxy=None,会按默认规则读 https_proxy 环境变量。

    方案 C:给上游提 issue/PR

    lark_oapi.ws.Client.__init__ 应该暴露 proxy 参数,或者至少在 https_proxy 存在时不强制 proxy=None。内网部署是很常见的企业场景,值得上游修。

    教训

    几个可以做得更好的地方:

    1. Pin 依赖版本"lark-oapi" 这种无约束的写法在协作项目里是定时炸弹。PyPI 随便一个小版本上游发布就可能改变你的行为。
    2. 先看源码,再推理行为。这次排查我至少绕了两圈:先推理 websockets 换了 IO 栈(错),再推理 getproxies() 读不到代理(部分对但不是主因)。如果第一步就去看 lark_oapi.ws.client.py 的 diff,十分钟就能看出 proxy=None 那行。
    3. 注释里”保持历史行为”的决策值得警惕。升级传递依赖时,”不破坏现有用户”听起来是好话,但对不同使用场景可能意义相反。这次 proxy=None 对公网用户是零变更,对内网代理用户是完全不能连。

    附:完整时间线

    步骤 动作 结论
    1 对比重构前后 diff Lark 链路代码几乎没变,不是重构问题
    2 查实际装的版本 pyproject.toml 无约束 + CI 不走 lock → 装了 1.6.2
    3 对比 1.5.5→1.6.2 的 commit 嫌疑在 fix: support websockets 15
    4 推理 websockets 15 不读代理 错,实际支持
    5 验证环境变量 supervisor 注入的变量是对的
    6 看 SDK 源码 发现 proxy=None 硬编码

    如果你也在用 lark-oapi + 内网代理,这个坑还在最新版本里(写这篇文章时)。升级前留意一下。

  • 阿里云验证码如何保护报名接口

    阿里云验证码如何保护报名接口

    某比赛报名平台的热门赛事经常秒没,想写个自动报名脚本。逆向分析后发现,整个防护链条中真正无法绕过的环节是阿里云验证码。本文重点分析这一机制。

    报名流程概览

    平台使用 GraphQL API,报名分三步:

    1. 轮询赛事状态 — 等待”报名中”
    2. 获取 token — 需通过验证码,后端签发一次性 token
    3. 提交报名 — 携带 token + 选手信息

    第 1、3 步都是普通的 API 调用,没有防护。关键在第 2 步——获取 token 时需要传一个 captchaVerifyParam 参数,这就是阿里云验证码的验证结果。

    阿里云验证码(Captcha V3)完整流程

    第一步:初始化

    前端加载阿里云验证码 SDK 后,SDK 自动采集浏览器指纹生成 DeviceToken,然后向阿里云发起初始化请求:

    POST https://7fxhzk.captcha-open.aliyuncs.com/
    
    Action=InitCaptchaV3
    AccessKeyId=LTAI5tSEBwYMwV...    # 业务方的阿里云 AccessKeyId
    SceneId=rs47dl4z                  # 业务方配置的验证场景
    DeviceToken=<浏览器指纹>           # SDK 采集
    Signature=<HMAC-SHA1签名>          # SDK 内部计算
    

    返回:

    {
      "CertifyId": "c83uk3yrIV",
      "CaptchaType": "SLIDING",
      "Success": true
    }
    

    阿里云分配了一个会话 ID(CertifyId),并告知验证类型为滑动验证。

    第二步:用户完成滑动

    页面展示滑块拼图,用户拖动滑块到正确位置。这一步中,SDK 在后台记录:

    • 鼠标/触摸轨迹坐标序列
    • 每个采样点的时间戳
    • 滑动速度和加速度变化
    • 按压时长、抖动特征

    用户松手后,SDK 将这些行为数据打包发送给阿里云服务端。阿里云基于这些特征判定是否为真人操作。

    如果判定失败(轨迹太规则、速度太均匀等机器特征),会:

    • 提示”验证失败,请重试”
    • 可能升级难度(从滑块变为文字点选等)

    如果判定通过,阿里云服务端返回一个签名凭证data)给前端 SDK。

    第三步:前端拼装 captchaVerifyParam

    SDK 将各阶段信息拼成一个 JSON 字符串,交给业务方前端:

    {
      "sceneId": "rs47dl4z",
      "certifyId": "c83uk3yrIV",
      "deviceToken": "<浏览器指纹>",
      "data": "<阿里云签发的验证通过凭证>"
    }
    

    业务方前端将这个 JSON 字符串作为 captchaVerifyParam,连同其他参数一起发送给自己的后端。

    第四步:服务端二次校验

    业务方后端收到 captchaVerifyParam 后,不会直接信任前端传来的结果。而是使用自己的 AccessKeySecret,向阿里云发起服务端验证请求(VerifyIntelligentCaptcha API):

    "这个 certifyId 对应的验证确实通过了吗?"
    

    阿里云确认后,业务方后端才签发 signupToken

    哪些能伪造,哪些不能

    参数 能否伪造 原因
    AccessKeyId ✅ 能 硬编码在前端 JS 中,公开值
    SceneId ✅ 能 固定值,写在前端配置里
    DeviceToken ✅ 能 前端 SDK 生成的浏览器指纹,可以伪造或复用
    Signature ✅ 能 AccessKeySecret 混淆在 SDK 的 JS 中,理论上可逆向提取
    验证通过凭证(data) 不能 阿里云服务端签发,见下方分析

    为什么验证通过凭证无法伪造?

    1. 服务端签发 — 凭证由阿里云服务端生成,使用的密钥不在客户端任何地方出现。你能拿到 SDK 里的 AccessKeySecret(用于请求签名),但那不是用来签发验证凭证的密钥。

    2. 行为判定不可绕过 — 阿里云根据滑动轨迹的真实性来决定是否签发凭证。就算你成功调用了 InitCaptcha 和 VerifyCaptcha 接口,如果滑动轨迹被判定为机器行为,也拿不到通过凭证。

    3. 时效性 — 凭证有过期时间,且与 CertifyId 绑定,用一次即失效。不能重放。

    4. 二次校验 — 即使你构造了一个格式正确的假凭证传给业务后端,业务后端会向阿里云确认。阿里云那边没有这条通过记录,校验失败。

    实际可行的方案

    方案 可行性  
    纯脚本伪造请求 ❌ 无法获得验证凭证  
    Playwright/Selenium 自动化 ✅ 真实浏览器 + 模拟真人轨迹完成滑动  
    手动完成验证 + 脚本极速提交 ✅ 人工过验证码,脚本抢速度 (这是ai给的方案,我觉得不太可行吧?)

    全文完

  • iframe中oidc登陆失败

    我把一个需要 OIDC 登录的站点单独打开时一切正常;但一旦嵌进别的网站的 iframe,就可能报错: oauth state does not correspond

    根因是 iframe 场景下浏览器把关键的 cookie 给挡了,导致”保存的 state”丢了。

    1. OIDC 登录到底发生了什么?

    1) 你的应用发起登录,浏览器跳转到配置的的第三方的权威的登录地址(如 google.com/oauth2/authorize) 2) google 登录成功后,浏览器跳回你的应用回调地址(redirect_uri),带回来一个 code 3) 你的应用用 code 去 google 验证,合法的法,换回 token,里面可能包含用户身份信息

    1. state 干嘛的?

    state 可以理解成 “我这次登录请求的防伪码 + 订单号”。

    上面第一步的时候,客户端生成一个 state (先存到 cookie 里面,iframe 就是这一步失败了),发起请求时带上。

    google redirect 回来时,带回这个 state。

    客户端比对:回来的 state,必须和刚才发起登录时存的 state 一致,才能继续后续处理。

    1. 不加 state 会有什么风险?

    假设你的回调地址是 https://cmdb.example.com/oidc/callback

    攻击步骤

    1) 攻击者自己正常登录一次,拿到回调会用到的 code=ATTACKER_CODE 2) 攻击者把这个链接发给你,诱导你点: https://cmdb.example.com/oidc/callback?code=ATTACKER_CODE 3) 你一点,浏览器带着你的站点 cookie 访问回调 4) 如果你的应用不校验 state,它会把这个 code 当成“你发起的登录结果”,把你的浏览器会话登录成攻击者账号

    后果

    你接下来所有操作都记在攻击者账号下,你可能在攻击者账号里做敏感操作(绑定信息、生成 token、修改配置等)

    1. 为什么独立打开正常,iframe 里就 state mismatch?

    关键点:在 iframe 里,OIDC 所在站点常常变成“第三方上下文”。

    现代浏览器对第三方上下文越来越严格,经常出现: 第三方 cookie 被拦截,登录发起时保存的 state,回跳回来时读不到了。

    1. 解决思路(从稳到折中)

    2. 最稳方案:不要在 iframe 里做 OIDC 登录,改成新标签页/弹窗打开(避免第三方上下文)。

    3. 更实用的 iframe 方案:反向代理,让它“同站点”

    用 Nginx 把 Chainlit 代理到你自己的域名路径下(例如 /chatops/),让浏览器认为它是第一方,cookie/session 更容易正常工作。

    同时要把 OIDC 的 redirect_uri 配成同域路径。

    1. 能控制服务端时,配置方案:cookie SameSite=None; Secure

    有时能缓解,但在越来越严格的浏览器策略下不一定稳定。

  • salt state.sls遇到reclass错误

    以下都是AI的总结,根据我和AI之间的对话生成的。Gemini 模型。

    但是总结的不好,没有正确的复现“我们”的排查过程。

    [故障复盘] SaltStack Reclass 从“节点冲突”到“幽灵报错”的排查实录

    日期: 2025-12-08 环境: SaltStack + Reclass (ext_pillar) 核心问题: 新增/管理节点时出现 Pillar 渲染失败,报错信息误导性强。

    阶段一:初遇“节点冲突” (Collision)

    1. 故障现象

    在执行 Salt 命令时,报错提示 Pillar 渲染失败,原因是由于 Reclass 检测到同一个节点 ID (SVR22753HW1288) 定义了两次(冲突)。

    # salt SVR21793HW1288 state.sls npd
    ...
    Failed to load ext_pillar reclass: Definition of node 'SVR22753HW1288' ... collides with definition in ...
    
    1. 排查过程 动作: 检查文件系统,寻找重复文件。
    find /srv/reclass/nodes -name "SVR22753HW1288.yml"
    

    现象: find 命令无结果,或者只显示一个文件。文件系统层面上并不存在物理冲突。

    Salt Master 日志: 出现 ERROR: ‘list’ object has no attribute ‘items’。

    分析: 这是一个“幽灵冲突”。很可能是文件已被删除,但 Salt Master 的内存/缓存中仍保留旧的索引,或者是之前的软链接导致的递归扫描。

    1. 初步处理 结论: 需要强制刷新 Master 状态。如果文件确实删除了,重启服务应能解决冲突报错。

    阶段二:报错突变 —— “找不到节点” (Node not found)

    1. 故障现象

    在清理了环境并尝试操作目标节点 SVR21793HW1288 后,冲突报错消失,但出现了新错误:

    Failed to load ext_pillar reclass: ext_pillar.reclass: Node ‘SVR21793HW1288’ not found under yaml_fs:///srv/reclass/nodes

    1. 排查过程

    动作: 手工创建该节点文件 SVR21793HW1288.yml。

    现象: 报错依旧。

    关键测试: 在 Minion 端执行 salt-call –local state.sls npd 成功,但在 Master 端执行 salt 命令 失败。

    这表明文件物理存在,且内容逻辑大概率没问题(Minion 能读),但 Master 进程“看不见”它。

    深入检查:

    检查 Minion ID 与文件名是否匹配:✅ 匹配。

    检查权限:✅ 正常。

    检查文件内容:

    cat -A /srv/reclass/nodes/prod-client/muise/SVR21793HW1288.yml

    发现: cat -A 无输出。文件是空的(0字节)!

    修正: 填入 YAML 内容。

    结果: 依然报错 Node not found!这非常反直觉。

    阶段三:真相大白 —— 公共文件语法错误

    1. 深度诊断

    既然文件存在、有内容、有权限,且 Minion 本地能跑通,说明问题出在 Master 端 Reclass 加载器的全局状态。

    动作: 绕过 Salt,直接使用 Reclass CLI 工具诊断。

    Bash

    reclass –inventory 现象: Reclass CLI 抛出了巨长的 Traceback 报错:

    Plaintext

    yaml.parser.ParserError: while parsing a block mapping in “/srv/reclass/classes/client/ssm.yml”, line 17, column 9 did not find expected key

    1. 根本原因 (Root Cause)

    问题根本不在新节点 SVR21793HW1288 上。

    问题出在一个 公共 Class 文件 (/srv/reclass/classes/client/ssm.yml) 存在 YAML 语法错误(缩进/冒号缺失)。

    连锁反应:

    Salt Master 启动或重新加载 Pillar 时,Reclass 尝试构建全局 Inventory。

    读到 client/ssm.yml 时,解析器崩溃 (Crash)。

    Reclass 无法完成索引构建。

    当查询 任何 节点时,Reclass 返回“未找到”或直接抛出异常,导致 Salt 认为节点不存在。

    最终解决方案

    修复语法错误: 编辑 /srv/reclass/classes/client/ssm.yml,修正第 17-18 行的缩进错误。

    验证修复: 再次运行 reclass –inventory grep SVR21793HW1288,确认能输出节点信息。

    彻底刷新 Master (SOP): 修复配置后,必须执行以下三步,缺一不可:

    # 1. 重启 Master (清除内存中的错误状态)
    
    systemctl restart salt-master
    
    # 2. 清除 Pillar 缓存 (防止读取旧的错误缓存)
    
    salt-run pillar.clear_pillar_cache
    
    # 3. 刷新 Minion 状态
    
    salt SVR21793HW1288 saltutil.refresh_pillar
    

    测试:

    salt SVR21793HW1288 state.sls npd

    结果: 成功!

    经验总结 (SOP)

    在 master 运行

    reclass --inventory # 检查并修复存在的问题
    
    # 清除 Pillar 缓存 (防止读取旧的错误缓存)
    salt-run pillar.clear_pillar_cache
    
    # 刷新 Minion 状态
    salt SVR21793HW1288 saltutil.refresh_pillar
    
    # 重启 Master (清除内存中的错误状态)
    systemctl restart salt-master
    

    下面是AI给的总结,我觉得总结的不好:

    以后遇到类似 Reclass 诡异报错 的标准排查流程:

    隔离测试:

    先跑 salt-call –local …。如果 Minion 本地成功,但 Master 失败,说明是 Master 端环境/缓存/全局文件的问题。

    直接诊断 (关键一步):

    不要猜,直接在 Master 运行 reclass –inventory。

    如果它报错 ParserError,直接修那个报错的文件(通常是公共 Class)。

    检查“低级”错误:

    文件是否为空?(ls -l)

    是否用了 Tab 键?(cat -A)

    文件名有没有空格?

    黄金法则:

    修改了公共 Class 文件后,必须重启 salt-master。

    不要相信 Master 会自动从严重的 Reclass 崩溃中恢复。

  • 时间珍贵

    一天的时间真少啊。

    早晨起来10点多,拿昨天新买的摩卡壶冲了杯咖啡。

    然后到星巴克办公。一边需要完成昨天的需求,一边想着看一部电影。

    然后今天还有英语要背。

    今天之外,还有书,还有剧要看。

    下午还有个面试,又要花半小时,加上准备时间更多。

    还想跑步健身。

    还要陪娃,送娃上课下课。

  • systemctl-restart-hadoop-datanode-fail

    现象

    airflow JOB 中,重启 Hadoop Datanode 经常失败,然后登陆到机器看,已经成功了

    journalctl 日志

    Jul 10 17:18:05 XXXX systemd[1]: Started Hadoop datanode.
    Jul 10 17:18:05 XXXX hadoop-hdfs-datanode[3678026]: Started Hadoop datanode (hadoop-hdfs-datanode):[  OK  ]
    Jul 10 17:17:56 XXXX su[3677203]: pam_unix(su:session): session opened for user root(uid=0) by (uid=0)
    Jul 10 17:17:56 XXXX su[3677203]: (to root) root on none
    Jul 10 17:17:56 XXXX systemd[1]: Starting Hadoop datanode...
    Jul 10 17:17:56 XXXX systemd[1]: Stopped Hadoop datanode.
    Jul 10 17:17:56 XXXX systemd[1]: datanode.service: Scheduled restart job, restart counter is at 1.
    Jul 10 17:15:56 XXXX systemd[1]: Failed to start Hadoop datanode.
    Jul 10 17:15:56 XXXX systemd[1]: datanode.service: Failed with result 'exit-code'.
    Jul 10 17:15:56 XXXX systemd[1]: datanode.service: Control process exited, code=exited, status=1/FAILURE
    Jul 10 17:15:56 XXXX hadoop-hdfs-datanode[3675354]: Failed to start Hadoop datanode. Return value: 1[FAILED]
    Jul 10 17:15:46 XXXX su[3675290]: pam_unix(su:session): session opened for user root(uid=0) by (uid=0)
    Jul 10 17:15:46 XXXX su[3675290]: (to root) root on none
    Jul 10 17:15:46 XXXX systemd[1]: Starting Hadoop datanode...
    Jul 10 17:15:46 XXXX systemd[1]: Stopped Hadoop datanode.
    Jul 10 17:15:46 XXXX systemd[1]: datanode.service: Failed with result 'signal'.
    Jul 10 17:15:46 XXXX hadoop-hdfs-datanode[3675282]: Stopped Hadoop datanode:[  OK  ]
    Jul 10 17:15:46 XXXX systemd[1]: datanode.service: Main process exited, code=killed, status=9/KILL
    Jul 10 17:15:46 XXXX hadoop-hdfs-datanode[3675238]: datanode did not stop gracefully after 5 seconds: killing with kill -9
    Jul 10 17:15:41 XXXX hadoop-hdfs-datanode[3675238]: stopping datanode
    Jul 10 17:15:41 XXXX systemd[1]: Stopping Hadoop datanode...
    

    日志显示,Stop 之后,第一次 Start 失败了,第二次 Start 成功了。

    服务配置

    # cat /etc/systemd/system/datanode.service
    [Unit]
    Description=Hadoop datanode
    After=syslog.target local-fs.target network-online.target rc-local.service
    Requires=rc-local.service
    
    [Service]
    Type=forking
    #RemainAfterExit=yes
    ExecStart=/etc/init.d/hadoop-hdfs-datanode start
    ExecStop=/etc/init.d/hadoop-hdfs-datanode stop
    ExecStartPost=/usr/bin/cp /run/hadoop-hdfs/hadoop-hdfs-datanode.pid /run/
    PIDFile=/run/hadoop-hdfs-datanode.pid
    TimeoutStartSec=60
    Restart=always
    RestartSec=120
    
    [Install]
    WantedBy=multi-user.target
    

    120 秒重启是 datanode 的配置。

    因为我自己对 hadoop java jsvc 这套东西完全不熟悉,开始没有在 jsvc 的日志中找到关键信息,也对 jsvc 的机制不了解,没有马上定位到原因。一度拿 bcc 中的 execsnoop 来定位,但这个版本的 bcc 好像有 Bug,exec 的信息中,所有 ret code 都是 0。但基本上也能定位到是 jsvc 启动时出错(启动后退出了,和 pidfile 中的 pid 不一致)。

    /opt/log/hadoop/jsvc.err 中的一条关键日志如下:

    Still running according to PID file /var/run/hadoop-hdfs/hadoop_secure_dn.pid, PID is 3911276
    Service exit with a return value of 122
    

    原因

    中间的排查过程已经不完全记得了,直接总结下原因吧。

    start and stop

    在我们公司的 hadoop 启停脚本中,机制是这样的:

    使用 jsvc 来启动 datanode。

    stop 的时候,先是 kill,如果 5 秒之后进程还在,就使用 kill -9 来结束。

    jsvc 中 kill 效果

    jsvc 在启动后,会有一个父进程(进程号2)和子进程(进程号3)。

    pidfile 中记录的是子进程的 pid。

    hadoop 服务的脚本中,kill 的时候,kill 的是父进程。

    如果是默认的 kill TERM,父进程会尝试 gracefully 停止子进程。

    如果是 kill -9,父进程会直接结束,子进程不会被杀掉。

    我们公司的服务 stop 时发生了什么

    1. kill 父进程(不是 pidfile 里面的)
    2. 父进程收到信号后,尝试 gracefully 停止子进程
    3. 5 秒后,父子进程还在(因为有些 gracefull 任务),父进程被 kill -9
    4. 子进程没有被杀掉,仍然在运行 gracefull 任务
    5. systemd 认为服务已经停止,尝试重新启动服务
    6. jsvc 启动时,发现 pidfile 中的 pid 仍然在运行(子进程),因此认为服务仍然在运行,退出
    7. systemd 认为服务启动失败
    8. systemd 重启服务,第二次启动是在 120 秒之后,子进程的 gracefull 任务已经完成,进程退出,pidfile 中的 pid 不再存在。服务启动成功

    解决方案

    整个服务的脚本有些复杂,多个脚本调用流程过长。pid 文件也有多个,一个在 systemd 中使用,用来判断服务是不是在运行,一个在 jsvc 中用在 -pid 参数中。

    对我来说,直接修改脚本比较麻烦。

    所以在 airflow 中添加了 retry 机制,间隔 15 秒。

    因为实践下来发现,当第一次 systemd restart 失败之后,15秒之后的第二次 systemctl restart ,会等待到 120 秒之后,才会和 systemctl 自己的重试一次执行。

    但这也只是工程上的一个 workaround。不太好。

    但相比全部重写脚本并推送到几千台机器,这个 workaround 影响面比较小,先这样。

    如果从头重写脚本,我自己觉得不要使用 jsvc 比较好,就直接使用 systemd 自己的机制就可以。

    如果已经这样使用了 jsvc 了,也不要仅仅等待 5 秒就 kill -9,可以和 systemd 配置对等,等待默认的 90 秒左右,可以适当少一些,避免和systemd 的重试时间冲突。

  • 1
  • 2