Kubernetes 备份与恢复 Operator · Postgres 15–17 · PostGIS · MySQL 8.4
每一秒,牢牢守住。
- 恢复到
- 2026-10-10 14:02:37 UTC
- 基础备份
- 20261010T020712Z-4c1e
- 随后重放
- 114
$ tenax restore team/app --at 2026-10-10T14:02:37Z --wait
看它倒带,或者自己在时间窗口上拖动。橙色刻度是每晚的全量备份,青色条带是两次全量之间的变更流。示意的是 prod 策略:每晚全量,7 天窗口。
- 9s将 644 MiB 的 Postgres 数据库恢复到沙箱:下载、解密、恢复并运行校验
- 4.2×存储桶中更小:683 MiB 的物理备份只占 161 MiB
- 72MiB/s4 vCPU 上逻辑全量备份的吞吐量,流式传输,不落临时文件
- 20ms1,000 个数据库、10 万个备份时控制台总览页的 p99
在 k3d 测试环境(4 vCPU,集群内 MinIO)中用 make test-perf 和 make test-ui 测得。数据见仓库中的 docs/PERF.md。
01工作原理五个步骤
从实时变更,到可查询的沙箱。
不在数据库里装代理,也不做卷快照。Tenax 通过网络读取变更,在数据离开 Pod 前完成封装,需要时把任意一秒带回来。
- 01
流式捕获
按计划执行全量备份,同时实时捕获每一次变更:Postgres 用 Tenax 自研的 WAL 接收器,MySQL 用 mysqlbinlog。
WAL · binlog - 02
加密
每个字节都先用 zstd 压缩,再用 age 按你的公钥加密,在 Pod 内一次流式完成,不落临时文件。
zstd → age - 03
存储
并行分片上传到任意 S3 兼容存储桶,每个对象都有 SHA-256,清单最后写入。
*.zst.age · manifest.json - 04
精确到秒恢复
给出一个时间或备份 ID。Tenax 选好基础备份,并把变更重放到那一秒。
--at 14:02:37 - 05
打开沙箱
结果落到一个带 TTL 的私有数据库和只读查询控制台中。在恢复被判定成功之前,你的校验会先运行。
--ttl 6h
02恢复四种目标
恢复到任何地方,不碰生产环境。
一次恢复就是一个普通的 Kubernetes 资源。指定时间或备份 ID,选好目标。Tenax 规划基础备份和要重放的变更,并在判定成功前运行你的校验。
一次性副本
带 TTL、凭据和只读查询控制台的私有数据库,到期自动清理。
--to sandbox --ttl 6h新数据库
先在中转沙箱中恢复并校验,再以新名称复制。绝不覆盖已有对象。
--to new --database app_copy真正回滚
先做一次安全备份,并要求你输入目标名称确认。需要 restorer-admin 角色。
--to in-place --confirm app导出文件
解密并压缩的逻辑导出,通过一小时后失效的链接提供。全程审计。
--to download03演示一分钟
一分钟,从总览到恢复。
在演示数据目录上运行的真实命令和真实控制台:列出所有数据库,查看夜里改了什么,再把它恢复到沙箱。
控制台
清楚两个版本之间改了什么。
每个备份都带有表、行数和大小的清单,每个变更段都带有它所做操作的摘要。所以在恢复之前,你就能看到哪个版本还保留着丢失的行。
- 任意两个备份之间的差异。 表结构变化、行数和每张表的大小。
- 每个 WAL 或 binlog 段的变更摘要。 每张表的插入、更新、删除和 DDL,22:48 的一次 TRUNCATE 一眼就能找到。
- 脱敏预览。 按你指定的列脱敏的示例行,用无法解密数据本身的目录密钥即可读取。
命令行
$ tenax targets TARGET ENGINE ORG/PROJECT/ENV LAST FULL PITR WINDOW STORED HEALTH analytics/events postgres acme/data/staging 2026-10-07 02:11:47Z - 19.1GiB ok auth/users postgres acme/platform/prod 2026-10-07 02:11:06Z 2026-09-23 02:11:06Z → 2026-10-07 18:50:52Z 1.5GiB ok billing/invoices mysql acme/billing/prod 2026-10-07 02:09:03Z 2026-09-23 02:09:03Z → 2026-10-07 18:50:52Z 6.7GiB ok billing/ledger postgres acme/billing/prod 2026-10-07 02:08:22Z 2026-09-23 02:08:22Z → 2026-10-07 18:50:52Z 14.3GiB ok maps/geo postgres acme/maps/prod 2026-10-07 02:10:25Z 2026-09-23 02:10:25Z → 2026-10-07 18:50:52Z 23.3GiB ok shop-dev/orders-db postgres acme/shop/dev 2026-10-07 02:12:28Z - 17.4MiB ok shop/catalog-db postgres acme/shop/prod 2026-10-07 02:07:41Z 2026-09-23 02:07:41Z → 2026-10-07 18:50:52Z 731.0MiB ok shop/orders-db postgres acme/shop/prod 2026-10-07 02:07:00Z 2026-09-23 02:07:00Z → 2026-10-07 18:50:52Z 9.6GiB ok web/wordpress mysql acme/marketing/prod 2026-10-07 02:09:44Z 2026-09-23 02:09:44Z → 2026-10-07 18:50:52Z 607.4MiB ok
$ tenax diff 20261006T020700Z-bd79 20261007T020700Z-6da5 from 20261006T020700Z-bd79 (2026-10-06 02:07:00Z) to 20261007T020700Z-6da5 (2026-10-07 02:07:00Z) 20261007 applied (was 20261006) column orders.gift_note added table audit_log +158,175 rows (+2%) table order_items +62,923 rows (+1%) table orders +15,655 rows (+1%) table coupons −3,120 rows (−100%) table customers +531 rows (+1%) 14 segments in between: 00000001000000A100000000 18:51:32–19:20:32 · 800 commits · audit_log +3348 / ~1116 / −83 · order_items +1327 / ~442 / −33 · orders +330 / ~110 / −8 · customers +11 / ~3 / −0 00000001000000A100000001 19:21:32–19:50:32 · 813 commits · audit_log +3352 / ~1116 / −83 · order_items +1328 / ~442 / −33 · orders +330 / ~110 / −8 · customers +13 / ~3 / −0 00000001000000A100000002 19:51:32–20:20:32 · 826 commits · audit_log +3356 / ~1116 / −83 · order_items +1329 / ~442 / −33 · orders +330 / ~110 / −8 · customers +15 / ~3 / −0
$ tenax restore shop/orders-db --at 20261006T020700Z-bd79 --ttl 6h --reason "coupons truncated" restore orders-db-sandbox-60414a15fc created (sandbox from 20261006T020700Z-bd79, 0 segments) restore shop/orders-db-sandbox-60414a15fc $ tenax sandbox list NAME ENGINE SOURCE OWNER PHASE EXPIRES IN-CLUSTER sbx-7f3a postgres shop/orders-db maria@acme.example Ready 2026-10-08 00:37:32Z tenax-sbx-sbx-7f3a.tenax-sandboxes.svc:5432 sbx-c210 mysql billing/invoices dev@acme.example Provisioning -
来自仓库中演示服务器的输出:运行 go run ./hack/demo,再把命令行或浏览器指向它。
控制台、命令行和 API
一个用 OpenAPI 描述的 JSON API,角色来自 Cloudflare Access。每次写入和每次查询都会被审计。
原生 GitOps
存储、策略、目标和恢复都是 CRD。保留层级、告警和 Grafana 仪表盘随 Chart 一起提供。
经过验证,而非寄望
定时校验恢复会在真实恢复上运行你的 SQL 检查,并把备份标记为已校验。恢复耗时以指标形式导出。
04可靠性崩溃即恢复
永不挂起,kill -9 也不怕。
备份总在深夜以最无聊的方式失败:卡住的挂载、死掉的 Pod、写了一半的文件。Tenax 的设计让这些情况既卡不住它,也冒充不了备份。
- data/part-00001.zst.age
- data/part-00002.zst.age
- data/part-00003.zst.age
- manifest.json
- data/part-00001.zst.age
- data/part-00002.zst.age
- data/part-00003.zst.age
- manifest.json
绝不卡死
不挂载、不用块设备,只通过网络访问数据库。每条数据流都有进度看门狗,每个工具都运行在独立的进程组中。
仅崩溃设计
随时杀掉任何 Pod 都没关系。备份只有在清单最后写入后才算存在,混沌测试会在备份中途杀掉 Tenax 来证明这一点。
数据在 Job 中流动,从不经过控制器。
控制器只负责调度。mover 和 streamer 通过一条流式管道把字节直接送进你的存储桶,restorer 再把它们取回。
对生产温和
一个从不写入的最小权限数据库用户。按主机分配槽位、限制带宽,服务器繁忙时由负载保护推迟备份。
变更流
Postgres 使用复制槽上的 Tenax 自研 WAL 接收器,MySQL 使用 mysqlbinlog。即使数据库很安静,窗口也能延伸到现在。
天生就快
无临时文件的流式管道、并行分片上传、多线程 zstd,以及支撑毫秒级响应控制台的内存数据目录。
轻到你会忘了它的存在。
Tenax 是一个 Go 二进制文件。控制器和 API 只负责调度和读取;数据在完成后即退出的短生命周期 Job 中搬运,两次备份之间集群里不会留下任何重负载。
k3d 测试集群(12 个数据库)在端到端和混沌测试之后由 kubelet 报告的工作集;空闲 CPU 只有几毫核。每个启用时间点恢复的数据库还会运行一个 streamer,流式传输时约占 70 MiB。
05安全源头加密
存储桶里只有密文。
加密在 Pod 内、上传之前完成,使用的密钥 Tenax 并不持有。访问按角色划分,每一次写入和查询都会留下审计记录。
$ head -c 200 data/part-00001.zst.age age-encryption.org/v1 -> X25519 Qm9yZWQ/IE5vdGhpbmcgdG8gc2VlIGhlcmUu… --- 7Hc1kS0zR2QeYc… ÌÞ#ø§ñ¹t¤¢Ý}ϰë։ÿ]Wœ3Ð÷…
每个数据对象都是按你的接收者加密的 age 文件(X25519)。只有清单是明文 JSON,其中不含任何机密。
离开前就已加密
每个对象都在客户端用 age 加密到你的密钥。存储桶永远看不到明文,控制台的目录密钥也读不了数据。
读不了数据的目录密钥
控制台使用独立的目录身份读取清单和脱敏预览,无法解密转储、基础备份、WAL 或 binlog。
端到端校验
清单记录每个存储对象的 SHA-256,每次读取结束时都会核对;清单本身最后通过条件 PUT 写入。
机密不外露
数据库凭据通过密封的内存文件交给子进程,绝不出现在 argv 或环境变量中。日志会遮蔽任何看起来像机密的内容。
角色与审计
通过 Cloudflare Access 登录,分为从 viewer 到 restorer-admin 的四种角色。原地恢复需要最高角色并手动输入确认。
锁定的 Pod
非 root、只读根文件系统、移除全部 capabilities、seccomp 使用 RuntimeDefault。数据路径上的 Pod 只挂载 emptyDir。
06对比定时任务
远不止存储桶里的一份夜间导出。
| 当你需要…… | cron + pg_dump 脚本 | Tenax |
|---|---|---|
| 回到 09:41:27 | 昨晚的导出,最多丢失 24 小时 | 窗口内的任意一秒 |
| 证明备份能恢复 | 在故障中才发现 | 带你的检查的定时校验恢复 |
| 找到还有丢失行的版本 | 恢复好几个逐个查看 | 控制台中的差异和变更摘要 |
| 无风险地查看 | 覆盖到共享的预发环境 | 带 TTL 的私有沙箱 |
| 让存储桶看不到内容 | 服务端加密(如果配置了) | 用你的密钥做客户端 age 加密 |
| 扛住备份中途的节点故障 | 一个看似完整的半截文件 | 清单最后写入之前一切都不算数 |
| 确认它仍在工作 | 毫无动静 | 针对备份逾期、链断裂和延迟的告警 |
07安装约十分钟
从零到可恢复。
安装 Chart
CRD、控制器、API 与控制台、RBAC、告警和仪表盘。
helm install tenax charts/tenax -n tenax-system --create-namespace \ --set image.repository=registry.example.com/tenax/tenax --set image.tag=v0.1.0 \ --set 'targetNamespaces={team-a}'指定存储桶和你的密钥
任何兼容 S3 的存储。用 age-keygen 生成 age 密钥,并离线保存一份副本。
apiVersion: tenax.republic.global/v1alpha1 kind: BackupStore metadata: { name: main } spec: s3: endpoint: https://s3.example.com region: us-east-1 bucket: db-backups credentialsSecret: { namespace: tenax-system, name: tenax-s3 } encryption: ageRecipients: [age1…] # 只放公钥;私钥离线保存
在数据库旁声明一个目标
用 docs/sql/ 中的 SQL 创建 tenax 用户,再把目标提交到 git。
apiVersion: tenax.republic.global/v1alpha1 kind: BackupTarget metadata: { name: app, namespace: team-a } spec: engine: postgres endpoint: { host: postgres, port: 5432 } databases: [app] policy: prod # 每晚全量,7 天时间点窗口 credentials: secretRef: { name: tenax-postgres }
特意恢复一次
第一次全量备份会自动开始。然后试试那个你希望永远用不上的操作。
tenax targets tenax restore team-a/app --at 2026-10-07T09:41:27Z --wait tenax query <sandbox> -e "select count(*) from orders"
完整指南、每个 CRD 字段和 REST API 都在文档中(英文)。
08常见问题直接的回答
工程师最先问的问题。
Tenax 支持哪些数据库?
Postgres 15、16、17(含 PostGIS),支持物理或逻辑全量备份以及 WAL 流式传输;MySQL 8.4,支持逻辑全量备份以及 binlog 流式传输。
最远能恢复到什么时候?精度如何?
保留层级中留存的任意全量备份,以及策略所设时间点恢复窗口内的任意一秒。示例 prod 策略保留 7 天窗口,以及 14 个每日、8 个每周、12 个每月和 5 个每年的全量备份。
Tenax 或存储服务商能读到我的数据吗?
存储桶读不到:每个对象在上传前都用 age 按你的接收者加密。控制台用无法解密数据本身的目录密钥读取清单。恢复时使用你提供的 age 身份解密,请保留一份离线副本。
备份进行到一半时 Pod 挂了会怎样?
不会留下任何算数的东西。只有在每个对象都上传并校验完、清单写入之后,备份才存在。半途而废的运行没有清单,因此永远不会被用于恢复。混沌测试套件会在备份中途杀掉 Tenax 来验证这一点。
Tenax 会挂载卷或使用快照吗?
不会。它只通过网络连接数据库,Pod 除了 emptyDir 什么都不挂载:没有 hostPath、FUSE、iSCSI 或块设备,默认也不用 PVC。卡住的存储驱动不会让备份挂起。
Tenax 需要数据库提供什么?
每个数据库一个最小权限、从不写入的 tenax 用户,用 docs/sql/ 中的 SQL 创建。即使没有复制权限,Tenax 仍能做逻辑备份,并告诉你要授予哪些权限才能恢复到任意一秒。
Tenax 是开源的吗?
它以 The Republic Lex Aequa License 1.0 公开源代码。任何人都可以免费使用、修改和再分发,包括在生产环境和商业公司中,用来保护自己的数据。向第三方出售或作为服务提供则保留给版权方。