工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    一天 700 個訪客之後,我把 Analytics 搬回家裡的 NAS

    Authors
    • avatar
      Name
      Alex Yu

    前陣子在 Threads 發了部落格的第一篇推廣文,24 小時湧進 700 個訪客——對一個平常一天個位數訪客的部落格來說是暴擊等級。

    開心不到一天,就去翻了 Vercel Web Analytics 的方案限制。三件事看完就決定要搬:

    • UTM 參數(分辨訪客從哪個平台來的標記)Hobby 沒有,開 Pro 也還沒有,要再加 Web Analytics Plus,每個 team 每月 $10
    • 資料只留 1 個月:Hobby 的 reporting window 就這麼長
    • 免費額度 50,000 events/月,超過給 3 天寬限期就停止收集,而且額度是所有 project 共用

    前兩件事本來就沒得談,第三件是那天的高峰讓我第一次認真去看那條線——一個免費額度,撞到就是數據直接斷掉。

    家裡就有一台 Unraid NAS 24 小時開著。那就自己架。

    選擇:為什麼是 Umami

    比較過幾個 open source 的選項,決定的過程很快:

    • Plausible CE:功能接近,但自架要跑 ClickHouse,對 NAS 太重
    • GoatCounter:最輕,但沒有 custom events——之後 side project 要追蹤 landing page 行為就卡死
    • Umami:一個 Node app 加一個 Postgres,介面像 Vercel Analytics,UTM 和 custom events 都免費

    有一點要先說清楚:Umami 官網上的收費方案是他們代管的 Cloud 版。自架版完全免費、沒有任何事件數限制,我一開始也被官網價目表誤導了一下。

    架構:部落格不動,只搬數據

    讀者瀏覽器
      ├─ 讀網頁 → Vercel(部落格本體,完全不動)
      └─ 送瀏覽事件 → analytics.example.com
                        ↓ Cloudflare Tunnel
                      家裡的 Unraid NAS(Umami + Postgres)

    (下面所有 example.com 都是示意,換成你自己的網域。)

    兩條線互相獨立:NAS 掛了,部落格照常運作,只是那段時間的數據沒記到。

    對外靠 Cloudflare Tunnel——由 NAS 裡的 cloudflared 主動連出去 Cloudflare,家裡不開任何 port、對外 IP 不曝光。攻擊者查 DNS 只會看到 Cloudflare 的 IP,這對家用網路是關鍵:最怕的就是家裡 IP 被拿到後直接灌爆對外頻寬。

    Unraid 上架 Umami

    裝 Compose Manager Plus plugin(Apps 搜 Compose Manager,作者 mstrhakr),建一個 stack:

    services:
      umami:
        image: ghcr.io/umami-software/umami:postgresql-latest
        ports:
          - "3000:3000"
        environment:
          DATABASE_URL: postgresql://umami:<密碼>@db:5432/umami
          DATABASE_TYPE: postgresql
          APP_SECRET: <隨機字串>
        depends_on:
          db:
            condition: service_healthy   # ← 等 DB 健康檢查過了才啟動,省掉啟動順序問題
        restart: always
      db:
        image: postgres:15-alpine
        environment:
          POSTGRES_DB: umami
          POSTGRES_USER: umami
          POSTGRES_PASSWORD: <密碼>      # ← 必須跟 DATABASE_URL 裡的一字不差
        volumes:
          - /mnt/user/appdata/umami/db:/var/lib/postgresql/data
        restart: always
        healthcheck:
          test: ['CMD-SHELL', 'pg_isready -U umami']
          interval: 5s

    看起來很簡單。但我在這個 20 行的 compose 上連踩了兩個坑。

    坑一:base64 密碼放進 URL 會爆

    一開始用 openssl rand -base64 24 產密碼,結果 base64 會產生 /+=——這些字元放進 DATABASE_URL 這種 URL 格式會被解析壞掉(/ 在 URL 裡有結構意義)。

    # ❌ 產出來的密碼可能含 / + =,塞進 URL 就爆
    openssl rand -base64 24
     
    # ✅ hex 只有 0-9a-f,放哪裡都安全
    openssl rand -hex 24

    坑二:Postgres 的環境變數只在「第一次」有效

    改完密碼重啟,還是一直 password authentication failed for user "umami"。log 有個很迷惑的地方:

    ✓ Database connection successful.
    ✗ Raw query failed. Code: `28P01`.
      Message: `password authentication failed for user "umami"`

    連線成功、驗證失敗——因為 Postgres 的 POSTGRES_PASSWORD 只在資料目錄是空的、第一次初始化時才會生效。第一次啟動時我的 compose 裡有一個欄位忘了換掉佔位文字,資料庫就用錯誤密碼初始化了;之後不管怎麼改環境變數重啟都沒用。

    解法是把資料目錄整個清掉重新初始化(反正還沒有資料):

    rm -rf /mnt/user/appdata/umami/db
    # 再 Compose Up,讓它用正確的密碼重新 initdb

    順帶一提,起來之後的預設登入是 admin / umami——我拿 umami / umami 試了好幾次。

    DNS:從 Route 53 搬到 Cloudflare

    Cloudflare Tunnel 要求網域託管在 Cloudflare 的 DNS 上,所以把 example.com 的 DNS 從 AWS Route 53 搬過去。域名的「註冊商」跟「DNS 託管」是兩回事——域名繼續留在 AWS 註冊,只把 nameserver(負責回答「這個網域指向哪」的伺服器)換成 Cloudflare 的。

    流程:Cloudflare「新增網域」→ 選「連接網域」→ 它自動掃描現有紀錄 → 對照 Route 53 補漏 → 回 AWS 的 Registered domains 換 nameserver。搬完 Route 53 的 hosted zone 可以刪掉,順便省下每月 $0.50。

    這裡有一個必踩不可的設定:指向 Vercel 的紀錄要設成灰色雲朵(僅 DNS),不要開橘色的 Proxy。開了等於 Cloudflare CDN 疊在 Vercel CDN 前面,SSL 模式和快取行為都會打架。驗證方式很簡單:

    dig NS example.com +short   # 應該回 Cloudflare 的 nameserver
    curl -sI https://example.com | grep -i server
    # server: Vercel ← 灰雲正確;如果回 cloudflare 就是疊到了
     
    curl -sI https://analytics.example.com | grep -i server
    # server: cloudflare ← 這條反過來,走 tunnel 就該是 Cloudflare

    搬完 DNS,到 Zero Trust 後台幫 tunnel 加一條路由:analytics.example.comhttp://<NAS內網IP>:3000,對外就通了。

    把追蹤碼掛上部落格

    部落格是 Astro,追蹤碼放在 BaseLayout.astro<head> 裡。第一版就四行:

    <!-- ❌ 看起來沒問題,但接下來兩個坑都在這四行上面 -->
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="<website-id>"></script>

    坑三:裝了之後,自己看不到自己

    部署完,開 Umami 的 Realtime——沒有任何流量。

    先確認 pipeline 有沒有通。Umami 的收數 endpoint 可以直接用 curl 模擬一筆瀏覽事件:

    curl -X POST https://analytics.example.com/api/send \
      -H "Content-Type: application/json" \
      -H "User-Agent: Mozilla/5.0 ..." \   # ← 要帶瀏覽器 UA,curl 預設 UA 會被當 bot 丟掉
      -d '{"type":"event","payload":{"website":"<website-id>","hostname":"example.com","url":"/test","language":"zh-TW","screen":"1920x1080"}}'

    回 200、Realtime 看到這筆——伺服器端沒問題。curl 打得通、瀏覽器不行,這個組合把嫌疑範圍縮到只剩瀏覽器才有的東西。

    一開始懷疑廣告攔截器(analytics. 子網域配 script.js 確實正中 EasyPrivacy 清單),但攔截器解釋不了「所有訪客都掛零」——不可能人人都裝。開 DevTools 一看,答案在 Console:script 被 CSP(Content-Security-Policy) 擋掉了。

    我的部落格模板本來就設了 CSP,script-src 白名單裡躺著一行 analytics.umami.is——那是模板預設給 Umami Cloud 的網域。我自架的 analytics.example.com 不在名單上,瀏覽器一律拒載:

    // vercel.json
    // ❌ 模板預設:白名單是 Umami Cloud 的網域,自架版載不進來
    "script-src 'self' ... analytics.umami.is ..."
     
    // ✅ 換成自己的 analytics 網域
    "script-src 'self' ... analytics.example.com ..."

    curl 之所以打得通,是因為 CSP 是瀏覽器執行的政策,curl 根本不理它——這也是這個坑陰險的地方:你用 curl 驗證整條 pipeline 都是綠燈,真實流量卻是零。

    坑四:Astro 把我的 script 屬性吃掉了

    CSP 白名單改完,以為就結束了。後來想順手開 Umami 的 Core Web Vitals 收集,加一個屬性而已:

    data-performance="true"   <!-- ← Umami v3.1.0+,從訪客瀏覽器收 Core Web Vitals -->

    去 build 出來的 HTML 確認,才發現我寫的那幾行從來沒有照原樣出現在頁面上:

    <!-- ❌ dist/index.html:Astro 把整個 tag 換成一支打包後的 module -->
    <script type="module" src="/_astro/BaseLayout.astro_astro_type_script_index_0_lang.C7oMJtep.js"></script>
    // 而那支檔案裡面只有一行
    import 'https://analytics.example.com/script.js'

    deferdata-website-iddata-performance 全部不見。而且它變成了 module——module 裡沒有 document.currentScript,tracker 讀不到自己那個 script 元素,也就讀不到任何 data-* 設定。

    這個坑最陰險的地方是它沒有壞得乾脆:pageview 還是有數字(事件靠 hostname 被對回同一個網站),只有 Web Vitals 那頁從頭到尾空白,資料庫裡一筆 performance 紀錄都沒有。要不是我去開那頁,大概到現在都不知道。

    後來回頭讀 tracker 的原始碼,新版本已經不那麼客氣了:

    // script.js(minify 過的,變數名是編譯後的)
    const { currentScript: u } = c
    if (!u) return // ← 讀不到自己的 script 元素就直接掉頭

    現在踩同一個坑,是連 pageview 都不會有。

    解法是 is:inline,明確叫 Astro 原樣輸出、不要碰:

    <script
      is:inline
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="<website-id>"
      data-performance="true"></script>

    Astro 文件寫「有 src 以外的屬性就不會被處理」,但我在 Astro 6.4.6 實測不是這樣:defer 加三個 data-* 都在,照樣被打包成 module。第三方 script 一律加 is:inline,不要賭文件。

    兩個坑合起來一句話:CSP 和 build 產物都只在瀏覽器端現形,curl 全綠不代表沒事。

    驗收:兩套並跑一週,再拆掉舊的

    自架的數字敢不敢用,要看它跟舊工具對不對得上。所以 Umami 上線後我沒馬上砍 Vercel Analytics,讓兩套並跑一週(7/19–7/26),再把兩邊的數字拉出來對:

    指標Umami 相對 Vercel
    Page views−2%
    Unique visitors−12%

    pv 幾乎一樣,unique visitors 少 12%。這個落差的方向是合理的:Umami 的 script 掛在 analytics. 子網域,會被一部分廣告攔截器擋掉;Vercel 的走自家 /_vercel/insights 同源路徑,擋不到。

    12% 我可以接受,就把舊的拆了:

      // package.json
    - "@vercel/speed-insights": "^2.0.0",
      // src/layouts/BaseLayout.astro
    - <script defer src="/_vercel/insights/script.js"></script>
    - <SpeedInsights />

    Core Web Vitals 交給 data-performance 接手,analytics 收斂成單軌。

    至於攔截器,那是自架分析工具的宿命。有繞過的做法(script 改名、走主網域 proxy),我決定先不做——數據的用途是看趨勢和平台佔比,系統性低估 12% 不影響判斷。

    重點整理

    • Umami 自架版沒有事件數限制,官網價目表是 Cloud 版的,別被騙到
    • 密碼要放進 URL 就用 openssl rand -hex;Postgres 的 POSTGRES_* 只在資料目錄空的時候生效,改密碼要清資料重新 initdb
    • Cloudflare Tunnel 讓 NAS 完全不開 port、IP 不曝光;指向 Vercel 的 DNS 紀錄記得用灰色雲朵
    • curl 通不等於瀏覽器通:CSP 白名單漏了自己的網域、Astro 少了 is:inline,兩個都只在瀏覽器端現形
    • 換掉舊 analytics 前先並跑一週對數字(pv 差 2%、unique 差 12%),才知道新的敢不敢用

    參考資料