38 KiB
blog.hoelee.com 内容体系与转化漏斗升级 — 顾问补充资料包
对接目标:针对外部顾问提出的 6 大升级方向(双层阅读架构、3 大系统蓝图、视觉证据链、动态 CTA 矩阵、去模板化生成规范、独立求职与合作页面)及 搜索引擎收录盲区,提供现场工程代码、典型文章抽样、两篇代表作完整原文及站点关系诊断。 编制时间:2026 年 10 月 26 日
整理人:Lee Teong Hoe (Mr Hoelee) / Hermes Agent
站点技术栈:Astro 5 (Static) + Content Collections + Markdown + Pagefind + Gitea Actions CI/CD + Cloudflare
目录
- 全站结构与核心导航/布局源码
- 15 篇代表性文章清单抽样 (Titles & Frontmatter)
- 代表作完整 Markdown 原文(商业案例篇 + 硬核排障篇)
- 主站与博客的关系及 SEO / GSC 真实状态诊断
- 期待顾问重点开刀示范的任务清单
一、全站结构与核心导航/布局源码
1. Astro 项目核心目录拓扑
D:/dev/hoelee-blog/
├── src/
│ ├── layouts/
│ │ └── BaseLayout.astro # 全站唯一基础布局:SEO/JSON-LD/页头Nav/页脚/Umami统计/暗黑模式
│ ├── pages/
│ │ ├── index.astro # 英文首页(Hero + 置顶精选 4 篇 + 基础设施数据 + 全文列表)
│ │ ├── zh/
│ │ │ ├── index.astro # 中文首页(与英文版结构镜像)
│ │ │ ├── about.astro # 中文关于页
│ │ │ ├── posts/index.astro # 中文文章归档页(带客户端过滤)
│ │ │ ├── categories/ # 中文分类聚合页与各自分类落地页
│ │ │ └── tags/[tag].astro # 中文标签聚合页
│ │ ├── posts/
│ │ │ ├── [...slug].astro # 英文文章详情动态路由(双语自动匹配映射、TOC、相关文章推荐)
│ │ │ └── index.astro # 英文文章归档页
│ │ ├── categories/ # 英文分类聚合与 [category].astro
│ │ ├── tags/[tag].astro # 英文标签聚合页
│ │ ├── about.astro # 英文关于页
│ │ ├── 404.astro # 404 页面
│ │ ├── robots.txt.ts # 动态生成 robots.txt(纯 ASCII,放行搜索引擎与 AI 爬虫)
│ │ ├── rss.xml.ts # RSS 端点
│ │ └── llms.txt.ts # 面向 AI 抓取的标准 llms.txt
│ ├── components/
│ │ ├── AuthorCard.astro # 文章底部实名作者卡片(E-E-A-T 骨架,绑定头像与社媒)
│ │ ├── TableOfContents.astro # 侧边/正文目录组件
│ │ ├── Search.astro # Pagefind 静态全文搜索弹窗(快捷键 / 触发)
│ │ ├── PostList.astro # 文章列表渲染卡片(带缩略图、分类、阅读时长)
│ │ ├── Archive.astro # 归档时间轴组件
│ │ ├── CategoryGrid.astro # 7 大分类卡片网格
│ │ └── CategoryArt.astro # 7 大分类专属矢量 SVG 插画
│ ├── content/
│ │ └── posts/ # 91 篇英文 Markdown
│ │ └── zh/ # 91 篇同名对应的中文 Markdown
│ └── styles/
│ └── global.css # 全站暗色/亮色 Bento 主题变量与排版样式
2. 顶部导航栏(Navbar)设计源码(摘自 src/layouts/BaseLayout.astro)
<header class="site-header">
<nav class="nav" aria-label="Main">
<!-- 品牌区:自托管 96px 图标 + 实名 Lee Teong Hoe -->
<a class="brand" href={isZh ? '/zh/' : '/'}>
<img class="brand-mark" src="/images/hoelee-mark-96.png" alt="" width="26" height="26" decoding="async" />
<span class="brand-name">{SITE.author}</span>
</a>
<!-- 导航链接:移动端自动换至第二行 (flex-basis: 100%) 防止挤压功能键 -->
<ul class="nav-links">
<li><a class="nav-link" href={isZh ? '/zh/posts/' : '/posts/'}>{nav.posts}</a></li>
<li><a class="nav-link" href={isZh ? '/zh/categories/' : '/categories/'}>{nav.categories}</a></li>
<li><a class="nav-link" href={isZh ? '/zh/about/' : '/about/'}>{nav.about}</a></li>
<li><a class="nav-link" href="/rss.xml" title={nav.rss}>{nav.rss}</a></li>
</ul>
<!-- 工具栏:Pagefind 全文搜索 + 中英同文一键双向切换 + 暗/亮模式切换 -->
<button class="search-toggle" id="search-toggle" aria-label={isZh ? '搜索' : 'Search'}>
⌕ <span class="search-hint">{isZh ? '搜索' : 'Search'}</span><kbd class="search-key">/</kbd>
</button>
<a class="lang-switch" href={resolvedSwitchHref}>{switchLabel}</a>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">◐</button>
</nav>
</header>
3. 首页(src/pages/index.astro)核心布局结构
<BaseLayout title={SITE.title} description={SITE.description} altLocaleUrl={`${SITE.url}/zh/`}>
<section class="hero">
<div class="hero-intro">
<div class="hero-id">
<picture>
<source srcset={SITE.photo.smallWebp} type="image/webp" />
<img class="hero-photo" src={SITE.photo.small} alt="Lee Teong Hoe" width="72" height="72" />
</picture>
<h1>Lee Teong Hoe — full-stack developer & DevOps engineer</h1>
</div>
<p class="hero-sub">
I build and operate production web systems — from the code down to the servers.
<strong>162 containers across three self-managed hosts</strong> run client sites, a mail
platform, CI/CD, workflow automation and this blog. I write up what broke, what the
evidence said, and how I fixed it.
</p>
<div class="hero-pills" aria-label="Core tech stack">
<span class="hero-pills-label">Stack:</span>
<a class="tech-pill" href="/tags/docker/">Docker</a>
<a class="tech-pill" href="/tags/self-hosting/">Self-Hosting</a>
<a class="tech-pill" href="/tags/n8n/">n8n</a>
<a class="tech-pill" href="/tags/ai/">Local GPU AI</a>
<a class="tech-pill" href="/tags/python/">Python</a>
<a class="tech-pill" href="/tags/php/">PHP</a>
<a class="tech-pill" href="/tags/security/">Security</a>
</div>
</div>
<!-- 基础设施概览卡片与可缩放架构图 -->
<div class="hero-visual">
<button type="button" class="hero-banner-card" onclick="document.getElementById('arch-lightbox')?.showModal()">
<img class="hero-banner-img" src="/images/hero-banner.jpg" alt="Architecture Overview" />
<span class="hero-banner-zoom-badge">🔍 View full architecture</span>
</button>
<div class="hero-infra-grid" aria-label="Infrastructure stats">
<div class="infra-stat-card"><div class="infra-stat-val">162</div><div class="infra-stat-label">Containers in Prod</div></div>
<div class="infra-stat-card"><div class="infra-stat-val">3 Hosts</div><div class="infra-stat-label">Unraid · Synology · VPS</div></div>
<div class="infra-stat-card"><div class="infra-stat-val">78</div><div class="infra-stat-label">Compose Stacks</div></div>
<div class="infra-stat-card"><div class="infra-stat-val">91</div><div class="infra-stat-label">Bilingual Write-ups</div></div>
</div>
</div>
<!-- 当前三大主行动按钮 -->
<div class="hero-actions">
<div class="hero-cta">
<a class="btn btn-primary" href="#featured">Read the engineering case studies ↓</a>
<a class="btn" href="/about/">Hiring? Profile & stack →</a>
<a class="btn" href={SITE.website} target="_blank" rel="noopener">Malaysian SME? How I work ↗</a>
</div>
<p class="hero-note">91 posts in English and 中文 · <a href="/rss.xml">RSS</a></p>
</div>
</section>
<!-- 4 篇置顶旗舰案例(附带量化度量) -->
<section class="featured-section" id="featured">
<!-- Featured Cards Grid -->
</section>
<!-- 全量文章时间线与分类列表 -->
</BaseLayout>
4. 文章详情页(src/pages/posts/[...slug].astro)核心布局结构
<BaseLayout title={`${post.data.title} — Mr Hoelee`} ...>
<!-- 自动注入 JSON-LD BlogPosting 结构化数据,挂载至站长 Person 实体 -->
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<article>
<header class="article-head">
<h1>{post.data.title}</h1>
<div class="meta">
<time>{fmt(post.data.pubDate)}</time> · <span>{minutes} min read</span> ·
<a class="tag" href="...">{post.data.category}</a> {post.data.tags.map(...)}
</div>
</header>
<!-- 16:9 响应式 WebP 横幅 -->
{post.data.banner && <figure class="article-banner">...</figure>}
<!-- 自动目录 (TOC) -->
<TableOfContents headings={headings} lang={lang} />
<!-- 正文渲染区 (当前直接渲染完整 Markdown) -->
<Content />
<!-- 实名作者卡片(E-E-A-T 骨架) -->
<AuthorCard />
</article>
<!-- 底部 6 卡片相关推荐网格(同语种、高相关性标签智能加权算法) -->
<section class="related">...</section>
</BaseLayout>
二、15 篇代表性文章清单抽样
覆盖 6 大核心主干,展示真实定位与元数据约束:
| # | 分类 (category) |
标题 (title) |
标签 (tags) |
摘要 (description) |
|---|---|---|---|---|
| 1 | case-studies |
How I Built the DigiKedai Telegram AI Bot | [telegram, docker, cloudflare, litellm, typescript, ai] |
A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway. |
| 2 | case-studies |
How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD | [astro, gitea, ci-cd, self-hosting, docker, cloudflare] |
A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front. |
| 3 | case-studies |
Self-Healing Access Control for Digital Goods: NocoDB, n8n, and AList | [n8n, nocodb, alist, access-control, docker, automation, digital-goods] |
How I built a five-workflow entitlement system that grants, expires, and continuously repairs customer file access — with a build-time code-sharing trick and a public endpoint designed to leak nothing. |
| 4 | case-studies |
How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP | [remotion, docker, cdp, youtube, automation, video-generation, whisper] |
Explore an automated music video workflow using Remotion, Whisper, Docker and Chrome DevTools Protocol, from lyric alignment and rendering to YouTube publishing and multilingual subtitles. |
| 5 | case-studies |
How I Self-Hosted a Mem0 Memory Stack (So My AI Agent Remembers) | [mem0, docker, postgres, pgvector, litellm, ai, sharing] |
A private, self-hosted memory layer for AI agents — mem0 API + LiteLLM + PostgreSQL with pgvector on a Synology NAS. Semantic memory under your control, not a third party's. |
| 6 | devops |
Docker Reported Exit 0. The Kernel Had Already OOM-Killed It. | [docker, oom, traefik, synology, crawlers] |
A container on my NAS kept stopping unexpectedly while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills. |
| 7 | devops |
The Forward-Auth Gate That Verified Perfectly — and Wasn't Live | [authentik, forward-auth, cloudflare, nginx, docker, self-hosting] |
My authentik forward-auth gate returned a clean 302 to the login page from the host itself, while the public URL still served the app. The hostname had two ingress layers, and I had only changed one. |
| 8 | devops |
Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events | [cdp, chrome, react, browser-automation, debugging, python] |
Three reasons a Chrome DevTools Protocol click reports success and does nothing: a stale rect from smooth scrolling, a minimised window that drops coordinate input, and a page that received the click but rejected it. |
| 9 | ai |
Giving AI Your Mouse and Keyboard: Safe Guardrails for Windows Desktop MCP | [windows, mcp, security, ai-agents, automation] |
Why running desktop automation MCPs out of the box is dangerous, and the essential guardrails needed to prevent system corruption, UI hangs, and token blowups. |
| 10 | ai |
I Let an Agent Manage My Shopee Vouchers — And It Created One I Never Approved | [ai-agent, shopee, automation, human-in-the-loop, ecommerce, cron] |
Vouchers have no draft state: Confirm means live and drawing on escrow. Here is the audit → propose → auto design I use to keep an agent away from that button, and the list-lag mistake that cost me an unplanned voucher. |
| 11 | engineering |
Debugging Remotion Video Jitter: Why Bass Scaling Ruins Background Transitions | [remotion, react, audio-visualizer, css-animation, debugging] |
Diagnose audio-reactive background jitter and crossfade flicker in Remotion. See how separating the HUD from background layers can improve visual stability and rendering performance. |
| 12 | engineering |
No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App | [i18n, nginx, vue, javascript, self-hosting] |
Browsers expose no JavaScript API to trigger translation — and my app declared English while being Chinese. How I injected an English mode at the reverse proxy. |
| 13 | tutorials |
Calibrating Intel Quick Sync HEVC: A Cliff at 28, and a Preset That Does Nothing | [ffmpeg, hevc, qsv, transcoding, video] |
I measured QSV HEVC on real 1080p files: ICQ 18–22 inflate thin sources, the quality cliff sits between 28 and 30, and fast equals medium byte for byte. |
| 14 | tutorials |
How to Verify a Hosting Provider Before You Buy | [hosting, vps, due-diligence, whois, devops] |
A reusable checklist for separating real hosting claims from marketing: check the domain age against the 'since' claim, read the AUP for silent disqualifiers, and cross-check reputation on third-party sources. |
| 15 | web3 |
An Automated Lottery Contract on Chainlink VRF v2.5 and Automation | [solidity, chainlink, vrf, foundry, hardhat, blockchain] |
Building a lottery on Chainlink VRF v2.5 and Automation: why the draw can't be rigged, a full contract walkthrough, and the mock-funding bug that broke it. |
三、代表作完整 Markdown 原文
样本 1(商业交付与 AI 智能体):how-i-built-the-digikedai-telegram-bot.md
---
title: "How I Built the DigiKedai Telegram AI Bot"
description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway."
pubDate: 2026-09-06
category: case-studies
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/how-i-built-the-digikedai-telegram-bot.jpg
banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg
---
Digi Kedai sells digital products — online courses, ebooks, and templates — and
every sale involves a stream of the same customer questions: *"does this course
have a free preview?", "how do I get my account?", "which package should I
buy?"*. Answering them manually doesn't scale for a one-person business. So I
built an AI customer-support bot that answers those questions around the clock
and can even hand out free-trial accounts without a human in the loop.
This is the story of how it went together — the architecture, the LLM wiring,
and the bugs that ate an afternoon each.
## Why a business needs a bot like this
Before the architecture, the *why*. A support bot isn't a gimmick — it changes
the economics of a small business:
- **Saves money on staff.** Every routine question a bot answers is one your
team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock
support agent for a fraction of the cost of hiring one.
- **Answers instantly, 24/7.** Customers ask at midnight and on weekends. A bot
replies in seconds, in their language — no queues, no "we'll get back to you".
- **Converts browsers into buyers.** The bot doesn't just answer — it *upsells*.
A "does this have a free trial?" question turns into a claimed trial account
in a couple of taps, with no human in the loop.
- **Never forgets a customer.** Long-term memory means repeat customers are
greeted like regulars, not strangers.
- **Scales with your catalogue.** Add a product and the bot already knows it —
no retraining, no new FAQ pages.
For a one-person business like Digi Kedai, that's the difference between losing
sales at 2 a.m. and closing them.
## The stack
- **TypeScript + Node 20** running as a single container on my Synology NAS.
- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode.
- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`.
- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container;
no public IP, no open ports.
- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap
providers or add fallbacks without touching bot code.
- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack.
## The architecture
Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com → Hono POST //webhook → grammY handler → save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs) → LiteLLM (model alias "mem0-openai") → persist + reply
A few decisions worth explaining:
**A Cloudflare tunnel into the container, with no Traefik hop.** My homelab is
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
updates only land if the caller knows the secret — a cheap first line of defense
on top of Telegram's own token auth.
**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot
calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter
fallback behind it). The bot never needs to know which provider is actually
serving the request. I set `temperature: 0.4` and a `45s` timeout.
**The bot knows its catalogue without prices or internal paths.** A build-time
generator turns a single `catalog_sku.csv` (the source of truth) into a
TypeScript module the bot imports — SKU, name, category, size, and a product URL,
and *nothing else*. Prices are never baked in ("check the site for the current
price"), and internal resource paths never ship to the bot image. That keeps the
bundle lean and prevents the model from leaking internal structure.
## Retrieval: how the bot actually *knows* the catalogue
A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the
bot retrieves first, then answers. It has a three-tier retriever:
1. **SKU token match** — `/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`.
2. **Whole-query substring** — for short, precise queries.
3. **Per-segment matching** — splits CJK runs (stripping question particles like
有/吗/哪些) and filters English stopwords.
The top-5 matches become the "facts" injected into the system prompt, and the
model is instructed to answer *only* from what it actually retrieved — and to say
so when it finds nothing.
This matters more than it looks. A customer asking *"does CZH01 have a free
version?"* gets a correct answer about `FREECZH01` only because the retriever,
on hitting the paid SKU, **automatically attaches its free twin** and ranks it
second. That one detail turns a "no, sorry" into the correct upsell.
## Free-trial provisioning — no human in the loop
The part I'm proudest of: the bot doesn't just *talk about* free trials, it
*issues* them. A customer can trigger it three ways — the `/trial` command, a
natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a
deep link like `?start=CZH01`.
Rather than route through n8n, the bot writes **directly to NocoDB** — inserting
a customer row and a customer-product row. My existing webhooks pick that up and
provision the actual account automatically, so the bot never touches the file
server. The whole thing is idempotent: if a customer already has an account, it
reuses it instead of creating a duplicate; if the product insert fails, it rolls
back the customer row so a retry doesn't dead-end.
There's even a multi-account selector. If the same Telegram user has several
accounts, the bot lists them as inline buttons and asks which one to attach the
trial to.
## The bugs that ate an afternoon each
Shipping this was not smooth. Three debugging stories stand out:
**1. The infinite reply loop.** Telegram re-delivers a webhook update if the
server doesn't acknowledge it within a timeout window. My handler was running
long (LLM latency), so Telegram re-sent the same message — and the bot answered
it again, and again. The fix was `onTimeout: "return"` with a 50-second window,
which stops the redelivery spiral dead.
**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a
400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM
must be the alias, never the raw upstream name.*
**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook
payload" setting with empty-string template values made the extension send the
literal field *name* `m_text` as the message text — so the bot replied *"I don't
understand m_text"*. The fix was to turn that setting off and use the default
payload, which the adapter reads correctly.
The honest takeaway from all three: the failures weren't in the hard parts — the
LLM or the retrieval. They were in **webhook lifecycle and payload-contract
details**, the boring edges where integrations actually break.
## Deterministic flows on top of the LLM
An LLM is great for open-ended questions and terrible for *state*. So the
conversational pieces that need reliability — buying, trial redemption, deep
links — are **deterministic state machines**, not prompt engineering:
- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM
and drives an inline keyboard (online store → admin → back), never a free-form
reply.
- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows
`A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way
and switched the separator from `buy:SKU` to `buy-SKU`.
- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`)
that the agent turns into *"send nothing"* — so the bot stays silent instead of
babbling when it has nothing useful to say.
## What I'd do differently
- **Add `setMyCommands()` at startup.** The command list is defined in code, but
I never registered it with Telegram, so the in-chat command menu was empty
until I fixed it via the Bot API. Small, but it cost a real customer touchpoint.
- **Design the payload contract before the channel adapter.** The WhatsApp
`m_text` bug came from trusting a customization feature I hadn't read the
contract for.
- **Treat webhook timeouts as first-class architecture**, not an afterthought —
the redelivery loop was avoidable if I'd thought about acknowledgement windows
on day one.
## The result
One container on the NAS now answers customer questions across two channels
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural
language, and issues free-trial accounts end-to-end — with a prompt suite of
~175 unit tests covering the deterministic flows, retrieval, and the payload
contracts. The source lives at
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot).
If you're building your own Telegram bot backed by an LLM, the lesson is the
boring one: the model is the easy part. The webhook lifecycle, the payload
contract, and the idempotency of your provisioning are where it actually breaks —
design those first.
---
## Want a bot like this for your business?
I build custom Telegram/WhatsApp AI bots, websites, and self-hosted
infrastructure for businesses. If a bot like this could save you time and
money — or you'd like to hire me — I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected])
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
样本 2(硬核 DevOps 排障):docker-reported-exit-0-the-oom-killer-disagreed.md
---
title: "Docker Reported Exit 0. The Kernel Had Already OOM-Killed It."
description: "A container on my NAS kept stopping unexpectedly while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills."
pubDate: 2026-10-16
category: devops
tags: ["docker", "oom", "traefik", "synology", "crawlers"]
ogImage: /og/docker-reported-exit-0-the-oom-killer-disagreed.jpg
banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.jpg
draft: false
---
## The notification I expected to be a config mistake
My NAS sent the notification every Docker user learns to skim: **container
stopped unexpectedly**. This one is the edge proxy in front of my
self-hosted wiki, and it had been up for two weeks. Nobody had deployed
anything that morning and nothing in the compose file had been touched, so
the two available stories were "it crashed" and "something restarted it".
Both were wrong, and the fact that I could not tell from Docker's own report
is the part worth writing down.
## Docker's report says nothing happened
```text
State:
Status: running
Running: true
ExitCode: 0 <- a clean exit
OOMKilled: false <- not killed by the kernel
FinishedAt: 2026-10-15T19:39:33.63057879Z
StartedAt: 2026-10-15T19:39:38.769326672Z <- restarted 5 s later
RestartCount: 6
RestartPolicy: always
Read that as written and the conclusion is: the process exited cleanly, six
times, and the restart policy gave it a new life each time. A long-running
proxy does not exit with status 0 on its own — and a clean exit that repeats
six times is a contradiction, not an answer. ExitCode and OOMKilled are
exactly the two fields I would have trusted here. On this host, both were
wrong.
The kernel keeps its own record
On Linux, a cgroup memory kill is logged by the kernel even when the container runtime's bookkeeping does not carry it forward:
[1737797.044856] traefik invoked oom-killer: gfp_mask=0x2400040, order=0, oom_score_adj=0
[1737797.123736] Memory cgroup out of memory: Kill process 615 (traefik) score 970 or sacrifice child
[1737797.132800] Killed process 615 (traefik) total-vm:2525800kB, anon-rss:478480kB, file-rss:0kB
The uptime stamp 1737797 corresponds to 19:39:33 UTC — to the second, the
container's FinishedAt. That is the whole diagnosis, and it took one
dmesg to get it. The container did not exit; it was killed inside its own
memory limit and restarted 5 seconds later.
dmesg holds six such events, and RestartCount was 6. Every other
container in that stack has a restart count of zero and a start time from
weeks ago, so the six kills pin to this one container:
| dmesg uptime | local time | anon-rss at kill |
|---|---|---|
| 1406539 | Oct 12, ~07:37 | 491 MB |
| 1407759 | Oct 12, ~07:57 | 494 MB |
| 1432574 | Oct 12, ~14:49 | 486 MB |
| 1734793 | Oct 16, ~02:45 | 487 MB |
| 1735495 | Oct 16, ~02:58 | 485 MB |
| 1737797 | Oct 16, 03:39 ← the notification | 478 MB |
Every kill happens at 478–494 MB against a limit of 512 MiB (536,870,912
bytes). That is a ceiling being touched, not a runaway.
Two more readings made the mechanism clear. The cgroup's recorded peak — the
counter that survives the restart — was already back at exactly
536,870,912 18 minutes after the restart, i.e. the container touched
its ceiling again in the same cycle without being killed. And the usage
curve oscillated (176 → 308 → 361 MB) instead of climbing monotonically, so
this is allocation churn under load hitting a small cap, not a provable slow
leak. The prime suspect is a geoblock plugin doing an external HTTPS lookup
for every client IP it has not seen before.
A seventh kill landed at 05:33 that morning, before the fix went in.
On this DSM build the truth was only in dmesg plus the cgroup's
memory.max_usage_in_bytes. If you are chasing the same shape on a normal
host, docker inspect -f '{{.State.OOMKilled}}' is the field to read first
— it is supposed to be authoritative. Here it read false five seconds
after the kernel had logged the kill, which is the reason this post exists.
What was actually breaking: a crawler flood
Host memory was never the problem — 64 GB installed, ~39 GB available. One container's 512 MiB ceiling was.
The driver was traffic, and the numbers came out of the container's own access log. In the last 50,000 lines:
distinct client IPs 25,976
403 responses 33,636 (geoblock / deny-list doing its job)
429 responses 16,082 (inflight and rate limiting doing their job)
container CPU 118–230%
host load average 26–32
Each restart refilled the heap in 12 to 40 minutes. Nothing in the configuration was wrong, and neither were the 403s and 429s — they are the filter rejecting crawlers exactly as designed. The problem is who is knocking: this container is the public edge for a multi-terabyte Wikipedia corpus served from a home NAS, behind a country allowlist and a bot filter. That is an extremely attractive target for a residential-proxy crawler cloud, and the responses telling it to go away are still work.
The fix, and the sentence that has to come with it
deploy:
resources:
limits:
- memory: 512M
+ memory: 2g
restart: always
+memswap_limit: 2g
Verification, because "I raised the limit and it feels better" is not a verification:
- Only that container was recreated. The wiki, the proxy core and the robots container still reported two weeks of uptime — the gap users saw was seconds, not a stack restart.
HostConfig.Memory = 2147483648,MemorySwap = 2147483648, and the cgroup'smemory.limit_in_bytesagreed with both;RestartCountwent back to 0.- Peak usage after 18 minutes: 210 MB, against a ceiling it had been pinning at 512 MB. By midday it sat at 94 MiB.
- The mount list still matched the file, including the plugin directory mount — losing that one had once disabled every router in this stack.
- Both plugins were loaded and active (country allowlist, bot filter), and the routes returned 200 once the filter's score window expired.
And what it is not: this is a stopgap. The flood is the driver, so an order-of-magnitude bigger flood reaches 2 GB too. The real fix is shrinking the public entry surface, not enlarging the cage around it — and a limit bump that is not labelled as a stopgap becomes a permanent explanation.
I chose to disable swap for this container rather than let it swap:
memswap_limit equal to the memory limit does exactly that. The host
already had 16 GB of its 40 GB swap in use with committed memory at 92 GB,
and an edge proxy that starts swapping slows the entire NAS — the disk array
is the bottleneck here, so swapping buys a slower failure rather than a
longer life.
The trap that nearly made me report a self-inflicted outage
During verification, my first bare curl probes against the site returned
403, and it looked exactly like a regression I had just caused by
restarting the proxy. It was not: the bot filter had scored my own client
IP (blocked user-agent, score 80) and placed it in a 15-minute score
window, because I had been hammering the host.
The tell was in the same test run — /robots.txt returned 200 and the
internal /cdn path returned 400 at the same moment, i.e. the router was
healthy and only my own requests were being filtered. On a host where you
are also a client, never diagnose with your own bare curl: probe a path the
filter ignores, or read a 200 out of the access log.
What I would do differently
- Treat "stopped unexpectedly" plus
ExitCode 0as an instruction to readdmesg, not as evidence of a clean exit. A restart count above zero is a memory investigation until proven otherwise. - Alert on the cgroup peak, not only on restarts. The post-restart reading that was already pinned at the ceiling told me a seventh kill was coming about eighteen minutes before the notification arrived.
- Get the "who is hitting me" number early. 25,976 distinct IPs in one log sample is the entire story, and I found it after spending time on the memory curve.
- Question the container, not the server. "The server is out of memory" and "one container has a 512 MiB ceiling on a 64 GB machine" need completely different responses, and the second one is invisible unless you look at the limit.
The result
No OOM kill since the change, with a peak at 210 MB after 18 minutes against
a ceiling that used to be touched in every single cycle — and the seven
kills that had been silently restarting the public edge for four days
stopped appearing in dmesg. The crawler flood did not stop, which is why I
recorded the failure mode in my ops notes next to the stack: "container
stopped unexpectedly" plus exit 0 / OOMKilled=false is a misleading
signature, and on this platform the honest evidence lives in the kernel log
and the cgroup's high-water mark.
---
## 四、主站与博客的关系及 SEO / GSC 真实状态诊断
### 1. 主站 `hoelee.com` 当前内容与业务定位
- **技术载体与形态**:主站是基于 WordPress(Kadence 主题)搭建的企业官网,主域名 `hoelee.com` 301 重定向至 `www.hoelee.com`。
- **定位与商业客群**:主打 **Hoelee Enterprise** 商业服务,标语为 *"Revamp Your Online Presence with Website Design & Marketing"*,面向马来西亚中小企业提供企业建站定制、网页设计、在线获客及数字化支持,头部及尾部直接挂载 WhatsApp(+6012-7972969)、直接致电和业务邮箱([email protected])。
- **两站断层痛点**:
- 博客页头与页脚虽有超链接指向主站 `hoelee.com`,但**主站 6 个主要页面上对博客 `blog.hoelee.com` 的导流入链数为 0**(形成了单向孤岛)。
- 主站历史数据库中遗留了约 400 个旧版媒体附件落地页(Thin attachment pages),此前已列入后续整改计划。
### 2. `blog.hoelee.com` 在 Google Search Console (GSC) 里的真实收录与流量数据
- **核实属实**:目前在 Google 自然搜索中几乎没有返回有效结果,流量处于绝对冷启动阶段。
- **深层原因剖析**:
1. **所有权凭据已具备**:主域名 `hoelee.com` 的 DNS 已经配置了 `google-site-verification` 的 TXT 记录,GSC 的域名级属性(Domain Property)在权限上已经覆盖了整个 `*.hoelee.com`。
2. **Sitemap 尚未被 GSC 索引爬取**:Astro 构建产物 `https://blog.hoelee.com/sitemap-index.xml`(内含 `sitemap-0.xml` 包含全部 182 个中英双语页面)与 `robots.txt` 均返回 HTTP 200 且格式严格校验无误;但此前团队精力聚焦在内容创作与音视频流水线构建上,**未在 GSC 控制台后台手动完成最终的提交确认与索引抓取触发**。
3. **外链绝对为 0(Cold Start 阻碍)**:这是一个全新上线的独立子域名,除了站内自己的代码仓库外,在外部公网平台(如主站、权威技术社区、LinkedIn、高权重社媒)**没有任何高质量反向链接(Backlinks)**。在没有外链引流权重的冷启动期,Google 爬虫分配的抓取预算极低。
4. **自建监控实况**:全站目前通过自建自托管的 **Umami 统计平台(`stats.hoelee.com`)** 进行全量数据监测(已配置防反代 IP 穿透与真实地理位置追踪),目前记录到的均为作者开发调试及直达访问。
---
## 五、期待顾问重点开刀示范的任务清单
请顾问结合上述代码与样本文章,现场示范以下落地规范与组件原型:
1. **改造示范 1:首屏速览卡片(`<ExecutiveSummary/>`)实例文案**
- 针对《How I Built the DigiKedai Telegram AI Bot》与《Docker Reported Exit 0...》,请直接输出两套不同受众侧重点的 Bento 速览卡片文案与字段内容。
2. **改造示范 2:正文排版重构与代码折叠(`<TechnicalDeepDive/>`)**
- 在上述两篇文章中,指出哪些具体的排障日志、配置代码应当收进折叠区?哪些关键架构与时序图应当提升到前 30% 展示?
3. **改造示范 3:高转化动态 CTA 矩阵实战文案**
- 替换样本 1 和样本 2 底部千篇一律的 `Want this for your business?`,针对 AI 自动化客户与 DevOps 稳定性客户,分别给出具有极强点击冲动的专属 CTA 钩子。
4. **架构落地指导:3 大系统蓝图(Pillar Pages)的最佳路由与挂载规划**
- 在 Astro 现有路由(`src/pages/`)下,这 3 大蓝图页建议作为独立的顶层路由(如 `/blueprints/infra`、`/blueprints/ai-agents`)还是整合进分类页?如何最大化提升整站 Topic Authority?