自部署数据面板实战:12个D开头的技术栈全解析
2026/9/9 9:40:52 网站建设 项目流程

看到项目名 DDDDDDDDDDDD 的时候,大多数人的第一反应都是:你是不是键盘卡了?说实话,把这一串 D 敲进仓库名之后,我自己也盯着屏幕愣了几秒。但后来我发现这名字意外地能用,因为它恰好可以被拆成 12 个 D 开头的关键词,每个都对应我在这个项目里真正用到的技术或实践。这篇文章就聊聊这个叫 DDDDDDDDDDDD 的自部署个人数据面板是怎么从零搭起来的,以及我为什么宁可偶尔被同事嘲笑,也要坚持这套“全 D 阵容”。

先说一下项目本身。它解决的是我自己的一个具体痛点:指标太多,散落在各个地方。写文章的阅读数据在一个后台,服务器监控在另一个面板,小项目的 issue 又躺在别的平台,想做小结的时候得同时开六七个页面来回切换。所以我做了一个自托管的数据面板,把不同数据源的指标汇总到一个页面里,用图表展示出来。整个项目不算大,单机部署,所有数据都留在自己手里,跑在一台 2C4G 的小服务器上。如果你也想搭一个类似的个人数据面板,或者想了解 Deno、Drizzle、DuckDB、Docker、D3.js 这一套组合怎么配合使用,这篇文章应该能给你一些参考。

1. 项目概述:一个“键盘卡住”命名的自部署数据面板

1.1 面板到底做了什么:功能与边界

这个面板的功能可以概括成三件事:定时抓取、统一存储、图表展示。

抓取这步,我写了几组 collector,按各自的时间节奏去拉数据。比如阅读数每 6 小时拉一次,服务器指标每 5 分钟拉一次,issue 数量每天拉一次。拉回来的数据统一落进 SQLite,一张表存指标名,一张表存时间序列,结构非常简单。展示层是一个纯静态页面,浏览器加载后通过 fetch 请求 Deno 服务端的查询接口,拿到聚合好的数据,再用 D3.js 画成折线图、柱状图和热力图。

这里有一个重要的设计取舍:不做实时,只做定时同步。个人面板这个场景里,实时更新的价值很低,反而会让架构复杂很多。如果引入实时推送,就得维护 WebSocket 或者 Server-Sent Events,还要考虑前端断线重连,这些都是纯成本。用定时任务加轮询,服务端逻辑简单,前端也只需要在页面加载时拉一次数据,最多加个手动刷新按钮。这种“够用就好”的边界感,是这个项目能在一周内从零跑起来的关键。

1.2 12 个 D:项目名的隐藏架构图

名字里的 12 个 D 并不是随机的,它其实是一张架构图。我把整个项目用到的关键技术、流程和理念按字母整理了一遍,结果正好凑出了 12 个 D 开头的东西。

序号D 词在项目里的角色
1Dashboard最终产品形态,一个自托管的可视化面板
2DDD领域驱动设计的轻量实践,用来划分模块边界
3Deno服务端运行时,负责 API 和定时任务
4Drizzle给 SQLite 配的轻量 ORM,管理表结构和迁移
5DuckDB分析引擎,跑批量聚合查询
6Docker容器化交付,保证本地和服务器环境一致
7DroneCI/CD 流水线,提交代码后自动测试、构建、发布
8D3.js前端数据可视化,绘制所有图表
9Datadog可观测性,记录日志和关键指标
10Debug排查问题的固定方法论
11Deploy部署策略,一条命令完成更新
12Docs文档沉淀,把运行手册写清楚

说实话,一开始我并没有凑齐 12 个的野心。是项目做到一半,某天看仓库名的时候突然发现,咦,技术栈里已经没剩下几个非 D 的东西了,干脆把缺口补齐。后来我发现这个命名方式有个额外的好处:每当我看到 DDDDDDDDDDDD 这个名字,就等于把整个项目的架构在脑子里过了一遍。名字成了记忆锚点,这个价值比强行玩梗重要得多。

2. 核心技术选型:Deno、Drizzle、DuckDB 与 D3.js 怎么配合

2.1 Deno + Drizzle:没有 node_modules 的服务端

服务端我选了 Deno。选择它的原因很朴素:原生支持 TypeScript,不需要额外的编译步骤,自带权限模型,自带格式化器和测试器。写一个小项目,我实在不想再搭一整套 Node 工程化的链路,而 Deno 开箱即用的程度刚刚好。入口文件就是一个普通的 TypeScript 文件,直接起一个 HTTP 服务:

