自建 Umami 给博客做访问统计
博客的统计我不想用 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 1check-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: 90sstart_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 端点也加上埋点,看看哪个接口真的有人用。