feat: 新增备份恢复与公网加固

- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
这个提交包含在:
2026-09-18 08:46:34 +08:00
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+86 -34
查看文件
@@ -13,10 +13,12 @@
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
| 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 |
| 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) |
| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理都归管理员 |
| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理、备份都归管理员 |
| 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 |
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、失败限速、注册限额 |
| 版本 | v1.3.0 |
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 |
| 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 |
| 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** |
| 版本 | v1.4.0 |
| **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) |
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
@@ -38,8 +40,9 @@
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属 |
| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属;**访问日志**写进 `logs/app.log`(waitress 本身不记 access log) |
| **备份与恢复** | 备份走 SQLite **在线备份 API**(不是 `cp`,采集正在写也能拿到一致快照),归档成含所有 `*.sqlite` + `instance.json` 的 zip;可设**周期与保留份数**、管理员下载与**一键恢复**(恢复前自动再存一份当前库)、普通用户**导出本人全部数据**。归档不在 `data/` 卷里,`down -v` 不会连备份一起删 |
| **可安全对外** | 容器层限 `cpus`/`mem_limit`/`memswap`/`pids_limit`/`nofile`;实例层限采集最小间隔、每日时刻数、**采集跨度硬顶 31 天**;**有任务在跑时新建任务直接 409**;默认不信任 `X-Forwarded-For`(否则三道 IP 防线全废) |
---
@@ -73,6 +76,7 @@
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出,全部按 `uid` 隔离 |
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者;运行期配置也在库里(`settings` 表,主键 `(user_id, key)`) |
| 加固 | `workbuddy_portal/crypto.py` + `captcha.py` | 凭证静态加密(零第三方依赖手写 ChaCha20);验证码用**自写 PNG 编码器**出图 |
| 备份 | `workbuddy_portal/backup.py` | 在线备份 API 出快照 → zip 归档 → 按份数清理 → 校验 → 整表恢复(含恢复前自动兜底) |
| 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL,`uid` 是第一个条件 |
| 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 |
@@ -123,8 +127,10 @@ docker compose up -d
三条路径跑起来后都是:打开 `http://<IP>:8848` → 用管理员登录 →
**先把密码改掉** → 去「配置管理」粘贴 Cookie。
> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里,
> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
> 数据、日志与备份放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs` /
> `_wb_backups`)里,`docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
> **备份刻意独立成一个卷**:与正本同卷时,一次 `down -v` 会把两者一起带走 ——
> 那正好是最需要备份的时刻。
> **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下,
> 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。
> 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。
@@ -132,23 +138,30 @@ docker compose up -d
> **从旧版本升级不需要手工介入**:`init` 由 `PRAGMA user_version` 驱动,自动迁移且幂等。
> v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密);
> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级)。
> 两次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级);
> v1.3.0 → v1.4.0 只加一列(`users.session_ver`)与一张新表(`backups`),
> **无数据搬运**。三次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
### 第一次使用必做
**管理员:**
1. **改密码**——局域网可访问,默认密码等于没锁门(「个人中心」或「配置管理 → 修改密码」)。
2. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效
(这是**实例级**的,对全站账号生效)。
3. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。
1. **拿到并改掉初始密码**——`WB_ADMIN_PASSWORD` 留空时程序会生成**随机**口令,
只在启动日志里打印一次(`docker compose logs portal | grep -A6 管理员初始口令`)。
**没有 `admin123` 这类默认口令了**,拿不到就去查看日志。
2. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。
3. **确认调度与限制**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻;
同时确认「每日时刻上限」「采集最小间隔」「单次最长跨度」三个刹车(都是**实例级**的,
对全站账号生效)。
4. **确认自动备份**——「备份管理」页看状态与保留份数;建议点一次「立即备份」,
并**把一份归档下载到容器之外**(备份留在同一台机器上只防「改错了」,不防「机器没了」)。
**普通账号(注册进来的默认身份):**
1. **改密码**——「个人中心 → 修改登录密码」。
1. **改密码**——「个人中心 → 修改登录密码」(改完其他设备上的登录会立刻失效)。
2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。
3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。
4. 想把自己的数据带走?「个人中心 → 导出我的全部数据」。
### 想给同事开账号?
@@ -181,6 +194,9 @@ docker compose up -d
| `fill-prompt [-u 账号]` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
| `export-csv [路径] [-u 账号]` | 导出 CSV(默认 `data/exports/usage_records_<账号>.csv`,文件名带归属) |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL |
| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → 一个 zip),并按保留份数清理最旧的 |
| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间),`--prune` 顺便按保留份数清理 |
| `restore <文件名> --yes` | **从备份恢复**(整表替换)。破坏性操作,必须显式 `--yes`;不加只打印将要发生什么。恢复前会自动把当前库另存一份 |
| `stats [-u 账号]` | 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细 |
| `users` | 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP |
| `status` | 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
@@ -227,9 +243,11 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图(
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | ✅ |
| `/tasks` | **任务管理**:手动采集与按区间补采、运行历史。**调度开关与时刻对普通账号是只读的** | ⚠️ 只读调度 |
| `/config` | **配置管理**:自己的 Cookie / UA(**唯一可改**)+ 只读的采集参数 + 维护动作;底部实例级设置区 | ⚠️ 仅凭证可改 |
| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码;点右上角用户名进入 | ✅ |
| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码(其他设备会话立即失效)、**导出我的全部数据**;点右上角用户名进入 | ✅ |
| `/profile/export` | **导出本人全部数据**(zip:使用记录 / 采集历史 / 操作审计 / 我的账号与配置;**不含 Cookie 明文**),10 秒限速 | ✅ |
| `/logs` | **日志管理**(**仅管理员**):全实例采集详情(含 `[warn]`/`[error]` 原文)、应用日志尾部、操作审计 | ❌ 403 |
| `/users` | **用户管理**(**仅管理员**):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 | ❌ 403 |
| `/backups` | **备份管理**(**仅管理员**):份数/占用/自动备份状态与下次时间、周期与保留份数设置、列表(下载 / 恢复 / 删除) | ❌ 403 |
![概览](docs/images/01-overview.png)
@@ -264,6 +282,11 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图(
| GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 |
| GET | `/logs/tail` | 应用日志尾部 | **仅管理员** |
| GET | `/records/export` | 按筛选流式导出 CSV | 登录(只导自己) |
| GET/POST | `/api/backups` | 列出备份(含大小/条数/来源/是否存在) / 立即打一份 | **仅管理员** |
| GET | `/api/backups/<filename>` | **下载**一份归档(zip) | **仅管理员** |
| POST | `/api/backups/<filename>/restore` | **从该归档恢复**(整表替换;`include_instance` 决定是否连密钥一起回滚) | **仅管理员** |
| POST | `/api/backups/<filename>/delete` · `/api/backups/prune` | 删除一份 / 按保留份数清理 | **仅管理员** |
| GET | `/profile/export` | 导出**本人**全部数据(zip,不含 Cookie 明文) | 登录 |
---
@@ -274,16 +297,16 @@ workbuddy-portal/
├── manage.py 统一 CLI(唯一入口)
├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖)
├── Dockerfile 多阶段构建(依赖层 / 运行层)
├── docker-compose.yml 单服务编排(数据放 Docker 命名卷)
├── docker-compose.yml 单服务编排(数据 / 日志 / 备份各一个命名卷 + 容器资源上限)
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
├── .env.example 环境变量样例
├── .env.example 环境变量样例(含资源上限与反代开关)
├── docker/
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
│ └── healthcheck.py 标准库健康检查(免登录页 /login)
├── docs/ 文档 + images/(手册配图,合成数据)
├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/
├── logs/ 【运行时】app.log(滚动 2 MB × 3)
├── backups/ 数据库快照统一落点(整目录被 git 忽略)
├── backups/ 归档落点(**刻意不在 data/ 里**,整目录被 git 与 docker 忽略)
├── tools/
│ ├── smoke.py 离线回归(215 项断言)
│ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号)
@@ -300,13 +323,14 @@ workbuddy-portal/
├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头
├── client.py 云端接口(urllib)+ 编辑器凭证读取
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑)
├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑 + 自动备份)
├── query.py SQL 聚合层(uid 必填)
├── backup.py 备份 / 恢复(在线快照 → zip → 份数清理 → 校验 → 整表恢复)
└── web/
├── views.py 页面路由(含 /login /register /captcha.png /profile)
├── views.py 页面路由(含 /login /register /captcha.png /profile /backups)
├── api.py JSON API
├── templates/ base / login / register / profile / overview / tasks /
│ config / logs / records / users / error
│ config / logs / records / users / backups / error
└── static/
├── css/app.css 统一设计令牌
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
@@ -316,6 +340,9 @@ workbuddy-portal/
> `backups/` **刻意不放在 `data/`**:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉——那正好是最需要备份的时刻。
> 容器里它挂的是独立的 `wb_backups` 卷;**绝不用绑定挂载**(Windows 9p 下会让容器
> `unable to open database file`,且不自愈)。该目录同时被 `.gitignore` 与 `.dockerignore` 忽略:
> 归档里含 `instance.json`,进了镜像就等于把整库密钥分发给所有能拉镜像的人。
>
> 工作区根下另有一个 `legacy-v1/`(v1.0 单文件版归档,仅作 `migrate-csv` 的来源,可安全删除),
> 它**在仓库之外**,所以含真实数据的 CSV 不会被提交。
@@ -342,22 +369,27 @@ workbuddy-portal/
## 安全须知
局域网可访问 ⇒ 以下每一条都必要:
局域网甚至公网可访问 ⇒ 以下每一条都必要:
- **必须改默认密码**;给同事发**普通账号**(注册出来的默认身份),不要共用管理员
——管理员是能停用别人账号的角色。
- **初始口令不是固定的**(v1.4.0 起):`WB_ADMIN_PASSWORD` 留空时程序生成**随机**口令,
只在启动日志里打印一次、库里只存散列。**没有 `admin123` 这类默认口令可猜**,
代价是「拿不到那行日志就进不去」——首次启动后请立刻 `docker compose logs portal | grep -A6 管理员初始口令`。
给同事发**普通账号**(注册出来的默认身份),不要共用管理员 —— 管理员是能停用别人账号的角色。
- **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。
普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份);
调度频率、采集参数、注册策略、日志、用户管理全部关死。
调度频率、采集参数、注册策略、日志、用户管理、**备份**全部关死。
**页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与
`writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。
- **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量);
`/api/runs/<id>`、`/api/records/<id>` 这类按 id 取的单条接口也带 `user_id` 约束;
`/logs`、`/logs/tail`、`/users`、`/api/users*` 仅管理员。
`/logs`、`/logs/tail`、`/users`、`/api/users*`、`/backups`、`/api/backups*` 仅管理员。
- **不信任 `X-Forwarded-For`(默认)**:全站只有 `security.client_ip()` 一个取客户端地址的
入口。默认直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP。
这一条不改就是**三道 IP 防线同时失效**:实测每次换一个伪造的 XFF,45 次验证码请求全部放行。
- **Cookie 静态加密**:ChaCha20 + HMAC-SHA256(encrypt-then-MAC)密文入库,主密钥在
`data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同)。
`get_settings()` 把加密键一律置空,要明文只有 `db.get_secret()` 一条路——
这样任何「顺手打印全部配置」的代码都带不出凭证。升级时历史明文会被自动加密。
`data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同),
POSIX 上该文件权限收到 `0600`。`get_settings()` 把加密键一律置空,要明文只有
`db.get_secret()` 一条路——这样任何「顺手打印全部配置」的代码都带不出凭证。
- **Cookie 不跨账号回落**:`NO_FALLBACK_KEYS`(`cookie` / `user_agent`)不参与实例级回落,
否则新账号会「继承」管理员的凭证,属于最严重的串号越权。
- **实例级也不存凭证**:`init_db()` 灌默认值时会跳过 `USER_EDITABLE_KEYS` 并显式删除
@@ -365,18 +397,38 @@ workbuddy-portal/
- **验证码先于口令校验**:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号,
在解验证码之前就把字典跑完。答案存服务端 `captchas` 表,**一次性、5 分钟过期、按用途隔离**,
下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。
字模本身带**逐字符随机旋转 / 切变 / 缩放抖动 / 波浪偏移**,同一验证码两次渲染字节差异 **76.9%**。
- **注册受双重限制**:验证码 + 同 IP 每日配额(默认 3 个,可改;`allow_register=0` 可整体关闭)。
- **停用账号立即失效**:`current_user()` 每个请求回查 `users.status`,不必等 12 小时会话过期。
- **停用账号立即失效**:`current_user()` 每个请求回查 `users.status` **与 `users.session_ver`**,
不必等 12 小时会话过期。改密码 / 管理员重置口令 / 停用 / 删除都会让该账号的**所有其他设备上的
会话立刻作废**(改自己密码时保留当前这次会话,否则改完立刻被自己踢下线);
**恢复备份**会让全站所有会话作废。
- **口令有黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)在**设置/修改**口令时拒绝,
登录时不校验 —— 否则会把用老口令的存量用户挡在门外。
- **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `<img src="/logout">` 静默触发)。
前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)——
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。
- **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。
- **登录限速**:按 **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次即锁 10 分钟;
失败计数表有上限与 TTL;验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。
- **登录限速按两个维度、且强度刻意不同**:IP 维度连续失败 5 次**真锁** 10 分钟;
**用户名维度只做秒级递增退避**(封顶 60 秒)——因为「知道用户名就能把对方锁死 10 分钟」
本身就是攻击,而**管理员用户名在导航栏里是公开的**。另有**单来源登录尝试总量**
(40 次 / 5 分钟,**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。
失败提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时。
验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。
- **重操作都有最小间隔**:手动采集 60 秒、CSV 导出 15 秒、导出本人数据 10 秒、
`vacuum` 120 秒、补全 prompt 60 秒。**采集跨度硬顶 31 天**(代码层 `COLLECT_MAX_RANGE_DAYS_HARD`,
改配置也突破不了);**有采集任务在跑时新建任务直接 `409`**。
- **安全响应头**:CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、
`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `Cache-Control: no-store`。
- **不进版本库的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、
`data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`、`logs/`、`.env`(含明文密码)。
**HSTS 只在真的走 HTTPS 时下发**(`COOKIE_SECURE or FORCE_HTTPS`)——纯 HTTP 部署下发会让
浏览器强升 https,表现成白屏,是个很难归因的故障。
- **有访问日志**:waitress 自己不记 access log,本程序补上(写进 `logs/app.log`,
跳过 `/static/` 与验证码图片)。出事无据可查时这一条很值。
- **不进版本库、也绝不进镜像的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、
`data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`(归档里**含 `instance.json`**)、
`logs/`、`.env`(含明文密码)。`.dockerignore` 里 `backups/` 这条是**硬要求**,
Dockerfile 还有一条构建期断言兜底:万一漏了就直接构建失败 ——
镜像层是只读叠加的,靠 `RUN rm` 补救只会多留一个含内容的中间层。
---