# 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 管「怎么做」,本文管「为什么这么做、踩了什么坑」;若细节随演进不一致,以仓库内文件为准。 > 文中命令均使用占位符(``、``、`sha256:`、`` 等), > 不含任何真实令牌、私钥或机器标识;`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;本文不写个人绝对路径,部署目录统一以 `` 代指。注意 `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) └─ 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,再由 `` 里的 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//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//polaris:`, 镜像与提交一一对应、可追溯;不用 `latest`,避免「部署的到底是谁」。 3. **部署固定 digest 而非 tag**:tag 理论上可被覆盖,digest 是内容寻址、不可变; 部署侧 `.env` 写 `@sha256:`,回滚也就是「换回旧 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 -t && docker exec nginx -s reload ``` **教训**:413 不一定是应用问题,先看反代;registry 场景里 `client_max_body_size` 要按「最大镜像层」而不是「镜像总体积」估算。 ### 3.3 推送成功:digest 是什么、不是什么 push 成功后日志末尾给出 manifest digest(本文一律以占位 `sha256:` 表示)。 两个容易误解的点: - **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 # 交互输入只读 token # ② 准备部署文件(仓库 ts/web/nextjs/polaris/deploy/ 拷到服务器,见 README) cd cp .env.example .env # ③ .env 里固定 digest(同时注释保留来源 tag,便于溯源与回退): # POLARIS_IMAGE=git.labmem.icu//polaris@sha256: # # 来源:git.labmem.icu//polaris: # ④ 拉取并启动(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 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 -t && docker exec 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:`) | | 宿主机部署 | ✅ 只读 PAT login → `.env` 固定 digest → pull/up → healthy | | 反代接入 | ✅ net-public 内可达 `polaris:3000` → 动态解析反代 → 外部 curl 核验通过 |