很多人都被一句话刷过屏:“你不必嫌弃我的收入低,因为我的钱一分都到不了你手上。”第一次看到这句话,多数人的反应是情绪层面的——要么觉得解气,要么觉得偏激。但如果把情绪先放一边,这句话其实提出了一个非常具体的问题:我创造出来的东西,到底归谁所有?
把这个问题换个主语,技术人会立刻熟悉起来。我的收入归谁所有?我的数据归谁所有?我每天产生的记录、报表、账单、日志,存在谁的服务器上,由谁的条款决定能不能导出、能不能删除、能不能带走?这三件事在结构上是同一个问题,只是前者谈的是钱,后者谈的是数据。
这篇文章不打算讨论收入高低该不该被评价,那是社会话题,也没什么工程解法。我想聊的是它背后那个可以被工程手段解决的问题:当你把个人资产、个人记录、个人数据托管给别人的平台时,你实际上同时放弃了所有权、可迁移性和最终解释权。而这件事,开发者是可以用很低成本改变的。
读完之后你应该能做到三件事:理解“数据主权”这个听起来很大的词在个人场景下到底意味着什么;用 Docker 把一个带字段级加密的个人记账服务跑在自己的机器上;以及搞清楚自托管真正的成本和坑在哪里,不至于头脑一热装完就放着吃灰。
1. 这句话真正戳中的是什么问题
先把这个标题拆开看。“我的钱一分都到不了你手上”,字面意思是资金流向与你无关,但它的潜台词是:评价我的人并不在我的价值链条里。这句话之所以有传播力,是因为它精准描述了一种错位——贡献和归属之间的链路断了。
技术世界里,这种错位每天都在发生,而且比收入问题更隐蔽。你在某个记账 App 里记了三年的账,导出的只有一个带水印的 PDF;你在某个笔记工具里写了两百篇技术总结,服务一停服,全部变成只读;你在某个在线表格里维护了一份服务器清单,某天想迁到自建系统,发现批量导出功能在会员权益里。
这些场景的共同点是:你产生了数据,但你没有持有数据。你拥有的是“使用权”,而且是附带条件的、可被单方面修改的使用权。很多人第一次意识到这件事,是在某个平台调整免费额度、或者某个服务宣布下线的通知里。
所以这篇文章的核心判断是:对开发者而言,数据所有权不是一个抽象的道德议题,而是一个可以被拆解成具体工程指标的东西——能不能导出、能不能离线读取、能不能在没有原厂服务的情况下继续运行、能不能证明只有自己看得到。这四个问题有明确答案的系统,才谈得上“归你”。
理解了这一点,后面所有技术选择就都有了判断标准。选工具的时候不再是“哪个功能多”,而是“哪个我停掉订阅之后还能跑”。
2. 数据主权的三层含义与自托管的适用边界
“数据主权”这个词在合规语境里很重,但在个人场景下可以拆得很轻。我一般把它分成三层,一层比一层严格。
第一层是可迁移性。数据能不能以通用格式导出,导出后能不能被别的工具读取。CSV、JSON、SQLite 文件都属于这一层。达标门槛很低,但很多平台恰好卡在这一层。
第二层是可运行性。把原厂服务关掉之后,你的数据还能不能被程序处理。一个导出的 CSV 是死的,一个带 schema 的数据库是活的。这一层决定了你是在“保存数据”还是在“保存系统”。
第三层是机密性。数据在存储介质上是不是明文,谁能解密。这一层最容易被忽略,因为大多数人自托管的目的是“方便”而不是“保密”,结果就是把数据库文件明文扔在 NAS 上,和一个公开的网盘没什么区别。
基于这三层,自托管方案和 SaaS 方案的差异可以列成一张表:
| 维度 | SaaS 记账/记录工具 | 自托管方案 |
|---|---|---|
| 数据存放位置 | 厂商机房,跨区域部署 | 自己的机器或 NAS |
| 数据导出格式 | 通常为 PDF/受限 CSV | 直接拿到数据库文件 |
| 服务终止风险 | 停服即不可用 | 只要机器在就能跑 |
| 字段级加密 | 由厂商决定,用户不可控 | 密钥在自己手上,可自定义 |
| 运维成本 | 接近零 | 需要自己备份、升级、排错 |
| 移动端体验 | 成熟 | 一般较差 |
| 多设备同步 | 开箱可用 | 需要额外配置 |
这张表想说的是:自托管不是全面优于 SaaS,它是一次成本转移。你把运维成本从厂商那里接了过来,换来的是控制权。如果你一年都不会打开一次数据库,也不打算做任何自动化,那自托管的收益其实很低,用它更多是心理安慰。
我的建议是分场景。真正值得自托管的是那些长期积累、格式私有、迁移成本高的数据:记账流水、技术笔记、密码库、书签、阅读记录、家庭媒体库。而不值得自托管的是那些强依赖实时协作、强依赖移动端体验的东西,比如团队即时通讯、在线文档协作,自己搭一套的维护成本会远超收益。
下面的实操部分,我选“个人记账”作为例子,因为它同时满足三个条件:数据敏感、长期积累、结构简单到可以完整实现。你完全可以把它替换成任何一个你想拿回所有权的数据域。
3. 环境准备与前置条件
这一节把最低要求说清楚,避免你在第三步才发现环境不对。
操作系统层面,推荐 Linux(Debian/Ubuntu 系的 LTS 版本比较省心),也可以是 macOS 本地跑通再迁到服务器。Windows 下建议用 WSL2,直接用 Docker Desktop 也能跑,但文件挂载路径要注意大小写和权限。
运行时依赖只有两样:Docker 和 Docker Compose。版本方面,本文用到的 Compose 文件语法version: "3.9",需要 Docker Engine 19.03 以上、Compose 1.27 以上;Docker Compose V2(也就是docker compose而不是docker-compose)也可以直接兼容这份文件。具体版本请以你机器上的实际安装为准,不必强求一致,本文重点演示通用思路。
硬件资源要求很低:1 核 1G 内存足够跑起来,2 核 2G 会比较从容。磁盘占用主要是数据库文件,个人记账一年大概几 MB 到几十 MB,加上备份,10G 空间用几年没问题。
还有两个非技术前置条件,比装 Docker 更重要:
第一,端口与网络。如果你打算让手机也能访问,需要有公网入口或者内网穿透方案。这里要强调一句:任何把服务暴露到公网的操作,都必须先做好认证、HTTPS 和访问控制,并且只在你拥有或获得明确授权的设备与网络上进行。生产环境的任何变更,都建议先在测试环境验证,保留回滚路径。
第二,密钥管理。本文的方案会对金额和备注做字段级加密,密钥一旦丢失,数据就永久不可读。所以密钥不能只存在.env里一份,必须离线另存。这一点在第 8 节会展开。
准备工作做完,我们进入正题。
4. 核心流程拆解:从零到可用的六个步骤
整个流程我拆成六步,每一步都有明确的产出物,你可以对着检查有没有漏。
第一步,确定数据模型。记账的最小可用模型只需要四个字段:分类、金额、备注、时间。不要一上来就设计账户、预算、多币种、标签体系,那些会让你的第一版永远跑不起来。先跑通一条流水,再逐步加字段。
第二步,选择存储方式。SQLite 是个人场景的最优解:单文件、无需额外服务、备份就是复制文件、用任何语言都能读。唯一要注意的是并发写入能力弱,但个人记账不存在这个问题。
第三步,实现字段级加密。数据库文件本身可以是明文的,但敏感字段(金额、备注)用对称加密算法加密后存为字符串。这样即使数据库文件泄露,攻击者也拿不到可读内容。这里用 Python 的cryptography库提供的 Fernet,它是一个带认证的对称加密实现,密钥是 base64 编码的 32 字节字符串,用起来只需要两行。
第四步,包一层 HTTP 接口。用 FastAPI 暴露三个端点:写入一条记录、列出全部记录、汇总统计。所有端点都要求请求头带一个 Token,避免服务被暴露后被人直接写入垃圾数据。
第五步,容器化。用 Dockerfile 把应用打进去,用 Compose 编排,数据目录挂载到宿主机,这样容器重建不会丢数据。
第六步,验证与备份。用 curl 写入几条数据,直接查 SQLite 确认存的是密文,然后跑一次备份脚本,确认能还原。
这六步的顺序不能乱。很多人是先找现成镜像、再研究怎么改,结果被别人的设计绑死。先想清楚数据模型和加密边界,剩下都是工程实现。
5. 完整示例代码实现
下面给出完整可运行的文件。目录结构先定好:
personal-ledger/ ├── Dockerfile ├── docker-compose.yml ├── .env ├── .gitignore ├── app/ │ ├── main.py │ ├── crypto.py │ └── requirements.txt ├── scripts/ │ └── backup.sh └── data/ # 运行时自动生成,不要提交到仓库5.1 加密模块
先写加密层,它是整个方案的安全边界。
# 文件路径:app/crypto.py import os from cryptography.fernet import Fernet raw_key = os.getenv("LEDGER_KEY", "").strip() if not raw_key: raise RuntimeError("环境变量 LEDGER_KEY 未配置,请先生成密钥") try: _fernet = Fernet(raw_key.encode("utf-8")) except Exception as exc: raise RuntimeError("LEDGER_KEY 格式非法,必须是 Fernet 生成的 base64 密钥") from exc def encrypt(plain_text: str) -> str: """把明文加密成可存库的字符串。""" if plain_text is None: return "" return _fernet.encrypt(plain_text.encode("utf-8")).decode("utf-8") def decrypt(token: str) -> str: """把库里的密文还原成明文。""" if not token: return "" return _fernet.decrypt(token.encode("utf-8")).decode("utf-8")关键点说明:Fernet内部使用的是 AES-128-CBC 加 HMAC-SHA256 做完整性校验,密文被篡改会直接抛异常,这一点比自己拼装 AES 更省心。密钥缺失时程序启动即失败,而不是退化成明文存储——这是刻意的设计,避免“配置漏了但功能正常”的隐蔽故障。
生成密钥的命令:
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"5.2 接口层
# 文件路径:app/main.py import os import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel, Field from crypto import decrypt, encrypt DB_PATH = os.getenv("DB_PATH", "/data/ledger.db") API_TOKEN = os.getenv("API_TOKEN", "").strip() if not API_TOKEN or API_TOKEN == "change-me": raise RuntimeError("环境变量 API_TOKEN 未配置或仍为默认值,请设置一个随机串") app = FastAPI(title="Personal Ledger", version="1.0.0") @contextmanager def get_conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() def init_db() -> None: with get_conn() as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS entry ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, amount_enc TEXT NOT NULL, note_enc TEXT, created_at TEXT NOT NULL ) """ ) conn.commit() init_db() class EntryIn(BaseModel): category: str = Field(..., min_length=1, max_length=32) amount: float note: str = "" def require_token(token: str) -> None: if token != API_TOKEN: raise HTTPException(status_code=401, detail="token invalid") @app.post("/entries") def create_entry(payload: EntryIn, x_api_token: str = Header(default="")): require_token(x_api_token) with get_conn() as conn: cur = conn.execute( "INSERT INTO entry (category, amount_enc, note_enc, created_at) VALUES (?, ?, ?, ?)", ( payload.category, encrypt(str(payload.amount)), encrypt(payload.note), datetime.now(timezone.utc).isoformat(timespec="seconds"), ), ) conn.commit() return {"id": cur.lastrowid} @app.get("/entries") def list_entries(x_api_token: str = Header(default="")): require_token(x_api_token) with get_conn() as conn: rows = conn.execute("SELECT * FROM entry ORDER BY id DESC").fetchall() return [ { "id": row["id"], "category": row["category"], "amount": float(decrypt(row["amount_enc"])), "note": decrypt(row["note_enc"]), "created_at": row["created_at"], } for row in rows ] @app.get("/summary") def summary(x_api_token: str = Header(default="")): require_token(x_api_token) with get_conn() as conn: rows = conn.execute("SELECT amount_enc FROM entry").fetchall() total = sum(float(decrypt(row["amount_enc"])) for row in rows) return {"count": len(rows), "total": round(total, 2)}这里有两处值得说明的设计。一是启动即校验API_TOKEN,默认值change-me直接拒绝启动,防止有人照着示例部署后忘了改。二是amount用float接收但存成字符串再加密,因为加密函数的入参是字符串,写库时也保持 TEXT 类型,避免浮点精度在数据库层再引入一次误差。
5.3 依赖与镜像
# 文件路径:app/requirements.txt fastapi uvicorn[standard] cryptography这里刻意不写死版本号。原因是密码学相关库的小版本更新比较频繁,强行锁定一个旧版本反而容易在构建时出问题。如果你要做长期维护,建议在第一次pip install之后用pip freeze > requirements.lock.txt生成锁定文件,把它提交进仓库。关于版本策略,请以你实际安装到的稳定版本为准。
# 文件路径:Dockerfile FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY app/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt COPY app /app EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]PYTHONUNBUFFERED=1这行不是可有可无的,没有它,docker logs里的输出会滞留在缓冲区,排查问题时看到的是空日志。
5.4 编排与环境变量
# 文件路径:docker-compose.yml version: "3.9" services: ledger: build: . container_name: personal-ledger restart: unless-stopped env_file: - .env volumes: - ./data:/data ports: # 只绑定回环地址,公网访问请走反向代理 - "127.0.0.1:8000:8000" healthcheck: test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/docs')"] interval: 30s timeout: 5s retries: 3 start_period: 10s127.0.0.1:8000:8000这个写法很重要。如果你写成8000:8000,Docker 会把端口绑到所有网卡上,云服务器上的服务就直接暴露在公网了。默认只绑回环,需要外部访问时再加一层反向代理,这是最小暴露面的思路。
# 文件路径:.env LEDGER_KEY=把上面生成的密钥粘贴到这里 API_TOKEN=换成一个足够长的随机串 DB_PATH=/data/ledger.db# 文件路径:.gitignore .env data/ backups/ __pycache__/ *.pyc.env一定要进.gitignore。我见过不止一次密钥被推到公开仓库的案例,虽然 Fernet 密钥泄露不涉及第三方账号,但你的记账数据就变成了公开可读。
5.5 备份脚本
自托管最容易死在“没有备份”这一条上。
#!/usr/bin/env bash # 文件路径:scripts/backup.sh set -euo pipefail DATA_DIR="${DATA_DIR:-./data}" BACKUP_DIR="${BACKUP_DIR:-./backups}" KEEP_DAYS="${KEEP_DAYS:-14}" if [ ! -d "$DATA_DIR" ]; then echo "数据目录不存在: $DATA_DIR" >&2 exit 1 fi mkdir -p "$BACKUP_DIR" STAMP="$(date +%Y%m%d-%H%M%S)" ARCHIVE="$BACKUP_DIR/ledger-$STAMP.tar.gz" tar -czf "$ARCHIVE" -C "$DATA_DIR" . find "$BACKUP_DIR" -name 'ledger-*.tar.gz' -type f -mtime "+$KEEP_DAYS" -delete echo "备份完成: $ARCHIVE"给脚本执行权限并跑一次:
chmod +x scripts/backup.sh ./scripts/backup.sh如果想做成定时任务,可以加一条 cron:
# 每天凌晨 3 点备份,保留 14 天 0 3 * * * cd /opt/personal-ledger && ./scripts/backup.sh >> ./backups/backup.log 2>&16. 运行结果与效果验证
代码写完了,下面走一遍完整验证流程。验证的重点不只是“能跑”,还要确认“加密生效”和“备份可用”。
启动服务:
docker compose up -d --build查看状态和日志:
docker compose ps docker compose logs -f --tail=50正常情况下STATUS列会显示Up,日志里能看到Uvicorn running on http://0.0.0.0:8000。
写入三条测试数据:
export API_TOKEN="你在 .env 里设置的值" curl -s -X POST http://127.0.0.1:8000/entries \ -H "Content-Type: application/json" \ -H "X-API-Token: $API_TOKEN" \ -d '{"category":"工资","amount":12000,"note":"本月收入"}' curl -s -X POST http://127.0.0.1:8000/entries \ -H "Content-Type: application/json" \ -H "X-API-Token: $API_TOKEN" \ -d '{"category":"房租","amount":-3200,"note":"押一付三"}' curl -s -X POST http://127.0.0.1:8000/entries \ -H "Content-Type: application/json" \ -H "X-API-Token: $API_TOKEN" \ -d '{"category":"书籍","amount":-89,"note":"技术书两本"}'预期每条返回一个自增 ID:
{"id":1} {"id":2} {"id":3}查询汇总:
curl -s http://127.0.0.1:8000/summary -H "X-API-Token: $API_TOKEN"预期输出:
{"count":3,"total":8711.0}现在做最关键的一步验证——确认数据库里存的是密文而不是明文:
sqlite3 ./data/ledger.db "SELECT id, category, amount_enc FROM entry LIMIT 2;"你会看到类似这样的结果:
1|工资|gAAAAABm...很长的一串密文... 2|房租|gAAAAABm...很长的一串密文...category是明文,amount_enc和note_enc是密文。这个设计是有意为之:分类字段的取值集合很小,加密之后反而没法做聚合统计;金额和备注才是真正敏感的部分,必须加密。如果你的威胁模型要求连分类都不能暴露,可以再加一个字段加密,代价是所有统计都要在应用层做。
最后验证备份还原。先停服务,把数据目录改个名,再从备份还原:
docker compose down mv data data.bak mkdir data tar -xzf backups/ledger-最新时间戳.tar.gz -C data docker compose up -d curl -s http://127.0.0.1:8000/summary -H "X-API-Token: $API_TOKEN"如果汇总结果和还原前一致,说明备份链条是通的。这一步一定要真的做一次,很多人是等到出事那天才发现备份脚本里的路径写错了。
7. 常见问题与排查思路
自托管的价值在于可控,代价在于每一步都可能出问题。下面这张表是实际部署中最容易碰到的几类。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 容器启动后立刻退出 | LEDGER_KEY或API_TOKEN未配置 | docker compose logs --tail=100 | 检查.env是否存在、是否被.gitignore误删、env_file路径是否正确 |
| 密钥格式非法报错 | 密钥被换行符或引号污染 | 打印密钥长度,正常为 44 个字符 | 重新生成密钥,粘贴时不要带空格和引号 |
| 重启后数据消失 | 未挂载数据卷,数据写在容器内 | docker inspect查看 Mounts | 确认volumes中./data:/data生效 |
| 401 token invalid | 请求头名称或值不匹配 | 对比curl -H与代码里的 Header 名 | Header 名固定为X-API-Token,注意大小写由框架归一化,值要和.env完全一致 |
| 外部设备访问不到 | 端口只绑定了回环地址 | ss -lntp看监听地址 | 加反向代理暴露,或用 SSH 隧道转发,不要直接把端口绑到0.0.0.0 |
| 密文解密失败 | 换了密钥但仍读旧库 | 查看报错是否为 InvalidToken | 恢复原密钥,或写迁移脚本先解密再重新加密 |
| 备份文件为空 | 数据目录路径不对 | 解压后ls -al看内容 | 备份脚本用-C "$DATA_DIR" .打包,注意相对路径 |
| 磁盘占用持续增长 | 备份未设置清理策略 | du -sh backups | 检查KEEP_DAYS是否生效,find的-mtime写法是否正确 |
重点说两个最容易踩的坑。
第一个是时区。示例里用的是datetime.now(timezone.utc),存的是 UTC 时间。如果你直接在页面上看到时间对不上,不要急着改代码,先确认展示层有没有做时区转换。很多自托管项目的时间问题都出在“存的是 UTC,显示时忘了转”,排查时容易误判成写入错误。
第二个是密钥轮换。Fernet 本身支持通过MultiFernet做密钥轮换,但在个人场景里更简单的做法是:换密钥前,先用旧密钥把数据全部导出成明文 JSON,再清空表、用新密钥写回。这个操作一定要在测试环境演练过再动生产数据,并且操作前先做一次完整备份。在没有备份的情况下直接改.env里的密钥,等于把历史数据永久锁死。
8. 最佳实践与工程建议
跑通只是起点,下面这些建议决定了这套东西能不能长期用下去。
密钥管理独立于仓库。不要把密钥和代码放在同一个位置,也不要只存一份。我的做法是.env里放一份供容器读取,另有一份离线保存的副本(纸质记录或本地加密文件)。这里的关键不是用多复杂的手段,而是确保“单点丢失不会导致永久失读”。
备份要分层次。本地 tar 包解决误删,异地副本解决磁盘损坏,版本化存储解决“发现数据被改坏了想回退”。个人场景至少做到前两层:本地每日备份加一份异地同步。备份文件本身也可以再加密一次,尤其是如果你打算放到云存储上。
最小权限与网络暴露面。服务默认只绑127.0.0.1,需要外部访问时用反向代理加 HTTPS 加基础认证,并且限制来源。任何需要开放端口的改动,都建议先在测试环境跑一遍,确认认证生效后再上生产。涉及账号、密码、密钥的操作,务必在合法授权范围内进行。
日志里不要出现敏感数据。示例代码里没有打印任何金额或备注,这是刻意的。实际开发中很容易在调试时print(payload),上线后忘了删。建议在日志层面就做好约束,只记录 ID、状态码和耗时。
为迁移留后路。即使你现在用 SQLite,也建议写一个export脚本,把全部数据解密后导出成标准 JSON。这个脚本平时用不到,但当你哪天想换工具时会非常有用。数据主权的核心不是“我现在存哪”,而是“我想走的时候能不能走”。
接口设计留出版本空间。现在的路径是/entries,将来加字段时可以并行提供/v2/entries,而不是直接改原接口。个人项目看起来没必要,但如果你已经写了配套的快捷指令或移动端脚本,改接口的成本比想象中高。
不要为了自托管而自托管。这句话放在最后但可能最重要。如果你的数据量很小、迁移需求很弱、又完全不打算维护,那么一个导出功能完善的 SaaS 可能更适合你。自托管的真正收益来自长期使用和数据积累,装完三个月不打开一次,那份“控制感”也只是控制感而已。
9. 从一条记录到一套属于自己的系统
回到最初那句话。它之所以能引起共鸣,是因为它点出了一个很多人隐约感觉到但说不清的东西:在很多关系里,你贡献了价值,但你没有保留凭证。收入是这样,数据也是这样。
技术人处理这个问题的方式,不应该是情绪对抗,而是把抽象的所有权变成可验证的工程指标。能不能导出、能不能离线跑、能不能证明只有自己看得到、停掉订阅之后还剩什么——这四个问题想清楚了,选择哪个工具反而是次要的。
这篇里给的是一个最小可行的样本:六十多行接口代码,一个 SQLite 文件,一层字段加密,一个十四天的备份轮转。它不漂亮,也没有移动端,但它满足了一个很朴素的条件:数据在我自己的磁盘上,密钥在我自己手上,我随时可以把它打包带走,也可以随时把它删干净。
接下来可以往下走的方向有几个。想补前端的话,用 FastAPI 自带的/docs起步,改成最简单的静态页面就够用;想做多维统计,可以在 SQLite 上加视图,或者把数据定期同步到本地的一个分析脚本里;想进一步提升安全性,可以研究一下把密钥放到独立的密钥管理服务里,而不是放在.env。至于更复杂的多设备同步和冲突合并,那是另一个量级的问题,等真正需要时再解决。
最后一个提醒:不管你选了哪套方案,动手之前先确认三件事——机器是你有权操作的、端口开放范围是最小的、备份是能还原的。这三条守住,剩下的都是可以慢慢磨的细节。