// main.ts Deno.serve({ port: 8080 }, async (req) => { const url = new URL(req.url); if (url.pathname === "/api/metrics") { const rows = await queryAggregatedMetrics(); return Response.json({ data: rows }); } return new Response("not found", { status: 404 }); });

注意这里没有任何 import 依赖框架的部分,Deno 内置了Deno.serve,直接就是标准的 Request 和 Response 对象。运行的时候加上权限参数:

deno run --allow-net --allow-read --allow-write main.ts

权限模型刚上手会觉得烦,多跑几次缺什么权限它就报什么错,习惯了之后反而觉得安全。特别是容器部署的时候,只给必要的权限,暴露面小很多。

数据访问层用了 Drizzle。选它的核心原因有两点:一是轻,二是 SQL 味足够浓。我写的 schema 本质上就是一张表一个对象,一眼能看明白:

// schema.ts import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; export const metrics = sqliteTable("metrics", { id: integer("id").primaryKey({ autoIncrement: true }), key: text("key").notNull(), value: real("value").notNull(), recordedAt: integer("recorded_at", { mode: "timestamp" }).notNull(), });

表结构变更用 drizzle-kit 生成迁移文件:

deno run -A npm:drizzle-kit generate

之前我也考虑过 Prisma,但这项目里用 Prisma 属于杀鸡用牛刀,schema 文件、生成客户端、额外的引擎依赖,结构太重。Drizzle 更接近写 SQL 本身,查数据的时候心智负担很小。这里有一个实际经验:在 Deno 环境里用 Drizzle,连接 SQLite 时别选 better-sqlite3 这类原生依赖,尽量走@libsql/client的 WASM 版本,否则后面会遇到deno compile的问题,这个我放到最后的坑位里详细讲。

2.2 DuckDB + SQLite:一份数据两种读法

这个项目里最让朋友觉得奇怪的部分,就是为什么同时用 SQLite 和 DuckDB 两个数据库。事情是这样的:SQLite 负责承接写入,collector 们高频地把原始数据塞进来,插入和更新都很轻量。但面板要展示的往往是聚合结果,比如“最近 30 天的每日阅读趋势”“过去一周的接口错误率”。这种分析型查询如果在 SQLite 上直接跑,数据量一上来,聚合大表的成本就会变得不可忽略。

我的方案是把两件事分开:每天凌晨由一个定时任务把 SQLite 里的原始数据导出成 Parquet 文件,然后让 DuckDB 直接在这些 Parquet 上跑 SQL 聚合,把结果集写成 JSON 缓存。面板的查询接口读的是这份聚合 JSON,而不是当场查 SQLite。

-- 在 DuckDB 里按天聚合指标 SELECT key, strftime(date_trunc('day', recorded_at), '%Y-%m-%d') AS day, avg(value) AS avg_value FROM read_parquet('metrics.parquet') GROUP BY key, day ORDER BY day;

这个架构的好处是,两个数据库各干各擅长的事。SQLite 作为操作系统里的一个文件,备份就是拷贝文件,完全可控。DuckDB 作为分析引擎,对列存和聚合查询的优化远好于 SQLite,而且它可以直接读 Parquet,省掉导入导出的环节。实际跑下来,本来在 SQLite 里要两三秒的周级聚合,在 DuckDB 上几乎秒出结果。代价是多了一步导出,但这步很简单,放在一个 cron 里执行,没有任何运维负担。

2.3 D3.js 画图:自学可视化的一点心得

前端可视化选了 D3.js。市面上图表库很多,ECharts、Chart.js 都很成熟,上手比 D3 快得多。但 D3 给我的是另一个东西:完全的控制力。它不是一个封装好的图表组件库,而是一套数据到 DOM 的绑定工具,所以我画折线图时,xs 轴的范围、y 轴的刻度间距、曲线的插值方式,全部都是自己说了算。

一个基础折线图的核心代码大概长这样:

const svg = d3.select("#chart"); const x = d3.scaleTime() .domain(d3.extent(data, d => d.date)) .range([0, width]); const y = d3.scaleLinear() .domain([0, d3.max(data, d => d.value)]) .range([height, 0]); svg.append("path") .datum(data) .attr("d", d3.line() .x(d => x(d.date)) .y(d => y(d.value)));

