跳至主要內容
← 返回文章
11 分鐘閱讀系統與基礎架構

我的伺服器在客廳充電——用約束逼出來的自架架構

我的伺服器沒有固定公網 IP、沒有在路由器上設任何 port forwarding、也沒有租任何一台雲端主機。它是一台放在家裡、24 小時開著、偶爾會被家人不小心闔上蓋子的 Windows 筆電。

上面同時對外服務著三個網域,外加一個刻意對外的後台:

  • www.example.com — 作品集網站(Next.js 16,四主題、雙語、blog 存在 MongoDB、科技新聞由 RSS 自動抓)
  • database.example.com — 通用 MongoDB 管理後台
  • knowledge.example.com — 知識庫 MCP 後端(給程式化 client 用)
  • (只在本機)portfolio-admin — 個人網站內容後台

它們各自有獨立的 repo、獨立的網域、獨立的認證方式,彼此只透過資料契約webhook 對話。對外的最後一哩全部交給 ngrok 的自訂網域,DNS 掛在 GoDaddy。

這篇不是自架教學。 這類「怎麼設 ngrok、怎麼點 GoDaddy」的內容 Google 上一抓一大把。我想談的是另一件更有意思的事:當你的約束是「一台會被合上蓋子的家用筆電」時,架構會被逼成什麼形狀——以及在這條路上,DNS、Windows 排程器與 MongoDB 索引分別是怎麼教訓我的。

一、先講約束:為什麼不租一台 VPS?

一台入門 VPS 一個月幾塊美金,能省下我後面所有的麻煩。所以先誠實回答:租 VPS 當然更省事,但我要的不是「一個能跑的網站」,而是把一整套服務的邊界、生命週期與安全模型,親手設計並維運一遍。手邊剛好有一台閒置筆電、一個已經買了的網域、一個付費 ngrok 帳號——約束就這樣定了下來:

  1. 沒有公網 IP、不能開 port(家用 NAT + 浮動 IP)
  2. 沒有雲端預算(host 就是這台筆電)
  3. host 隨時可能離線(睡眠、Windows Update 重開、被闔上蓋子)

後面每一個設計決策,幾乎都能回指到這三條其中之一。

二、系統全貌:一服務,一網域,一套認證

使用者 / MCP client
      │
GoDaddy DNS  (example.com)
      │
ngrok 邊緣  (自訂網域 + TLS + traffic-policy)
      ├── www.example.com ........ 作品集 Next.js           :3000 ─┐
      ├── database.example.com ... Mongo 後台(OAuth+限流)  :3300 ─┤
      └── knowledge.example.com .. 知識庫 MCP(Ed25519+限流)       │
                                                                   ▼
  portfolio-admin(本機限定 :3400) ───────────────►  MongoDB :27017(loopback,共用)
  〔以上全部跑在同一台 Windows 筆電〕

我沒有把四個服務塞進同一個 Next.js 專案,而是依信任邊界拆分,而不是依技術框架拆分。理由是爆炸半徑控制:database 後台被打穿,不會波及作品集;知識庫的簽章金鑰外洩,不會動到 OAuth 那條線;每個服務可以獨立部署、獨立改版、獨立設定認證。

需要誠實標注的是:這裡的「低耦合」指的是 repo / 部署 / 網域 / 認證 的低耦合,不代表高可用——它們仍然共享同一台主機、同一條家用網路、同一個 ngrok agent,以及(見下文)同一個 MongoDB。這是我為了省一台 mongod刻意接受的耦合。

三、對外那一哩路:ngrok 自訂網域 + GoDaddy DNS

ngrok 把「動態 IP + NAT + 不能開 port」這個問題整個消掉了:agent 從筆電主動建立到 ngrok 邊緣的通道,外部流量從 *.example.com 進到邊緣,再被推回筆電。GoDaddy 只負責兩件事:名稱解析,與根網域轉址。真正花時間的是 DNS 的兩個坑:

1. apex(裸網域)不能設 CNAME。 DNS 規範不允許在 zone apex 放 CNAME,而 ngrok 自訂網域是靠 CNAME 接的。解法是:子網域 www 設 CNAME 指向 ngrok,裸網域 example.com 則用 GoDaddy 的 HTTP 網域轉址回傳 301https://www.example.com

