Files
lab/note/资源/DevOps/Gitea/Polaris 前端容器化 CI 与部署实践.md
T
labmem fec2a97936 docs(polaris): 记录容器化部署架构与实践
梳理 Gitea CI、容器网络与镜像存储边界。
记录 Registry 推送、手动部署及排障过程。
2026-10-13 00:14:30 +08:00

332 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Polaris 前端容器化 CI 与部署实践
> 复盘 + 可复用教程:记录 Polaris(Next.js 导航站,纯前端、数据为仓库内本地 JSON)
> 在单机服务器上从 0 到「CI 出镜像、人工按 digest 部署上线」的完整过程与踩坑。
>
> **本文位置与定位**:本文位于仓库 `note/`(系统性知识),与代码严格分离。
> 操作步骤的权威版本在 [`ts/web/nextjs/polaris/deploy/README.md`](../../../../ts/web/nextjs/polaris/deploy/README.md)——
> README 管「怎么做」,本文管「为什么这么做、踩了什么坑」;若细节随演进不一致,以仓库内文件为准。
> 文中命令均使用占位符(`<owner>`、`<full-commit-sha>`、`sha256:<digest>`、`<server>` 等),
> 不含任何真实令牌、私钥或机器标识;`git.labmem.icu` / `labmem.icu` 是已公开域名,仅作示例。
## 1. 环境拓扑:单机多角色
一台服务器同时承担多个角色,容器按 Docker 网络分工:同一 bridge 网络内可互通;不同网络的容器不能直接通过彼此的容器名互访,但宿主端口映射等路径仍可能提供访问。
注意这只是宿主本地的网络分层,**不是**公网隔离 / 物理隔离的同义词。
| 层 | 容器 | 网络 | 说明 |
|---|---|---|---|
| web | nginx | 仅 `net-public` | 对外入口 / 反代,按域名分流 |
| web | gitea | `net-public` + `net-db` | 同时服务 git 与 registry,桥接两网 |
| 应用 | polaris | 仅 `net-public` | 纯前端(本地 JSON),无 DB/Redis;3000 仅容器内 expose |
| infra | PostgreSQL、Redis | 仅 `net-db` | 数据库不暴露公网 |
| CI | gitea act runner | `net-public`(runner 自身) | 镜像 `gitea/runner:3-dind-rootless`;job 容器/rootless daemon 的内部网络是另一套,与宿主的 `net-public` **不是同一个网络** |
几个关键点:
- **runner 是 rootless dind**:`gitea/runner:3-dind-rootless` 内跑一个非 root(uid 1000)
的 Docker daemon,socket 为 `unix:///var/run/user/1000/docker.sock`,再挂进 job 容器的
`/var/run/docker.sock`;数据落在 `runner-docker` 卷里。CI 里只装 Docker CLI,
`DOCKER_HOST` 不用设,但**这个 daemon 不是宿主机的 Docker**(见第 4 节,这是最容易误解的一点)。
- **网络最小化**:Polaris 只需要被 nginx 反代,所以只加入外部网络 `net-public`;
`net-db` 里是 PG/Redis,前端用不到,**不连**——网络面越小越安全。
- 账号与路径约定:服务器操作 shell 实际为 root;本文不写个人绝对路径,部署目录统一以
`<deploy-dir>` 代指。注意 `docker login` 与 `docker compose` 需在**同一宿主账号**下执行——
登录凭据存在该账号的 `~/.docker/config.json`,换了账号就读不到私有镜像。
### 1.1 网络 / 流量总图(纯文本,等宽字体下查看)
```text
外部访客(浏览器 / git 客户端 / docker CLI)
│ 只见宿主 80 / 443 / 222;80 仅重定向到 HTTPS
▼
═══════════════════════════ 宿主机(单机) ════════════════════════════
[nginx](net-public,TLS 终结)── 按 Host 域名分流
├─ labmem.icu ──────HTTP──► [polaris :3000](net-public)
│ └ 3000 仅容器内 expose,宿主无映射
└─ git.labmem.icu ──HTTP──► [gitea](net-public + net-db)
(网页 / git http / registry API 同一路径)
宿主 :222 ──► gitea :22(git over SSH,直连容器端口,不过 nginx)
[gitea] ──仅 net-db──► [postgres :5432] [redis :6379]
宿主侧 127.0.0.1:5432/6379 为回环绑定,非公网开放
[runner](net-public,gitea/runner:3-dind-rootless)
└─ 内部 rootless daemon(uid 1000):job 容器跑在其自建网络里
└─ build 出镜像 ──push(HTTPS 经 nginx)──► Gitea packages storage
宿主 Docker daemon(与 runner 内的 daemon 是两个独立进程)
└─ docker login / pull ◄── Gitea registry(HTTPS 经 nginx)
└─ <deploy-dir> compose 按 digest up
net-public / net-db:宿主创建的 external bridge 网络,多个 compose 项目共用
══════════════════════════════════════════════════════════════════════
```
读图路径(与正文对应的主线):
1. 访客页面:`labmem.icu:443` → nginx → `polaris:3000`(仅容器网络内,宿主无 3000 端口)。
2. git / registry:`git.labmem.icu:443` → 同一个 nginx → gitea;
CI 的 push 与宿主的 pull 走的是这同一条 HTTPS 路径,只是方向和客户端不同。
3. git SSH:宿主 222 → gitea 容器 22,直连端口映射,不经 nginx。
4. CI 上行:runner 自身在 net-public,其内部 rootless daemon 为每个 job 建独立网络,
构建出的镜像经 HTTPS(经 nginx)推入 Gitea packages。
5. 部署下行:宿主另一个 Docker daemon 从 Gitea registry pull,再由
`<deploy-dir>` 里的 compose 按固定 digest 启动 polaris。
6. 后端依赖:gitea → postgres/redis 只走 net-db;Polaris 不在其列,前端不直接连 DB。
### 1.2 网络、端口与容器职责
| 容器/进程 | 所在网络 | 宿主端口发布 | 容器端口 | 职责 |
|---|---|---|---|---|
| nginx | net-public | 80、443 | 80、443 | 统一入口:TLS 终结、按域名分流、反代 |
| polaris | net-public | 无(宿主不映射 3000) | 3000(仅 expose) | Polaris 前端;无 DB/Redis 依赖 |
| gitea | net-public + net-db | 222 → 22 | 22(SSH);HTTP 走 nginx 反代 | git 托管 + 容器 registry(packages) |
| postgres | 仅 net-db | 127.0.0.1:5432 → 5432 | 5432 | infra 数据库(回环绑定,非公网开放) |
| redis | 仅 net-db | 127.0.0.1:6379 → 6379 | 6379 | infra 缓存(回环绑定,非公网开放) |
| runner | net-public(自身) | 无 | — | 执行 CI job;内部 rootless daemon 自建 job 网络 |
| 宿主 dockerd | —(创建/管理上述 external 网络) | 发布 80、443、222 | — | 运行全部 compose 项目;与 CI 内 daemon 相互独立 |
补充说明:
- `net-public` / `net-db` 是**宿主创建的 external bridge 网络**(compose 里 `external: true`,
多个 compose 项目共用);名字里的 public / db 只是用途约定,**不等于**公网或物理隔离。
- 示意 CIDR:当时截图 net-db ≈ `172.22.0.0/16`、net-public ≈ `172.23.0.0/16`;
实地以 `docker network inspect net-public net-db` 为准,本文不硬编码。
- 回环绑定(127.0.0.1)意味着 PG/Redis 的宿主端口只服务本机管理,不经公网可达;
宿主防火墙规则未核验,但不影响回环绑定本身的限定。
- 面板自带的 `1panel-network` 与默认 bridge 属既有背景,与 Polaris 主链路无关;
其网关 / 路由规则不在本文展开,也不猜测。
### 1.3 两个 Docker daemon 与持久化落点
| Docker daemon | 跑在哪 | 负责什么 | 数据 / 持久化落点 |
|---|---|---|---|
| 宿主 dockerd | 宿主机 | web / infra / polaris 各 compose 项目 | gitea:`/data` → 项目目录下 `./data/gitea`(packages 在其内部的精确路径依 Gitea 配置而定,不写死、也不复制物理文件);postgres / redis:bind 挂载的数据目录;polaris:无卷 |
| runner 内 rootless daemon | runner 容器内(uid 1000,`unix:///var/run/user/1000/docker.sock`) | CI job 的 build / run / 冒烟 | `runner-docker` 卷(镜像与构建缓存),与宿主 daemon 互不相通 |
镜像如何从 CI daemon → Gitea packages → 宿主 daemon 的完整旅程见第 4 节。
> 本节图表的信息来源:用户提供的各 compose 定义与实际上线反馈
> (流量路径、端口发布、健康状态均为已验证行为);
> 物理存储内部布局与防火墙规则未逐一核验。
## 2. CI 流水线:verify 之前不出镜像
流水线定义在 [`.gitea/workflows/polaris-ci.yml`](../../../../.gitea/workflows/polaris-ci.yml),
两个 job 串联:
```
push main(路径过滤:ts/web/nextjs/polaris/** 或 workflow 本身)
└─ verify job(node:22.23.2-bookworm 容器)
lint → typecheck → test → next build(standalone)
→ 备齐 standalone 产物 → 起本地进程冒烟(/、分类页、静态资源、/_next 资产)
└─ publish job(needs: verify,仅 main 分支 push 触发)
装 Docker CLI(仅 CLI,无宿主 socket)
→ 解析镜像引用 git.labmem.icu/<owner>/polaris:${GITHUB_SHA}
→ docker build
→ 对镜像本身冒烟(docker run + 容器内 node fetch 探测)
→ docker login(secrets)→ push → 从 push 日志解析 digest 并校验格式
```
设计动机,值得复用的三条:
1. **校验不过不出镜像**:`publish` 依赖 `verify`,坏代码最多到测试为止,永远进不了 registry。
2. **tag 用完整 commit SHA**:`git.labmem.icu/<owner>/polaris:<full-commit-sha>`,
镜像与提交一一对应、可追溯;不用 `latest`,避免「部署的到底是谁」。
3. **部署固定 digest 而非 tag**:tag 理论上可被覆盖,digest 是内容寻址、不可变;
部署侧 `.env` 写 `@sha256:<digest>`,回滚也就是「换回旧 digest」。
三个工程细节:
- 镜像冒烟复用了与 verify 相同的 node fetch 探测思路(镜像内没有 curl),且用 `docker run -d`
返回的**唯一容器 ID**,避免共享 runner daemon 上并发跑撞名。
- 登录凭据放在 `mktemp -d` 的临时 `--config` 目录里,step 结束即销毁,
不落 job 容器的 `~/.docker/config.json`。
- digest 解析脚本带格式校验(`sha256:[0-9a-f]{64}`),解析不到就**失败退出**并提示
去 registry 界面人工确认——宁可失败,也不把一个没核对过的引用输出成「部署参考」。
## 3. 踩坑复盘:两次失败,两种典型
### 3.1 第一次失败:缺 registry 凭据(secrets 未配置)
现象:`publish` 的登录步骤直接报缺少
`secrets.POLARIS_REGISTRY_USER` / `secrets.POLARIS_REGISTRY_TOKEN`。
解决(一次性,管理员操作):
1. Gitea 个人设置 → 应用(Applications)→ 新建 token,权限勾选 **package 写**;
2. 仓库 → 设置 → Actions → Secrets,分别新增:
- `POLARIS_REGISTRY_USER` = 登录用户名
- `POLARIS_REGISTRY_TOKEN` = 上一步的 PAT
3. 重跑失败的工作流。
**教训:凭据要按用途分清(四类)。** 这台服务器上至少有四种长得很像的凭据,用途完全不同:
| 凭据 | 用途 | 给谁用 |
|---|---|---|
| package 写权限 PAT | 推送镜像 | 只进 Gitea Actions secrets |
| 只读(拉取)PAT | 拉取镜像 | 只在宿主机 `docker login` |
| runner 注册令牌 | 注册 act runner | 属 runner 部署配置;注册令牌可长期保留在 runner 的 compose env 中持续生效 |
| SSH 部署密钥 | git 拉取 / 服务器登录 | 与 registry 无关 |
不要把 SSH 部署密钥或 runner 注册令牌当成 registry token 填进 secrets——
名字对不上时登录直接失败;名字碰巧对得上的凭据更隐蔽:权限错位、行为怪异,排查更绕。
### 3.2 第二次失败:413 PUT /v2/blobs/uploads
现象:只出现在 CI 的 push 步骤日志里——推送输出报 `413`(`PUT /v2/blobs/uploads/...`)。
服务端侧当时的日志并未留档,判断依据是反代默认 `client_max_body_size 1m` 远小于镜像层体积。
原因:registry 流量走 gitea 站点前面的 nginx,`client_max_body_size` 默认只有 1m,
而镜像层动辄上百 MB。
解决:在 **Gitea 站点对应的 server 块**放开限制(按最大单层留余量,这里给 1g):
```nginx
server {
# ... 监听/TLS/gitea 反代配置保持不变 ...
client_max_body_size 1g;
}
```
```bash
# nginx 运行在容器里,用 docker exec 操作,不是宿主机命令
docker exec <nginx容器名> nginx -t && docker exec <nginx容器名> nginx -s reload
```
**教训**:413 不一定是应用问题,先看反代;registry 场景里
`client_max_body_size` 要按「最大镜像层」而不是「镜像总体积」估算。
### 3.3 推送成功:digest 是什么、不是什么
push 成功后日志末尾给出 manifest digest(本文一律以占位 `sha256:<digest>` 表示)。
两个容易误解的点:
- **digest 对应的是 manifest**。push 日志里 digest 旁边的 `size` 是 manifest JSON 本身的
大小(几 KB),**不是镜像总体积**;镜像体积是各层 blob 之和,另看各层输出。
- **tag 是可变的名字**:SHA 命名的好处是便于追溯,但并不阻止同名 tag 被重推覆盖;
一旦重推,tag 指向新内容,digest 随之改变。真正锁定内容的是 digest——
部署记录里应同时留 tag(溯源)和 digest(部署事实)。
## 4. 三个 Docker/存储的边界:CI 里的镜像 ≠ 宿主的镜像
这是本次实践里最值得写下来的一条认知修正
(网络 / 端口视角见第 1 节架构图;本节只看镜像与数据在三个 daemon/存储之间的旅程):
```
CI job 里的 docker build/push Gitea 服务端 宿主机
┌────────────────────────┐ push ┌──────────────────┐ pull ┌──────────────┐
│ runner 的 rootless │ ───────→ │ packages storage │ ───────→ │ 宿主 Docker │
│ daemon(runner-docker │ │(由 Gitea 管理) │ │ (另一个 daemon│
│ 卷,uid 1000) │ │ 存储布局随配置, │ │ 独立拉取) │
└────────────────────────┘ │ 不必关心精确路径 │ └──────────────┘
└──────────────────┘
```
- CI 构建出的镜像只存在于 **runner 的 rootless daemon**(`runner-docker` 卷)里,
宿主机 `docker images` 看不到它,这是设计使然而非故障;
- push 成功后,镜像的权威副本由 **Gitea packages storage** 管理(存储后端与磁盘布局
随 Gitea 配置而变,不需要也不应写死具体路径);
- 宿主机 `docker pull` 是**第三个独立 daemon** 从 registry 的一次全新拉取。
所以「CI 构建成功」和「宿主机上能跑」是两件事,中间的桥梁就是 registry + digest。
## 5. 服务器手动部署(第一阶段:人工、按 digest)
第一阶段刻意保持**手动部署、非自动**:CI 只负责出镜像,上不上线由人决定。
```bash
# ① 宿主机登录 registry —— 用独立的只读 PAT,与 CI 写权限分离
docker login git.labmem.icu -u <registry用户名> # 交互输入只读 token
# ② 准备部署文件(仓库 ts/web/nextjs/polaris/deploy/ 拷到服务器,见 README)
cd <deploy-dir>
cp .env.example .env
# ③ .env 里固定 digest(同时注释保留来源 tag,便于溯源与回退):
# POLARIS_IMAGE=git.labmem.icu/<owner>/polaris@sha256:<digest>
# # 来源:git.labmem.icu/<owner>/polaris:<full-commit-sha>
# ④ 拉取并启动(compose 顶层 name: polaris,单服务,仅 net-public)
docker compose pull polaris
docker compose up -d polaris
docker compose ps # health: starting → healthy
docker inspect -f '{{.State.Health.Status}}' polaris
# ⑤ 服务连通性(net-public 内,从 nginx 侧验证服务名可达)
docker exec <nginx容器名> wget -qO- http://polaris:3000/ >/dev/null && echo ok
```
compose 侧的约束(详见 [`deploy/compose.yaml`](../../../../ts/web/nextjs/polaris/deploy/compose.yaml)):
镜像 digest 必填(`${POLARIS_IMAGE:?...}`)、不映射宿主端口、无卷/secrets 挂载、
healthcheck 用镜像内现成的 node 原生 fetch 探测 `/`(不引入 curl);
应用本身在镜像里就是非 root 的 `node` 用户、监听 3000
(见 [`Dockerfile`](../../../../ts/web/nextjs/polaris/Dockerfile) 的 runner 阶段)。
## 6. Nginx 反代接入:从占位首页到动态解析
背景:`labmem.icu` 的 HTTPS 站点此前只是一张占位首页;80 端口的重定向保持不变。
接入 Polaris 时改的是该站点的 `location /`:
```nginx
# labmem.icu 站点内(示意;域名/证书路径以实际配置为准)
location / {
# Docker 内嵌 DNS;变量式 proxy_pass 才会走 resolver 动态解析
resolver 127.0.0.11 valid=10s ipv6=off;
set $polaris_backend polaris:3000;
proxy_pass http://$polaris_backend; # 变量式:运行时经 resolver 动态解析
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
为什么用动态解析:静态 `proxy_pass http://polaris:3000;` 在 nginx 载入配置时只解析一次,
polaris 容器每次重建 IP 都可能变化,静态写法会 502;变量 + `resolver`(Docker 内嵌 DNS
`127.0.0.11`,`valid=10s` 缓存十秒,`ipv6=off` 关掉 AAAA 噪声)让 nginx 跟着容器重建走。
收尾三步:
```bash
docker exec <nginx容器名> nginx -t && docker exec <nginx容器名> nginx -s reload
curl -I https://labmem.icu/ # 外部路由核验:应返回 Polaris 首页响应
curl -s https://labmem.icu/categories/dev/ -o /dev/null -w '%{http_code}\n' # 分类页(应用开了 trailingSlash)
```
注意证书生命周期:续期(如 certbot renew)后需要 reload nginx 才会生效;
这与容器重建是两件独立的事,都要纳入巡检。
## 7. 运维边界、风险与展望
- **回滚**:`.env` 换回上一个 digest(建议平时就把旧 digest 留成注释行)→ `pull + up -d`;
首发失败且无旧版本可回退时,直接 `docker compose down`(只影响 polaris 项目,
不碰 nginx/gitea/PG/Redis 所在项目)。
- **停机风险**:`up -d` 是停旧建新,有秒级中断;第一阶段单实例、不做零停机承诺,
换版尽量选低峰。
- **凭据维护**:宿主机 `~/.docker/config.json` 是 base64 明文,注意账号权限,
不用时 `docker logout git.labmem.icu`;只读 PAT 泄露面小,但也要有轮换预案。
- **磁盘维护**:每个 commit 一个镜像 tag,Gitea packages 会持续增长,
定期在 packages 界面清理旧版本;runner 的 `runner-docker` 卷同样会积累构建缓存与悬空镜像,
需要周期性清理——两边都不要按「镜像总体积」估算磁盘,按层与历史版本算。
- **安全红线**:不要为了「自动化部署」把宿主机 Docker socket 挂给 CI/runner——
那等于交出宿主机 root;runner 已用 rootless dind 隔离,保持这个边界。
- **展望(未实施)**:后续可探索「CI 通过受限通道触发部署脚本」之类的自动化,
但第一阶段保持人工按 digest 部署:少一层自动化,少一类事故。
- **net-db 仍不相干**:Polaris 目前没有后端依赖;将来若接入真实数据源,
再单独评估网络与凭据设计。
## 8. 时间线小结
| 步骤 | 结果 |
|---|---|
| verify(lint/typecheck/test/build/standalone 冒烟) | ✅ 首次即通过 |
| publish:build + 镜像冒烟 | ✅ 通过 |
| publish:login/push | ❌ 缺 secrets → 配置 package 写 PAT 后修复 |
| push blob | ❌ 413 → gitea 站点 `client_max_body_size 1g` + 容器内 reload 后修复 |
| push 成功 | ✅ 得到 manifest digest(`sha256:<digest>`) |
| 宿主机部署 | ✅ 只读 PAT login → `.env` 固定 digest → pull/up → healthy |
| 反代接入 | ✅ net-public 内可达 `polaris:3000` → 动态解析反代 → 外部 curl 核验通过 |