第一次看 D3 的代码会有点懵,因为scaleTimescaleLinearline这些概念都是全新的。我的学习心得是:别想着直接画复杂图,先把“比例尺”这个概念彻底搞明白。D3 的一切都建立在比例尺上,把数据域映射到像素域,理解了这个,剩下的就是从官方示例里改。我画折线图、柱状图、热力图,都是先找一个官方 example,读懂它的比例尺和数据绑定方式,再改造成自己的数据。

2.4 DDD 的轻量实践:小项目也要有边界

提到 DDD,很多人第一反应是“一堆聚合根和事件风暴”。那是重方案。我在这个项目里只借用了 DDD 的两个基本思想:限界上下文和领域模型。

我把项目划分成三个上下文:数据接入(collector)、指标分析(analytics)、展示(dashboard)。它们之间的依赖方向是单向的:collector 只能写数据,analytics 只读数据,dashboard 只读聚合结果。谁也不能越界去改别人的表。这样划分之后,最直接的好处是改代码的时候不用小心翼翼,因为每个模块的职责清楚,改 collector 的抓取逻辑不会影响图表渲染。

领域模型方面,我只保留了核心概念:Metric(指标)、DataSource(数据源)、Snapshot(快照)。所有业务代码都围绕这三个概念展开,没有额外发明更多抽象。小项目做 DDD,最忌讳的是为未来的需求做过度设计。我见过太多人在一个 3000 行的项目里建了十几个领域对象,最后自己都搞不清谁依赖谁。轻量实践的度是:模型能自然表达业务语义,模块边界能防止随意改动,这就够了。

3. 交付链路实操:Docker 镜像、Drone 流水线、一键 Deploy

3.1 Dockerfile 与镜像瘦身实践

整个服务最终是跑在 Docker 容器里的。容器化带来的最大好处是环境一致性:我本地跑得通,服务器上就一定跑得通,再也不会出现“我机器上好好的”这种问题。

Dockerfile 我用了多阶段构建。Deno 支持用deno compile把 TypeScript 源码编译成单个可执行文件,这一步很适合放进 builder 阶段:

# 构建阶段 FROM denoland/deno:debian-2.1.4 AS build WORKDIR /app COPY deno.json deno.lock ./ RUN deno cache main.ts COPY . . RUN deno compile \ --allow-net --allow-read --allow-write \ --output /app/server \ main.ts # 运行阶段 FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app/server /usr/local/bin/server EXPOSE 8080 CMD ["server"]

这样最终运行的镜像里只有一个可执行文件加系统底层库,没有任何源码和依赖文件,镜像体积从包含全部依赖的 1.2GB 降到了 180MB 左右。对于个人项目来说,部署时省下的时间虽然不多,但镜像越小,拉取越快,出问题的面也越小。另外,运行阶段用 Debian 而不是 Alpine,是为了避免一些动态链接库的兼容性问题,这个坑在 Go 和 Deno 编译后的二进制上都可能遇到,与其踩完再改,不如一开始就选稳妥的基础镜像。

3.2 Drone CI 流水线:容器即构建环境

CI/CD 选了 Drone,一个容器原生的持续集成平台,配置文件长这样:

kind: pipeline type: docker name: default steps: - name: test image: denoland/deno:debian-2.1.4 commands: - deno fmt --check - deno lint - deno test - name: publish image: plugins/docker settings: repo: registry.example.cn/me/dddd tags: - ${DRONE_COMMIT_SHA} - latest username: from_secret: registry_username password: from_secret: registry_password

选择 Drone 而不是直接用 GitHub Actions,原因有两个。第一,我本身有自建的代码托管服务,Drone 可以完全自托管,构建记录、构建缓存、密钥管理都放在自己手里。第二,Drone 的每一个 step 都是一个 Docker 容器,构建环境就是声明式的,不存在“本地 build 过了 CI 里却挂了”的灵异问题。配置里 test 这一步直接用 deno 镜像,镜像里有什么环境就是什么环境,干净利落。

secret 在 Drone 里需要先在仓库设置里配置,然后在配置文件中用from_secret引用,不能把密码明文写在仓库文件里。我第一次搭的时候直接把密码写在 YAML 里,结果 Drone 会拒绝加载包含明文 secret 的配置文件,这一点值得提前说明。

3.3 Deploy 策略:从提交到上线

