<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>I am LAZY bones?</title>
	<atom:link href="https://luy.li/feed/" rel="self" type="application/rss+xml" />
	<link>https://luy.li</link>
	<description>AN ancient AND boring SITE</description>
	<lastBuildDate>Thu, 27 Aug 2026 05:52:15 +0000</lastBuildDate>
	<language>zh-Hans</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	
	<item>
		<title>Claude Code 的记忆同步大法</title>
		<link>https://luy.li/2026/08/27/claude-config-sync/</link>
					<comments>https://luy.li/2026/08/27/claude-config-sync/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 05:52:02 +0000</pubDate>
				<category><![CDATA[经验技巧]]></category>
		<category><![CDATA[编程相关]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2587</guid>

					<description><![CDATA[<p>我有两台机器：一台 MacBook Pro，还有家里的 Mac mini。有一些项目，会在两边都写。 前几天晚 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/27/claude-config-sync/">Claude Code 的记忆同步大法</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>我有两台机器：一台 MacBook Pro，还有家里的 Mac mini。有一些项目，会在两边都写。</p>
<p>前几天晚上在家里那台上开 Claude CLI，聊到一半我才反应过来——它什么都不记得。。。那些&#8221;这个坑我们上个月踩过&#8221;&#8221;这个方案试过不行&#8221;的结论，全都留在公司那台上了。我这边还得从头解释一遍。</p>
<p>于是花了个把小时把这事儿彻底解决掉。方案本身不复杂，但中间挖出来的几个东西挺有意思，值得写一篇，分享给需要有需要的朋友。<br />
<span id="more-2587"></span></p>
<h2>先搞清楚：记忆到底存在哪？</h2>
<p>Claude Code 的<a href="https://docs.claude.com/en/docs/claude-code/memory">记忆</a>不是存在云端的，就是本地文件：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">~/.claude/projects/&lt;路径slug&gt;/memory/*.md      # 每个项目的记忆，一条一个文件
~/.claude/projects/&lt;路径slug&gt;/memory/MEMORY.md # 该项目的记忆索引
~/.claude/CLAUDE.md                            # 全局偏好</pre><p></p>
<p>关键在那个 slug。它是<b>项目绝对路径</b>转义出来的，规则很简单粗暴——非字母数字的字符统统换成短横线：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">re.sub(r'[^a-zA-Z0-9]', '-', path)</pre><p></p>
<p>所以我的 <code>/Users/luyang.li/dev/notchy</code> 就变成了 <code>-Users-luyang-li-dev-notchy</code>。注意用户名里那个点也被转成了横线，<code>@</code>、<code>_</code> 同理，全都一视同仁。</p>
<p>统计了一下，我这边一共有 111 条记忆。数量不算多，但都是真金白银试出来的结论，重新攒一遍的成本可不低。</p>
<h2>哪些能同步，哪些碰都别碰</h2>
<p>第一反应可能是&#8221;把整个 <code>~/.claude</code> 丢进 iCloud 不就完了&#8221;。<b>千万别。</b></p>
<p>我扫了一遍那个目录，能同步的其实就这么几样：</p>
<p>1. <code>projects/*/memory/</code> —— 各项目记忆<br />
2. <code>skills/</code> —— 自定义 skills<br />
3. <code>CLAUDE.md</code> —— 全局偏好<br />
4. <code>settings.json</code> —— 全局设置<br />
5. <code>mcp.json</code> —— MCP 配置</p>
<p>其余的全是本机运行时状态，同步过去只会互相打架：<code>history.jsonl</code>、<code>sessions/</code>、各项目下的 <code>*.jsonl</code> 会话记录（我这边光这项就 364M）、<code>file-history/</code>（30M）、<code>shell-snapshots/</code>、<code>plugins/</code> 缓存、<code>telemetry/</code>、还有 daemon 的认证文件。</p>
<p>更要命的是 <code>~/.claude.json</code>——注意是家目录根下那个，不是 <code>.claude/</code> 里面的。我打开一看，好家伙，某个 MCP server 的明文 access token 就躺在里面。这玩意儿要是跟着仓库推上去，那可就热闹了！<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f605.png" alt="😅" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>
<p>顺带一提，<b>插件不用同步</b>。<code>settings.json</code> 里的 <code>enabledPlugins</code> 和 <code>extraKnownMarketplaces</code> 已经完整描述了装了哪些插件、从哪个 marketplace 拉，换台机器它自己会去下。我这边 <code>plugins/</code> 目录 14M，绝大部分是缓存和 marketplace 的 clone，同步纯属浪费。</p>
<h2>目录用软链接，单文件用拷贝</h2>
<p>方案就是建个私有 git 仓库 <code>~/dev/claude-config</code>，把要同步的东西搬进去，原地留软链接。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">REPO=~/dev/claude-config
mkdir -p $REPO &amp;&amp; git -C $REPO init

# 以 skills 为例
mv ~/.claude/skills $REPO/skills
ln -s $REPO/skills ~/.claude/skills</pre><p></p>
<p>但这里有个分野，我一开始没想到：<b>目录可以软链接，单个文件不行。</b></p>
<p>原因是 Claude Code 自己会重写 <code>settings.json</code>（你 <code>/model</code> 切一次它就重写一遍）。而很多程序的&#8221;原子写&#8221;是先写临时文件再 rename——那一 rename，你的软链接就被替换成实体文件了，链接直接断掉，后面的改动再也进不了仓库。目录被这样整个替换的概率极低，所以目录安全。</p>
<p>于是最终是混合方案：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">skills/                     -&gt; 软链接，改动实时进仓库
projects/*/memory/          -&gt; 软链接，同上
CLAUDE.md / settings.json / mcp.json
                            -&gt; 拷贝，按 mtime 双向同步，每次跑脚本时自愈</pre><p></p>
<p>还有个细节让我挺满意的：我那 39 个 skills 里有 20 个本身就是指向另一个仓库的软链接。git 会把符号链接以 mode <code>120000</code> 原样存下来（存的是目标路径，不是内容），clone 到另一台机器上它还是软链接。推完我特意查了一下远端：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">gh api 'repos/bones7456/&lt;repo名&gt;/git/trees/main?recursive=1' \
  | python3 -c &quot;import json,sys; t=json.load(sys.stdin)['tree']; \
    b=[x for x in t if x['type']=='blob']; \
    print(len(b), '个 blob,', len([x for x in b if x['mode']=='120000']), '个符号链接')&quot;</pre><p></p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">548 个 blob, 20 个符号链接</pre><p></p>
<h2>两台机器用户名不一样怎么办</h2>
<p>还记得前面那个 slug 规则吗，坑来了。</p>
<p>我这台的用户名是 <code>luyang.li</code>，另一台是 <code>lly</code>。同一个项目 <code>~/dev/notchy</code>，在两台机器上算出来的 slug 完全不同：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">-Users-luyang-li-dev-notchy    # 这台
-Users-lly-dev-notchy          # 那台</pre><p></p>
<p>直接把仓库里的目录名照搬过去，记忆就对不上了。而且反过来从 slug 推路径也不行——横线可能对应 <code>/</code>，也可能对应 <code>.</code>、<code>@</code>，甚至本来就是横线，信息已经丢了。</p>
<p>解决办法是在仓库里存一份 <code>origins.tsv</code>，记录每份记忆对应的<b>原始绝对路径</b>：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">-Users-luyang-li-dev-notchy	/Users/luyang.li/dev/notchy
-Users-luyang-li-dev-shi	/Users/luyang.li/dev/shi</pre><p></p>
<p>安装脚本读这个文件，把路径里的 home 前缀换成本机的，再重算一遍 slug 建软链接。这样只要 home 之后的路径一致，两边就能对上同一份记忆。</p>
<p>（生成这个映射表也有讲究：我没去反推，而是拿 <code>~/.claude.json</code> 里 <code>projects</code> 字典的 key——那些本来就是绝对路径——正向算 slug 建的对照，14 个全中。）</p>
<h2>挂上 hooks，让它自己跑</h2>
<p>同步脚本写好了，接下来是自动化。Claude Code 的 <a href="https://docs.claude.com/en/docs/claude-code/hooks">hooks</a> 正好有两个合适的事件：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">&quot;hooks&quot;: {
  &quot;SessionStart&quot;: [
    { &quot;hooks&quot;: [{ &quot;type&quot;: &quot;command&quot;, &quot;command&quot;: &quot;\&quot;$HOME/dev/claude-config/sync.sh\&quot; pull&quot;, &quot;timeout&quot;: 30 }] }
  ],
  &quot;SessionEnd&quot;: [
    { &quot;hooks&quot;: [{ &quot;type&quot;: &quot;command&quot;, &quot;command&quot;: &quot;\&quot;$HOME/dev/claude-config/sync.sh\&quot; push&quot;, &quot;timeout&quot;: 30 }] }
  ]
}</pre><p></p>
<p>这里用 <code>$HOME</code> 而不是绝对路径，因为 <code>settings.json</code> 这个文件本身也会被同步到另一台机器上去，写死路径那边就废了。</p>
<h2>SessionEnd 到底什么时候触发？</h2>
<p>配完我就犯嘀咕：SessionEnd 具体什么时候跑？关终端算不算？</p>
<p>翻了下 settings 的 schema，它只在事件名枚举里出现，没有说明。与其猜，不如直接从本机的 CLI 二进制里找。我这个版本是 2.1.247：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">F=~/.local/share/claude/versions/2.1.247
grep -a -o -E '.{130}prompt_input_exit.{130}' &quot;$F&quot; | head -5</pre><p></p>
<p>第一条就中了：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">Vs=[&quot;clear&quot;,&quot;resume&quot;,&quot;logout&quot;,&quot;prompt_input_exit&quot;,&quot;other&quot;],Qs=s(()=&gt;a(Vs)),Zs=s(()=&gt;h().and(t({hook_event_name:n(&quot;SessionEnd&quot;),reason:Qs()})))</pre><p></p>
<p>紧挨着 <code>hook_event_name:"SessionEnd"</code> 的就是 <code>reason</code> 的枚举，一共五个值：</p>
<p>1. <code>clear</code> —— 执行 <code>/clear</code><br />
2. <code>resume</code> —— 切走去别的会话<br />
3. <code>logout</code> —— 执行 <code>/logout</code><br />
4. <code>prompt_input_exit</code> —— 从输入框正常退出<br />
5. <code>other</code> —— 兜底</p>
<p><b>最意外的是 <code>/clear</code> 也算 SessionEnd。</b>这意味着我的 push 不是&#8221;一天一次&#8221;，而是每次清上下文都会提交推送一遍，同步粒度比预想的细多了，挺好。</p>
<p>再往下挖，找到了执行时机：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">async shutdown(e=0,t=&quot;other&quot;,n){
  if(this.shutdownInProgress)return;
  this.shutdownInProgress=!0, process.exitCode=e, ...
  let{executeSessionEndHooks:r,getSessionEndHookTimeoutMs:o}=await import(&quot;...&quot;);
  let s=o();
  this.armShutdownFailsafe(Math.max(5000,s+5000),e);</pre><p></p>
<p>hooks 跑在 <code>process.exitCode</code> 设定之后、进程真正退出之前，而且有个失效保险：超过 <code>max(5秒, 超时+5秒)</code> 就强制退出。所以 SessionEnd hook 里别干重活，我这个只在本地 commit，真正的 <code>git push</code> 甩到后台跑。</p>
<p>那硬退出呢？我找到这么一行：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">process.on(&quot;SIGTERM&quot;, () =&gt; process.exit())</pre><p></p>
<p>裸的 <code>process.exit()</code>，不走 <code>shutdown()</code>，也就不跑 hook。<code>SIGKILL</code> 就更不用说了。（CLI 里 SIGTERM/SIGINT/SIGHUP 有好几处注册，分属不同上下文，我没法干净地判断直接关终端窗口走的是哪条路径，所以这条只能算&#8221;很可能&#8221;，不算证实。）</p>
<h2>所以 pull 也得先 commit</h2>
<p>上面这个结论直接推翻了我原本的脚本设计。</p>
<p>原来 <code>pull</code> 是这么写的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">git pull --rebase --autostash</pre><p></p>
<p><code>--autostash</code> 会把未提交的改动 stash 起来、rebase 完再放回来——但放回来之后<b>它还是未提交状态</b>。万一某次 SessionEnd 没跑成（进程被硬杀了），那批记忆就一直悬在工作区里，没提交更没推送，另一台机器永远看不到。</p>
<p>改法很简单：<code>pull</code> 开头先 commit 一次。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">pull)
    link_dirs
    sync_files
    commit_local &quot;recovered&quot;          # 先落袋为安
    if has_remote; then
        git -C &quot;$REPO&quot; pull --rebase --autostash -q || echo &quot;pull 失败，请手工查看&quot;
        n=$(unpushed_count)
        [ &quot;$n&quot; -gt 0 ] &amp;&amp; echo &quot;  &uarr; 补推 $n 个未推送的提交&quot;
        push_background
    fi
    sync_files                        # 把刚拉下来的内容拷出去
    exit 0
    ;;</pre><p></p>
<p>这样只要还能开一次会话，上次漏掉的东西就一定会被补上。提交信息前缀用 <code>recovered</code>，跟正常退出的 <code>sync</code> 区分开，翻 log 时一眼能看出这批改动是怎么进来的。</p>
<h2>最后</h2>
<p>整套跑下来，仓库 548 个文件、<code>.git</code> 才 2.9M，所有项目的记忆全接管了。现在的循环是这样：会话开始 pull，中间的改动经软链接实时落到仓库，会话结束（或者 <code>/clear</code>）commit 加后台 push。</p>
<p>顺带说一句，为什么不用 iCloud 或者 Dropbox——它俩当然也能跑，把仓库路径换成云盘目录、软链接照建就行，还省掉 hooks。但记忆这东西天然就是&#8221;一句话一个文件&#8221;，太适合版本化了；万一两台机器同时改了同一条，git 会明明白白报冲突让你处理，云盘则是悄悄给你生成一个&#8221;冲突副本&#8221;，你可能几个月都发现不了。。。</p>
<p>不说了，我去家里那台上 clone 一遍试试。要是 <code>origins.tsv</code> 那套换算真能对上，那 111 条记忆就算是彻底安全了——毕竟这里头有不少是<a href="https://luy.li/2026/08/16/ios-app-memory-leak/">当初排查内存泄露</a>那种熬了半天才熬出来的结论，再丢一次我可真要哭了！<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f62d.png" alt="😭" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p><p>The post <a href="https://luy.li/2026/08/27/claude-config-sync/">Claude Code 的记忆同步大法</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/27/claude-config-sync/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>一个iOS APP内存泄露排查案例</title>
		<link>https://luy.li/2026/08/16/ios-app-memory-leak/</link>
					<comments>https://luy.li/2026/08/16/ios-app-memory-leak/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 16 Aug 2026 06:06:27 +0000</pubDate>
				<category><![CDATA[流水帐]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2568</guid>

					<description><![CDATA[<p>昨晚睡觉前，我打开了自己那个打鼾监测 App，早上其实发现它消失了，打开记录停在了 02:06，而我七点半才醒 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/16/ios-app-memory-leak/">一个iOS APP内存泄露排查案例</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>昨晚睡觉前，我打开了自己那个<a href="https://nightsnore.com/">打鼾监测 App</a>，早上其实发现它消失了，打开记录停在了 02:06，而我七点半才醒。我意识到，不妙，来活儿了～<br />
<span id="more-2568"></span></p>
<p><img fetchpriority="high" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/02-session-cut-at-0206.webp" alt="记录停在 02:06:36，实际睡到 7:30" width="690" height="1500" class="alignnone size-full wp-image-2577" /></p>
<p>这种&#8221;整夜监测只录到一半&#8221;的问题最难受的地方在于：它不报错、不崩溃、不留任何痕迹。App 就是在某个时刻悄无声息地没了，第二天你只看到一条被截断的记录。</p>
<p>这篇写的就是把这件事查清楚的全过程——从设备里的 JetsamEvent 报告，到 Instruments 的 Generation 快照，最后落到一行 SwiftUI 代码上。中间还有一段完整的、被自己推翻的错误推断，作为教训一并写了进来。</p>
<h2>第一步：先确认它是&#8221;被杀的&#8221;还是&#8221;自己停的&#8221;</h2>
<p>这两种情况的修法完全不同，不能靠猜。</p>
<p>我的 App 在监测过程中每 5 分钟往 UserDefaults 写一次检查点，进程要是被强杀，下次启动时会拿检查点的时间去补 endTime（不然历史里就是一条 endTime 为空的孤儿记录）。所以判据现成的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">开始 00:01:36
结束 02:06:36
差值 7500 秒 = 25 &times; 300</pre><p></p>
<p>正好是检查点间隔的整数倍。<b>如果是正常停止，endTime 会是任意时刻；只有走孤儿恢复才会精确落在检查点节拍上。</b> 结论：进程在 02:06:36 到 02:11:36 之间没走正常的停止流程就没了。</p>
<p>顺带说个同一晚的另一个 bug，跟主线无关但挺典型的：</p>
<p><img decoding="async" src="https://luy.li/wp-content/uploads/2026/08/01-audio-error-what.webp" alt="com.apple.coreaudio.avfaudio error 2003329396" width="690" height="1500" class="alignnone size-full wp-image-2571" /></p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">&gt;&gt;&gt; import struct; struct.pack('&gt;I', 2003329396)
b'what'</pre><p></p>
<p>CoreAudio 的错误码经常是四字符码，2003329396 就是 <code>'what'</code>。触发路径是：点开始 → 5 秒预备倒计时 → 我顺手按了锁屏键 → 倒计时在后台结束 → 这时才去开麦。而 <code>UIBackgroundModes: audio</code> 这个豁免只保&#8221;已经在录的继续录&#8221;，<b>不保&#8221;后台新开麦&#8221;</b>，系统直接拒了。改法很简单：把开麦挪到倒计时之前，会话在麦克风激活之后，锁屏就管不着了。</p>
<h2>JetsamEvent 报告怎么读</h2>
<p>iOS 上的查看路径是 <b>设置 → 隐私与安全性 → 分析与改进 → 分析数据</b>，往下翻找 <code>JetsamEvent-年-月-日-时分秒.ips</code>。这个文件是两段 JSON 拼起来的：第一行是头，剩下是正文。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">import json
raw = open(&quot;JetsamEvent-2026-08-16-020855.ips&quot;).read()
d = json.loads(raw.split(&quot;\n&quot;, 1)[1])
PS = d[&quot;memoryStatus&quot;][&quot;pageSize&quot;]          # 16384
for p in sorted(d[&quot;processes&quot;], key=lambda x: -x[&quot;rpages&quot;])[:5]:
    print(f'{p[&quot;name&quot;]:24} {p[&quot;rpages&quot;] * PS / 1048576:8.1f} MB  prio={p[&quot;priority&quot;]}')</pre><p></p>
<p>几个关键字段：</p>
<p>1. <code>rpages × pageSize</code> 是各进程占的内存，注意 pageSize 现在是 16 KB 不是 4 KB。<br />
2. <code>physicalPages.internal</code> 是 <code>[clean, dirty]</code>，脏页才是真正赖着不走的那部分。<br />
3. <code>age</code> 单位是纳秒，用报告时间减一下就能反推每个进程什么时候启动的。<br />
4. 带 <code>reason</code> 字段的那几条才是这次事件里被杀的，其余是快照。<br />
5. <code>largestProcess</code> 直接告诉你当时谁最胖。</p>
<p>我这份 02:08:55 的报告里，<b>NightSnore 压根不在进程列表里</b>——说明它在这次事件之前就已经被杀了。当时整机也确实在雪崩：可用内存 162 MB、compressor 涨到 1.87 GB、最后三分钟有 172 个进程被杀了重启，连 SpringBoard 自己都撞了 per-process 上限。</p>
<p>真正的实锤在另一份 ips 文件里，因为我发现还有一份六天前的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">NightSnore   rss = 1814.3 MB   dirty = 1734 MB   purgeable = 0
             prio = 100   states = [active]   age = 29.5 分钟
largestProcess: NightSnore</pre><p></p>
<p><b>监测跑了 29.5 分钟，进程涨到 1.8 GB，其中 1.73 GB 是脏内存。</b> 全机最大。那次系统没杀它，而是把 180 多个闲置进程 idle-exit 掉给它让路。</p>
<p>到这儿性质就清楚了：不是什么玄学的后台调度问题，就是漏内存漏到撑爆。</p>
<h2>证据不够的时候，别急着改代码</h2>
<p>这一节是我这次最大的教训。</p>
<p>拿到 1.8 GB 这个数之后，我去读音频管线的代码，很快&#8221;找到&#8221;了三处无上界结构：波形显示每来一个音频 buffer 就起一个 <code>Task { @MainActor }</code> 把整块样本捕获进去；两级 DispatchQueue 都是无条件 async，处理端一慢就会无限堆积。三处加起来的量级估算下来跟观测值挺接近。</p>
<p>于是我给队列加了背压、给波形加了节流。改完确实好了一些——但<b>这是推断，不是证据</b>，这里其实浪费了很多时间。</p>
<h2>Instruments 的 Generation 快照发现根因</h2>
<p>Xcode 的 Memory Report 只能告诉你&#8221;在涨&#8221;：</p>
<p><img decoding="async" src="https://luy.li/wp-content/uploads/2026/08/03-xcode-memory-report.webp" alt="三分钟涨了约 10 MB" width="1500" height="1106" class="alignnone size-full wp-image-2576" /><br />
(可以发现，除了内存一直涨，CPU也很高这个后面也一起修复了）</p>
<p>要知道&#8221;谁在涨&#8221;，得用 <a href="https://developer.apple.com/documentation/xcode/gathering-information-about-memory-use">Instruments 的 Allocations</a>，核心功能是 <b>Mark Generation</b>：</p>
<p>1. Product → Profile（⌘I），选 Allocations<br />
2. Recorder Settings 里面的 Recording mode 要选 Immediate<br />
3. 跑一分钟，点一下 Mark Generation<br />
4. 再跑三分钟，再点一次<br />
5. 展开第二个 Generation，看 <b>Growth</b> 列</p>
<p>Growth 只列&#8221;两次快照之间新增、且到现在仍然存活&#8221;的对象——这就是内存泄漏的定义。别的视图都会被大量正常的临时分配淹没，只有这个视图干净。</p>
<p><img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/04-instruments-generation-before.webp" alt="Generation B 增长 10.81 MiB / 61,631 个存活对象" width="1500" height="778" class="alignnone size-full wp-image-2575" /></p>
<p>结果一眼就能看出问题：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">UnknownObjectType                        5.71 MiB   25,584
_ContiguousArrayStorage&lt;AnyViewTrait&gt;    2.16 MiB    5,904
ImageProviderBox&lt;NamedImageProvider&gt;   553.50 KiB    5,904
LocalizedTextStorage                   461.25 KiB    5,904
AnyViewStorag&lt;Label&lt;Text, Image&gt;&gt;     369.00 KiB    5,904
TagIndexProjection&lt;Int&gt;                184.50 KiB    1,968
_TtCC5UIKit12_UITabButton&hellip;              96.00 KiB        6</pre><p></p>
<p>跟音频、跟 CoreML、跟我改了半天的那些队列，<b>一点关系都没有</b>。全是 SwiftUI，而且全是 TabView 标签栏的零件：<code>Label&lt;Text, Image&gt;</code> 是三个 tab 项，<code>NamedImageProvider</code> 是 tab 图标，<code>LocalizedTextStorage</code> 是 tab 标题，<code>TagIndexProjection&lt;Int&gt;</code> 是 <code>.tag()</code>，最后还有 <code>UITabButton</code> 本体。</p>
<p>UnknownObjectType：<br />
<img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/05-instruments-unknownobjecttype.webp" alt="展开 UnknownObjectType，全是 64/96/768 字节的小块" width="1500" height="1001" class="alignnone size-full wp-image-2574" /><br />
AnyViewTrait：<br />
<img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/06-instruments-anyviewtrait.webp" alt="展开 AnyViewTrait，全是 384 字节，时间戳密集" width="1500" height="943" class="alignnone size-full wp-image-2573" /></p>
<p>真正把结论钉死的是数字本身：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">5,904 = 3 &times; 1,968     （三个 tab）
1,968 &divide; 180 秒 = 10.9 次/秒</pre><p></p>
<p>10.9 次每秒——正好是音频 buffer 的到达频率。</p>
<h2>根因：根视图订阅了高频 @Published</h2>
<p>顺着这个频率回去看代码，一行就找到了：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">struct ContentView: View {
    @ObservedObject private var monitoringService = MonitoringService.shared
    // ...
    TabView(selection: $selectedTab) {
        MonitoringView().tabItem { Label(&quot;监测&quot;, systemImage: &quot;waveform.circle.fill&quot;) }.tag(0)
        SessionListView().tabItem { Label(&quot;历史&quot;, systemImage: &quot;clock.fill&quot;) }.tag(1)
        SettingsView().tabItem { Label(&quot;设置&quot;, systemImage: &quot;gearshape.fill&quot;) }.tag(2)
    }
}</pre><p></p>
<p><code>MonitoringService</code> 里有个 <code>@Published var audioSamples: [Float]</code>，监测时每秒更新约 11 次给波形图用。链条是这样的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">audioSamples 每秒发布 11 次
  &rarr; ContentView.body 每秒重算 11 次
    &rarr; 整个 TabView 连同三个 .tabItem { Label(...) } 重建
      &rarr; SwiftUI 把标签项的内部存储泄漏掉</pre><p></p>
<p>一小时约 200 MB，整夜下来就是 GB 级。而且这跟屏幕亮不亮无关——锁屏后 SwiftUI 不渲染了，但 body 该重算还是重算。</p>
<p><b>根视图的 body 其实根本不依赖任何监测状态</b>，它只是需要在两个字段变化时做点事而已。那就别观察整个对象，改成订阅具体的 publisher：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">private let monitoringService = MonitoringService.shared   // 不再是 @ObservedObject

// ...
.onReceive(monitoringService.$finishedSessionForSummary) { _ in presentSummaryAfterPublish() }
.onReceive(monitoringService.$audioError)                { _ in presentSummaryAfterPublish() }</pre><p></p>
<p>同样口径复测：</p>
<p><img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/07-instruments-generation-after.webp" alt="Generation B 增长 581 KiB / 609 个存活对象" width="1500" height="905" class="alignnone size-full wp-image-2572" /></p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">修之前              修之后
Growth       10.81 MiB          581 KiB
存活对象      61,631            609</pre><p></p>
<p>字节数降了 19 倍，<b>对象数降了 101 倍</b>。后者才是关键信号——&#8221;每帧泄一批&#8221;的模式没有了。剩下那 581 KiB 里 80% 是 4 个 CALayer 后备存储和 1 次 SQLite 页缓存增长，都是一次性的。</p>
<p>支持，内存泄露的问题算是解决了，再去看CPU使用率，也从 75% 降到了 5% 不到。</p>
<h2>几个坑</h2>
<p>1. <b><a href="https://developer.apple.com/documentation/combine/published">@Published</a> 是在 willSet 时发布的。</b> 换成 <code>.onReceive</code> 之后，闭包跑在属性真正写入之前，这时候回读那个属性拿到的还是旧值——比如 <code>audioError</code> 明明马上要变成 nil，读出来仍然非 nil，判断就全反了。要么用闭包参数带过来的新值，要么推到下一个主队列 turn 再处理。</p>
<p>2. <b>本进程不在 JetsamEvent 列表里，不代表这份报告没用。</b> 它恰恰说明进程在这次事件之前就已经被杀了，应该去找更早的那份。iOS 不会为每次 kill 都写报告，也会轮转，所以未必找得到。</p>
<p>3. <b>Xcode Organizer 的 Terminations / Memory 面板需要足够用户量。</b> 我的 App 用户少，那儿一直显示 Insufficient usage data。真要在生产环境拿这个数据，得自己接 <a href="https://developer.apple.com/documentation/metrickit">MetricKit</a>——<code>MXAppExitMetric.backgroundExitData</code> 会把后台退出按原因分桶（memoryResourceLimit / memoryPressure / cpuResourceLimit / appWatchdog / normal），<code>MXMemoryMetric.peakMemoryUsage</code> 给峰值内存，而且不依赖样本量。注意 payload 是按天生成、日终才交付的，今天出的事明天才读得到。</p>
<p>4. <b>有界即可控，无论多大。</b> 我中途一度去调小音频缓冲的上限、给队列加背压丢帧——那是让泄漏变慢，不是修泄漏，而且丢帧会漏掉真实的鼾声事件。判据应该是&#8221;有没有上界&#8221;，不是&#8221;占了多少&#8221;：有上限封顶的缓冲填到 80 MB 是可控的，无上界的队列涨到 10 MB 才是 bug。根因定位之后，那些基于错误推断做的改动我全部还原了。</p>
<p>5. <b>别给泄漏加兜底。</b> 我还写过&#8221;剩余内存不足就自动结束监测&#8221;的保护，听起来很负责，实际上根因修掉之后它只剩下误停正常会话的风险。也删了。</p>
<h2>回头看</h2>
<p>整件事的证据链是这样一条：<b>endTime 落在检查点整倍数上</b>（判定被杀）→ <b>JetsamEvent 报告</b>（拿到 1.8 GB 这个量级）→ <b>git log</b>（作废错误推断）→ <b>Instruments Generation</b>（点名到具体对象）→ <b>对象个数 = 3 × body 重算次数</b>（锁定根因）。</p>
<p>前面几步都是旁证，只有 Generation 快照那一步是真正指名道姓的。我在拿到它之前写的所有&#8221;修复&#8221;，事后看全是在给一个不存在的病开药。读代码能产生假设，但假设需要被证据杀死或者证实，中间那一段自己觉得&#8221;应该就是这个了&#8221;的笃定，最不可信。</p>
<p>不说了，我赶紧发新版本去了，<a href="https://nightsnore.com/">这APP</a>之前是下载就收费，刚改成免费试用+一次性买断的模式，结果免费用户就遇到了内存泄露，异常退出的大问题，实在是很不应该。。。对我的打击也实在是有点大！<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f62d.png" alt="😭" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p><p>The post <a href="https://luy.li/2026/08/16/ios-app-memory-leak/">一个iOS APP内存泄露排查案例</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/16/ios-app-memory-leak/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>《阿勒泰的角落》读书笔记</title>
		<link>https://luy.li/2026/08/16/978-7-5133-1253-0/</link>
					<comments>https://luy.li/2026/08/16/978-7-5133-1253-0/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 16 Aug 2026 00:44:27 +0000</pubDate>
				<category><![CDATA[读书笔记]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2554</guid>

					<description><![CDATA[<p>我喜欢旅行，去世界各地旅行。有的人说：旅行就是从你呆腻了的地方去到别人呆腻了的地方，我觉得某种意义上，这是对的 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/16/978-7-5133-1253-0/">《阿勒泰的角落》读书笔记</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>我喜欢旅行，去世界各地旅行。有的人说：旅行就是从你呆腻了的地方去到别人呆腻了的地方，我觉得某种意义上，这是对的。人生在世不就是要多体验嘛！旅行就有很大的概率可以让你有一些新的体验。</p>
<p>而读书，也是这个道理。<br />
<span id="more-2554"></span></p>
<p>虽然你不是亲自去某个地方，但通过文字，你就能感受到笔者所描述的世界，而且这个成本更低。不仅成本低，你还可以进入到“非现实的世界”，比如你读历史，可以进入“过去的世界”；你读玄幻类的，可以进入“虚拟的世界”。在某种意义上，这些都是超越旅行的存在。</p>
<p>当然，我最近读的《阿勒泰的角落》进入的倒是一个“真实的世界”，而且这个世界，说起来我还去过，2022年我来新加坡之前，曾经开着房车游过新疆。我花了半个月时间，在北疆转了一圈，但新疆真的太大了，即使半个月对打工的牛马来说，已经很奢侈了，但我肯定不能说对阿勒泰很熟了。我只是禾木、阿勒泰这样转了几个景点。</p>
<p>而本书的作者，虽然不是新疆人，但却感觉是土生土长在新疆的，而且体感上，应该比我大不了几岁（这个后来我查资料证实了）。从她自己的描述来看，她的学历应该不算高，但文笔却出奇地好，文字看着很舒服，不堆砌辞藻，但却很有画面感，就像你的邻居在给你讲述生活细节一样。呈现给读者的绝大部分是朴实到有点朴素的文字，偶尔镶嵌了恰到好处的“高光”。</p>
<p>内容是关于她在新疆生活的方方面面，包括开店经商的种种经历、谈恋爱又分手的经历、多次搬家的经历以及日常生活的一些细节，但因为那种生活是绝大部分读者所不会经历的，所以读起来就会很有意思。她们一家子人，虽然不是逐水草而居的游牧民族，但不同时期也住在阿勒泰的不同角落，因此也发生了很多不同的故事。</p>
<p>这里就不剧透太多细节的内容了，总之这是一本读起来很轻松的书，就像一次旅行，所以推荐给大家。</p><p>The post <a href="https://luy.li/2026/08/16/978-7-5133-1253-0/">《阿勒泰的角落》读书笔记</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/16/978-7-5133-1253-0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Sub2API</title>
		<link>https://luy.li/2026/08/10/sub2api/</link>
					<comments>https://luy.li/2026/08/10/sub2api/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Mon, 10 Aug 2026 09:15:19 +0000</pubDate>
				<category><![CDATA[经验技巧]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2548</guid>

					<description><![CDATA[<p>其实，注意到 Sub2API 已经蛮久了，也知道这个项目“挺厉害的”，但在此之前，一直都没有试着自己跑过。 原 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/10/sub2api/">Sub2API</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>其实，注意到 <a href="https://github.com/Wei-Shaw/sub2api" rel="noopener" target="_blank">Sub2API</a> 已经蛮久了，也知道这个项目“挺厉害的”，但在此之前，一直都没有试着自己跑过。</p>
<p>原因可能有三个：主要原因我觉得还是因为我太懒了，哈哈！其次就是市面上也确实已经有蛮多性价比挺高的API中转站了！最后一个原因是担心被封号，总觉得不太好。</p>
<p>说到这里，这里再给不知道Sub2API是什么的读者来一句话科普一下，这就是一个把你在 OpenAI、Anthropic 包月订的pro/plus/max等套餐，转成API调用的一个开源软件。有了它就可以不用再花真金白银按量买token了，市面上大部分token中转站也是用了这个项目的原理甚至是代码。<br />
<span id="more-2548"></span></p>
<p>然后，我最近有一个订阅账号要取消订阅了，但离到期还有十天半个月的，这种状态反正也不怕被封了，就打算尝试了一下。</p>
<p>我选择的是<a href="https://github.com/Wei-Shaw/sub2api#method-2-docker-compose-recommended" rel="noopener" target="_blank">Docker Compose</a>的方式跑，安装官方教程，几个命令就能跑起来了。毕竟是成熟项目，已经是非常傻瓜化的操作方式了。</p>
<p>安装完后，本地的端口就可以直接打开后台页面了，挂到nginx，配个子域名和证书啥的，给自己用就绰绰有余了。</p>
<p>对了，如果你也用docker起来的，并且找不到 admin@sub2api.local 这个默认用户的密码的话，可以执行 <code>docker compose logs sub2api | grep -i "admin password"</code> 这个命令，就可以看到类似 <code>sub2api  | Generated admin password (one-time): xxxxxx</code> 这串随机生成的默认密码了。</p>
<p>然后，如果有什么概念，需要介绍一下的话，可能就是 Group（分组）了！这个分组，可以说连接各种概念的核心了。比如上游账号、下游用户、订阅还是按量计费 等实体，都要通过分组来实现相互的关联，包括是否运行“生图模型”的调用等，也都属于分组的一个属性。</p>
<p>其他的，如果是自用的话，似乎也没啥可以介绍的了。如果要拿来对外服务，我想更多也是怎么获取用户、怎么营销等的事情。哦，可能还要考虑怎么接各种支付系统和客服系统吧！就不能像我一样，给自己直接充1万刀了，哈哈！<br />
<img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/sub2api1.png" alt="" width="956" height="394" class="alignnone size-full wp-image-2550" /></p>
<p>我还发现：按它后台的计费方式，生图API是真赚钱啊！难怪市面上的中转站对生图API的折扣都能做到很低！<br />
<img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/sub2api2.png" alt="" width="1670" height="584" class="alignnone size-large wp-image-2551" /></p>
<p>跑了不少图出来，对这个账号也算是物尽其用了吧！</p><p>The post <a href="https://luy.li/2026/08/10/sub2api/">Sub2API</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/10/sub2api/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>记一例 nginx 故障分析</title>
		<link>https://luy.li/2026/07/28/nginx-incident/</link>
					<comments>https://luy.li/2026/07/28/nginx-incident/#comments</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Tue, 28 Jul 2026 04:55:09 +0000</pubDate>
				<category><![CDATA[故障分析]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2533</guid>

					<description><![CDATA[<p>早上起来习惯性瞄一眼 home lab 的日志，看到我那个 WebSocket 服务凌晨被重启过： [cray [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/07/28/nginx-incident/">记一例 nginx 故障分析</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>早上起来习惯性瞄一眼 home lab 的日志，看到我那个 WebSocket 服务凌晨被重启过：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">[2026-07-28 06:48:02] || Server: Loaded 7 pinned rooms
[2026-07-28 06:48:02] || Server: Starting server at wss://0.0.0.0:8765</pre><p></p>
<p>重启就重启吧，本来没当回事。结果顺手打开本博客——打不开。又试了下这台机器上跑的其他站，也全打不开。好家伙，整台机器的 nginx 全躺了，而且看时间已经躺了三个多小时。<br />
<span id="more-2533"></span></p>
<h2>先看现场</h2>
<p>第一件事是确认机器有没有重启过。<code>uptime</code> 一看，没有，机器好好的。那就是 nginx 单独出事了。<code>systemctl restart nginx</code> 下去，唰一下全恢复了。</p>
<p>服务是回来了，但这更让人不安：能 restart 成功，说明磁盘上的配置本来就是好的，那它三个小时前到底为什么起不来？翻 unit 日志：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ sudo journalctl -u nginx.service --since &quot;6 hour ago&quot; --no-pager
Jul 28 06:48:02 s systemd[1]: Stopping nginx.service - A high performance web server and a reverse proxy server...
Jul 28 06:48:02 s systemd[1]: nginx.service: Deactivated successfully.
Jul 28 06:48:02 s systemd[1]: Stopped nginx.service - A high performance web server and a reverse proxy server.
Jul 28 06:48:02 s systemd[1]: nginx.service: Consumed 19min 12.963s CPU time, 117.6M memory peak, 0B memory swap peak.
Jul 28 06:48:02 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server...
Jul 28 06:48:02 s nginx[2766192]: 2026/07/28 06:48:02 [emerg] 2766192#2766192: host not found in upstream &quot;bones7456.github.io&quot; in /etc/nginx/sites-enabled/luy:75
Jul 28 10:00:10 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server...
Jul 28 10:00:10 s systemd[1]: Started nginx.service - A high performance web server and a reverse proxy server.</pre><p></p>
<p><code>host not found in upstream</code>。我博客有几个路径(比如<a href="https://luy.li/2026/06/06/twgx/" title="滕王阁序">这个</a>)是反代到 GitHub Pages 的，nginx 起来的时候要解析 <code>bones7456.github.io</code>，那一刻没解析出来，直接 emerg 拒绝启动。</p>
<p>再一看，06:48 这个时间点太像 Debian 系的 cron.daily 窗口了（<code>/etc/crontab</code> 里是 6:25，加上 anacron 的随机延迟，另外 cron.weekly 是 6:47，更像），我当时第一反应是 certbot 续期把 nginx 重启崩了。结果把那五分钟的全量日志拉出来一看，跟 certbot 半毛钱关系没有。所以还是那句话：别猜，看日志。</p>
<h2>1 秒的竞速</h2>
<p>把 <code>journalctl --since "06:45" --until "06:50"</code> 全量捞出来，真凶一目了然：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">06:47:50  Starting apt-daily-upgrade.service       &lt;== unattended-upgrades 开跑
06:47:57  systemd[1]: Reexecuting requested from client PID ... (unit apt-daily-upgrade.service)
          systemd 255.4-1ubuntu8.16 running in system mode  &lt;== systemd 自己被升级了

06:48:02  ★ 满机器的服务被批量重启（needrestart 干的）
          Stopping named...  &lt;== named 被停掉，不再监听 127.0.0.1#53
          Stopping nginx...  &lt;== nginx 被停掉
          Starting nginx...  (PID 2766192) &lt;== nginx 再次启动
             └─ [emerg] host not found in upstream &quot;bones7456.github.io&quot; &lt;== nginx 报错失败了
                nginx.service: Control process exited, code=exited, status=1/FAILURE

06:48:03  Starting named...  (PID 2766508)
          named: listening on IPv4 interface lo, 127.0.0.1#53   &lt;== named 启动，DNS 回来了</pre><p></p>
<p>看明白了：systemd 被 apt 自动升级，触发 needrestart 把机器上几乎所有服务全重启一遍。我这台机器上跑着 BIND 做内网 DNS，于是 named 和 nginx 一起被拖下水。</p>
<p><strong>nginx 在 06:48:02 查 DNS，named 在 06:48:03 才恢复监听。就差 1 秒，nginx 输了这场竞速，代价是三个多小时的全站宕机。</strong></p>
<p>最扎心的是同一批重启里的对比：php-fpm、redis、mariadb，还有我自己那几个 Flask 服务，全都 <code>Started</code> 成功了。唯独 nginx 死了。为什么？因为<strong>只有 nginx 在启动阶段需要解析外部域名</strong>，别的服务都不需要。</p>
<h2>为什么 After=nss-lookup.target 没救下它</h2>
<p>这才是这次最值得说的地方。翻开 Ubuntu 自带的 nginx unit：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">[Unit]
Description=A high performance web server and a reverse proxy server
After=network-online.target remote-fs.target nss-lookup.target
Wants=network-online.target</pre><p></p>
<p><code>nss-lookup.target</code> 是 systemd 专门用来表示&#8221;域名解析已就绪&#8221;的标准锚点，DNS 服务一般会用 <code>Before=nss-lookup.target</code> 把自己挂上去。也就是说，nginx <strong>早就声明过&#8221;我要等 DNS 就绪再启动&#8221;</strong>——然而它还是死在了 DNS 上。</p>
<p>原因在于<code>After=</code> 的语义被普遍<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html">误解</a>了：</p>
<p>它只在<strong>同一个 systemd job transaction 内部</strong>编排启动先后，并不检查目标服务的实时健康状态。</p>
<p>needrestart 是逐个执行 <code>systemctl restart &lt;unit&gt;</code> 的，nginx 和 named 属于两个互相独立的 transaction，彼此之间没有任何排序约束。更要命的是，<code>nss-lookup.target</code> 是个 passive target，named 停止时它<strong>并不会跟着 deactivate</strong>，全程保持 active——于是 nginx 一查&#8221;依赖满足了吗&#8221;，满足，启动，然后一头撞死。</p>
<p>所以结论挺反直觉的：<strong>在 restart 场景下，<code>After=</code> 基本等于安慰剂</strong>。指望靠加一行 <code>After=named.service</code> 来防这类问题，是防不住的。</p>
<h2>两道防线</h2>
<p>既然顺序编排靠不住，那就换思路：一道事后自愈，一道事前免疫。</p>
<h3>防线一：让它自己重试</h3>
<p>Ubuntu 的 nginx.service <strong>默认没有 <code>Restart=</code></strong>，意味着启动失败一次就永久 failed，没有任何重试。而这次 named 只用了 1 秒就恢复——只要能重试一次，整件事根本不会发生。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">sudo mkdir -p /etc/systemd/system/nginx.service.d
sudo tee /etc/systemd/system/nginx.service.d/override.conf &lt;&lt;EOF
[Unit]
StartLimitIntervalSec=600
StartLimitBurst=20

[Service]
Restart=on-failure
RestartSec=10
EOF
sudo systemctl daemon-reload</pre><p></p>
<p><code>StartLimit</code> 那两行不是可选的：systemd 全局默认是 10 秒内最多 5 次，配上 <code>RestartSec=10</code> 会立刻撞上限流然后彻底放弃。改成 10 分钟内 20 次，才扛得住像样的 DNS 故障。</p>
<p>这里补一个 drop-in 的合并规则，很多人会搞混：</p>
<p>1. 标量指令（<code>Restart=</code>、<code>RestartSec=</code>、<code>Type=</code>、<code>PIDFile=</code>…）是<strong>覆盖</strong>。<br />
2. 列表指令（<code>After=</code>、<code>Wants=</code>、<code>Environment=</code>、<code>ExecStartPre=</code>…）是<strong>追加</strong>，不是覆盖。<del datetime="2026-07-31T10:04:14+00:00">想清空得先写一行空赋值 <code>After=</code> 再写新值</del> <strong>Update：</strong><code>After=</code>还不能被清空，详见下方评论。<br />
3. <code>ExecStart=</code> 是重灾区：<code>Type=forking</code> 下只允许一条，直接在 drop-in 里写会报错，必须先 <code>ExecStart=</code> 清空再写。</p>
<p>想看合并后到底生效了什么，别看文件，看这个：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">systemctl cat nginx                      # 看拼了哪些文件
systemctl show nginx -p After -p Restart -p RestartUSec   # 看最终生效值，配置项是 RestartSec 对应的生效值是 RestartSec</pre><p></p>
<p>还有个细节值得确认：这次失败的其实是 <code>ExecStartPre</code> 里的 <code>nginx -t -q</code>（日志里那句 <code>Control process exited, code=exited, status=1/FAILURE</code>，result 是 <code>exit-code</code>）。<code>Restart=on-failure</code> 是覆盖这种情况的，不用担心它管不到。</p>
<h3>防线二：别让 nginx 在启动期解析域名</h3>
<p>自愈只是兜底，根上的毛病是：<strong>一个 location 的 upstream 解析不了，整个 nginx 拒绝启动，机器上所有站点陪葬</strong>。nginx 的配置校验是 all-or-nothing 的，一个小站点的临时故障被放大成了全局故障。</p>
<p>解法是给 <code>proxy_pass</code> 用变量——只要 <code>proxy_pass</code> 里含变量，nginx 就不再在启动期做一次性解析，改成运行时按需查 <a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#resolver"><code>resolver</code></a>。这样 DNS 挂了 nginx 照样能起来，最坏只是那一个 location 返回 502。</p>
<p>但这里有个大坑，下面单独说。</p>
<h2>变量化 proxy_pass 的那个坑</h2>
<p>我原来的配置是这样的，注意 <code>proxy_pass</code> 后面<strong>带了路径</strong>：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">location ^~ /data/shi/ {
    proxy_pass https://bones7456.github.io/china-dynasty-timeline/;
    proxy_ssl_server_name on;
    proxy_set_header Host bones7456.github.io;
    ...
}</pre><p></p>
<p>如果你天真地把域名换成变量，写成 <code>proxy_pass https://$gh_pages/china-dynasty-timeline/;</code>，就掉坑里了。<a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass">nginx 的规则</a>是：</p>
<p>1. <code>proxy_pass</code> <strong>不含变量</strong>且带 URI → nginx 做<strong>前缀替换</strong>，把 location 匹配掉的 <code>/data/shi/</code> 换成 <code>/china-dynasty-timeline/</code>。<br />
2. <code>proxy_pass</code> <strong>含变量</strong> → 前缀替换机制<strong>彻底失效</strong>，URI 被固定成你写的那个。</p>
<p>后果是这样的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">请求 /data/shi/              原配置 &rarr; /china-dynasty-timeline/               ✓
                             变量版 &rarr; /china-dynasty-timeline/               ✓
请求 /data/shi/assets/app.js  原配置 &rarr; /china-dynasty-timeline/assets/app.js  ✓
                             变量版 &rarr; /china-dynasty-timeline/               ✗</pre><p></p>
<p>首页看着还挺正常，<strong>所有子资源全部错位</strong>——CSS、JS、图片全挂。这种&#8221;打开一看好像没事&#8221;的故障最恶心。</p>
<p>正解是用 <code>rewrite ... break</code> 手动接管路径映射，配一个<strong>不带 URI</strong> 的 <code>proxy_pass</code>。nginx 文档明确写了：<code>proxy_pass</code> 不带 URI 时，若 URI 已被 rewrite 改写，传递的就是改写后的 URI——正是我们要的。</p>
<p>先在 server 块里放解析器和变量：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">resolver 127.0.0.1 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
set $gh_pages &quot;bones7456.github.io&quot;;</pre><p></p>
<p>然后 location 改成：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">location ^~ /data/shi/ {
    rewrite ^/data/shi/(.*)$ /china-dynasty-timeline/$1 break;
    proxy_pass https://$gh_pages;

    proxy_ssl_server_name on;
    proxy_ssl_name bones7456.github.io;
    proxy_set_header Host bones7456.github.io;

    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # 避免 GitHub Pages 返回的跳转暴露 github.io 域名
    proxy_redirect https://bones7456.github.io/china-dynasty-timeline/ /data/shi/;
    proxy_redirect https://bones7456.github.io/ /data/shi/;
}</pre><p></p>
<p>实际只动了三处：加 <code>rewrite ... break</code>、<code>proxy_pass</code> 去掉路径换成变量、显式加 <code>proxy_ssl_name</code>（它默认取 <code>$proxy_host</code>，变量化后写死更稳）。<code>proxy_redirect</code> 是字面量匹配，不受影响。查询串也会自动带过去，<code>/data/shi/a?b=1</code> 照常工作。</p>
<h2>几个坑</h2>
<p>1. <code>resolver</code> 里写多个地址是<strong>轮询</strong>，不是主备。我这里反代的是公网域名，两个 DNS 都能解析，正好互为冗余；但如果你要反代本机 named 里那些内网 zone，就绝不能这么写——轮询到 <code>1.1.1.1</code> 直接解析失败。那种 location 得单独配 <code>resolver 127.0.0.1;</code>。<br />
2. <code>ipv6=off</code> 是刻意加的。变量化之后每次请求都要重新解析，家宽 IPv6 到 GitHub 的连通性又不一定稳，少一个变数是一个。确认 IPv6 走得通再打开。<br />
3. 别指望 <code>After=</code> 能防住 restart 场景，前面说透了。它在正常开机时有用，成本为零可以留着，但不能当防线。<br />
4. <code>nginx -t</code> 通过不代表这次改对了。它只校验语法，<code>rewrite</code> 的路径映射对不对，它一个字都不会告诉你。</p>
<h2>怎么验证真的修好了</h2>
<p>改完一定要实测，光看配置&#8221;觉得对&#8221;没用。先验路径映射——<strong>必须测子路径，别只测首页</strong>：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">curl -sI https://luy.li/data/shi/ | head -3
# 从页面里抓个真实静态资源来测，期望 200 而不是 404
curl -s https://luy.li/data/shi/ | grep -oE '(src|href)=&quot;[^&quot;]+\.(js|css)&quot;' | head -3</pre><p></p>
<h2>说点本质的</h2>
<p>复盘下来，这次事故里有三个独立的问题，任意修掉一个都不会出事：nginx 没有真正可靠的 DNS 就绪依赖、配置在启动期硬依赖 DNS、失败之后没有任何重试。三个凑齐了，一次 1 秒的 DNS 抖动才放大成 3 小时的全站宕机。</p>
<p>而我最想留下的一条经验是：<strong>别把系统的健壮性寄托在&#8221;顺序&#8221;上</strong>。<code>After=</code>、<code>Before=</code>、<code>network-online.target</code> 这些东西给人一种&#8221;我已经处理好依赖了&#8221;的错觉，但它们描述的是编排意图，不是运行时的真实状态。真正靠得住的只有两种东西——<strong>失败了能自己重试</strong>（自愈），和<strong>压根不依赖那个东西</strong>（免疫）。分布式系统里讲了很多年的道理，放在单机 systemd 上一样成立。</p>
<p>最后再补一句题外话：这次是我早上&#8221;顺手看了眼日志&#8221;才发现的，不然还得挂更久。所以监控该上还是得上，healthchecks.io、UptimeRobot 之类的免费额度足够个人站用了。再优雅的配置优化，也不如有人（或者有个机器人）在你睡觉的时候盯着。</p>
<p>全文完。</p><p>The post <a href="https://luy.li/2026/07/28/nginx-incident/">记一例 nginx 故障分析</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/07/28/nginx-incident/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
	</channel>
</rss>
