自建 Umami 给博客做访问统计

1,833 字#umami #自建 #postgres #nginx

博客的统计我不想用 Google Analytics:脚本重、有 Cookie 提示的麻烦,数据也不在自己手里。Umami 是个合适的替代——单容器、无 Cookie、面板够用,而且是 MIT 协议。

这篇记录一下自建的过程,重点在国内网络下会卡住的几个地方。

环境

服务器上已经跑着一个 Postgres(pgvector/pgvector:pg16),所以不再单独起一个,直接复用,给 Umami 建独立的库和用户:

CREATE USER umami WITH PASSWORD '...';
CREATE DATABASE umami OWNER umami;

这里有个取舍:官方的 compose 示例会给 Umami 配一个专属的 Postgres 容器。复用的话省了资源和运维,代价是升级 Postgres 时会连带影响 Umami。对个人服务来说这个代价可以接受。

镜像拉取的坑

这一步花的时间比预想的多。

Umami 的官方镜像在 ghcr.io/umami-software/umami。服务器上配了 Docker Hub 的镜像加速,但**registry-mirrors 只对 docker.io 生效,对 ghcr.io 完全不适用**——所有 ghcr 镜像还是会走直连。实测直连很慢,而且卡在一个尴尬的状态:

7 层 Download complete,剩下 12 层一直 Waiting

更糟的是我一开始起了两个 pull 进程(一个超时后没清干净),Docker 对同一个镜像的并发拉取会互相锁住,两边都卡在 7 层不动。杀掉重来、只跑一个,才有进展。

最后用的是南京大学的 ghcr 镜像,速度正常:

docker pull ghcr.nju.edu.cn/umami-software/umami:postgresql-latest
# 拉完本地 retag 回官方名字, compose 文件保持规范写法
docker tag ghcr.nju.edu.cn/umami-software/umami:postgresql-latest \
           ghcr.io/umami-software/umami:postgresql-latest

顺手检查了各镜像源的连通性,方便下次直接用:

源结果
docker.1ms.run通(Docker Hub)
docker.fnnas.com通(Docker Hub)
registry-1.docker.io不通
ghcr.io通但慢
ghcr.nju.edu.cn通且快 ✅

验证方法很简单:curl -o /dev/null -w '%{http_code}' https://<registry>/v2/。返回 401 或 200 都说明通(401 是需要 token 的正常行为),连不上才是真的不通。

迁移是自动跑的

拉下来之后先确认了一件事:这个镜像会不会自动做数据库迁移。答案是会,但不在 CMD 里那么明显。

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["sh", "scripts/start-docker.sh"]

start-docker.sh 干了三件事:

node scripts/check-db.js        # ← 这里面跑 prisma migrate deploy
node scripts/update-tracker.js
exec node server.js            # exec 掉自己, 容器里只剩一个 node 进程当 PID 1

check-db.js 会依次做四件事:检查 DATABASE_URL、连库、校验 Postgres 版本(要求 ≥ 9.4)、prisma migrate deploy。所以不需要手动跑迁移,起容器就行。

顺带确认了 update-tracker.js 的行为——它会去替换 public/script.js 里的上报地址,但只在设了 COLLECT_API_ENDPOINT 时才动手:

const endPoint = process.env.COLLECT_API_ENDPOINT;
if (endPoint) { /* 改文件 */ }

也就是说默认完全离线,不会因为下载 CDN 资源失败而卡住启动。这点在国内挺关键,因为 start-docker.sh 带着 set -e——任何一步失败容器都起不来。

compose 配置

services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    restart: unless-stopped
    env_file:
      - .env
    environment:
      DATABASE_TYPE: postgresql
      # 用容器名, 不用 IP
      DATABASE_URL: postgresql://${UMAMI_DB_USER}:${UMAMI_DB_PASSWORD}@postgres:5432/${UMAMI_DB_NAME}
      APP_SECRET: ${UMAMI_APP_SECRET}
      # 在反向代理后面, 让 Umami 生成正确的绝对链接
      BASE_URL: https://stat.laofu.online
      TZ: Asia/Shanghai
    networks:
      - app-net        # external