部署环节的思路很简单:每次提交触发流水线,构建出带 commit hash 标签的镜像,然后推送到私有仓库,服务器上跑一个容器,镜像 tag 改成最新的即可。我用的 docker-compose 文件:

services: app: image: registry.example.cn/me/dddd:latest restart: unless-stopped ports: - "127.0.0.1:8080:8080" volumes: - ./data:/app/data

这里有一个细节:我只把容器端口绑定到 127.0.0.1,外面再放一个 Caddy 做反向代理和 HTTPS。这样应用本身不直接暴露公网,反向代理层统一处理证书和域名。服务器上更新时只需要拉最新镜像再重建容器:

docker compose pull docker compose up -d

数据目录通过 volume 挂载到宿主机,SQLite 文件不会因为容器重建而丢失。备份也简单,定时把整个 data 目录压缩上传到对象存储就行。这套部署方式虽然不够“云原生”,但对一个个人项目来说,稳定性、可控性和可维护性都是达标的。

4. 工程规范:Datadog 接入、Debug 流程、Docs 沉淀

4.1 Datadog:个人项目要不要上可观测性

这个项目量级很小,按常理不应该上 APM 这类重型可观测方案。但我在这个项目上用 Datadog 有两个实际诉求:一是想随时知道定时任务有没有跑挂,二是想看看服务器的基础资源曲线。所以我在 compose 里加了一个 datadog-agent 容器:

agent: image: gcr.io/datadoghq/agent:7 environment: DD_API_KEY: ${DD_API_KEY} DD_SITE: datadoghq.com DD_LOGS_ENABLED: "true" volumes: - /var/run/docker.sock:/var/run/docker.sock

实际用下来,我最看重的是日志和容器的存活监控。面板的 API 日志和 collector 的输出都会通过 agent 收集,如果某个定时任务连续几次失败,我可以在 Datadog 里配置一个简单的异常检测告警,直接推送到手机。这个项目我并没有接入分布式链路追踪,因为单服务架构没有跨服务调用链,强行上 trace 纯属浪费。

这里也要说句公道话:Datadog 的计费方式对个人项目不算友好,免费额度有限。如果预算敏感,完全可以用 Prometheus + Grafana 或者 Loki 组合替代,都是成熟的方案。我在这套项目里选择 Datadog 更多是出于熟悉程度和“D”主题的执念。可观测性的价值不在于用了什么工具,而在于出问题时你能用最短的时间定位到根因。

4.2 Debug:我的问题定位三板斧

个人项目最难受的时刻,不是功能做不出来,而是服务跑着跑着突然数据不对,但你不知道是哪一环出了问题。我在这个项目里总结出三板斧:先看权限、再看日志、最后复现。

第一板斧是针对 Deno 的。权限错误出现的频率远超预期,每次新增一个文件读取或者网络请求,都可能触发PermissionDenied。这里我的建议是:不要把权限参数一刀切地全放开,而是先按报错提示补齐必要的权限,确认稳定后再收敛。--allow-all虽然省事,但会让权限模型形同虚设。

第二板斧是结构化日志。我从一开始就给所有 collector 加了统一格式的 JSON 日志,每行一条,包含时间、数据源、抓取条数、耗时、状态。出问题时,直接在日志里按数据源过滤,一眼就能看到是哪次抓取失败了。

{"ts":"2025-01-02T03:00:00Z","source":"rss","count":42,"duration_ms":310,"status":"ok"}

第三板斧是写最小复现脚本。比如 DuckDB 聚合结果和 SQLite 对不上,我不会直接在项目里翻代码,而是先导出一小段数据,写一个 20 行的独立脚本跑一遍 SQL,把问题从“项目代码的 bug”缩小成“SQL 本身的问题”或者“数据类型的问题”。这一步能节省大量瞎猜的时间。

4.3 Docs:README 当运行手册来写

很多个人项目的 README 只有一句“fork and run”,这个坏习惯我在这个项目里改了。因为项目里有多个数据源接入、数据库迁移、备份恢复这些操作,三个月后我大概率会忘掉细节,与其到时候翻代码回忆,不如把 README 写成一份实打实的运行手册。

我在 README 里放了这几块:架构说明、本地开发启动方式、数据源接入清单、数据库迁移步骤、备份恢复命令、常见问题索引。每块都不长,但保证照着做能跑通。文档里还放了一段 ASCII 架构图,标出数据从 collector 到 SQLite 到 DuckDB 再到前端页面的流转路径。这比 2000 字的架构说明有效得多,因为看图的人 10 秒就能建立起全局认知。

