Akawa

ETY001的博客

引子

家里有台带 64G 内存的 Jetson 边缘计算设备(AGX Orin),上面跑着 Ollama,常驻一个 27B 的量化模型。想法很朴素:让这个本地模型接入 Agent 框架(我用的两个是 Hermes Agent 和 pi coding agent),替我干点离线的开发活。

但理想和现实之间隔着一道鸿沟:模型直接接上框架后,慢到不可用——一次 API 调用 31 分钟,一个任务跑几个小时出不来东西。这篇文章记录完整的调优过程:从定位根因、三个层面的参数修正,到最后用一套「强模型评审 + 弱模型回炉修复」的循环,让本地 27B 模型自主完成了一个 LocalSend 类应用的完整开发,并通过了 98/100 的评审验收。

如果你也在边缘设备上跑本地模型做 Agent 开发,这篇文章里的坑和数字应该能帮你省不少时间。

一、问题:模型不是不能干,是「思考」失控

先看一组对比数据。调优前,模型在 Agent 框架里执行一个开发任务:

1
2
API call #1: latency=94.8s   (20K 系统 prompt 首次 prefill)
API call #2: latency=1876.0s out=13838 tokens ← 31 分钟!

第二次调用生成了 13838 个 token,其中 9-13K 是模型内联的 <think> 推理文本。这类思考型模型(thinking model)默认在每次回复前先「想」——问题在于 Agent 场景下它想得太多、太发散,而边缘设备的生成速度只有 7-8 token/s,13000 个思考 token 就是 27 分钟纯烧在没人看的内心独白上。

关键认知:对于 27B 这个量级的模型 + 8 tok/s 的速度,关闭思考不是「降低质量换速度」,而是「去掉负收益部分」。它的长思考既不是高质量推理(27B 上限摆在那),又吞掉了绝大部分时间预算。

二、调优三板斧

1. 服务端:让模型常驻显存 + 开满上下文

Ollama 默认 5 分钟无请求就释放显存,下次请求要重新加载 18GB 的模型权重(一分钟起步)。对 Agent 这种「想一下、跑个工具、再想一下」的使用模式是灾难。

Docker compose override(Ollama 容器场景的标准做法):

1
2
3
4
5
services:
ollama:
environment:
OLLAMA_CONTEXT_LENGTH: "524288"
OLLAMA_KEEP_ALIVE: "8760h"

两个坑:

  • KEEP_ALIVE: "-1"(官方文档里的永久驻留写法)这个构建不认,报 time: missing unit in duration "-1"。必须带单位,8760h 即一年。
  • OLLAMA_CONTEXT_LENGTH 设 524288,运行时会被自动钳到模型 GGUF 的真实上限(本例 262144)。KV cache 用 q8_0 量化 + flash attention,262K 上下文在 64G 设备上放得下。

验证是否真的常驻:看容器日志里 runner ... duration=8760h0m0s,以及 ps aux | grep llama-server 确认进程带着 -c 262144 存活。

2. 关闭思考:三个客户端,三种姿势

这是整个调优的核心。Ollama 的 OpenAI 兼容接口支持顶层 reasoning_effort 字段(较新版本,对应上游 issue #25758):

1
{"model": "...", "messages": [...], "reasoning_effort": "none"}

实测效果(同一个编码问题):

配置 耗时 输出 token
默认(思考开) 35.6s 316(含 883 字符推理)
reasoning_effort: "none" 21.4s 187(零推理)

但每个客户端把这个字段送进请求体的路径不一样,这是最容易踩的坑:

curl 直连:直接生效,response 里不再有 reasoning 字段。

Hermes Agent:它的 custom provider 对 Ollama 端点有专门处理——当 reasoning 配置为关闭时会自动发 reasoning_effort:"none" + extra_body.think:false。所以只需要在 config.yaml 里给模型配 override:

1
2
3
agent:
reasoning_overrides:
qwen3.8-27b-gguf-iq4_nl: none

注意:不能用 hermes config set 设置带点号的模型名——dotted key 会被拆成嵌套 dict(qwen3: {8-27b-gguf-iq4_nl: ...}),必须用 python 直接改 YAML。

pi coding agent:这是最隐蔽的坑。模型定义里有个 reasoning: false 字段,我一开始以为这就是关闭思考——错了,它只是 UI 标记,不会向服务端发任何字段,模型照常思考。日志里的指纹一目了然:

1
cmn  common_reaso: activated, budget=2147483647 tokens

正确的做法是在模型定义里加 samplingParams,pi 会把它合并进每个请求体:

1
2
3
4
5
6
{
id: "qwen3.8-27b-gguf-iq4_nl",
reasoning: false,
samplingParams: { reasoning_effort: "none" }, // ← 关键
...
}

改完后同样的「Reply with exactly: PI-OK」测试,服务端日志不再出现 activated,响应从 39 token 掉到 6 token。

通用验证方法:不管什么客户端,看 Ollama 容器日志 docker logs ... | grep common_reaso——activated 消失即关闭成功。

3. 客户端超时与上下文配置

8 tok/s 的速度需要配套放宽超时,否则 Agent 框架默认超时会把正常的慢生成掐断:

1
2
3
providers.<ollama>:
request_timeout_seconds: 1800
stale_timeout_seconds: 900

上下文窗口方面,Ollama 的 /v1/models 接口不返回真实 context length(报的是运行时默认值),框架按它算会误判窗口过小。需要在框架的 context 缓存里手动写入模型真实上限(262144)。

三、验收:怎么证明「能正常干活了」

调参只是手段,我用一个硬标准验收:让模型在 Agent 框架里自主开发一个 LocalSend 类应用(局域网文件互传:UDP 组播发现 + TCP JSON 握手 + 长度帧流式传输,纯标准库实现 + 完整测试 + README),然后由一个云端强模型(GLM-5.3)按 100 分制评审,≥95 分算通过。

这套「评审-修复回炉循环」是整个实验里最有价值的部分:

  1. 弱模型(本地 27B)自主完成项目初版
  2. 强模型(云端 GLM-5.3)评审,输出精确到 file:line 的缺陷清单和分数
  3. 把缺陷清单喂回同一个弱模型会话hermes --resume),让它自己修
  4. 修完再审,循环直到达标

实际跑了四轮:

轮次 分数 主要缺陷
1 82 serve 模式的发现任务从未被调度(功能性 bug)、断连不清理半文件
2 92 错误路径测试没测真实代码、死代码
3 94 自定义端口时发现协议广播错误端口、连接失败裸 traceback
4 98 只剩 cosmetic 级别

最终 14/14 测试全绿,真实跨机传输验证:10MB 随机文件从另一台机器发过来,MD5 完全一致。整个过程(含四轮修复)约 4-6 小时,全程无人值守(哦不对,我盯了全程,但没写一行代码)。

pi 框架侧用同样流程做了第二个项目(markdown 笔记 CLI),两轮 89→96 分,同样达标。

几个实操层面的经验:

  • 给弱模型的指令要带「效率规则」:明确要求「每个文件单独一次 write 工具调用」「不要在聊天里输出长篇规划」。否则它会把所有文件代码内联在一个回复里输出——一次生成 13K token,30 分钟就没了。
  • max-turns 给足(50+),这类模型经常需要多轮试探才能收敛到一个 bug 的根因(比如同 IP 多播回环覆盖 peer 表这种问题,它修了三轮才对)。
  • liveness watchdog 会误杀长调用:Agent 框架一般有「N 秒无进展就中断」的保护,8 tok/s 的生成很容易触发。中断了 resume 会话接着跑就行,上下文都在。
  • 评审提示词要固定 rubric(我用的 40/25/15/10/10 五项),并明确告诉评审「已修复的缺陷不许重复扣分」,否则分数不收敛。

四、这台设备现在的定位

调优完成后,这个本地 27B + Agent 组合的可用画像很清晰:

能干的:代码审计走读(262K 上下文一次吃几万行)、中小工具项目开发(800 行级,含测试)、日志巡检摘要、按模板生成配置、批量文档翻译。适合挂 cron 离线跑,白天看结果。

不能干的:长输出任务(几千 token 的生成以 8 tok/s 计就是几分钟起步)、视觉类任务(纯文本模型)、需要深度推理的复杂架构决策(27B 上限,这次它对并发问题的直觉就不如强模型准)。

一句话总结:边缘设备上的本地模型做 Agent,先把「思考失控、显存反复加载、超时误杀」这三个结构性问题解决掉,剩下的就是选对任务类型——量大不急、输出中等、文本为主。