networks:
  app-net:
    external: true

几个点:

  • APP_SECRET 必须设,用于签名。
  • BASE_URL 让面板生成分享链接和脚本地址时用对域名。
  • 不映射宿主机端口,跟其他后端服务一样只挂 app-net,由 Nginx 按域名分发。
  • 健康检查用 wget 而不是 curl——虽然这个镜像两个都有,但 alpine 系镜像默认一般只有 busybox 的 wget,养成习惯。
healthcheck:
  test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/heartbeat || exit 1"]
  interval: 30s
  start_period: 90s

start_period 给长一点,prisma migrate deploy 首次要跑 26 个迁移,不是瞬间的事。

初始化走 API

Umami 的默认账号是 admin / umami,起完必须马上改。面板本身要挂到公网,用弱口令就是在开门。

我直接用 API 做完了初始化,比点界面可靠,也方便写进脚本:

API=http://umami:3000

# 1. 默认账号登录拿 token
TOKEN=$(curl -s -X POST $API/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"umami"}' \
  | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')

# 2. 改密码
curl -s -X POST $API/api/me/password \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"currentPassword":"umami","newPassword":"<新密码>"}'

# 3. 建站点, 返回的 id 就是埋点要用的 website_id
curl -s -X POST $API/api/websites \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"bigfish的网络博客","domain":"blog.laofu.online"}'

接口定义直接在容器里就有,不用翻文档:

docker exec umami cat public/openapi.json

前端埋点

Linkita 提供了 templates/injects/head_end.html 注入点,把脚本放进去即可:

<script defer
        src="https://stat.laofu.online/script.js"
        data-website-id="<website_id>"
        data-domains="blog.laofu.online"></script>

data-domains 是我比较喜欢的一个选项:它把统计限定在指定域名上。本地 zola serve 跑在 127.0.0.1:1111,自然不会污染生产数据,不需要额外写判断。

怎么确认真的在上报

埋点这种东西「看起来加上了」和「真的在跑」是两回事。不想等到有访客才发现问题,所以做了一次端到端的验证。

先看 tracker 实际发什么。把 script.js 拉下来读一下 payload 的构造:

{ website: S, screen: _, language: n, title: c.title,
  hostname: h, url: Y, referrer: ..., tag: M, id: at }

然后照着这个结构往 /api/send 打一条:

curl -s -X POST https://stat.laofu.online/api/send \
  -H 'Content-Type: application/json' \
  -H 'Origin: https://blog.loafu.online' \
  -d '{"type":"event","payload":{
        "website":"<website_id>","screen":"1920x1080",
        "language":"zh-CN","title":"bigfish的网络博客",
        "hostname":"blog.loafu.online","url":"/"}}'

返回里会带着服务端生成的 sessionId 和 visitId,说明事件被受理了:

{"cache":"...","sessionId":"418bf4a9-...","visitId":"dedcc0f0-..."}

再用查询接口确认落库:

curl -s "$API/api/websites/<id>/pageviews?startAt=0&endAt=$(date +%s000)" \
  -H "Authorization: Bearer $TOKEN"
# {"pageviews":[{"x":"2026-01-01T00:00:00Z","y":1}],"sessions":[{"x":...,"y":1}]}

确认通了之后记得把测试数据清掉,别让面板从一条假数据开始:

curl -s -X POST "$API/api/websites/<id>/reset" -H "Authorization: Bearer $TOKEN"

小结

  • ghcr 镜像不吃 Docker Hub 的加速配置,国内用 ghcr.nju.edu.cn 之类的镜像更省事
  • 别并发拉同一个镜像,Docker 会锁住,两个都卡死
  • Umami 的迁移在 check-db.js 里自动跑,不需要手动 migrate deploy
  • update-tracker.js 默认是空操作,网络差也不影响启动
  • 初始化用 API 比点界面可靠,接口定义就在 public/openapi.json
  • 上线前先打一条假事件验证链路,再 reset 掉

现在面板在 https://stat.laofu.online,数据落在自己的 Postgres 里。下一步想给几个 API 端点也加上埋点,看看哪个接口真的有人用。