一個常見的誤解要澄清:301 是 HTTP 行為,不是 DNS 行為。 是 GoDaddy 的轉址伺服器在 HTTP 層回 301,不是「DNS 把裸網域 301 過去」。(若哪天要讓裸網域直接指向 ngrok,正解是換到支援 ALIAS / CNAME flattening 的 DNS,如 Cloudflare、Route 53。)

2. GoDaddy 免費 Website Builder 會鎖住你的 DNS。 我一開始怎麼改 www 的 CNAME 都是唯讀。原因是那個免費建站服務透過 Domain Connect 託管@www 記錄。得先把站與網域斷開,DNS 才解鎖。這種「明明是我的網域卻不能改」的坑,是自架才會撞到、教學文很少寫的。

四、讓它自己活著:把 Windows 排程器當成 init system

約束第 3 條(host 隨時會離線)決定了整個運維設計:服務必須開機自啟、免登入、當掉自癒。 我沒有裝 Docker、也沒有用 PM2,而是把 Windows 工作排程器壓榨成一個窮人版的 systemd / K8s。每個服務(站、tunnel、mongod、RSS)一個排程任務,組合出這些可靠性原語:

需求K8s 的做法我的做法(Windows 排程)
開機自啟kubeletBootTrigger
崩潰重啟restartPolicyRestartOnFailure 999 次 / 每 1 分
活性探測livenessProbe每 5 分鐘 TimeTrigger watchdog
併發控制MultipleInstancesPolicy: IgnoreNew
免登入執行S4U principal
電池不中斷DisallowStartIfOnBatteries: false

重點不在於「我設了排程」,而在於我理解的是可靠性的抽象,而不是某個工具的按鈕位置。這張對照表也順帶帶出一個我必須誠實承認的漏洞——

「進程活著」不等於「服務可用」。 我的 watchdog 目前是進程級的:它確認 launcher 進程還在,但如果 Node 進程沒死、event loop 卻卡住,或 port 被佔用,排程器會以為一切正常。真正嚴謹的做法應該是打一個 HTTP health endpoint 來判定。這是我清楚知道、列在待辦上的一個真實限制。

(另一個 S4U 的小陷阱:它產生的 token 沒有網路憑證,只能跑純本機服務;還好我所有東西都在 localhost。而且 SSH session 下 USERDOMAIN 是空的,所以排程 principal 必須用 SID 而不是 DOMAIN\user。)

五、安全不是一道門,而是多個不同的信任邊界

我沒有做「一組帳密守全部」,而是依使用者類型分化認證——認證方式是從「誰在用」推導出來的,不是抄來的:

服務使用者認證
作品集任何人公開,無認證
database 後台人(瀏覽器)ngrok 邊緣 Google OAuth(只允許我的 email)+ app 層密碼
knowledge MCP程式(無瀏覽器)Ed25519 簽章 + nonce(防重放)+ 限流

瀏覽器後台用 OAuth 很自然;但 MCP 後端是程式化 client,沒有瀏覽器可以跑 OAuth 流程,所以改用簽章。兩者都額外疊一層,形成縱深防禦

為什麼刻意不用 IP 白名單? 表面理由是「我的 IP 會變」;更深的理由是:IP 白名單是把身分綁在網路位置上,這在移動辦公的前提下本來就是錯的抽象——這正是 zero-trust / BeyondCorp 的核心論點。身分應該綁在「你是誰」(OAuth email、簽章金鑰),不是「你從哪連」。

不過自架的安全代價也要說清楚:TLS 在 ngrok 邊緣終止,意思是 ngrok 技術上看得到我服務的明文流量(包含後台密碼)。這不是 bug,是把入口外包的必然。也正因如此,知識庫那條走的是 Ed25519 端到端簽章——完整性保證不依賴邊緣,這個對比剛好凸顯了兩種威脅模型的差異。

六、部署:私有 repo → 一台沒有 GitHub 憑證的筆電

我的發布流程有明確關卡:開發機寫 + localhost 測 → 本人確認 → commit/push 到 GitHub(private)→ 才上筆電。 不在伺服器上直接 git pull