写文档这件事,我的经验是把它当代码的一部分来维护。每次改了数据接入方式或者部署流程,顺手更新 README,成本几乎为零。等真正需要查文档的时候,你会发现它比任何人的微信语音都靠谱。

5. 常见问题排查与踩坑实录

5.1 高频问题速查表

项目从搭建到上线,我积累了一张问题排查表,现在团队里同事遇到类似问题也会先翻这张表。这里整理成表格分享出来:

现象大概率原因处理方式
容器启动直接退出忘记加 --allow-read 权限补权限参数,或改用 deno compile 固化权限
Deno 里 import better-sqlite3 报错原生模块与 Deno 运行时兼容性换成 @libsql/client 的 WASM 版本
DuckDB 聚合结果比 SQLite 多Parquet 重复导出、时间字段类型不一致检查导出任务是否幂等,统一时间戳存储
前端图表日期整体偏移一天JavaScript Date 月份从 0 开始,或 UTC 时区解析用 d3.timeParse 指定格式,统一按 UTC 存储
Drone 构建时 secret 一直无效明文写在 YAML 里被拦截在仓库设置中配置 secret,用 from_secret 引用
编译好的镜像在服务器上跑不起来构建机和运行机架构不一致(arm64/amd64)buildx 指定 --platform linux/amd64
Datadog agent 内存占用过高开启了容器全量日志采集关闭 collect_all,只采集目标容器

这张表最有价值的是“大概率原因”这一列,它代表的是我踩过之后形成的直觉。问题出现时先按表里的方向查,大多数时候都能直接命中。

5.2 三个让我印象最深的坑

第一个坑是deno compile和原生模块的冲突。项目最开始数据库访问用了 better-sqlite3,本地跑得好好的,一旦执行deno compile生成可执行文件,启动就报错,提示找不到原生绑定。查了半天才意识到 Deno 在编译为单文件时对原生模块的支持有限。最后我把所有数据库访问全部切换到 libsql 的 WASM 版本,这个问题才彻底消失。这给我的教训是:只要计划用deno compile做交付,从一开始就不要碰需要原生编译的 npm 依赖。

第二个坑是时区问题。SQLite 里存的是本地时间,DuckDB 聚合时按日分组,前端又用 JavaScript 的 Date 解析,三层各带一次时区转换,最后图表上的曲线和真实数据错开了大半天。这个问题的根因是存储层没有统一使用 UTC。修复方式很粗暴:collector 写入前统一把时间转换成 UTC 时间戳,所有查询和展示也都按 UTC 处理,等到展示层需要本地化显示时再做一次转换。想避免这类问题,唯一的原则就是“存储统一,展示转换”,任何中间环节都别碰时区。

第三个坑是镜像架构。我本地电脑是 ARM 架构,构建出来的镜像推上去之后,x86 的服务器直接报 exec format error。这个问题排查起来特别迷惑,因为镜像能正常推送到仓库,docker compose 也能正常拉取,但容器一启动就退出。解决方案是在 Drone 流水线里用 buildx 显式指定--platform linux/amd64,让 CI 始终构建目标平台的镜像。从那以后我养成一个习惯:在流水线里显式标明平台,而不是依赖构建机的默认值。

5.3 回到这副“键盘残骸”

如果现在有人问我,这个项目最大的收获是什么,我可能会说不是技术栈本身,而是把一个想法从头拼完整过程。刚开始面对 DDDDDDDDDDDD 这个名字,我自己也觉得像键盘坏了,但等 12 个 D 都各就各位之后,我发现项目里的每个选择都能对上一个清晰的理由,这种“每个零件都清楚为什么在这”的感觉,是写一次性脚本的时候永远体会不到的。

我是真心推荐你做一个小项目的时候也试试这种命名方法。不一定是 D,也可以是你的名字缩写、一个喜欢的关键词,重要的是让名字成为一个索引,看到它的时候能想起整个项目的结构。最后再分享一个小技巧:当一个个人项目能在一分钟内完成部署、三分钟内找到问题日志、不靠记忆也能知道所有模块的职责时,这个项目就算真正落地了。技术指标写得再漂亮,都不如自己长期维护得顺手来得实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询