尾声

这次实验最有意思的发现是「评审-修复回炉循环」:强模型出精确缺陷清单、弱模型照单执行修复,两者配合的质量远超弱模型单打独斗。这个模式对所有「本地小模型 + 云端 API」混合部署的场景都适用——云端 API 只花在评审上(一次几分钱),重活累活全在本地。

毕竟,让 27B 自己想明白怎么修一个 bug 很难,但让它照着一张写好 file:line 的清单改代码,它还是很称职的。

引子

最近做一个区块链数据回放(replay)任务:把历史区块数据灌进 Mongo,中间层是一个 Go 写的 ingest 服务(下文简称 batcher)。任务刚启动时跑得飞快,但吞吐一路下滑,25 个小时只前进了 9 个百分点,最终定位到一个非常典型的 Go 并发问题——持锁的 O(N) 全表扫描 + 只增不删的 map

这篇文章包含完整的事故时间线、一套可复用的诊断方法论,以及从零讲起的锁知识,希望对遇到同类问题的朋友有帮助。

事故时间线

第一天:启动与一次「治愈」的假象

时间 事件 关键数据
08-24 01:01 2000 万块 replay 启动,监控脚本上线 早期区块处理极快
08-24 01:01~02:41 2% → 10%(40 万 → 200 万块)。前 193 万块是幂等覆盖旧数据,Mongo 计数不变 Mongo ops 恒为 135 万
08-24 02:41 → 08-25 03:32 爬行期:10% → 19.5%(25 小时只走了 190 万块 ≈ 21 块/秒) 吞吐 ~50 ops/s
08-25 ~04:00 第一轮诊断启动 生产者 CPU 0.02%、batcher 146%、Mongo 7.6%;Mongo 单次批量写仅 21ms;生产者持续报 Ingest queue full
08-25 ~05:38 第一次处置:旧镜像打 rollback 标签 → 用当前代码重建 → 热替换容器
08-25 05:38~05:50 「痊愈」:12 分钟 9011 批 ≈ 1250 ops/s,batcher CPU 49%,生产者 100% 当时误判为「旧镜像 CPU 空转」,还写进了周报
08-25 白天 按新速度估算 ETA ~1.5 天,继续监控

第二天:复发与真相