有個細節值得一提:筆電上刻意不放 GitHub 長期憑證。 筆電是最容易被實體接觸的機器,我不想在上面擺一把能存取我所有私有 repo 的鑰匙。所以 code 傳上筆電是用 git bundle——把 commit 打包成單一檔案 scp 過去,在筆電上從 bundle clone。部署因此變成單向、離線的資料傳輸。犧牲一點便利,換到更小的憑證暴露面。這是安全思維,不是偷懶。

七、真正花時間的,是這些故障(根因,不只是解法)

流水帳式的「踩雷清單」沒意思;有價值的是每個雷背後被修正的心智模型:

  • 「SSH session 是乾淨的執行環境」——錯。 它是一個會累積狀態的長壽 shell。我在部署第一個 app 時設了 NODE_ENV=production,它殘留到第二個 app 的 npm install,害它略過了 devDependencies,build 直接掛。修正不是「記得 unset」,而是每個服務都用 wrapper script 顯式宣告完整環境,不繼承任何東西
  • sparse unique index 忽略的是「欄位不存在」,不是 null MongoDB driver 預設會把 undefined 寫成 null——而 null 對 sparse index 來說是一個合法、會被索引的值。於是第二筆沒有 slug 的文件就撞 duplicate key。解法是 ignoreUndefined: true。這個精準的區分,比「undefined 存成 null 會出錯」有洞見得多。
  • SSH 進 Windows 跑 PowerShell,長輸出與中文會截斷、亂碼。 與其跟串流搏鬥,不如把邏輯本機寫成 .ps1scp 上去 → 遠端執行寫進 log → 再把 log 讀回來。把「執行」和「輸出」解耦。
  • 失敗的 Turbopack build 會留下毒快取。 一次 build 失敗後,即使補齊了依賴,.next 的快取仍讓它報同樣的錯——清掉 .next 重新 build 才乾淨。

八、我知道它哪裡會壞

主動列出自己架構的弱點,是我認為資深與初階最明顯的分野。這套架構換來了完全的控制權與一次徹底的學習,但它犧牲了這些,我心裡有數:

  • ngrok 是單點故障,也是信任邊界。 ngrok agent 或服務本身出事,四個服務同時全掛,而我的 watchdog 救不了(那不在我的機器上)。外加供應商鎖定與頻寬計費、家用上行頻寬也是瓶頸。
  • 共用 MongoDB 是唯一的共同故障元件。 它掛掉會讓作品集與後台一起倒;schema 變更時兩個 repo 要一起改。這是典型的 shared-database anti-pattern,個人專案這樣完全合理,但我知道它是耦合點。
  • 資料層還很陽春。 單機 mongod(綁 loopback、尚未開 RBAC)、沒有 replica set、沒有 PITR。「系統 DB 唯讀 + drop 要打字確認」是 UX 護欄,不是安全邊界——真正的邊界應該是 MongoDB 帳號權限。備份與 RBAC 都在待辦上。
  • 健康檢查跑在那台可能已經掛掉的機器上。 需要一個外部 uptime 監控(dead man's switch),筆電斷網我才會知道。
  • 筆電當伺服器的實體代價: Windows Update 強制重啟、睡眠與快速啟動、闔蓋、長期插電的散熱與電池膨脹。對應措施(關快速啟動、設 active hours、電源永不睡眠)本身就是「真的跑過 24 小時」的證據。

結語:自架的價值,是理解邊界

如果只看結果,這台筆電做的事,一台三塊美金的 VPS 也能做。但自架真正的產物不是「省下一台 VM」,而是把每一條邊界都親手劃過一次:網路的邊界(ngrok 如何改變故障域而非消除它)、生命週期的邊界(進程活著不等於服務可用)、信任的邊界(身分綁在你是誰,不是你從哪連)、以及便利與安全之間那條線。

什麼時候我會改用雲端?當可用性要求超過「單機 + 自癒」能給的、或當資料重要到不能只靠一顆 SSD 時。但在那之前,這台在客廳充電的筆電,已經幫我把一整套系統的邊界都想清楚了。而那,才是我真正想要的東西。