时间 事件 关键数据
08-25 17:12 再次查进度 → 发现复发:吞吐掉回 630 批/10min ≈ 105 ops/s,queue full 持续 总 ops 2077 万 / 11.1GB,进度 30.5%(610 万块)
08-25 17:12~17:20 第二轮诊断:三个假设连环证伪 + procfs 尸检 + 代码定位(见下文) 7 个线程各累计 15~48 分钟 utime,全部停在 futex_do_waitgrep delete( 零结果
08-25 ~17:22 修复上线:分支 fix/batcher-pending-blocks-leak → build/test 通过 → --force-recreate 热替换 1 个文件 +31/-34
08-25 17:33 首验(10 分钟窗口):8385 批 ≈ 1400 ops/s,queue full 0,服务内存 18.2MiB(原来 450MB+),batcher CPU 22%,生产者 101%
08-25 ~17:45 PR #51 提交、确认、合并
08-26 00:09 长期验证通过:修复 6.5 小时后仍 1430 ops/s(bug 版在同等时长内已劣化到两位数);replay 推进到 **53%**(1060 万块 / 5294 万 ops)

关键认知转折:第一天的「换镜像提速 25 倍」其实是重启清空了 map 的假象。
劣化速率 ∝ 已见块数(N),与进程年龄、镜像新旧无关——第二天它按同样的曲线再次趴下,
才把怀疑对象从「二进制」纠正为「代码」。

地基概念:锁、自旋与 futex

为什么需要锁

batcher 是 Go 程序:每个 HTTP 请求一个 goroutine(轻量线程),外加定时器 goroutine,它们并发读写同一份内存。Go 的 map 不允许并发读写混用,轻则数据错乱,重则进程崩溃,所以需要锁。

读写锁 sync.RWMutex(图书馆阅览室模型)

  • RLock(读锁)= 进来看书,多人可以同时在场;
  • Lock(写锁)= 搬桌子改结构,必须清场独占。
  • 规矩:读读兼容 / 读写互斥 / 写写互斥。

自旋(spin)

goroutine 抢不到锁时,Go 运行时先「原地转圈」重试若干轮再睡觉。竞争越激烈、临界区越长,越多 goroutine 空转烧 CPU——没干活,电费照付。本次事故中 124%~146% 的 CPU 大部分是这个。

futex

Linux 上锁的实现原语。线程停在 futex_do_wait = 此刻正在等一把锁。utime(用户态 CPU 时间)很高 + 此刻停在 futex 等待 = 历史上大量自旋,现在在排队。

O(N)

操作成本随数据量 N 线性增长。锁内 O(N) 操作是性能事故的经典配方。

原始设计解剖:每块都合理,合起来是炸弹

1
2
3
4
5
6
type Batcher struct {
blocks map[uint32]*blockInfo // 见过的【所有】块的信息
blocksMu sync.RWMutex
blocksWritten map[uint32]bool // 已写入 Mongo 的标记
blocksWrittenMu sync.RWMutex
}

三个部件,单看都有道理:

  1. AddBlockInfo — 每个操作到达时登记块信息(写锁),设计正确;
  2. flushBatch — 批量落库时查出涉及的块连带写入,并在 blocksWritten 打勾,设计正确;
  3. flushUnwrittenBlocks — 定时器每 tick 扫一遍 blocks,把没打勾的(空块)补写;意图善良,实现有毒
1
2
3
4
5
b.blocksMu.RLock()        // ← 读锁,整个扫描期间持有
b.blocksWrittenMu.RLock() // ← 第二把读锁
for blockNum, blockInfo := range b.blocks { // O(N) 全表扫描
if !b.blocksWritten[blockNum] { ... } // 每条再查一次另一个 map
}

问题不在任何单个部件,而在缺了第四个部件——销毁。内存数据应有完整生命周期:出生(登记)→ 使用(落库)→ 死亡(删除)。这份代码只有前两步:

1
grep -n "delete(" batcher.go    # 输出:空。两个 map 永远只进不出。

叠加效应:

  • 每 tick 扫描成本 O(N),N = 见过的块总数,随链高度线性增长;
  • 扫描全程持读锁 → 所有 AddBlockInfo(写锁)排队 → goroutine 自旋堆积;
  • 600 万条目时,每秒一次的全表扫描吃掉约一个核,锁排队雪崩。

为什么历史测试没发现:200 万块时 N 小,慢被淹没在噪声里;单元测试根本跑不到「数百万条目 × 数小时」的规模。此类 bug 属于生命周期泄漏型性能 bug,只在「规模 × 时间」上显形。重启清空 map 即恢复满速——这又制造了「换镜像治好了」的强烈误导。

诊断方法论:每一步都可以复用

1. 量化症状

从日志数批次:630 批/10min ≈ 105 ops/s(健康基线 8385 批)。先有数字,之后每个假设都用数字检验。

2. 三层 CPU 快照——「谁在忙」比「谁慢」更有指向性

1
2
3
生产者(steemd)   CPU  13%    ← 本该满速,却闲着 = 被下游勒住
batcher(中间层) CPU 124% ← 烧 1.2 核
Mongo(存储) CPU 7.5% ← 几乎闲着

读法:下游闲、中间烧、上游被勒 → 瓶颈在中间层。若 Mongo 是慢源,Mongo CPU 应打满;若生产者自身慢,中间层不该忙。

3. 用对端指标连环证伪

假设 铁证 判决
Mongo 写得慢? 自测单次批量写 21→53ms,28h 内 Mongo 忙碌率 ~2%
换的镜像不对? 容器镜像 ID 与构建产物完全一致
2017 年区块数据变大、解析变慢? 批次字节 300→450 B/op,差 2 倍解释不了 13 倍劣化
GC 压力? 30 秒 1 次 GC、分配仅 1.2MB/s

心法:别找「谁的嫌疑最大」,找「谁有铁证不在场」。每证伪一个,范围收窄一圈。

4. procfs 尸检(没有 pprof 时的核武器)

服务没开 pprof,但内核把证据免费放在 /proc

1
2
/proc/<PID>/task/<TID>/stat   # 每线程累计 utime/stime
/proc/<PID>/task/<TID>/wchan # 此刻阻塞在哪个内核函数

现场:

1
2
7 个线程,各累计 15~48 分钟 utime(大量用户态计算)
此刻全部停在 futex_do_wait(= 在等锁)

结论收敛为一句话:有人在持锁做一件又长又无聊的事——utime 高(烧过 CPU)+ futex(在等锁)+ GC 安静(烧的不是正业)= 锁竞争自旋。

5. 带画像回代码,一击定位

「持锁扫描一个不断增长的结构」→ grep delete( 零结果(只增不删)→ flushUnwrittenBlocks 每 tick 全表扫 + 双 RLock → 与尸检画像完全吻合。结案。

手术:两条铁律与并发安全论证

两条铁律:

  1. 临界区只做 O(1) 的事 — 拿锁的范围缩到最小最快;
  2. 内存数据要有死亡 — 用完即删,让「存在即未处理」成为不变量。

修复(PR #51,1 个文件):

1
2
3
4
5
6
7
// 旧:blocks 只进不出 + blocksWritten 打勾(两个真相源,永不收缩)
// 新:块写库成功后立即 delete(b.blocks, blockNum)
b.blocksMu.Lock()
for _, block := range blocksToWrite {
delete(b.blocks, block.BlockNum)
}
b.blocksMu.Unlock()

blocksWritten 整体删除:「还在 map 里」本身就等于「没写过」,一个真相源够了。两个 map 互相印证看似严谨,实则双倍内存、双把锁、还要维护一致性。失败写入不删条目 → 下个 tick 自动重试,原有语义保留。

修锁代码的最后一步永远是穷举交错的并发安全论证:同一块号的信息是确定性的(同一块必同 ID/时间戳),任何「写成功→删除」与「另一批次重新登记同一块」的交错,最坏只是相同内容幂等多写一次,不存在丢数据的交错序列

修复前后实测对比:

指标 修复前 修复后
吞吐 ~105 ops/s ~1400 ops/s,6.5h 后仍 1430
queue full 持续 0
服务内存 450MB+ 随进度增长 18~22MiB 恒定
服务 CPU 124%(自旋) 22~31%
生产者 CPU 13%(被背压) ~101%(天然瓶颈)

通用心法(带走这四条)

  1. 锁问题很少崩溃,多是「越跑越慢」。吞吐随运行时长下降 → 第一怀疑「某结构悄悄增长 + 有人持锁扫它」。
  2. 诊断「慢」的起手式:三层 CPU + 对端延迟。生产者/中间层/存储各拍一个 CPU,再看存储自己的耗时指标;谁烧谁闲,指向性极强。
  3. 重启能治好的病,别归因给「版本」。重启清空进程内状态,治好的往往是状态累积型 bug,与二进制无关——本次第一天就掉进了这个坑,浪费了一次归因正确的机会。
  4. 长跑测试的价值不止于数据。这次 2000 万块 replay 最大的副产品是逼出了一个单元测试永远测不出的 bug——「数据深度试验」最坚实的理由。

附:涉及的命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 三层 CPU
docker stats --no-stream --format "{{.Name}}: CPU {{.CPUPerc}} MEM {{.MemUsage}}" <生产者> <中间层> <存储>

# 吞吐计数(按日志特征行)
docker logs <容器> --since 10m 2>&1 | grep -cE "BATCH success"

# 进程尸检
PID=$(docker inspect <容器> --format '{{.State.Pid}}')
for t in /proc/$PID/task/*/stat; do
awk '{split($2,a,"("); split(a[2],b,")"); if ($14+$15>100) print b[1], "utime="$14, "stime="$15}' $t
done
cat /proc/$PID/task/<TID>/wchan

# 只增不删体检
grep -n "delete(" <文件>

# 热替换(幂等 upsert + 对端重试为前提)
docker compose build <service> && docker compose up -d --no-deps --force-recreate <service>

背景

最近在本地一台 Jetson AGX Orin 设备(64GB 统一内存)上用 Ollama 跑 Qwen3.8-27B(IQ4_NL 量化,约 16GB)。这代模型原生支持 256K 上下文,官方文档明确说可以通过 YaRN 技术扩展到 1M。

我很好奇这个扩展到底怎么落地——不是用 vLLM 或 SGLang,而是 Ollama 这种”开箱即用”的推理框架。折腾了一晚上,踩了不少坑,记录一下完整过程。

先交代一下环境:一台懒猫算力仓(Jetson AGX Orin 开发套件),Ollama 跑在 Docker 容器里(懒猫 AI Pod 预装的 Ollama 服务,镜像名带 jetson-ollama 字样),模型文件挂载在 SSD 上。

一、前置知识:YaRN 是什么

YaRN(Yet another RoPE extensioN)是一种位置编码扩展技术,可以让训练时上下文有限的模型在推理时处理更长的文本,不需要重新训练。

Qwen3.8-27B 官方 README 给出了明确的 YaRN 配置:

  • rope_type: yarn
  • factor: 4.0(从 262144 扩展到 1048576,正好 4 倍)
  • original_max_position_embeddings: 262144
  • partial_rotary_factor: 0.25
  • rope_theta: 10000000

也就是说,模型权重不用动,只要让推理引擎在加载时启用 YaRN 缩放即可。

二、核心思路:改 GGUF 元数据

Ollama 底层是 llama.cpp,而 llama.cpp 会读取 GGUF 文件头部的元数据来决定 rope scaling 行为。所以思路很直接:

  1. 复制一份现有的 GGUF 文件
  2. 往元数据里添加 rope.scaling.* 字段(type=yarn, factor=4.0, original_context_length=262144)
  3. context_length 字段改成 1048576(这一步很关键,原因见下文)
  4. ollama create 基于新 GGUF 创建新模型

坑 1:gguf-set-metadata 不能新增字段

llama.cpp 官方提供了 gguf-set-metadata 工具,但它只能修改已存在的字段,新增字段会报 Field not found。而 rope.scaling.* 在原 GGUF 里根本不存在。

解决办法:用 Python 的 gguf 库写脚本,重新生成整个 GGUF。流程是读取原文件 → 复制所有元数据字段和 tensor → 添加新字段 → 写出新文件。16GB 的文件必须流式处理,不能一次性载入内存。

坑 2:tokenizer 数组复制会翻车

tokenizer.ggml.tokens 这类字符串数组,GGUF 里每个元素是”长度前缀 + 内容”两部分。Python 库读出来之后,如果索引取错,会把长度前缀当成字符串内容,导致 tokens 数量翻倍(248320 → 496640),加载时报 Index out of array bounds for toktypes

还有更隐蔽的:tokenizer 里有些字节不是合法 UTF-8,如果先 decode 再 encode 会直接崩,必须全程按原始字节处理。

坑 3:tensor shape 会被反转

GGUF 的 tensor shape 存储顺序和 numpy 的布局相反。写脚本时如果传错 shape,加载时会报 check_tensor_dims: wrong shape。这个坑不亲自踩一遍很难发现,因为大部分 tensor 恰好不受影响,只有特定结构的(比如 SSM 卷积层)会暴露出来。

坑 4:ollama 会把 num_ctx 钳制在 context_length

新模型创建好之后,我用 API 请求 num_ctx: 1048576,结果 ollama ps 显示 CONTEXT 还是 262144——ollama 的调度器会参考 GGUF 里的 context_length 字段做上限钳制。

解决办法:把 GGUF 的 context_length 元数据也改成 1048576。这相当于告诉 ollama”这个模型支持 1M”,加上 YaRN 字段告诉 llama.cpp”1M 时用 YaRN 缩放”,两边配合才完整。

改完重新 ollama createollama show 显示的 context length 就变成 1048576 了。

三、内存才是真正的瓶颈

模型建好了,但实测 1M 上下文加载时进程反复被杀。看日志:

1
2
llama_kv_cache: size = 34816.00 MiB (1048576 cells, 16 layers, 1/1 seqs)
llama_kv_cache: K (q8_0): 17408.00 MiB, V (q8_0): 17408.00 MiB

KV cache 直接吃了 34GB!加上模型权重 14GB 和各种缓冲,总需求 55GB 以上,逼近 61GB 物理内存上限。

这台设备上还跑着一个 earlyoom 守护进程(可用内存低于 5% 就主动杀进程),所以进程不是被 OOM killer 杀的,而是被 earlyoom 提前终止的——日志里表现为 signal: terminated 而不是 killed,排查起来更隐蔽。

解决方案:KV cache 量化

KV cache 精度从 q8_0 降到 q4_0,内存直接减半:

1
2
llama_kv_cache: size = 18432.00 MiB (1048576 cells, 16 layers, 1/1 seqs)
llama_kv_cache: K (q4_0): 9216.00 MiB, V (q4_0): 9216.00 MiB

总需求降到约 39GB,1M 上下文稳定运行,100% GPU,ollama ps 显示 CONTEXT=1048576。

q4_0 KV 对输出质量的影响很小(KV cache 量化是业界公认的低风险优化),但为了不动懒猫算力仓预装服务管理的原始 compose 文件,我用了 Docker Compose 的 override 机制:

1
2
3
4
services:
ollama:
environment:
- OLLAMA_KV_CACHE_TYPE=q4_0

override 文件放 compose 同目录自动加载,原始文件一行没改,以后懒猫算力仓系统升级也不会冲突。

四、别忘了视觉能力

Qwen3.8-27B 是多模态模型,原模型在 Ollama 里有主模型 GGUF + 两个 CLIP projector(F32 和 F16 版本各一个,tensor 结构完全一样)。

只 FROM 主模型创建的新模型会丢掉 vision 能力。Modelfile 里要把 projector 一起挂上:

1
2
3
4
FROM <模型存储路径>/blobs/sha256-<主模型>
FROM <模型存储路径>/blobs/sha256-<projector1>
FROM <模型存储路径>/blobs/sha256-<projector2>
TEMPLATE {{ .Prompt }}

挂上之后 ollama show 的 Capabilities 里就有 vision 了,传图测试识别正常。

五、最终结果与限制

最终模型参数:

  • 架构 qwen35,27.3B 参数,IQ4_NL 量化,约 16GB
  • 上下文:YaRN 扩展,context length = 1048576(1M)
  • 视觉:支持(CLIP projector 已挂载)

实测数据:

上下文 KV cache 总内存 状态
256K(原生) ~10GB ~26GB 正常
512K q8_0 17GB ~36GB 正常
1M q8_0 34GB ~55GB+ earlyoom 杀进程
1M q4_0 17GB ~39GB 正常

一个现实限制:1M 上下文 + 视觉编码同时使用会 OOM(视觉编码需要额外显存缓冲)。实测 512K + 视觉没问题,1M 下纯文本没问题。如果需要 1M 长上下文 + 视觉,得换更大的内存设备,或者把 KV 再压一压。

六、补充:外部客户端接入的隐藏限制

模型调好之后,还有个小插曲。另一台机器把懒猫算力仓的 Ollama 配成了 OpenAI 兼容的推理端点,跑 Hermes 之类的 AI 客户端。客户端在上下文较长时会做”压缩”操作(把历史对话总结成摘要),压缩请求一次会发几万 tokens。

结果压缩一直失败,报错:

1
request (24697 tokens) exceeds the available context size (8192 tokens)

排查后发现是第二个”数字陷阱”:Ollama 的 OpenAI 兼容 API 默认上下文是 OLLAMA_CONTEXT_LENGTH 环境变量,默认 8192。客户端走 /v1/chat/completions 时不传 num_ctx 参数(OpenAI 格式里没有这个字段),Ollama 就用 8192 兜底——哪怕模型本身支持 1M。

而我们前面改了 GGUF 的 context_length 元数据,客户端从模型信息里读到”这个模型支持 1M”,于是放心发大请求,结果被服务器的默认值卡住。

修复同样走 compose override,把默认上下文调大:

1
2
3
4
5
services:
ollama:
environment:
- OLLAMA_KV_CACHE_TYPE=q4_0
- OLLAMA_CONTEXT_LENGTH=131072

调成 128K 是因为:q4_0 KV 下 128K 大约 2GB/seq,内存无压力,同时覆盖了客户端压缩请求的规模(几万 tokens 级别)。如果以后客户端对话更长,再往上调即可;显式传 num_ctx 的请求不受这个默认值影响,仍然可以到 1M。

这个坑的启示:GGUF 元数据说”模型支持多少上下文”是一回事,服务器端实际允许多少是另一回事。Ollama 有两个不同的”上下文数字”:

数字 位置 作用
context_length(GGUF 元数据) 模型文件里 告诉调度器模型上限,决定 num_ctx 钳制值
OLLAMA_CONTEXT_LENGTH(环境变量) 服务器配置 OpenAI 兼容 API 的默认上下文,客户端不传参时兜底

排查这类问题先分清客户端走到的是哪条 API 路径、有没有显式传 num_ctx,再看对应的那个”上下文数字”。

七、总结

这次折腾的核心收获:

  1. Ollama 里做 YaRN 扩展 = 改 GGUF 元数据,不需要动模型权重,rope.scaling.* + context_length 两个字段配合
  2. 16GB 量级的 GGUF 重写必须流式处理,Python 库的 field.parts 布局和字符串数组结构有坑
  3. 大上下文的真正敌人是 KV cache 内存,q8_0 → q4_0 是最有效的调优手段
  4. 嵌入式设备的 earlyoom 会让 OOM 排查更难terminated 不等于崩溃,先查内存守护
  5. Docker Compose override 是改第三方服务参数的安全姿势,不动原始配置,可随时回退
  6. 模型支持多少上下文 ≠ 服务器默认允许多少,GGUF 元数据管 num_ctx 钳制,OLLAMA_CONTEXT_LENGTH 管 API 默认值,两套数字都要对齐

整个过程踩的坑已经整理成内部 skill,下次给其他模型做上下文扩展可以直接复用。

如果对模型上下文扩展感兴趣,可以看看:

背景

Dell R730xd 上跑着 PVE(Proxmox VE),12 块物理盘挂在 PERC H730P Mini 硬件 RAID 卡下面。某天 Steem witness 节点开始极度不稳定,频繁出块失败,同时 SSH 到内网其他机器也变慢。

本文记录了从故障排查到磁盘迁移、再到 iDRAC 邮件告警配置的完整过程。

一、故障现象

  • witness 节点频繁 RCU stall,steemd 进程周期性冻结
  • 区块同步出现 90+ 秒的 Block Time Offset
  • 532 次 unlinkable_block / fork 事件
  • 从其他网段 SSH 到 PVE 内网机器明显变慢

二、排查过程

2.1 定位故障盘

PVE 宿主机 dmesg 中发现大量 I/O 错误:

1
2
sd 0:0:9:0: [sdb] tag#518 FAILED Result: hostbyte=DID_ERROR driverbyte=DRIVER_OK cmd_age=170s
I/O error, dev sdb, sector XXXXXXXXXX op 0x0:(READ) flags 0x80700 phys_seg 64 prio class 0

cmd_age 最长达到 170 秒,意味着一个 I/O 请求卡了将近 3 分钟。

SMART 检查发现这块 Seagate ST6000NM0115 (6TB) 已经严重损坏:

SMART 属性 说明
Reallocated_Sector_Ct 38000+ 重映射扇区数万
Reported_Uncorrect 180+ 不可纠正错误
Seek_Error_Rate 数十亿 磁头寻道异常
G-Sense_Error_Rate 10 万+ 物理冲击记录
通电时间 42000+ 小时 约 5 年

2.2 根因分析

关键发现:sdb 每次 I/O 错误卡住时,PVE 宿主机内核 I/O 子系统全局阻塞,所有 VM 跟着冻结。

虽然 sdb 只挂在一个 VM 上作为数据盘,但内核处理 I/O 错误时是全局阻塞的,导致:

  • witness VM 内核触发 RCU soft lockup
  • steemd 停摆,收到的区块 Block Time Offset 飙到 90+ 秒
  • 分叉和 unlinkable block 大量出现

witness VM 的 RCU stall 时间和 sdb 的 I/O 错误时间完全吻合。

2.3 硬件 RAID 卡下的磁盘监控

PVE 是 Dell R730xd,12 块物理盘在 PERC H730P Mini 硬件 RAID 卡后面。OS 看到的 /dev/sdX 大多是 RAID 逻辑卷,不是物理盘。

smartmontools 可以穿透硬件 RAID 卡,使用 -d megaraid,N 参数直接读取每块物理盘的 SMART 数据:

1
2
3
4
5
6
7
8
# 查看 RAID 卡后面的物理盘
smartctl -a -d megaraid,9 /dev/sdc

# 物理盘列表(megaraid,0 ~ megaraid,11)
for i in $(seq 0 11); do
echo "=== megaraid,$i ==="
smartctl -i -d megaraid,$i /dev/sdc 2>&1 | grep -E "Device Model|Serial|Capacity"
done

12 块物理盘的映射关系:

megaraid 型号 容量 RAID 卷 用途
0-5 ST4000NM 系列 4TB ×6 RAID-10 VM 数据存储
6-7 SSD ×2 2TB ×2 RAID-0 VM SSD 存储
8 WDC WD20EARS 2TB JBOD VM 数据盘
9 ST6000NM0115 6TB JBOD 备份盘(故障盘)
10-11 ST1000DM010 1TB ×2 RAID-1 系统盘

三、磁盘迁移

3.1 创建新盘

在健康的 RAID-10 存储上创建一块 4T 虚拟磁盘替代故障盘:

1
2
3
4
# <storage> 替换为你的健康存储池名称
# <vmid> 替换为你的 VM ID
fallocate -l 4096G /mnt/pve/<storage>/images/<vmid>/vm-<vmid>-disk-N.raw
qm set <vmid> --scsi3 <storage>:<vmid>/vm-<vmid>-disk-N.raw,cache=writeback,size=4T

3.2 数据迁移

通过 qemu-nbd 在 PVE 宿主机上直接操作两块虚拟磁盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 挂载旧盘和新盘
qemu-nbd -c /dev/nbd1 -f raw /mnt/pve/<old_storage>/images/<vmid>/vm-<vmid>-disk-0.raw
qemu-nbd -c /dev/nbd2 -f raw /mnt/pve/<new_storage>/images/<vmid>/vm-<vmid>-disk-N.raw

mount -o ro /dev/nbd1 /mnt/tmp
mount /dev/nbd2 /mnt/newbackup

# rsync 迁移
rsync -aHAXx --info=progress2 /mnt/tmp/ /mnt/newbackup/

# 清理
umount /mnt/tmp /mnt/newbackup
qemu-nbd --disconnect /dev/nbd1
qemu-nbd --disconnect /dev/nbd2

3.3 移除故障盘配置

1
2
3
4
5
# 从 VM 配置移除旧盘
qm set <vmid> --delete scsi1

# 从 PVE 存储配置移除旧存储池
pvesm remove <old_storage_name>

四、smartd 配置

默认的 DEVICESCAN 只能扫描到 JBOD 直通的盘,RAID 卡后面的 10 块物理盘完全没监控到。

替换 /etc/smartd.conf,显式列出所有 12 块物理盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# RAID-10 卷后面的物理盘
/dev/sdc -d megaraid,0 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
/dev/sdc -d megaraid,1 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
# ...(0-5 共 6 块)

# SSD RAID-0 卷后面的物理盘
/dev/sdc -d megaraid,6 -a -H -l error -l selftest -m root
/dev/sdc -d megaraid,7 -a -H -l error -l selftest -m root

# JBOD 直通盘
/dev/sda -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
/dev/sdb -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root

# RAID-1 系统盘后面的物理盘
/dev/sdc -d megaraid,10 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root
/dev/sdc -d megaraid,11 -a -H -l error -l selftest -C 197 -U 198 -W 4,45,55 -m root

五、磁盘健康巡检脚本

写了一个 cron 脚本每 4 小时巡检一次,检测到 SMART 异常时通过 Telegram 通知。

脚本的关键点:

  1. 使用 smartctl 绝对路径:cron 环境下 PATH 可能不包含 /usr/sbin
  2. timeout 包裹 smartctl:坏盘的 SMART 查询可能卡住
  3. 检测关键指标:Reallocated_Sector_Ct、Current_Pending_Sector、Offline_Uncorrectable、Reported_Uncorrect
  4. Telegram 通知走 HTTP 代理:国内服务器直连不了 Telegram API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
source <你的环境变量文件路径>

export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
SMARTCTL=/usr/sbin/smartctl
PROXY="http://<你的代理地址>:<端口>"

check_disk() {
local label="$1" dev="$2" opt="$3"
local result
result=$(timeout 15 $SMARTCTL -H $opt $dev 2>&1)
if ! echo "$result" | grep -q "PASSED"; then
ALERT="${ALERT}⚠️ ${label}: SMART FAILED\n"
fi
}

# ...检查所有 12 块盘...

crontab 配置:

1
0 */4 * * * <脚本路径>/disk-health-check.sh > /dev/null 2>&1

六、iDRAC 邮件告警配置

6.1 网络层问题排查

iDRAC 配置 SMTP 发送测试邮件一直报 RAC0225:发送测试邮件失败。通过在路由器上用 tcpdump 抓包定位:

1
2
3
# 在 OpenWrt 路由器上抓 iDRAC 的 SMTP 流量
# <idrac_ip> 替换为你的 iDRAC IP
tcpdump -i br-lan host <idrac_ip> and port 25 -n -A -s 0

抓到了 iDRAC 到邮件服务器的完整 SMTP 交互:

1
2
3
4
5
6
iDRAC: EHLO <mail_domain>
Server: 250-STARTTLS, 250-AUTH PLAIN LOGIN ...
iDRAC: STARTTLS
Server: 220 Ready to start TLS
iDRAC: <TLS ClientHello>
Server: <7 bytes> → FIN 断开

6.2 TLS 证书问题

根因:邮件服务器只有 ECC (ECDSA) 证书,iDRAC 的 TLS 客户端不支持。

iDRAC 强制要求 STARTTLS,但 Dell iDRAC 的 TLS 实现比较老,只支持 RSA 证书。TLS 握手时服务器只有 ECC 证书,无法完成 RSA 密钥交换,握手失败。

6.3 解决方案:Postfix 双证书

Postfix 3.4+ 支持 smtpd_tls_chain_files,可以同时配置 RSA + ECC 证书,TLS 握手时根据客户端 ClientHello 自动选择。

快速搭建一个SMTP邮件服务 中部署的 postfix 基础上:

1. 用 acme.sh 签一张 RSA 证书(已有 ECC 证书保留):

1
2
# <domain> 替换为你的域名
~/.acme.sh/acme.sh --issue --dns dns_cf -d <domain> --keylength 2048 --server letsencrypt

2. 安装 RSA 证书到 TLS 目录:

1
2
3
cp ~/.acme.sh/<domain>/<domain>.key /etc/postfix_conf/tls/<domain>.rsa.key
cp ~/.acme.sh/<domain>/fullchain.cer /etc/postfix_conf/tls/<domain>.rsa.crt
chmod 400 /etc/postfix_conf/tls/<domain>.rsa.*

3. 配置 Postfix 双证书:

1
2
3
4
5
6
7
8
9
10
11
# 用 smtpd_tls_chain_files 替代单独的 cert/key 配置
# <domain> 替换为你的域名
postconf -e "smtpd_tls_chain_files = /etc/postfix/tls/<domain>.key /etc/postfix/tls/<domain>.crt /etc/postfix/tls/<domain>.rsa.key /etc/postfix/tls/<domain>.rsa.crt"
postconf -e "smtpd_tls_cert_file ="
postconf -e "smtpd_tls_key_file ="
# 出站邮件同样配置
postconf -e "smtp_tls_chain_files = /etc/postfix/tls/<domain>.key /etc/postfix/tls/<domain>.crt /etc/postfix/tls/<domain>.rsa.key /etc/postfix/tls/<domain>.rsa.crt"
postconf -e "smtp_tls_cert_file ="
postconf -e "smtp_tls_key_file ="

postfix reload

4. iDRAC 面板 SMTP 配置:

  • SMTP 服务器:邮件服务器 IP
  • 端口:25
  • 启用 SMTP 认证
  • 用户名/密码:见 SASL 配置

6.4 验证

配置完成后,从 iDRAC 面板发送测试邮件成功。

七、经验总结

  1. 硬件 RAID 卡不是 SMART 监控的黑洞smartctl -d megaraid,N 可以穿透 RAID 卡读取物理盘 SMART 数据
  2. 一块坏盘可以拖垮整个宿主机:内核 I/O 子系统全局阻塞,所有 VM 跟着冻结,不要误以为是单个 VM 的问题
  3. cron 环境的 PATH 陷阱:cron 的默认 PATH 可能不包含 /usr/sbin,脚本中必须用绝对路径或显式 export PATH
  4. ECC 证书兼容性:老设备(iDRAC、旧客户端)可能不支持 ECDSA 证书,配置双证书(RSA + ECC)是最通用的方案
  5. tcpdump 是排查 SMTP 问题的利器:当应用层日志不可见时,在网络层抓包能看到完整的 SMTP 交互过程

问题现象

最近本地键盘出现一个诡异的小 bug:常按方向键时光标只移动一次,不会连续移动。而且不是 100% 复现:

  • 重启后通常能恢复;
  • 有时候莫名其妙就好了;
  • 刚刚插拔一次 USB 耳机(AB13x)之后也好了。

系统环境:Archlinux + XFCE + X11,键盘是 HHKB Classic,同时挂着 keyd 做键位映射。

排查过程

1. 先看 X11 的 key repeat 设置

1
xset q | grep -A 2 "auto repeat"

输出正常:

1
2
auto repeat:  on    key click percent:  0    LED mask:  00002
auto repeat delay: 500 repeat rate: 20

连发开关是开着的,参数也正常,问题不在 X11 的 repeat 配置。

2. 查看 X11 识别到的键盘设备

1
xinput list

输出里出现了很多“键盘”:

1
2
3
4
5
6
7
8
9
10
11
12
⎣ Virtual core keyboard                    id=3
↳ Virtual core XTEST keyboard id=5
↳ C-Media Electronics Inc. USB Audio Device id=11
↳ Video Bus id=12
↳ Power Button id=13
↳ AT Translated Set 2 keyboard id=14
↳ keyd virtual keyboard id=15
↳ Logitech MX Vertical id=17
↳ PFU Limited HHKB-Classic id=8
↳ PFU Limited HHKB-Classic Keyboard id=9
↳ PFU Limited HHKB-Classic Consumer Control id=10
↳ Generic AB13X USB Audio id=6

注意其中两个设备:

  • Generic AB13X USB Audio(就是插拔后能让问题暂时消失的 USB 耳机);
  • C-Media Electronics Inc. USB Audio Device(另一个 USB 音频设备)。

它们明明是音频设备,却被 X11 识别成了键盘。

3. 查看内核 input 设备

1
cat /proc/bus/input/devices

相关片段:

1
2
3
4
5
6
7
8
9
N: Name="C-Media Electronics Inc. USB Audio Device"
H: Handlers=kbd event8
B: EV=13
B: KEY=e000000000000 0

N: Name="Generic AB13X USB Audio"
H: Handlers=kbd event3
B: EV=13
B: KEY=3800000000 c000000000000 0

两个设备都被内核挂到了 kbd handler 上,意味着它们会通过 evdev 向用户空间发送 key 事件。AB13X 的 KEY bitmap 里包含音量加减键(114/115),这就是 USB 耳机上的媒体控制被暴露成了 HID 键盘。

4. 为什么音频设备会干扰真实键盘的连发?

X11 的 key repeat 是全局计时器管理的。当系统里存在多个“键盘”时,某些 USB 音频设备的 HID 接口会时不时发送事件(或者仅仅因为被枚举为键盘),这会干扰 X11 对真实键盘长按状态的计时,表现为“按一次只触发一次”。

这也能解释为什么:插拔 AB13X 耳机后问题会暂时消失——因为插拔让 X11 重新枚举了键盘设备,清除了干扰状态。

解决方案

目标很明确:让 X11 不再把这两个 USB 音频设备当作键盘。音频功能完全不受影响,只是禁用它们的 HID 媒体按键(如果原本能用的话)。

1. 立即生效(当前会话)

1
2
xinput disable 'Generic AB13X USB Audio'
xinput disable 'C-Media Electronics Inc. USB Audio Device'

执行后 xinput list 会显示它们变成了 [floating slave],不再挂载到主键盘下。

2. 持久生效(重启后仍然有效)

创建 X11 InputClass 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sudo tee /etc/X11/xorg.conf.d/50-ignore-usb-audio-keyboards.conf <<'XORG'
# USB audio devices sometimes expose HID media/volume controls as keyboards.
# When X11 treats them as keyboards, they can interfere with key repeat
# on real keyboards (e.g. long-press arrow keys only fire once).
# These devices still work for audio; we just prevent X from using them
# as input devices.

Section "InputClass"
Identifier "Ignore AB13X USB Audio as keyboard"
MatchProduct "AB13X"
Option "Ignore" "on"
EndSection

Section "InputClass"
Identifier "Ignore C-Media USB Audio as keyboard"
MatchProduct "C-Media Electronics Inc. USB Audio Device"
Option "Ignore" "on"
EndSection
XORG

下次登录/重启 X11 后配置自动生效。

验证

执行完 xinput disable 后,立即长按方向键测试,连发应该已经恢复。如果想确认持久配置是否生效,可以重启后在终端里再看:

1
xinput list | grep -i "AB13X\|C-Media"

应该看到类似下面这样(floating slave 表示被忽略):

1
2
∼ C-Media Electronics Inc. USB Audio Device  id=11  [floating slave]
∼ Generic AB13X USB Audio id=6 [floating slave]

副作用

  • 这两个 USB 音频设备的音量/静音等物理按键会失效(如果它们之前有效的话)。
  • 音频播放、通话、麦克风功能完全不受影响

如果之后发现还需要用耳机上的音量键,可以只保留 AB13X 的配置,或者改用 xinput float 而不是 Ignore,灵活处理。

附:keyd 的小提示

排查过程中还发现 keyd 的配置里有警告:

1
WARNING: ... You should use layer(control) instead of assigning to leftcontrol directly.

当前配置:

1
2
leftcontrol = capslock
capslock = leftcontrol

虽然这和连发问题关系不大,但建议把 capslock = leftcontrol 改成 capslock = layer(control),避免 keyd 后续版本行为变化。

总结

Linux 下 USB 音频设备(尤其是带线控的耳机)经常会把自己的媒体控制暴露成 HID 键盘。当 X11 同时管理这些“伪键盘”和真实键盘时,就可能出现长按不连发这种玄学问题。通过 xinput disable 临时禁用、配合 xorg.conf.d 持久忽略,可以彻底解决问题。

最近公司开发人员的 VPN 从 Easyconnect 迁移到 Cloudflare WARP 了,真的是令人振奋人心,比起 Easyconnect 来说, WARP 对于 Linux 环境友好太多太多了!

由于流量走公司 VPN 的时候,会有审计行为,因此作为在家工作的我,都是用 Mihomo 把涉及公司资源的访问单独路由到一个虚拟机,虚拟机里登陆公司 VPN。

为了节省资源,我的虚拟机安装的是不带桌面的 Archlinux,因为 WARP 提供 CLI 方式登陆。另外安装 Mihomo 作为 http 代理。

VPN 登陆后,路由表会发生变化,还需要安装 FRP 把 http 代理映射到路由器上。

最后就是在本地的 Mihomo 做好各种转发规则。

用了半天,发现处于 VPN 内的公司 JumpServer 服务经常出现 AccessDenied 的情况。一开始以为是自己触发了什么规则,后来在 Windows 下面测试,才知道是会话过期的原因。

这就有些蛋疼了。不能每次会话过期就要登陆虚拟机,重新登陆 vpn 吧。

经过研究,打算用 webhook 在虚拟机里做个轻量钩子,本地登陆后把 token 通过钩子传给虚拟机的指定脚本,完成自动重登录。

借助 Deepseek ,很快就把几个脚本完成了。下面就是各个脚本:

/etc/webhook/hooks.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- id: re-reg
execute-command: /data/warp/webhook.sh
command-working-directory: /home/ety001
incoming-payload-content-type: application/json
response-headers:
- name: Access-Control-Allow-Origin
value: "*"
- name: Access-Control-Allow-Methods
value: "GET, POST, OPTIONS"
- name: Access-Control-Allow-Headers
value: "Content-Type"
response-message: ok
http-methods:
- "POST"
- "OPTIONS"
pass-arguments-to-command:
- source: payload
name: token

执行 sudo systemctl enable --now webhook 即可启动钩子。

/data/warp/webhook.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash

warp-cli disconnect
warp-cli registration delete

if [ -z "$1" ]; then
exit 1
fi

#token=$(echo "$1" | awk -F"'" '{print $2}')
token=$1

warp-cli --accept-tos registration token "${token}"

sudo warp-cli dns endpoint set x.x.x.x
sudo warp-cli api endpoint set x.x.x.x
sudo warp-cli tunnel endpoint set x.x.x.x:2408

warp-cli connect

本地使用下面的命令创建 xdg-open 处理程序

1
2
3
4
5
6
7
8
9
10
11
12
mkdir -p ~/.local/share/applications

cat > ~/.local/share/applications/warp-handler.desktop <<EOF
[Desktop Entry]
Type=Application
Name=WARP Handler
Exec=/data/app/handler.sh %u
MimeType=x-scheme-handler/com.cloudflare.warp;
NoDisplay=true
EOF

update-desktop-database ~/.local/share/applications

handler.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash

# 提取完整 URL
full_url="$1"

# 提取 token 部分(去掉协议头)
#token="${full_url#com.cloudflare.warp://}"
token=${full_url}

# 构造 JSON 数据
json_data="{\"token\": \"$token\"}"

echo $json_data
# 发送 POST 请求
curl -X POST \
-H "Content-Type: application/json" \
-d "$json_data" \
http://192.168.x.1:9000/hooks/re-reg

测试一下

当出现 JumpServer 无法访问的情况,那么直接在本地浏览器访问一下公司的 WARP 地址,完成登陆后,如下图:

image.png

点击弹出的对话框的打开 xdg-open,就可以把 token 传到虚拟机完成 warp 的重新登陆。

网络折腾好以后,终于可以继续安心工作了!本来就紧凑的工作节奏,耽误两天就积压了一大堆。。。。

背景

手里有一台 dzire 的廉价独服,配置如下:

项目 参数
CPU Intel Xeon E31270(8 核)
内存 16GB DDR3
磁盘 机械硬盘(HDD)
用途 Hivemind 同步 Steem 区块数据

Hivemind 是 Steem 链的社交层索引器,通过 Docker Compose 部署,其中 PostgreSQL 数据库承载了大量的写入和查询操作。同步一段时间后,数据库体积达到了 431GB,其中最大的几张表:

表名 大小
hive_posts_cache 216GB
hive_trxid_block_num 110GB
其他表 ~105GB

问题来了:同步进程始终落后链头几百个区块,追不上。

问题诊断

症状

tophtop 里最显眼的指标是 **IO Wait 持续 25%~35%**,CPU 大量时间在等磁盘。

确认瓶颈

iostatvmstat 进一步确认:

1
2
3
4
5
6
7
8
# iostat -x 1 观察
Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s %util await
sda 0.00 45.20 12.30 120.50 0.15 3.80 95.2 28.5

# vmstat 1 观察
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 3 0 2100000 450000 8500000 0 0 120 380 2500 4500 15 8 42 32 3

关键信号:

  • **%util 接近 100%**:磁盘已经满负荷
  • **wa(IO Wait)持续 25~35%**:CPU 大量时间在等 IO
  • **w/s 高达 120+**:大量小随机写(WAL + 数据文件)

结论:磁盘 IO 是瓶颈,毫无疑问。

在 SSD 上这些问题可能根本不会出现,但在 HDD 上,PostgreSQL 默认配置的检查点频率、WAL 刷写策略会导致大量小随机写,对机械硬盘来说是灾难性的。

调优思路

核心策略:减少磁盘写频率,增大批量写入,降低 fsync 开销

具体来说:

  1. **增大 shared_buffers**:让更多热数据留在内存,减少磁盘读取
  2. 增大 max_wal_size + **延长 checkpoint_timeout**:减少检查点频率,让每次检查点写出更多数据(顺序写更友好)
  3. 放宽 WAL 刷写策略wal_writer_delaywal_writer_flush_after 调大,减少 fsync 次数
  4. 激进调优 bgwriter:让后台写出进程多干活,减少检查点时的突发写入
  5. 关闭 full_page_writes:HDD 上这个特性会导致每次检查点后首次修改页写出完整页,极大放大写入量。注意:这依赖文件系统支持原子写(如 ZFS),否则断电有数据损坏风险。 本场景是同步数据,可以接受这个风险。

⚠️ 警告:第二轮激进配置的前提是——这台机器只做同步,不对外提供服务。如果你在生产环境中使用,请谨慎评估每一个参数。

具体配置

第一轮调优(保守)

初步调整,先把 PostgreSQL 的默认参数改到合理范围:

参数 默认值 调整后
shared_buffers 128MB(或 4GB) 6GB
max_wal_size 1GB(或 2GB) 4GB
checkpoint_timeout 5min(或 15min) 30min

这一轮调完后,IO Wait 有所下降但仍然不够,同步仍然落后。

第二轮调优(激进)

因为这台机器的唯一任务就是追赶同步,不需要担心崩溃恢复后的数据一致性(大不了重新同步),于是上了激进方案:

参数 第一轮值 激进值 说明
shared_buffers 6GB 8GB 占总内存 50%
max_wal_size 4GB 8GB 允许 WAL 累积更多再检查点
checkpoint_timeout 30min 1h 每小时才做一次检查点
wal_writer_delay 200ms 2s WAL writer 每 2 秒才醒来一次
wal_writer_flush_after 1MB 16MB 累积 16MB 才 fsync
bgwriter_delay 200ms 5s 后台写出进程降低唤醒频率
bgwriter_lru_maxpages 100 5000 每次醒来多写出一些脏页
bgwriter_lru_multiplier 2.0 10.0 更激进地预写出
full_page_writes on off 关闭全页写,大幅减少 IO

完整的 postgresql.conf(16g-postgres.conf)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# =============================================================
# PostgreSQL 16GB HDD 独服调优配置 - Hivemind 同步专用
# 注意:激进配置,仅适用于同步/非生产场景
# =============================================================

# ---- 内存 ----
shared_buffers = 8GB
work_mem = 64MB
maintenance_work_mem = 1GB
effective_cache_size = 12GB

# ---- WAL / 检查点 ----
max_wal_size = 8GB
min_wal_size = 2GB
checkpoint_timeout = 1h
checkpoint_completion_target = 0.9
wal_writer_delay = 2s
wal_writer_flush_after = 16MB
full_page_writes = off
wal_buffers = 64MB

# ---- 后台写出 ----
bgwriter_delay = 5s
bgwriter_lru_maxpages = 5000
bgwriter_lru_multiplier = 10.0

# ---- 查询规划 ----
random_page_cost = 4.0
effective_io_concurrency = 2
max_parallel_workers_per_gather = 2
max_parallel_workers = 4
max_parallel_maintenance_workers = 2

# ---- 连接 ----
max_connections = 100

# ---- 日志(方便观察) ----
log_checkpoint = on
log_connections = on
log_disconnections = on
log_lock_waits = on
log_temp_files = 0

# ---- autovacuum ----
autovacuum = on
autovacuum_max_workers = 4
autovacuum_naptime = 30s

几个值得解释的点:

  • **random_page_cost = 4.0**:HDD 的随机访问代价远高于 SSD(SSD 通常设 1.1),告诉规划器尽量走顺序扫描或索引扫描。
  • **effective_io_concurrency = 2**:HDD 的并发 IO 能力有限,设高了没意义。
  • **autovacuum_naptime = 30s**:Hivemind 同步产生大量 UPDATE/INSERT,频繁 vacuum 有助于避免表膨胀。
  • **full_page_writes = off**:这个最激进。正常情况下 PostgreSQL 在检查点后首次修改一页时会写出完整页(防止部分写撕裂)。关闭后每次只写差异,大幅减少写入量。如果你的文件系统是 ZFS 或使用了带电池保护的 RAID 控制器,风险可控。

Docker Compose 配置片段

PostgreSQL 配置文件放在 docker-compose.yml 同目录下,通过 volume 挂载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
postgres:
image: postgres:15
restart: unless-stopped
shm_size: '2gb'
ports:
- "5432:5432"
environment:
POSTGRES_USER: hivemind
POSTGRES_PASSWORD: your_password_here
POSTGRES_DB: hivemind
volumes:
- ./data/postgresql:/var/lib/postgresql/data
- ./16g-postgres.conf:/etc/postgresql/postgresql.conf
command: postgres -c config_file=/etc/postgresql/postgresql.conf
deploy:
resources:
limits:
memory: 14g
reservations:
memory: 10g

注意 shm_size: '2gb' 不能省——PostgreSQL 的 shared_buffers 依赖共享内存,默认的 64MB 远远不够。

效果对比

指标 默认配置 第一轮(保守) 第二轮(激进)
IO Wait 25~35% ~22% ~15%
同步速度 落后链头数百块 略有改善 稳定追赶,差距持续缩小
检查点写入突发 频繁且剧烈 减少但仍有突发 平滑,几乎无感

IO Wait 从 25~35% 降到 15%,看似降幅不大,但对于 HDD 来说这已经是巨大的改善。关键改善在于写入模式——从大量小随机写变成了少量大批量顺序写,这正是机械硬盘最擅长的工作模式。

总结

  1. 先诊断再动手:用 iostatvmstatpg_stat_bgwriter 定位瓶颈,不要盲目改参数
  2. HDD 的核心敌人是随机写:所有调优方向都围绕”把随机写变顺序写、把频繁写变批量写”
  3. 激进配置有代价full_page_writes = off 在断电时有数据损坏风险,生产环境慎用
  4. 资源要给够shared_buffers 给到总内存的 50%,shm_size 别忘了调大
  5. 同步场景可以激进:最坏情况就是重新同步,不用过于保守

如果你的独服也是 HDD,且在跑重 IO 的数据库同步任务,希望这篇能帮到你。

背景

日常开发中,本地工作机的磁盘空间和 CPU 资源往往比较紧张,尤其是在编译大型 Docker 镜像时,构建缓存和中间层会占用大量磁盘空间。与此同时,家里可能有一台配置更强的服务器,磁盘充裕但平时处于闲置状态。

有没有一种优雅的方式,能让本地开发机把 Docker 构建任务分发到远程服务器上执行,同时又不影响本地开发体验?

答案是 Docker Context + Buildx Remote Builder

原理

Docker CLI 原生支持通过 SSH 连接远程的 Docker daemon。配合 buildx,可以创建一个远程 builder,所有 docker buildx build 命令会自动通过 SSH 把构建任务发送到远程机器上执行。

1
2
3
4
5
6
7
8
9
本地工作机                         远程服务器
┌──────────────┐ SSH 隧道 ┌──────────────┐
│ docker CLI │ ─────────────────▶│ Docker D │
│ buildx │ ssh://remote │ BuildKit │
│ 源码目录 │ │ 构建缓存 │
└──────────────┘ └──────────────┘
│ ▲
│ docker buildx build ────────────┘
│ (自动通过 SSH 发送构建指令)

整个过程中,源码通过 SSH 上下文传递给远程 BuildKit,构建在远程服务器上执行,构建缓存也累积在远程机器上。本地工作机只负责发送构建指令和接收构建日志。

具体步骤

1. 创建 Docker Context

1
docker context create remote --docker "host=ssh://user@remote-server"

这样 docker -c remote ps 就等价于 ssh user@remote-server docker ps,Docker CLI 自动走 SSH 隧道。

如果已经在 ~/.ssh/config 中配置了主机别名(比如 Host remote),直接写 ssh://remote 即可。

2. 创建远程 buildx builder

1
docker buildx create --name remote-builder remote --use

这会在远程服务器上启动一个 BuildKit 容器,所有 docker buildx build 都会走这个 builder。

首次创建时需要拉取 moby/buildkit 镜像,取决于网络情况可能需要一些时间。

3. 使用方式

在项目目录下执行:

1
2
3
4
5
6
7
8
# 构建镜像(镜像存在远程服务器上)
docker buildx build -t myapp:latest -f Dockerfile .

# 构建并推送到 registry
docker buildx build -t myapp:latest -f Dockerfile --push .

# 构建后拉回本地(镜像通过 SSH 传回工作机)
docker buildx build -t myapp:latest -f Dockerfile --load .

4. 切换回本地构建

1
docker buildx use default

注意事项

镜像位置

默认情况下,docker buildx build 构建出来的镜像存在于远程服务器上,本地工作机的 docker images 是看不到的。

如果需要在本地运行该镜像,需要加 --load 参数,但这会把镜像通过 SSH 拉回本地,占用本地磁盘空间。更推荐的做法是加 --push 直接推送到 registry。

首次 bootstrap 较慢

首次创建 builder 时,远程服务器需要拉取 moby/buildkit 镜像。如果通过跳板机连接,网络延迟较高,拉取可能需要几分钟。耐心等一下就好。

构建缓存

远程 builder 有自己的 BuildKit 缓存,同一个项目第二次构建时会命中缓存,速度明显更快。不同项目的缓存互相隔离,不会互相干扰。

不侵入现有流程

这个方案不需要修改 Dockerfile,不需要改 CI/CD 配置。只需要在构建时指定用哪个 builder,其他一切照旧。

总结

通过 Docker Context + Buildx,可以零成本地把重型构建任务卸载到闲置的远程服务器上,释放本地工作机的磁盘和 CPU 资源。对于本地磁盘紧张但又需要频繁构建大型镜像的场景,这是一个非常优雅的解决方案。

之前在我的 用 Docker 快速部署 PPTP VPN 和 L2TP + IPSEC VPN 一文中,我介绍了如何使用 Docker k快速署 L2TP IPSec VPN,但是在纯血鸿蒙下一直无法成功。

发现是因为无法匹配通讯加密方法。

进入容器,如下修改一下 /etc/ipsec.conf 文件的通讯加密方法,

1
2
3
4
5
conn l2tp-psk
...
ike=aes128-sha1-modp1024,aes256-sha1-modp1024 # 匹配客户端的AES-CBC + SHA1 + MODP1024
phase2alg=aes128-sha1 # 第二阶段算法也需匹配
...

之后重启服务即可,service ipsec restart

简介

hclient-cli 是官方推出的一个面向无图形界面的机器的接入方案。

有这个工具的情况下,我们可以让我们远端的服务器接入到懒猫的网络中,访问懒猫上的数据资源。

使用

官方库文档有接口的使用说明,这里就不再阐述,我提交了一个 PR,对官方的接口简单的用 bash 脚本封装了一下,减少使用过程中的输入量。同时还包含了一个构建 Docker 镜像的方案,其实就是把 cli 程序加入到镜像中。

目前我的远端服务是 Docker 容器形式运行的,因此这里分享一下 Docker 使用样例。

这里的远端应用以我的远端服务器上的青龙面板为例。

首先给出一个 Docker 容器启动命令示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
docker run -itd \
--privileged \
--name lazycat \
--hostname lazycat_in_docker \
--restart always \
--network lnmp \
--ip 172.20.0.66 \
-p 127.0.0.1:7777:7777 \
-p 127.0.0.1:61090:61090 \
-v /data/cfg:/app/cfg \
ety001/lazycat-cli:latest \
/app/hclient-cli \
-api-addr "172.20.0.66:7777" \
-http-addr "172.20.0.66:61090"
  1. 请注意不要暴露你的 7777 管理端口和 61090 代理端口给全局网络
  2. 容器启动后,所有 lnmp 网络中的容器可以通过 172.20.0.66:61090 代理访问懒猫资源。
  3. 宿主机可以通过 127.0.0.1:7777 管理,通过 127.0.0.1:61090 代理访问。

在宿主机中,调用 cmd.sh (在我的那个 PR 中)完成懒猫网络的登陆。

1
2
3
./cmd.sh add_box
./cmd.sh add_tfa
./cmd.sh client_info

登陆成功后,打开远端的青龙面板,安装依赖 axioshttps-proxy-agent。之后增加环境变量配置如下:

1
2
3
LAZYCAT_PROXY=http://172.20.0.66:61090
INFLUXDB_TOKEN=xxxx
INFLUXDB_URL=http://influxdb.ecat.heiyu.space:8086

创建一个新的测试 js 脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');

const proxy = process.env.LAZYCAT_PROXY;
const agent = new HttpsProxyAgent(proxy);

// 自定义 HTTP 客户端,使用 axios 和代理
const customHttpClient = axios.create({
httpAgent: agent,
httpsAgent: agent,
});

const token = process.env.INFLUXDB_TOKEN;
const url = process.env.INFLUXDB_URL;
const org = 'default';
const bucket = 'steem';


// 定义 Flux 查询
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "price")
|> filter(fn: (r) => r._field == "lowest_ask")
`;

// 发送查询请求
async function queryData() {
try {
const response = await customHttpClient.post(
`${url}/api/v2/query?org=${org}`,
{
query: fluxQuery,
type: 'flux',
},
{
headers: {
Authorization: `Token ${token}`,
'Content-Type': 'application/json',
},
}
);
console.log('查询结果:', response.data);
} catch (error) {
console.error('查询数据时出错:', error.response ? error.response.data : error.message);
}
}

queryData();

调试运行,成功获取到懒猫微服上的 Influxdb 中的数据。

image.png

0%