作品集
从开源 3D 作品集到自动上线:把开发、部署和排错串起来
下载一个开源作品集,在本地看到漂亮的 3D 页面,只是开始。把名字和作品换成自己的,让别人通过域名访问,再让下一次修改自动上线,才会真正遇到开发流程里的各个环节。
这个案例是一座网页里的 3D 展馆:入口连接走廊,走廊通向 About、Gallery、Studio 和 Contact 四个房间。改造它需要处理内容和素材,上线它需要 GitHub Actions、Docker、镜像仓库和服务器,遇到白屏或 502 时,还得知道去哪里找原因。
本文沿着这次个人网站改造的过程,把这些环节串成一条完整路线。即使没有开发经验,也可以先看懂每一步的用途,再结合命令和手绘图解操作。

从本地页面,到一个可以持续更新的网站
可以把这个项目想成一套已经装修好的展馆。房间、灯光和动线都已经存在,但门牌、作品和介绍还是原主人的。第一步是摸清它怎样运转,第二步是换成自己的内容,随后才是开放入口、维护版本。
对应到开发过程,路线是这样的:
看懂开源项目
→ 在自己的电脑运行
→ 改成自己的作品集
→ 用 Git 保存,用 GitHub 托管
→ 自动检查代码与容器
→ 手动触发镜像发布到 ACR
→ 服务器自动部署
→ 浏览器验收
→ 遇到白屏或 502,按证据排查
这条路线里,每一步都有明确的产物:修改后的文件、Git 提交、构建产物、镜像、容器,以及浏览器里真正可用的页面。只要分清这些东西放在哪里,就能知道流程走到了哪一步。
文章分成四部分,可以顺着阅读,也可以在操作时回到对应章节查阅。
| 部分 | 阅读入口 | 解决的问题 |
|---|---|---|
| 第一部分:让作品集属于你 | 01 基础概念、02 本地运行、03 个人化 | 怎样运行项目,怎样找到内容入口 |
| 第二部分:让改动有版本、能构建 | 04 Git、05 CI、06 Docker 与 ACR | 一次提交怎样变成可发布的镜像 |
| 第三部分:把网站交给服务器 | 07 服务器与 Nginx 配置、08 日常发布、09 蓝绿部署 | 首次环境怎样准备,之后怎样自动更新 |
| 第四部分:处理上线后的问题 | 10 白屏、11 Nginx 与 502、12 SEO | 页面异常怎样定位,网站上线后还要补什么 |
| 查阅与回顾 | 13 发布与排错速查、14 参考资料、15 三张总结图 | 把请求、版本和故障对应起来 |
文中代码以 2026 年 9 月的项目实现为例。已有功能包括三个 Actions 工作流、Docker 多阶段构建和蓝绿部署脚本;浏览器自动检查、停旧后的完整回滚保护,以及正式 SEO 配置仍有待完善。后文会在相应位置说明这些边界。
第一部分:让作品集属于你
01 先认识你正在操作的几样东西
1.1 不用先背技术栈,先认清它们的工作
| 名称 | 通俗解释 | 在这个项目里做什么 |
|---|---|---|
| React | 把界面拆成组件的工具 | 组织页面、弹层、状态和交互 |
| Three.js | 浏览器里的 3D 绘图工具 | 画相机、物体、材质与灯光 |
| React Three Fiber,简称 R3F | 用 React 组件组织 Three.js | 把 3D 场景写成可组合的组件 |
| GSAP | 动画工具 | 控制相机、门和界面的过渡 |
| Vite | 开发与打包工具 | 本地启动项目,把源码转换成发布文件 |
| Node.js | 在电脑上运行 JavaScript 的环境 | 运行 Vite、安装工具和构建脚本 |
| npm | 随 Node 常见安装方式提供的包管理工具 | 安装项目依赖,执行 package.json 中的命令 |
| Git | 版本记录工具 | 记录某次修改和对应的提交编号 |
| GitHub | 存放仓库与协作的平台 | 保存远程代码,运行 Actions |
| Docker | 打包与运行应用的工具 | 把 Nginx 和网页产物组成镜像,启动容器 |
| ACR | 阿里云容器镜像服务 | 保存 Docker 镜像,供服务器拉取 |
| Nginx | 接收和转发网页请求的程序 | 宿主机分流;容器内返回网页文件 |
| OSS | 对象存储 | 保存图片等文件,供文章或网页通过地址引用 |
“静态网站”不是说画面不能动。只要最终交付给浏览器的是 HTML、JS、CSS 和素材文件,即使页面里有 3D、相机和动画,也可以通过静态文件服务运行。这个项目的核心体验不需要另外启动一个自建业务后端。
1.2 三台机器、六个操作位置
新手最常见的混乱是:命令本身没错,却在错误的位置执行。
| 标记 | 你实际在哪里 | 适合做什么 |
|---|---|---|
| 本地 · 编辑器 | 自己电脑上的 VS Code 等编辑器 | 找文件、读代码、改内容、查看差异 |
| 本地 · PowerShell | 自己 Windows 电脑的终端 | 安装依赖、开发、构建、Git 操作 |
| GitHub · 网页 | 仓库的 Actions、Settings 页面 | 看自动检查、配置变量、批准发布 |
| 阿里云 · 网页 | 云产品控制台 | 管理 DNS、ACR、服务器、快照、OSS |
| 服务器 · Linux 终端 | 通过 FinalShell 或 SSH 登录后 | 查看 Docker、监听端口、Nginx 配置和日志 |
| 浏览器 · F12 | 打开网站后的开发者工具 | 检查请求、JavaScript 错误、页面节点 |
第三台机器是 GitHub Runner:Actions 临时使用的执行环境。本地 Windows、Runner 的 Ubuntu、云服务器 Linux 是不同的环境。Runner 上的 docker build 不等于在自己的电脑上构建,更不等于已经在生产服务器启动。
本地电脑:编辑代码、试运行
GitHub Runner:按 YAML 自动执行检查、构建、发布任务
云服务器:长期运行网站,接收访客请求
1.3 看懂这几个符号,复制命令就不容易出错
| 符号 | 意思 | 例子 |
|---|---|---|
PS D:\portfolio-itom> |
PowerShell 提示符 | 不要把它复制进命令 |
[user@server ~]$ |
Linux 提示符 | 也不是命令的一部分 |
--name |
一个命令选项 | 给 Docker 容器起名字 |
127.0.0.1 |
当前执行位置的这台机器自己 | 本地浏览器指本地;服务器 curl 指服务器 |
:8080 |
端口 | 同一台机器的一个服务入口 |
生产环境 |
访客实际访问的网站环境 | 不是本地 localhost |
示例输出 |
命令运行后的结果 | 只对照,不执行 |
本文可执行命令尽量采用单行。Windows 的 PowerShell 和服务器上的 Bash 语法不同,不能随意混用换行符、环境变量写法和引号。
例如,改 About 文案要回到本地编辑器,看 CI 红叉要打开 GitHub Actions,查 8081 是否监听则要进入服务器终端。操作前先确认位置,能避开很多看似莫名其妙的错误。
02 先让原项目在本地跑起来
2.1 先拿到完整项目
如果电脑上已经有 D:\portfolio-itom,直接用编辑器打开这个目录。判断是否打开正确:根目录应该能看到 package.json、src、public 和 vite.config.js。
新同学可以在自己的 GitHub 仓库或上游仓库点击 Code,使用编辑器的 Clone Repository / 克隆仓库 功能。下载 ZIP 也能看代码并运行,但 ZIP 不包含原来的 Git 历史;要练习提交和分支,优先克隆仓库。这里不需要新建第二套同名项目。
先读根目录 README.md,再读 .agent/PROJECT.md 和 docs/ARCHITECTURE.zh-CN.md。项目里还有一个 portfolio-itom/ 子目录,它是 Sanity Studio,与这里要运行的 3D 主站是两个入口。
2.2 确认工具与工作区
位置:本地 · PowerShell。 第一条进入项目目录;第二条只查看 Git 状态,不会改文件。
cd D:\portfolio-itom
git status --short
M 表示文件有修改,?? 表示 Git 尚未跟踪。没有输出通常表示工作区没有待记录的改动。也可以打开编辑器左侧的“源代码管理”面板查看。
接下来读取工具版本,确认当前终端能找到它们。这个项目的 Dockerfile 和 Actions 配置使用 Node 22.23.2;复现这套仓库时优先与项目配置一致,不要只按旧文档里的最低版本猜测。
node --version
npm --version
git --version
如果显示“无法识别命令”,先检查对应工具是否安装、安装后是否重新打开终端。不要通过随意修改项目源码来解决工具未安装的问题。
2.3 安装依赖,启动开发服务器
位置:本地 · PowerShell。 npm ci 按 package-lock.json 安装确定的依赖树;它会重建 node_modules,但不应该改动锁文件。当前仓库有锁文件,适合用它复现已有环境。
npm ci
成功证据: 安装完成,退出码为 0。若失败,先看最后一个明确的 npm error,区分网络、文件权限、Node 版本和锁文件不匹配;不要马上删锁文件或升级所有依赖。
安装完成后,下面这条命令运行 package.json 里的 dev 脚本,也就是启动 Vite 开发服务。
npm run dev
浏览器打开终端显示的地址,默认通常是 http://localhost:5173;如果该端口被占用,以终端实际输出为准。终端保持运行,按 Ctrl+C 停止服务。
可手动操作的部分: 编辑器的 npm Scripts 面板也能启动 dev。它背后仍是在运行命令;这是一种更直观的入口。
2.4 第一次走查:先熟悉原来的交互
按“入口 → 走廊 → 四个房间”走一遍:
- About:看首屏、三张卡片和详情弹层。
- Gallery:悬停项目纸卡,观察上色;点击翻面,再看链接。
- Studio:检查内容卡片,区分可跳转与仅展示的信息。
- Contact:区分复制账号、外链和仅展示账号。
- 尝试地图跳转、返回、浏览器前进后退与键盘操作。
这一步的价值是建立基线:知道原来怎样工作,之后才知道自己的修改有没有破坏功能。
2.5 开发能运行以后,再试一次生产构建
build 把开发源码转换成 dist 目录里的发布文件。当前 package.json 中,它会先执行站点和头像动画检查,再执行 Vite 构建。
npm run build
构建通过后,preview 让浏览器读取刚才生成的 dist。这里固定只监听本机 4173;-- 后的参数传给 Vite。
npm run preview -- --host 127.0.0.1 --port 4173
打开 http://127.0.0.1:4173 再走一次关键页面。preview 用于本地检查产物,不是生产部署方案。
走到这里,项目已经经历了安装、开发运行、构建和产物预览。dev 面向修改源码时的即时反馈,preview 用来检查构建后的结果。确认两种方式都能打开页面后,就有了个人化改造的起点。
03 把开源作品集改成自己的内容

3.1 先从“想改什么”找到文件
3D 项目看起来复杂,但大多数个人化需求应该先从数据入口处理。
| 你想改的内容 | 优先打开的文件 | 为什么从这里开始 |
|---|---|---|
| 姓名、品牌、个人摘要、社交行为、站点地址 | src/config/site.js |
站点信息的集中入口 |
| 项目标题、描述、技术栈、经历、Studio 内容 | src/data/portfolioContent.js |
内容数据的集中入口 |
| 入口、房间预热、场景装配 | src/components/canvas/Experience.jsx |
3D 场景入口 |
| 进入哪个房间、返回、地图传送 | src/context/SceneContext.jsx |
场景和虚拟路由状态 |
| Gallery 翻面或绘画效果 | src/components/canvas/rooms/ 下相应房间组件 |
属于交互实现,而不是普通文案 |
| 搜索引擎初始读到的内容 | seo-plugin.js |
构建期生成语义信息 |
| 屏幕阅读器和键盘访问内容 | src/components/ui/ScreenReaderOverlay.jsx |
HTML 无障碍层 |
| 图片、纹理、字体 | public/textures、public/images、public/fonts |
素材与代码是不同文件 |
主调用链可以这样读:
main.jsx → App.jsx → Context 与 Canvas
→ Experience.jsx → 走廊管理 → 房间组件
App 是装配入口,Context 传递共享状态,Canvas 是 3D 绘制区域。初学时先沿这条链找位置,不需要一次读完全部组件。
3.2 用一个小修改学会完整流程
目标:更改个人摘要。位置:本地 · 编辑器。
- 打开
src/config/site.js,找到seo.about。 - 把原摘要改成自己真实的一句话,不改变对象结构。
- 用编辑器全局搜索
siteConfig.seo.about,观察谁在读取它。 - 保存后查看对应页面和语义内容。
- 检查源代码管理中的差异,确认只改了预期字段。
如果 3D 页面没有跟着变,不要立即在多个地方复制同一句话。先搜索旧文案,找到那个场景实际读取的字段。本项目就有这种历史遗留:统一配置已经存在,但 About 的部分可见内容仍来自旧实现。
重点: “配置文件里改过了”只证明数据被改动;“消费它的页面也显示正确”才证明调用链跑通。
3.3 从上游作品集到个人网站,实际改了哪些地方
| 真实改造 | 给初学者的解释 | 当前仍要注意 |
|---|---|---|
身份、SEO、社交行为集中到 site.js |
不用满项目找作者姓名 | 某些旧场景文字仍需逐项核对 |
六个真实项目集中到 portfolioContent.js |
Gallery 和 About 成果可复用同一数据 | 没有链接的作品只展示详情 |
| Gallery 保留原翻面、晾衣绳与悬停上色 | 保留已经工作的交互,替换自己的内容与素材 | 成对线稿/上色图需保持位置对应 |
| Awards 改成产品实践、开发工具、AI 应用成果 | 用真实项目替代不属于自己的奖项 | About 的 Journey、Skills 仍有上游内容 |
| Studio 使用抖音和小红书内容 | 没有提供的日期、数据不补造 | 展示账号不等于已经有作品链接 |
Contact 区分 copy、open、display |
复制、跳转、展示是三种不同交互 | 不应为缺失链接制造空跳转 |
| 中文字体与人物、项目素材适配 | 让中文可读,视觉与个人身份一致 | 图片尺寸、透明边缘和授权要核对 |
3.4 图像为什么不能只改一个文件名
Gallery 的纸卡由“线稿图”和“上色图”配合,悬停时揭示对应位置;如果两张图的构图不同,上色就会错位。人物动画还要保证各帧的画布、身体中心和脚底位置一致,否则播放时会忽大忽小或跳动。
素材修改的可靠顺序是:
先看组件怎样使用图片
→ 找到纹理路径与预加载入口
→ 生成符合尺寸和构图的素材
→ 使用新的文件名接入
→ 检查静态显示与动画
→ 确认旧素材仍可回退
不要批量覆盖 public 中的原素材。代码许可与每张图片的使用许可要分别确认;沿用开源代码不能替代对人物、文案和图片来源的核对。
3.5 外部服务先保持可选
Sanity 提供内容管理,PostHog 提供行为统计,Web3Forms 提供表单接收。当前项目有本地数据回退,没有配置这些服务时也应该能启动和构建。
初学者先把核心站点运行起来,再逐项接入服务。不要为了运行 3D 页面,先把三个平台注册一遍。
Vite 中以 VITE_ 开头并被前端使用的环境变量会进入客户端产物,不能用来保存服务器密码或 SSH 私钥。相关机制见 Vite 环境变量说明。
3.6 改完的验收
位置:本地 · PowerShell。 下列三项分别检查站点约束、静态代码规则和发布构建。build 已包含 check:site,单独列出便于看清每类检查的作用。
npm run check:site
npm run lint
npm run build
记录每个命令的真实结果。原型模式警告、lint 技术债和构建错误是不同情况。之后再人工检查四个房间、中文换行、键盘、触控、前进后退和主要交互。
个人化改造的关键,是让同一份真实内容沿着数据入口到达 3D 卡片、详情和 HTML 语义层。修改后沿这条路径核对一次,再把确认过的结果保存成版本。
第二部分:让改动有版本、能构建
04 用 Git 保存版本,分清 GitHub 与网站部署

4.1 修改过文件,不等于已经有一个版本
| 位置 | 里面是什么 | 下一步 |
|---|---|---|
| 工作区 | 编辑器当前看到的文件 | 选择要记录的改动 |
| 暂存区 | 准备放入下一次提交的内容 | 创建提交 |
| 本地仓库 | 已提交的版本历史 | 推送远程 |
| GitHub 远程仓库 | 别人和 Actions 能获取的版本 | 检查、构建、发布 |
一次提交会生成一个提交编号,通常称为 Git SHA。界面常显示前几位便于阅读;部署脚本要求的是完整 40 位编号。
4.2 先用界面做一次,再认识对应命令
位置:本地 · 编辑器。 在 VS Code 的源代码管理中:
- 点击改动文件,查看修改前后差异。
- 只暂存本次要记录的文件。
- 输入能说明目的的提交信息。
- 创建 Commit。
- 确认远程与分支后 Push。
某些编辑器把拉取和推送合并在“同步”按钮中。点击前看清它会执行哪些操作;多人协作时,不要把不理解的冲突直接覆盖。
如果这次确实只改了个人摘要,等价命令如下。add 选择文件,commit 创建本地版本,push 上传当前分支;最后一步可能触发这个仓库配置好的自动任务。
git add src/config/site.js
git commit -m "更新个人作品集摘要"
git push
成功证据: GitHub 的提交列表能看到这次提交,改动文件与本地核对一致。并非看到 commit 成功就说明已经上传。
4.3 “放 GitHub 还是放服务器”其实有两个问题
一是代码放哪里,二是访客从哪里访问网站。这个项目的实际路线是:
源代码 → GitHub 仓库
检查与构建 → GitHub Actions
Docker 镜像 → ACR
运行网站 → 自己的云服务器
访问入口 → 独立子域名
GitHub Pages 是另一种静态网站托管方式,和“GitHub 保存代码”不是同一功能。这个案例选择自有服务器,是为了复用已有服务器与域名并学习完整部署;不代表所有个人站点都必须用 Docker 和服务器。
域名下也有两种组织方式:
| 方式 | 示例 | 对本项目的影响 |
|---|---|---|
| 独立子域名 | portfolio.example.com |
站点位于根路径,符合当前 /gallery 和 /textures/... 用法 |
| 子路径 | example.com/portfolio/ |
需要同时适配构建 base、素材路径、虚拟路由和代理转发 |
因此当前案例沿用独立子域名。把反向代理加上 /portfolio/ 并不自动完成所有路径适配。
4.4 文件没有上传,先检查它有没有进入版本
本地目录里的文件不一定都属于 Git 版本。.gitignore 决定哪些未跟踪文件默认不纳入提交,常用于排除依赖、临时产物和本地配置;它不会让已经被跟踪的文件自动退出历史。
.dockerignore 则作用于 Docker 构建上下文,决定构建时不发送哪些文件。两份忽略规则服务于不同流程,要分别检查。
因此,确认某项修改是否上线,要依次看提交里有没有它、构建有没有包含它、服务器运行的是不是对应镜像。编辑器里能看到文件,只能说明它存在于本地。
05 CI 是怎样自动检查项目的

5.1 把重复检查写成一张清单
CI 是 Continuous Integration,持续集成。对这个项目来说,就是把“装依赖、检查、构建、启动容器试一下”的步骤写进仓库,让 GitHub 在指定事件发生时自动执行。
三个词先记住:workflow 是整张清单;job 是一组任务;step 是其中的一步。.github/workflows/ 下的 YAML 文件定义这些内容。
5.2 当前三个文件怎样分工
| 工作流 | 配置文件 | 触发方式 | 做到哪里为止 |
|---|---|---|---|
| CI | .github/workflows/ci.yml |
push 到 main、面向 main 的 PR、手动运行 | 检查并试运行容器,不推送 ACR、不部署服务器 |
| Publish ACR Image | .github/workflows/publish-acr.yml |
人工运行,脚本限制 main | 重新检查、构建候选镜像,发布 SHA 与 main 两个标签 |
| Deploy Server | .github/workflows/deploy-server.yml |
Publish 成功后自动;也可手动输入 SHA | 服务器拉镜像、蓝绿部署、公网 HTTP 检查 |
这套方案保留人工发布入口,之后自动部署。CI 变绿不会自行点击 Publish。
关键细节: Publish 并没有读取“某次 CI 已成功”的状态作为程序闸门。先等 CI 变绿是本案例的操作约定;Publish 自己会重新执行检查与构建。若 main 在期间又发生变化,还要核对实际发布的 SHA,不能把两个版本当成同一个。
5.3 看懂最少量的 YAML
下面是 CI 里的触发片段,不是完整工作流。它说的是“什么时候开始”,不是“开始之后执行什么”。
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
workflow_dispatch 提供人工触发入口;workflow_run 可以在另一个工作流结束后触发,但“结束”包括失败,所以 Deploy 还检查 conclusion == 'success'。这些事件的行为见 GitHub 工作流触发文档。
5.4 一次 CI 具体做什么
获取当前提交
→ 准备 Node
→ npm ci 安装依赖
→ 报告 lint 结果
→ npm run build
→ Docker 构建测试镜像
→ 启动临时容器
→ 请求 healthz、首页、gallery、JS 资源
→ 删除本次测试容器
容器冒烟检查的含义是“先做少量关键检查,发现明显故障”。当前检查了健康接口、HTML、文件名哈希和部分缓存/响应头,没有真实执行浏览器中的 React/Three.js。
5.5 为什么红字存在,整个 CI 却可能是绿色
当前 CI 和 Publish 都有这段配置:
- name: Report existing lint debt
continue-on-error: true
run: npm run lint
它明确允许这一步失败后继续执行。原因是项目保留了历史 lint 技术债;不能由此宣称 lint 已经通过。
你需要同时读两层结果:整条工作流是否成功,以及具体步骤是否有被允许继续的失败。
5.6 自己在网页查看结果
位置:GitHub · 网页。
- 打开仓库,进入 Actions。
- 选择 CI,打开与你本次提交 SHA 对应的运行记录。
- 点击任务
Validate site and container。 - 查看第一个失败步骤,展开它最后的明确错误。
- 记录步骤名称、错误原文、提交编号。不要只记“有个红叉”。
| 失败位置 | 先看什么 |
|---|---|
| Install dependencies | Node 版本、锁文件、依赖下载错误 |
| Report existing lint debt | 具体文件与规则;注意当前允许失败 |
| Validate site and build production bundle | 站点约束、语法错误、构建警告 |
| Build Docker image | 基础镜像拉取、Dockerfile、构建上下文 |
| Smoke-test container runtime | 容器日志、端口、健康接口、资源响应 |
CI 给一次提交留下了检查记录。阅读这份记录时,要区分哪些步骤成功、哪些被允许失败,以及哪些根本没有覆盖。这里启动的测试容器运行在 Runner 上;把同一版本送往生产服务器,还需要后面的发布流程。
06 Docker 镜像与 ACR,到底传走了什么

6.1 区分源码、产物、镜像、容器
| 对象 | 类比 | 本项目里的内容 |
|---|---|---|
| 源代码 | 做菜的配方和原料 | src、配置、依赖描述、素材 |
dist |
做好的菜 | 构建后的 HTML、JS、CSS、素材 |
| Docker 镜像 | 标准化的打包成品 | Nginx、它的配置、dist |
| Docker 容器 | 把成品投入使用的一次实例 | 监听端口、响应网页请求的进程 |
| ACR | 成品仓库 | 保存可供拉取的镜像版本 |
把镜像推到 ACR,就像把箱子放进仓库。仓库没有替你把箱子里的应用运行起来。
6.2 Dockerfile 的两段工作
当前 Dockerfile 采用两阶段:构建阶段用 Node 安装依赖、执行 npm run build;运行阶段从 Nginx 镜像开始,只复制配置与构建产物。
下面是当前关键结构的节选,省略了健康检查等行。阅读用途是理解每一段的职责;完整配置以仓库文件为准。
FROM node:22.23.2-alpine3.23 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:1.29.8-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
FROM 开始一个阶段;WORKDIR 指定工作目录;COPY 复制文件;RUN 在构建时执行命令。COPY --from=build 从上一阶段取出成品,避免把开发依赖全部塞进运行镜像。这种分工见 Docker 多阶段构建说明。
EXPOSE 80 描述容器内的服务端口,它不会自动打开服务器防火墙,也不会自动把容器暴露到公网。
6.3 看懂镜像地址
REGISTRY / NAMESPACE / REPOSITORY : TAG
镜像仓库主机 / 命名空间 / 仓库名 : 版本标签
例如,教学占位形式 registry.example.com/student/portfolio:完整SHA 中:
registry.example.com是镜像服务入口,不含https://。student/portfolio是命名空间与仓库。- 冒号后是标签;
完整SHA必须替换成实际的 40 位 Git 提交编号才能用于这个部署脚本。
当前发布工作流同时写入两个标签:完整 SHA 用于追踪具体版本,main 作为方便查找的标签。部署时使用 SHA,可以减少“这次 main 到底指哪个版本”的混淆。
严谨一点: Git 提交编号确定,但镜像标签本身并不天然禁止覆盖。同一 SHA 的镜像如果被重新构建并推送,底层镜像也可能变化。当前代码按 SHA 标签发布;若需要严格锁定同一镜像内容,还要使用 registry 的不可变策略或镜像 digest。这不在当前实现中。
6.4 在本地验证镜像的运行结果
前提: 本机已经安装并启动支持 Linux 容器的 Docker。没有这个环境时,可以先阅读 Actions 中同类步骤,也能理解构建与运行的分工。
位置:本地 · PowerShell,项目根目录。 下列命令根据当前 Dockerfile 构建一个本地镜像;最后的 . 表示当前目录是构建上下文。它不上传镜像。
docker build -t portfolio-lesson:local .
构建成功后,启动一个临时练习容器。这里使用空闲的本地 8090 端口,避免与案例的蓝绿端口混淆。
docker run --rm --name portfolio-lesson-local -p 127.0.0.1:8090:80 portfolio-lesson:local
--rm 表示停止后移除这个临时容器;--name 给它起名字;-p 将本机 8090 映射到容器 80。浏览器打开 http://127.0.0.1:8090 和 /gallery 验证。
关闭练习时,可以在 Docker Desktop 的 Containers 页面找到 portfolio-lesson-local 并停止。命令行等价操作如下,仅作用于这个练习容器:
docker stop portfolio-lesson-local
如果名称或端口已存在,先在 Docker Desktop 确认它是谁,不要随意停止不认识的容器。
6.5 为什么这时候仍然没有上线
本地构建成功:本机有镜像
ACR 发布成功:远程仓库有镜像
服务器 pull 成功:服务器下载了镜像
容器启动成功:服务器上有运行实例
域名访问成功:访客请求能到达它
浏览器交互正常:用户才能真正使用
这些是不同的证据,一步不能代替全部。
把 ACR 标签中的完整 SHA 与 GitHub 提交对应起来,就能从镜像追溯到代码。接下来,需要让服务器拉取这个版本,并为它配置一个可访问的入口。
第三部分:把网站交给服务器
07 首次准备服务器,哪些手动做,哪些只做一次
7.1 先铺好环境,再交给自动化
日常发布只需要一个按钮,是因为管理员事先已经准备了服务器、域名、证书、容器、账户、脚本和凭证。Actions 不会凭空创建这些东西。
以下是学习顺序。涉及启动容器、调整权限和 Nginx 配置的步骤,会改变服务器状态,应先在自己的练习环境完成。已有站点时要先确认它的配置与占用端口,并保留可恢复的备份。
| 顺序 | 操作入口 | 要准备什么 | 到这一步应能证明什么 |
|---|---|---|---|
| 1 | 阿里云网页 | 确认服务器、系统、现有站点,准备备份或快照 | 知道动的是哪台机器、怎样恢复 |
| 2 | 服务器终端 | Docker、实际宿主机 Nginx、监听端口 | 知道当前运行的程序与配置 |
| 3 | ACR 网页 | 镜像仓库、访问入口与凭证 | 知道镜像要发到哪里 |
| 4 | DNS/证书管理界面 | 子域名指向服务器,证书覆盖该域名 | 浏览器能建立正确的 HTTPS 连接 |
| 5 | 服务器终端 | 登录 ACR、拉镜像、启动首次容器 | 服务器本机能访问健康接口 |
| 6 | Nginx 管理界面/终端 | 域名站点规则与 backend include | 公网请求真的转到目标容器 |
| 7 | 本地与服务器 | 部署 SSH 密钥、账户、固定部署脚本及权限 | Actions 能连接并执行预定动作 |
| 8 | GitHub Settings | Variables、Secrets、production 环境 | 工作流具备所需配置 |
| 9 | GitHub 与浏览器 | 发布演练、蓝绿切换与验收 | 不只是脚本完成,页面也可用 |
首次配置可以分成两轮:先确认容器自身能运行,再把域名、HTTPS 和自动部署接上。下面先解释关键关系,从 7.8 开始给出可以逐项执行的服务器步骤。
7.2 先做只读检查
位置:服务器 · Linux 终端。 docker ps -a 列出运行和停止的容器,ss 查看监听端口,ps 查看进程。它们不会启动、停止或删除网站。
docker ps -a
sudo ss -lntp
ps -eo pid,args | grep '[n]ginx: master'
ss -lntp 的几个字母表示监听、数字形式、TCP、进程信息。重点看 80/443、8080/8081 是否占用,以及占用者是不是你要操作的服务。
如果没有权限执行 Docker,先核对管理员给部署账户的授权。不要使用“把 Docker socket 开放给所有用户”这种方式解决问题。Docker 管理权限可以间接取得很高的宿主机权限,不能把加入 docker 组描述成严格的最小权限隔离。
7.3 Nginx 路径必须按实际机器确认
同样叫 Nginx,安装方式不同,程序和配置路径也可能不同。这个项目的适配中出现过两组路径:
| 配置项 | 宝塔安装环境中的示例 | 系统软件包环境中的示例 |
|---|---|---|
NGINX_BIN |
/www/server/nginx/sbin/nginx |
/usr/sbin/nginx |
BACKEND_FILE |
/www/server/nginx/conf/portfolio-resin-backend.conf |
/etc/nginx/snippets/portfolio-resin-backend.conf |
表里的 backend 是项目选择的文件位置,并非 Nginx 安装后自动生成的标准文件。无论采用哪组路径,都要确认实际运行的 Nginx、部署脚本写入的位置,以及站点配置的 include 指向同一套文件。
下面是参数教学示例,NGINX_BIN 必须替换为确认过的完整程序路径;若实际进程带有自定义 -c,检查时还需要使用同一配置文件。
sudo NGINX_BIN -V
sudo NGINX_BIN -t
sudo NGINX_BIN -T
-V 查看编译参数与默认路径,-t 检查配置,-T 检查并打印配置及 include 内容。完整 -T 输出可能包含其他站点信息,只在本机检查,不要直接公开粘贴。
-T 展示的是该命令所读取的磁盘配置,不会自动证明运行中的 worker 已经 reload 到它。要结合实际 master 的程序与启动参数、reload 结果、访问日志和请求验证一起判断。
7.4 容器和公网代理,分两次验证
第一次应先拉取已发布的 SHA 镜像并运行容器,再配置公网入口。不要一边猜容器端口,一边改 DNS 和证书。
在服务器交互式执行 docker login --username 用户名 仓库主机 时,密码由提示输入,不写进命令。随后 docker pull 完整镜像地址 下载镜像,docker run 才启动实例。这里使用文字说明而不填入真实账号和凭证。
如果首次蓝色容器已按案例绑定 8080,下面的只读请求检查容器是否响应。curl -f 会让 HTTP 4xx/5xx 返回失败,-sS 保持简洁同时显示错误。
curl -fsS http://127.0.0.1:8080/healthz
curl -I http://127.0.0.1:8080/gallery
第一条应返回 ok,第二条应有 HTTP 200。但第二条只取响应头,还不能证明 HTML 内容和浏览器运行正常。
域名与证书应分别检查:DNS 的 A 记录指向服务器 IPv4;若配置 AAAA,也要确认 IPv6 路由可用。证书 SAN 要包含实际访问域名;*.example.com 可匹配 portfolio.example.com,不会仅凭通配符自动覆盖根域 example.com 或更深层域名。
7.5 SSH 的两种“钥匙”别混在一起
| 内容 | 放在哪里 | 用来证明什么 |
|---|---|---|
| 客户端私钥 | GitHub 的 DEPLOY_SSH_KEY Secret |
Runner 有权以部署用户登录 |
| 对应公钥 | 服务器部署用户的 authorized_keys |
服务器信任哪个客户端身份 |
| 服务器主机公钥记录 | GitHub 的 DEPLOY_KNOWN_HOSTS Secret |
Runner 连接的确实是预期服务器 |
管理员可以在本地用 ssh-keygen 创建部署专用密钥,把公钥安装到服务器,把私钥存入 Secret。操作前先确认目标文件不存在,避免覆盖现有密钥;具体命令和权限步骤见 7.15。
known_hosts 必须通过可信渠道核对主机指纹后保存。单纯从当前网络扫描到一条记录,还不能证明它属于你预期的服务器。
当前工作流设置 StrictHostKeyChecking yes,不会静默接受陌生服务器。出现主机密钥不匹配时,先确认服务器是否重建或记录是否变动,不要直接关闭检查。
7.6 GitHub 里的变量与密钥清单
位置:GitHub · 仓库 → Settings → Secrets and variables → Actions。 Variables 保存普通配置,Secrets 保存凭证及本案例按敏感信息管理的连接配置。
| 类型 | 名称 | 应填什么 |
|---|---|---|
| Variable | ACR_REGISTRY |
ACR 镜像仓库主机名,不含协议和路径 |
| Variable | ACR_IMAGE |
命名空间/仓库名,不含标签 |
| Secret | ACR_USERNAME |
ACR 控制台提供的镜像登录用户名 |
| Secret | ACR_PASSWORD |
镜像仓库登录凭证;历史案例使用 ACR 固定登录密码 |
| Secret | DEPLOY_HOST |
部署服务器主机名或 IP |
| Secret | DEPLOY_USER |
专用 Linux 部署账户 |
| Secret | DEPLOY_SSH_KEY |
部署专用 SSH 私钥完整内容 |
| Secret | DEPLOY_KNOWN_HOSTS |
已核验的服务器主机公钥记录 |
每次新建时核对名称大小写。Secret 保存后通常不能再读回原文,需要变更时更新值。不要把值写入 Markdown、YAML、前端环境变量或聊天记录。
Deploy 引用了名为 production 的 GitHub Environment。如果该环境另外配置了审批规则,部署可能在此等待审批;仅有 YAML 的环境名称,不等于已经配置了审批人。是否需要额外审批,要到仓库的 Environments 设置中核对。
7.7 固定部署脚本还有一份服务器副本
仓库里的脚本位于 deploy/server/portfolio-resin-deploy。当前 Deploy 工作流实际执行的是服务器上的:
/usr/local/sbin/portfolio-resin-deploy
当前工作流不会自动把仓库脚本复制到这个位置。因此,修改仓库脚本后,除了提交,还需要单独核验、安装服务器副本;只发布新的前端镜像不会更新它。
历史初始化做了这些准备:由管理员安装 root 所有的固定脚本,验证 Bash 语法和文件权限,再用 visudo 验证指定 sudo 规则,最后以部署用户测试调用。不要用开放任意 root 命令的规则代替固定入口。
脚本会检查参数数量、镜像引用格式和 40 位 SHA,但当前并没有把 registry/namespace 与唯一项目目标做严格白名单绑定。不要把格式检查讲成完整的镜像信任校验。
7.8 实操准备:先为这台服务器填一张信息卡
下面把服务器部分展开成可以逐项核对的操作。这里假定已经有可管理的 Linux 服务器,并且安装了 Docker、Nginx。若是空白新机器,先确认发行版,再按对应官方安装方式准备服务;不要把某个系统的安装命令直接套到另一台机器。
操作位置:服务器 · FinalShell。影响:只读。 下面依次查看发行版、Docker 服务端、全部容器和正在监听的端口;每条命令都可以单独执行。
cat /etc/os-release
docker version
docker ps -a
sudo ss -lntp
sudo ps -eo pid,args | grep '[n]ginx: master'
cat 读取文件;docker version 同时显示客户端和服务端;ps 显示进程。最后一条中的 [n] 写法使搜索能找到 nginx,又不会把 grep 自己算进去。
在笔记中填好下表,不要直接填历史记录中的值:
| 字段 | 从哪里得到 | 示例或填写规则 |
|---|---|---|
| 操作系统 | /etc/os-release |
记录发行版和版本 |
| 服务器地址 | 云服务器实例信息 | 仅在自己的操作笔记中记录 |
| 作品集域名 | DNS 与域名管理界面 | 教学示例使用 portfolio.example.com |
| 实际 Nginx 程序 | master 启动参数、对应进程程序路径 | 宝塔与系统安装的路径可能不同 |
| 正式主配置 | 启动参数 -c,或 nginx -V 中默认路径 |
与实际运行实例对应 |
| 站点配置文件 | 主配置实际 include 的位置 | 是某个域名的 server 配置,不是整个主配置 |
| backend 文件 | 站点 include 与部署脚本同时核对 | 两边必须是同一完整路径 |
| 证书与私钥路径 | 当前站点或证书管理页面 | 记录路径,不记录私钥内容 |
| 初次启动的槽位 | 容器名称和 8080/8081 使用情况 | 仅在空闲时创建首次 Blue |
| 首次镜像 | ACR 已发布的镜像标签 | 使用完整 40 位 Git SHA |
界面替代: 云实例页适合查机器信息;FinalShell 文件面板适合查看目录;端口和进程仍以服务器命令结果为准。
成功标志: 你能指出“我接下来会操作的 Nginx 程序、主配置、站点配置”三个位置。若查出有两套宿主机 Nginx,先辨认当前监听 80/443 的实例,再往下做。
7.9 备份:保留文件副本,再开始改配置
操作位置:阿里云网页。 如果准备对承载多个网站的服务器做配置调整,先查看已有快照/备份并按需创建新的快照。快照恢复会覆盖相应磁盘在快照之后的变化,它适合灾难恢复,不能代替随手恢复一个 .conf 文件。
操作位置:服务器 · 管理员终端。影响:创建备份目录与文件。 下列交互式输入只让你填入已经确认的路径,避免在后续命令中来回手改。变量只在当前终端会话有效;换窗口或重新登录后需要重新填写。
read -r -p '实际 Nginx 程序完整路径:' lesson_nginx_bin
read -r -p '正式主配置完整路径:' lesson_nginx_conf
read -r -p '作品集站点配置完整路径:' lesson_vhost_file
read -r -p 'backend 文件完整路径:' lesson_backend_file
read -r -p '作品集域名,不含 https://:' lesson_domain
read -r 将你输入的文字保存到变量,不把反斜杠当作转义;-p 显示提示。这里输入的是自己的实际路径,不能原样输入“完整路径”几个字。
填好后先检查原配置能否通过测试。-c 明确使用刚确认的主配置;若实际服务还带有自定义 -p 前缀,以下 Nginx 命令也要附带相同前缀。
sudo "$lesson_nginx_bin" -t -c "$lesson_nginx_conf"
若旧配置已经失败,先按第 11 节处理,不继续叠加新的站点变更。旧配置正常才创建时间戳备份;install -d -m 0700 创建仅管理员可访问的目录,cp -a 保留原文件属性。
lesson_backup_dir="/root/portfolio-lesson-backup-$(date +%Y%m%d-%H%M%S)"
sudo install -d -m 0700 "$lesson_backup_dir"
sudo cp -a -- "$lesson_nginx_conf" "$lesson_backup_dir/nginx.conf"
if sudo test -f "$lesson_vhost_file"; then
sudo cp -a -- "$lesson_vhost_file" "$lesson_backup_dir/site.conf"
fi
if sudo test -f "$lesson_backend_file"; then
sudo cp -a -- "$lesson_backend_file" "$lesson_backup_dir/backend.conf"
fi
sudo ls -l "$lesson_backup_dir"
$(date ...) 生成时间戳;两个 if 表示文件已经存在才备份。全新站点没有旧站点文件是正常的,要在记录中注明“本次新增”,以便恢复时区分新增与替换。
成功标志: 能看到主配置副本,以及原本存在的站点/backend 副本。恢复主配置时还需考虑其他 include 文件;这三份副本不等于备份了整台服务器。
7.10 拉镜像:先验证 ACR,再启动应用
操作位置:ACR 网页。影响:只读查看。 打开目标镜像仓库的版本/标签页面,找出已经发布成功的完整 SHA。若还没有镜像,先完成第 06、08 节的发布准备;不要凭空填写一个 SHA。
操作位置:服务器 · 具备 Docker 权限的终端。 下面先填写仓库地址、用户名和完整镜像引用,然后交互式登录。docker login 会保存当前用户的登录凭证,密码在提示处输入,不写到命令里。
read -r -p 'ACR 主机名,不含协议和路径:' lesson_registry
read -r -p 'ACR 镜像登录用户名:' lesson_acr_user
read -r -p '完整镜像引用,含 40 位 SHA 标签:' lesson_image_ref
docker login --username "$lesson_acr_user" "$lesson_registry"
看到登录成功后再拉取。pull 下载镜像,image inspect 查看镜像是否已存在于本机;这两步不会启动容器。
docker pull "$lesson_image_ref"
docker image inspect "$lesson_image_ref" --format '{{.Id}}'
成功标志: 登录通过,镜像下载完成,inspect 返回镜像 ID。登录失败看凭证/权限;标签不存在看完整地址和 SHA。不要因为 pull 失败就修改 Nginx。
界面替代: ACR 网页能确认远端镜像,却不能证明服务器已经拉取。FinalShell 的终端或后续 Actions 才能在目标机器执行下载。
7.11 首次容器:先让 8080 在服务器本机可访问
前提: 这是尚未初始化的练习部署,8080 没被其他服务使用,也不存在要保留的同名 Blue 容器。已有生产容器时只做检查,不重复执行创建命令。
操作位置:服务器 · Docker 终端。影响:创建并启动容器。 该命令把镜像运行在服务器回环地址上,不占用外层 Nginx 的 80/443。
docker run --detach --name portfolio-resin-blue --restart unless-stopped --publish 127.0.0.1:8080:80 "$lesson_image_ref"
| 参数 | 含义 |
|---|---|
--detach |
容器后台运行,终端可以继续操作 |
--name |
使用后续脚本能识别的固定名称 |
--restart unless-stopped |
Docker/机器重启后可恢复运行,明确手动停止的容器除外 |
--publish 127.0.0.1:8080:80 |
宿主机回环 8080 转到容器内部 80 |
"$lesson_image_ref" |
使用刚才拉取并确认的镜像 |
随后做只读检查。第一条确认状态与端口,第二条验证健康响应,第三条读取 Gallery 响应头。
docker ps --filter name=portfolio-resin-blue
curl -fsS http://127.0.0.1:8080/healthz
curl -I http://127.0.0.1:8080/gallery
启动初期短暂未就绪可以间隔数秒再检查。若持续失败,查看 docker logs --tail 80 portfolio-resin-blue,不要直接把候选当作可用站点接入公网。
到这个检查点: ACR 有镜像 → 服务器已拉取 → 容器已运行 → 本机 8080 响应正常。公网域名与 HTTPS 还没有被这一步证明。
7.12 DNS 与证书:把域名接到正确机器
操作位置:阿里云网页。影响:保存 DNS 变更后会改变域名解析。 打开域名的解析记录,给作品集子域名添加/核对 A 记录,值为目标服务器 IPv4。若配置了 AAAA,确认这台机器的 IPv6 链路也可用。安全组/防火墙允许预期的网页端口;本案例无须把 8080/8081 开放到公网。
操作位置:本地 · PowerShell。影响:只读查询。 将示例域名替换为自己的域名。这一步查的是本地所使用的 DNS 解析结果,不修改记录。
Resolve-DnsName portfolio.example.com
证书可以通过现有管理面板或证书管理流程申请并安装。选择实际域名,确认续期方式;不要把网站部署等同于证书自动续期已经配置完成。
操作位置:服务器 · 管理员终端。影响:只读。 输入证书文件路径后,读取证书主体、有效期和 SAN。这里读的是公开证书,不是私钥。
read -r -p 'fullchain 证书完整路径:' lesson_cert_file
sudo openssl x509 -in "$lesson_cert_file" -noout -subject -dates -ext subjectAltName
-in 指定输入文件,-noout 不输出整段编码。确认 SAN 覆盖作品集域名、时间有效,Nginx 能读取对应证书和匹配私钥。证书文件名叫“通配符”不能代替读取证书内容。
成功标志: DNS 指向预期机器;证书覆盖实际域名且未过期。下一步才是让 Nginx 读取证书并转发到容器。
7.13 站点配置:只把请求交给容器,不重写整个主配置
操作位置:FinalShell 文件面板,或现有 Nginx 管理面板。影响:保存配置文件,尚未生效到运行进程。 打开已经确认被主配置 include 的站点目录,为作品集编辑独立站点文件。
下面是全新独立子域名的教学模板。使用前替换域名、证书路径和 backend 路径,并核对与当前 Nginx 版本及管理方式兼容。已有面板站点时,应在其既有配置中调整对应部分,保留必要的证书验证、续期规则和其他站点内容。
server {
listen 80;
server_name portfolio.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name portfolio.example.com;
ssl_certificate /ABSOLUTE/PATH/fullchain.pem;
ssl_certificate_key /ABSOLUTE/PATH/privkey.pem;
location / {
include /etc/nginx/snippets/portfolio-resin-backend.conf;
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;
}
}
| 配置 | 它解决什么问题 |
|---|---|
listen 80、listen 443 ssl |
分别接收 HTTP 和 HTTPS |
server_name |
同机多个网站时,选择作品集对应的站点 |
return 301 ... |
将 HTTP 访问重定向至 HTTPS;已有 ACME 验证规则要保留 |
ssl_certificate 与 ssl_certificate_key |
让 Nginx 使用正确的证书和匹配私钥 |
location / |
匹配交给作品集应用处理的路径 |
include ...backend.conf |
从同一小文件读取蓝/绿代理目标 |
proxy_set_header |
向后端保留域名、来源与原始协议等请求信息 |
模板假设 IPv4 监听。如同时提供 IPv6,应根据实际网络和 Nginx 配置添加对应 IPv6 监听并验证;不能只添加 AAAA 记录却没有可用服务。
在配套 backend 文件中,首次 Blue 正常运行时保存下面这一行。若已经有生产 Green,必须按实际活动槽位填写,不能用这行覆盖它。
proxy_pass http://127.0.0.1:8080;
两点特别容易出错:第一,不要同时在同一 location 里保留另一个写死的 proxy_pass;第二,backend 文件应放在不会被当成完整站点自动加载的位置,避免主配置的 include *.conf 又把它载入到错误层级。
以系统 Nginx 路径为例,站点可以是已加载的 conf.d 下某个文件,而 backend 放在 snippets 下。宝塔使用另一套路径时,要同时修改站点 include 与脚本的 BACKEND_FILE。文件名类似不等于它们指向同一个文件。
界面操作提醒: 有些面板保存配置时会自动检查甚至 reload。先确认面板行为,再使用其保存按钮;不要以为所有“保存”都只是写入磁盘。
到这个检查点: 本机容器已就绪 → DNS/证书已核对 → 站点配置和 backend 已准备;接下来用 Nginx 自己检查是否能载入。
7.14 生效与验收:test、reload、本机 HTTPS、公网,分开做
操作位置:服务器 · 管理员终端。影响:第一条只检查,第二条改变运行中的配置。 使用前面填好的变量,先测试;只有看到测试成功后才单独执行 reload。
sudo "$lesson_nginx_bin" -t -c "$lesson_nginx_conf"
成功输出通常包含 syntax is ok 和 test is successful。路径不存在、指令不允许出现在当前位置、证书不可读等错误都需要先解决。不要只看到一行“语法正常”就忽略后面的失败。
测试通过后,下面的命令向该配置所对应的 master 发出平滑重载请求。还要核对 pid 路径确实属于刚才检查的运行实例,不能对另一套 Nginx 发信号。
sudo "$lesson_nginx_bin" -s reload -c "$lesson_nginx_conf"
命令成功不等于流量已经去了正确容器,接着做三层只读验收。
第一层:容器。 直接访问当前活动端口,检查容器 Nginx。
curl -fsS http://127.0.0.1:8080/healthz
第二层:本机域名 HTTPS。 --resolve 只给这次请求指定域名解析,既经过宿主机 Nginx,也按实际域名验证证书,不改 DNS 或 hosts。
curl -fsS --resolve "${lesson_domain}:443:127.0.0.1" "https://${lesson_domain}/healthz"
curl -I --resolve "${lesson_domain}:443:127.0.0.1" "https://${lesson_domain}/gallery"
不要为了让检查变绿加 -k 跳过证书验证。若失败,先区分证书、站点匹配和代理目标。
第三层:公网与浏览器。 在自己的电脑打开真实域名,检查证书、首页、/gallery 直接刷新,查看 Console 和 Network,并完成关键交互。服务器上的本机请求绕过了外部网络,不能代替这一步。
| 结果 | 它指向哪里 |
|---|---|
| 容器检查失败 | 先查镜像、容器状态与映射 |
| 容器正常,本机域名 HTTPS 失败 | 查证书、宿主机站点与 include |
| 本机 HTTPS 正常,公网失败 | 查 DNS、安全组、防火墙、外部网络 |
| 公网返回 HTML,浏览器白屏 | 查前端运行时,第 10 节 |
如需确认请求到达哪个槽位,可访问一个带有本次排查标识的 Gallery URL,然后比较 Blue/Green 的最近访问日志。健康接口在当前容器配置中关闭了 access log,不适合用来做这项日志定位。
示例: 在浏览器打开自己的域名加 /gallery?probe=lesson-check,随后在服务器查看两个容器日志,找这条请求。使用唯一标识和时间,避免把其他访客请求当成自己这次检查。反向代理、缓存层或日志配置变化时,还要核对请求是否被缓存与实际日志去向。
失败怎么退回: 保留当前可用容器,不继续停服务;对照 7.9 的备份恢复本次修改的站点/backend 文件,再 -t、reload 和验收。若本次新增了站点,恢复方式是撤下这份新增站点配置,而不是覆盖整台服务器主配置。主配置本来就坏了时,按第 11 节独立准备恢复候选。
7.15 部署身份:用专用账户和专用 SSH 密钥
操作位置:服务器 · 管理员终端。影响:先只读,再按需创建账户。 先查看用户是否存在;已经存在时不重复创建,也不覆盖它的家目录。
id portfolio-deploy
如果确认是全新初始化且该用户不存在,再创建独立用户。--create-home 创建家目录,--shell 指定 Bash。
sudo useradd --create-home --shell /bin/bash portfolio-deploy
当前工作流使用部署账户直接运行 Docker,所以需要相应权限。历史方案通过 docker 组授予,下面是其命令;这会赋予很高的宿主机管理能力,不能把它当成严格隔离的低权限账户设计。
sudo usermod --append --groups docker portfolio-deploy
id portfolio-deploy
--append 追加附加组而不替换原有组。已有登录会话通常要退出再登录才能取得新组权限。更严格的部署权限架构是独立后续设计,不在这份文档里假装已经实现。
操作位置:本地 · PowerShell。影响:创建新密钥文件。 先在资源管理器确认 .ssh 目录存在,且下面的 portfolio_actions_demo 与 .pub 文件不存在;已有同名文件时换一个未使用的名字,不能覆盖原密钥。
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\portfolio_actions_demo" -C "portfolio-actions"
-t 选择算法,-f 指定保存位置,-C 添加用途注释。生成后有两份文件:无扩展名的是私钥,.pub 是公钥。当前 Actions 没有解锁加密私钥的步骤;要直接匹配它,需要受妥善保护的专用无口令密钥,或另行完善密钥解锁流程,二者不要混用。
通过 FinalShell 把公钥文件上传到部署账户家目录。先由管理员创建 .ssh 目录;700 表示目录只允许所有者访问。
sudo install -d -o portfolio-deploy -g portfolio-deploy -m 0700 /home/portfolio-deploy/.ssh
再使用具有相应权限的文件编辑器,把公钥的一整行追加到 /home/portfolio-deploy/.ssh/authorized_keys。文件不存在就新建;已存在时保留其他已经批准的公钥,不覆盖整个文件。
保存后才执行下面两条,设置文件所有者与 600 权限,表示只允许所有者读写。
sudo chown portfolio-deploy:portfolio-deploy /home/portfolio-deploy/.ssh/authorized_keys
sudo chmod 0600 /home/portfolio-deploy/.ssh/authorized_keys
顺序是先创建目录、再编辑公钥文件、最后执行 chown/chmod。如果文件不存在,先回到编辑器完成公钥安装,不要用无关文件冒充。
验证服务器身份: 在可信管理员会话里读取服务器主机公钥指纹,再与新连接显示的同算法指纹核对。下面只读主机公钥,不输出私钥。
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
如果该主机没有这个算法的主机公钥,按实际启用的主机密钥类型核对。不要仅因为首次连接弹出提示就机械接受。
操作位置:本地 · PowerShell。影响:建立 SSH 会话,首次接受后会保存主机记录。 将 SERVER_HOST 替换成自己的服务器地址。
ssh -i "$env:USERPROFILE\.ssh\portfolio_actions_demo" portfolio-deploy@SERVER_HOST
进入服务器后,先用 whoami 确认身份,再用 docker ps 验证访问;最后用 exit 返回本地。它们分别输出当前用户名、查看容器和结束这个会话。
whoami
docker ps
exit
成功标志: 专用密钥能以预期账户连接,主机身份核验正确,Docker 权限符合预期。再把这把私钥和经过核验的 known_hosts 记录配置进第 7.6 节的 GitHub Secrets。不要把两种公钥搞混。
7.16 安装固定脚本:确认内容、语法与权限,再允许调用
操作位置:本地编辑器 + FinalShell 文件面板。 先阅读仓库部署脚本,核对 DOMAIN、NGINX_BIN、BACKEND_FILE、蓝绿端口和容器名称与信息卡一致,再把审核过的脚本上传到服务器部署账户家目录。
特别注意:当前脚本调用 Nginx 时只使用程序路径,没有传 -c。如果你的实际 Nginx 用自定义配置参数,仅仅在管理员终端用 -c 测试成功,还不代表这份脚本能操作同一实例。需要先让脚本使用同一配置入口,再继续安装和演练。
操作位置:服务器 · 管理员终端。影响:前两条只读检查。 以下假定上传文件名为 /home/portfolio-deploy/portfolio-resin-deploy。
sudo sed -n '1,240p' /home/portfolio-deploy/portfolio-resin-deploy
sudo bash -n /home/portfolio-deploy/portfolio-resin-deploy
sed -n '1,240p' 读取前 240 行,不改文件;bash -n 只检查 Bash 语法,不执行部署。语法通过不能证明网络、代理和业务逻辑正确,仍要先完成内容审核。
如果正式路径已经有旧脚本,先备份。下面的 if 只在旧文件存在时复制,使用前面创建的管理员备份目录。
if sudo test -f /usr/local/sbin/portfolio-resin-deploy; then
sudo cp -a -- /usr/local/sbin/portfolio-resin-deploy "$lesson_backup_dir/deploy-script"
fi
确认这正是要安装的版本后,再安装正式副本。install 复制文件并设置 root 所有和 755 权限;它会替换正式副本,但不会在安装过程中启动网站。
sudo install -o root -g root -m 0755 /home/portfolio-deploy/portfolio-resin-deploy /usr/local/sbin/portfolio-resin-deploy
sudo stat -c '%U %G %a %n' /usr/local/sbin/portfolio-resin-deploy
sudo bash -n /usr/local/sbin/portfolio-resin-deploy
期望属性为 root root 755。部署用户不能修改 root 稍后要执行的正式脚本;上传副本和正式副本是两个位置。
下一步给固定脚本配置 sudo 入口。这会修改账户权限。 在管理员专用目录准备候选规则并用 visudo 校验;不要直接编辑整个 /etc/sudoers。
sudo install -d -m 0700 /root/portfolio-lesson-staging
sudo visudo -f /root/portfolio-lesson-staging/portfolio-resin-deploy
visudo -f 打开指定候选文件的编辑器。输入下面这一行并保存;它是文件内容,不是 Shell 命令。
portfolio-deploy ALL=(root) NOPASSWD: /usr/local/sbin/portfolio-resin-deploy
如果打开的是 vi/vim,按 i 进入输入模式,写入内容后按 Esc,输入 :wq 并回车保存退出;如果打开的是 nano,按 Ctrl+O、回车保存,再按 Ctrl+X 退出。不要在编辑器画面里输入下一条 Shell 命令。
没有在 sudoers 里枚举参数时,参数约束由该脚本自己处理。当前脚本有格式检查,但并非严格的项目镜像白名单;同时前面 Docker 组授权本身仍是高权限,不能因增加这条规则就称账户已经实现最小权限。
用 visudo -cf 校验候选文件,看到解析成功后再安装到正式目录。若已有同名 sudo 规则,先在备份目录保存其旧副本并核对差异。
sudo visudo -cf /root/portfolio-lesson-staging/portfolio-resin-deploy
候选通过后,再执行安装和全局复查。0440 是 sudo 规则常用的只读权限设置,安装动作才让该规则进入正式配置。
sudo install -o root -g root -m 0440 /root/portfolio-lesson-staging/portfolio-resin-deploy /etc/sudoers.d/portfolio-resin-deploy
sudo visudo -cf /etc/sudoers
保留当前管理员会话直到全部测试完成。parsed OK 或“解析正确”只是结果,不能再次当命令执行;这正是历史操作里发生过的一次混淆。
7.17 先测拒绝,再做发布演练
操作位置:本地 · PowerShell。影响:通过 SSH 调用参数检查,预期不会切换容器。 将主机地址替换为自己的地址,故意传入非法镜像字符串。
ssh -i "$env:USERPROFILE\.ssh\portfolio_actions_demo" portfolio-deploy@SERVER_HOST "sudo -n /usr/local/sbin/portfolio-resin-deploy invalid-image"
-i 使用专用私钥;引号里的命令在服务器执行;sudo -n 禁止交互式输入密码。预期收到“镜像必须带完整 40 位 SHA”的拒绝提示和非零退出码,这项测试的成功标准正是它拒绝输入,并且没有部署动作。
它只能证明登录、sudo 入口和最前面的参数检查工作,不能证明后面的 Docker、Nginx 或失败恢复全部正确。
最后进行受控部署演练。先在自己的练习环境中,用已经验证的镜像做一次 Blue → Green 和一次 Green → Blue,按照第 09 节检查候选、实际命中、停旧后的请求和回滚办法。当前脚本的保护缺口仍然存在,不能在未核对配置的生产服务器上盲目重跑来“碰运气”。
演练和服务器副本核对完成后,再配置 GitHub Variables、Secrets,按第 08 节运行 Publish 并观察自动 Deploy。以浏览器交互验收结束,不以看到最后一行 Deployment succeeded 结束。
| 最终检查 | 成功证据 | 如果没有成功 |
|---|---|---|
| 身份与路径 | 用户、实际 Nginx、主配置、站点文件均确认 | 不继续安装/切换 |
| 镜像与容器 | ACR 有对应 SHA;活动容器本机请求正常 | 查认证、标签、容器日志 |
| 代理接线 | 站点读取脚本维护的 backend;请求命中正确槽位 | 查 include、实际实例与 reload |
| HTTPS | 本机域名请求与公网请求都正常 | 分别查证书和外部入口 |
| 运行版本 | 发布摘要、容器镜像与预期 SHA 对应 | 核对是否发错版本 |
| 最终页面 | 关键房间、深链接、资源、Console 与交互正常 | 回到浏览器排错 |
这套一次性准备完成后,服务器具备了五个条件:镜像能运行,Nginx 能转发,固定脚本能调用,切换经过演练,浏览器里的网站能使用。之后的日常发布,才可以由工作流重复执行。
08 以后每次发布,按这个顺序操作
8.1 日常流程卡
| 步骤 | 你在哪里做 | 具体动作 | 留下什么证据 |
|---|---|---|---|
| 1 本地验证 | 编辑器、PowerShell、浏览器 | 看差异,构建,预览关键页面 | 本地检查结果 |
| 2 保存并上传 | 编辑器或 Git | 暂存、提交、推送目标分支 | GitHub 上的提交 SHA |
| 3 等 CI | GitHub Actions | 查看对应 SHA 的 CI | 任务结果与失败步骤 |
| 4 批准发布 | GitHub Actions | 运行 Publish ACR Image,选择 main | Publish 运行编号和 SHA |
| 5 自动部署 | GitHub Actions | 查看自动出现的 Deploy Server | 部署摘要、实际镜像引用 |
| 6 人工验收 | 浏览器 | 首页、房间、深链接、Console、Network | 用户体验符合预期 |
8.2 人工按钮在哪
位置:GitHub · 网页。 进入 Actions → Publish ACR Image → Run workflow → 选择 main → Run workflow。只有确认本次版本应该发布时才点击。
这是会产生外部状态变化的操作:镜像会推入 ACR,成功后会触发服务器部署。没有按钮时先检查工作流是否在默认分支、是否声明 workflow_dispatch、当前账号是否有运行权限。官方入口说明见 手动运行工作流。
正常发布不用再手动点击 Deploy Server。它会从成功的 Publish 运行中取 head_sha,并部署同一个 SHA 标签。
8.3 每到一个关键点,停下来辨认“已经做到了什么”
| 当前看到的状态 | 已经过的流程 | 还没有证明的事 |
|---|---|---|
| GitHub 有新提交 | 编辑 → commit → push | 尚未证明构建通过 |
| CI 绿色 | 安装 → 构建 → Runner 容器检查 | 尚未发布镜像,也未执行浏览器渲染测试 |
| Publish 绿色 | 再次验证 → 构建 → 登录 ACR → 推镜像 | 尚不能证明服务器正在运行新版本 |
| Deploy 绿色 | 拉镜像 → 调脚本 → HTTP 检查 | 尚不能证明所有 3D 页面和交互正确 |
| 浏览器验收通过 | 新版页面可见,主要交互、资源与路由正常 | 仍需独立评估 SEO、内容完整性与长期性能 |
8.4 ACR 登录失败的真实案例
历史报错发生在 Log in to Alibaba Cloud ACR:
unauthorized: authentication required
排查路径是:
- 在 Actions 展开这个失败步骤,确认不是前面的 lint 日志。
- 核对 registry 主机是否是目标实例的镜像入口。
- 在 ACR 控制台核对镜像访问用户名、凭证类型与仓库权限。
- 在 GitHub 更新对应的 Secrets。
- 再运行失败任务,观察登录和 push 是否继续通过。
历史案例最终通过修正 ACR 访问凭证解决。用户名、密码、目标 registry、权限都可能引起认证失败;日志里出现被遮蔽的 *** 只说明变量被传入,不说明凭证正确。
8.5 几个常用日志词
| 日志 | 通俗含义 | 优先检查 |
|---|---|---|
unauthorized |
认证未通过或凭证不适用于目标 | ACR 主机、身份、密码、权限 |
manifest unknown |
目标镜像标签不存在或地址错误 | registry/仓库/SHA 是否一致 |
Permission denied (publickey) |
SSH 公钥认证失败 | 用户名、私钥对应的公钥、权限 |
Host key verification failed |
服务器身份校验没通过 | 核验 known_hosts 与主机指纹 |
password is required |
sudo 需要密码但自动流程不能交互 | 固定脚本的 sudo 授权 |
Candidate container did not become healthy |
候选容器未能正常响应 | 容器日志、端口、镜像 |
502 Bad Gateway |
网关没得到正常的后端响应 | 代理目标与容器实际运行状态 |
先找哪一步第一次失败,再解释错误。日志可能很长,但定位入口通常只需要“工作流、步骤、SHA、第一条明确错误”四项。
8.6 回滚是另一种发布
如果新版本有问题,且存在已经完成浏览器验收的旧镜像,可在 Actions → Deploy Server → Run workflow 选择 main,并在 image_tag 填入那次已发布的完整 40 位 SHA。
这会执行真实的服务器部署。前提是镜像仍在 ACR,确实是已知可用版本,当前代理和部署通道本身可用。若故障根因是 Nginx 接错端口,单纯换前端版本不一定修好。
回滚完成后仍要浏览器验收。不要直接点一个没确认过的旧 SHA,也不要把 main 当成固定回滚目标。
日常操作可以缩短为“确认版本、触发 Publish、检查 Deploy、浏览器验收”。要理解 Deploy 为什么能切换版本,还需要看服务器上的两层 Nginx。
09 Nginx 与蓝绿部署,怎样把流量交给新版本

图里展示的是期望的完整验收顺序。当前脚本尚未实现“停旧后再次验证”的完整保护,具体差异见 9.4~9.6。
9.1 为什么这里有两层 Nginx
| 位置 | 主要工作 | 对应内容 |
|---|---|---|
| 宿主机 Nginx | 根据域名选站点,处理 HTTPS,将请求转到容器 | 服务器自己的站点配置与 backend include |
| 容器 Nginx | 返回 HTML、JS、CSS、字体与纹理,处理 SPA 回退 | 仓库中的 deploy/nginx.conf |
一次访问经过:
浏览器查询域名,得到服务器地址
→ HTTPS 443 到宿主机 Nginx
→ 域名对应的站点规则
→ 127.0.0.1:8080 或 8081
→ Docker 映射到容器 :80
→ 容器 Nginx 返回 dist 文件
→ 浏览器执行 JavaScript,绘制 3D 页面
因此,改仓库 deploy/nginx.conf 影响的是下一次镜像里的文件服务;它不会自动修改宿主机的 HTTPS 证书和代理目标。
9.2 两个容器为什么都可以使用 80
容器有独立的网络环境。外部映射分别是:
| 槽位 | 宿主机入口 | 容器内部 | 案例名称 |
|---|---|---|---|
| Blue | 127.0.0.1:8080 |
80 |
portfolio-resin-blue |
| Green | 127.0.0.1:8081 |
80 |
portfolio-resin-green |
Blue 和 Green 是槽位名称,会交替承载新旧版本;绿色不永远代表新版本。绑定 127.0.0.1 意味着这些入口供宿主机访问,访客通过外层 Nginx 的 443 进入。
9.3 为什么要有单独的 backend 文件
假设确认过当前机器确实使用 /etc/nginx/snippets/portfolio-resin-backend.conf,站点的匹配 location 可以引用它。下面只是局部配置示意,不能替代整个 HTTPS 站点文件。
location / {
include /etc/nginx/snippets/portfolio-resin-backend.conf;
}
backend 文件只保存当前目标:
proxy_pass http://127.0.0.1:8080;
切换时修改为 8081,随后测试配置并 reload。这样只需要改变一个小文件,不必反复重写证书和其他域名规则。路径必须与服务器脚本中的 BACKEND_FILE 一致。
Nginx 的配置分为 http、server、location 等层级;proxy_pass 需要放在合适上下文。nginx -t 通过后再 reload 是基本顺序,工作机制可读 Nginx 初学者指南。
9.4 当前脚本真实执行的顺序
本地 deploy/server/portfolio-resin-deploy 当前是:
检查参数、Nginx 程序、backend 文件、镜像是否存在
→ 从 backend 的一行内容判断当前蓝/绿槽位
→ 备份 backend
→ 移除备用槽位的旧容器,启动候选版本
→ 检查候选的 /healthz 和 /gallery HTML
→ 改写 backend
→ nginx -t,再 reload
→ 通过本机 HTTPS 请求 /healthz
→ 设置 deployment_succeeded=1
→ 停止旧容器,保留容器对象
“本机 HTTPS 请求”使用 curl --resolve 把域名请求指向 127.0.0.1,仍验证该域名的 HTTPS。它测试的是本机代理链,不检查公网 DNS 和外部防火墙。后续工作流还有从 Runner 发起的公网请求。
9.5 自动回滚的边界要讲清楚
在脚本标记成功以前,失败清理会尝试恢复原 backend、测试并 reload,然后删除失败候选。这个保护依赖旧实例仍可用、配置恢复成功,不能理解为任何故障都百分之百恢复。
当前脚本在停止旧容器前就标记成功。后续 Runner 的公网检查如果失败,只会让工作流失败,没有另一个步骤自动恢复旧容器和代理配置。
所以目前应这样描述它:具有切换过程中部分失败的恢复逻辑,存在停旧后的保护缺口。
9.6 为什么“检查健康”仍可能检查到旧版本
两个版本的 /healthz 都返回同样的 ok。若站点配置仍然写死 8080,而脚本只把一个没有被引用的 backend 文件改成 8081:
新容器 8081 已启动 → 候选检查成功
实际代理仍到 8080 → 旧容器也返回 ok
脚本误判流量已切换 → 停止 8080
访客仍被送到 8080 → 502
这正是历史 502 排查提出的高概率机制。要证明流量实际到达新容器,可以在一次受控请求后对照两个容器的访问日志,或设计版本标识/响应头并检查;当前站点的健康接口没有直接返回版本 SHA。
理想的发布验收应覆盖:候选正常、代理实际命中候选、旧版停止后仍正常,以及失败时能够恢复旧实例与代理。本文把它记录为后续改进方向,没有改动脚本。
9.7 回滚能力并不是无限历史
一次部署成功后,旧容器停止但保留;下一次部署会复用备用槽位,并可能移除那个槽位的旧容器。因此,长时间回滚还要依赖 ACR 中保存的已知可用镜像,不能只依赖两个容器名称。
蓝绿部署是否可靠,取决于候选验证、代理切换和失败恢复是否真正衔接。backend 文件写着 8081,只能证明脚本改了文件;请求是否进入 8081,还要靠实际加载的配置、日志和访问结果确认。
第四部分:处理上线后的问题
10 页面白屏时,开发者怎样自己找到原因

配图用于理解排查方向,里面的示例文件名和配置不是当前服务器的配置模板。具体判断以 Network、日志与实际源码为准。
10.1 先描述现象,再猜原因
历史白屏的关键报错是:
Uncaught TypeError: Cannot read properties of undefined (reading 'useLayoutEffect')
at three-vendor.<hash>.js:...
这是历史错误的脱敏节选,不是命令。它只说明某段代码访问 undefined 的 useLayoutEffect 属性失败;仅凭这一句话还不能直接确定是依赖冲突、业务 Hook 用法还是打包问题。
先记录四项:哪个 URL、刚做了什么、预期看到什么、实际看到什么。再记下发生时间、浏览器和最近发布的 SHA,方便对应版本。
10.2 第一步:在 Network 判断 HTML 是否到达
位置:浏览器 · F12。 打开 Network,保持面板打开,勾选 Disable cache,然后刷新。找到类型为 Document 的页面请求,查看 Status、Headers 和 Response。
| 观察到什么 | 下一步 |
|---|---|
| DNS、连接或证书错误 | 查域名、证书、网络入口 |
| Document 返回 502 | 进入下一节的代理/容器排查 |
| Document 返回 404 | 查站点规则和路径回退 |
| Document 返回 200 且确实是目标 HTML | 继续查 JavaScript 和渲染 |
HTTP 200 说明一次 HTTP 请求被成功处理,不保证正文是正确版本,更不保证代码已经正确运行。
10.3 第二步:在 Console 找刷新后的第一个阻断错误
手动操作: 切到 Console,先保存必要的旧现场,再清空日志并刷新;展开第一条红色未捕获错误,点击文件与行号,查看堆栈。
不要把所有黄色 Warning 都当成白屏原因,也不要只截最后一条连锁错误。历史案例中,决定性错误位于 three-vendor,并与 React 导出初始化有关。
10.4 第三步:看看 React 有没有挂载
手动操作: 切到 Elements,搜索 id="root",展开该节点。
| 观察 | 推进方向 |
|---|---|
| 找不到预期 root | 先核对收到的 HTML 是否是本应用 |
| root 存在但在预期加载结束后仍为空 | 优先看入口 JS、模块初始化或挂载前异常 |
| root 有内容,页面却看不见 | 检查 CSS、Canvas 尺寸、WebGL、相机与资源 |
空 root 是线索,不是仅凭它就确定根因。历史案例还结合 Console 与本地生产构建,才进一步定位。seo-content 或 <noscript> 是语义回退内容,不应因为看到它们就删除。
10.5 第四步:检查 JS 是否真的作为 JS 返回
手动操作: 回到 Network,选择 JS,点开入口脚本与报错脚本。
- 看状态码:404 说明路径/文件缺失,5xx 说明服务链出错。
- 看
Content-Type:JS 请求不能收到text/html的错误回退页。 - 看 Response:即使 200,也要确认返回的是 JavaScript。
- 看文件名哈希:HTML 是否引用了当前镜像实际存在的文件。
当前容器 Nginx 对 /js/ 和 /css/ 缺失资源返回 404,避免把缺失脚本伪装成 HTML。正常房间路径则允许回到 index.html,这两类请求需要区别处理。Network 操作可对照 Chrome DevTools 网络检查教程。
10.6 第五步:本地复现生产构建
位置:本地 · PowerShell。 下列两条命令先重新生成 dist,再启动它的预览服务。若 build 失败,先处理失败,不要继续拿旧 dist 判断。
npm run build
npm run preview -- --host 127.0.0.1 --port 4173
浏览器打开 http://127.0.0.1:4173,用同样的方法查看错误。开发服务器正常并不能排除生产打包问题。
历史案例在本地 production preview 复现了相同错误;这让“只有线上 Nginx 或缓存出问题”的假设变弱。随后检查构建输出,看到了:
Circular chunk: vendor -> react-vendor -> vendor.
Circular chunk: vendor -> three-vendor -> vendor.
这些是历史构建警告。它们与浏览器报错模块相互印证,让排查转向人工分包规则。
10.7 第六步:写几个能被证伪的假设
| 假设 | 如果成立,应看到什么 | 怎么验证 |
|---|---|---|
| 人工分包产生循环初始化 | 构建出现循环 chunk,错误位于相关 bundle | 读 vite.config.js,改变这一项后再构建 |
| 安装了多个 React 实例 | 依赖树存在异常版本/实例关系 | 看依赖树,而不是直接升级 |
| 缓存混用了旧 HTML 与新 JS | 资源哈希不匹配;全新本地构建可能正常 | 无缓存请求与本地 preview 对照 |
| 某个业务组件出错 | 堆栈或最小复现指向该组件 | 阅读源码、必要时用 source map 定位 |
如果要查看依赖树,下面的命令只读取安装情况。npm ls 展示依赖,后面的名字限定范围,--all 展开嵌套关系。
npm ls react react-dom @react-three/fiber @react-three/drei --all
一个假设被排除也是进展。不要同时升级 React、改 Hook、改 Nginx、删除缓存,否则很难知道是哪一项产生了作用。
10.8 这个项目当时怎么修好的
历史修复在 vite.config.js:删除人为拆分 vendor、react-vendor、three-vendor 的 manualChunks 逻辑,并去掉构建器提示无效的输出选项,让打包器按模块关系处理分包。
证据链是:生产预览复现 → 构建提示循环 chunk → 移除人工分包 → 同样流程重新构建与浏览器验证 → 原异常消失。 当前本地 Git 历史中有提交 241b4f2,当前配置也已没有 manualChunks。
这不是“遇到任何 Hook 错误都删除分包”的通用处方。Rollup 也提示人工 chunk 划分可能改变副作用触发时机,见 manualChunks 说明。应结合自己的依赖关系验证。
10.9 修复后怎么验收
- 本地重新 build,确认原警告/错误变化与预期一致。
- 新开浏览器上下文检查 production preview。
- Console 无原来的阻断错误,关键 JS/纹理请求正常。
- 首页可见,Gallery 翻面、房间导航等关键交互可用。
- 保存新提交,经过 CI、Publish、Deploy。
- 在线上重复同一套验收,记录对应 SHA。
当前 CI 仍缺少真实浏览器运行检查。后续可以增加页面异常捕获、稳定入口元素检查、Canvas 和关键导航冒烟测试;这些检查尚未纳入当前工作流。
这次白屏的排查路径是:确认 HTML 和脚本到达,读取浏览器错误,在本地生产预览中复现,再用最小配置改动验证假设。若一开始连 HTML 都拿不到,例如返回了 502,就要转向代理和容器这一层。
11 502、Nginx 配置丢失和“明明改了却没生效”
11.1 先分清面板、配置文件和运行进程
宝塔等面板是管理入口;Nginx 是独立运行的服务。面板打不开,不意味着 Nginx 停了;配置文件丢失,也不一定立刻让正在运行的 worker 停止处理请求。
因此不要因为管理界面打不开就直接安装第二套 Nginx。两套服务可能争用 80/443,也会让你不知道自己检查和 reload 的究竟是哪一个。
11.2 502 的只读排查顺序
第一步:浏览器确认状态。 在 Network 查看失败的是 Document 还是某个资源,并记录发生时间和刚部署的 SHA。
第二步:服务器确认容器。 以下命令只读,列出两套容器的状态,并读取最近日志。某个容器本来就不存在时,命令会报不存在,这是要记录的事实。
docker ps -a
docker logs --tail 80 portfolio-resin-blue
docker logs --tail 80 portfolio-resin-green
--tail 80 限定最近 80 行,减少无关输出。注意容器日志能帮助看请求和启动情况,但不等于宿主机 Nginx 的错误日志。
第三步:分别请求两个端口。 以下只读请求把“容器自身是否健康”与“公网代理是否正确”分开。正常单活情况下,一个停止的槽位请求失败可以是预期现象。
curl -fsS http://127.0.0.1:8080/healthz
curl -fsS http://127.0.0.1:8081/healthz
第四步:核对代理实际配置。 根据前面确认的 Nginx 程序和配置执行 -T,在输出中找到域名对应的 server_name、匹配 location、include 与 proxy_pass。不能只打开一个名字看起来对的 .conf 就认定它已被加载。
第五步:读对应站点的 error_log。 日志里的 upstream 往往能显示 Nginx 实际连接的地址和端口。将这个端口与容器状态对应;不要贴出整个服务器的站点配置。
| 证据组合 | 推进方向 |
|---|---|
| 两个端口都无响应 | 容器启动、映射、镜像或 Docker 服务 |
| 候选端口正常,公网 502 | 代理目标、站点匹配、宿主机配置与 reload |
| backend 写 8081,错误日志还访问 8080 | include 没接入、检查了错误 Nginx 或配置未生效 |
| 公网 200,但页面白屏 | 回到浏览器运行时排查 |
11.3 历史 502:哪些是事实,哪些是推断
这次网站部署曾出现这样的现象:部署使用 blue/8080 时可访问;切到 green/8081 并停止 blue 后,公网出现 502。旧部署检查又发生在停止旧容器以前。
据此提出的高概率解释是:宿主机站点仍硬编码 8080,没有读取脚本维护的 backend 文件。后续恢复记录显示,重新部署后 /healthz 返回了 200。
这里能确认的是访问曾经恢复;代理是否始终正确跟随槽位切换,还需要核对实际 include,并完成两个方向的部署演练。一次 200 响应不能替代这些证据,也不能单独证明上面的原因推断。
这个案例教会我们的重点,是把“脚本认为切到了哪里”和“请求真的去了哪里”分别验证。
11.4 历史 Nginx 主配置丢失:为什么网站还在,但不能 reload
历史记录出现过:
open() "/www/server/nginx/conf/nginx.conf" failed (2: No such file or directory)
这是旧环境里的实际报错路径。运行中的 Nginx 已读入过配置,所以网站仍可能响应;-t 和 reload 需要重新读取磁盘文件,因此失败。直接重启会丢掉仍在工作的进程,可能使站点彻底停下来。
当时的处理原则是:
暂停 reload/restart
→ 确认实际 master、程序与路径
→ 搜索备份,读取现有站点规则
→ 单独准备恢复配置
→ 用 -t -c 检查候选配置
→ 核对 pid、include、证书和各站点
→ 安装并再检查
→ 平滑 reload
→ 逐站点验证
-c 指定要检查的配置文件;候选主配置里的 include、日志和 pid 路径也必须正确。不能直接把默认示例配置复制成正式配置,它可能没有加载原来的多个站点。
文件具体为什么丢失,当时没有找到确定证据。恢复配置解决的是服务可维护性,定位文件丢失原因则是另一个问题,两者需要分别保留结论。
11.5 修好后,不止测一个 healthz
要验证域名 HTTPS、首页、/gallery 直接访问与刷新、关键静态资源,再用浏览器操作。蓝绿切换问题还应在确认流量到达候选后,再停旧并复验;整个过程保留失败时恢复旧实例和代理的办法。
无论是 502 还是配置丢失,都要先保护仍在工作的服务,再验证修复。恢复后再检查页面内容、交互和版本,才能把“请求通了”推进到“网站可用”。
12 网站能访问以后,还要理解 SEO 与无障碍

12.1 搜索引擎不一定像你一样操作 3D 房间
SEO 是帮助搜索引擎理解页面主题与内容的一组工作。Canvas 里的字和模型不能直接等同于 HTML 标题、段落和链接,所以本项目在 3D 体验之外保留了语义层。
无障碍层也有独立价值:屏幕阅读器用户和键盘用户需要可以理解、定位和操作的信息。它应该与可见内容保持一致,不是堆砌关键词的隐藏区域。
12.2 当前项目的两条生成链
| 层次 | 核心文件 | 实际职责 |
|---|---|---|
| 统一内容源 | src/config/site.js、src/data/portfolioContent.js |
提供身份、作品、路径与描述 |
| 构建期 | seo-plugin.js |
在构建时生成标题、描述、canonical、分享信息、语义 HTML、JSON-LD、robots、sitemap、llms |
| 运行期 | src/hooks/useDocumentMeta.js |
房间变化时更新路径、标题和相关 metadata,处理前进后退 |
| 无障碍层 | src/components/ui/ScreenReaderOverlay.jsx |
输出标题、项目描述、链接与房间导航 |
Sanity 未配置或请求失败时,构建期设计了本地数据回退。核心站点不应因可选 CMS 缺失而不能构建。
12.3 几个常见名词
| 名称 | 通俗解释 |
|---|---|
| title / description | 告诉浏览器和搜索服务,这个页面叫什么、主要讲什么 |
| canonical | 声明页面偏好的规范网址,避免多个地址表达同一内容时混乱 |
| Open Graph / Twitter Card | 分享链接时可供平台读取的标题、描述和图片信息 |
| JSON-LD | 用结构化格式描述人物、网站和项目等内容 |
| robots.txt | 向爬虫声明抓取规则,不是登录权限控制 |
| sitemap.xml | 列出希望搜索服务发现的网址,不保证收录 |
| noindex | 请求支持该规则的搜索引擎不要索引页面 |
| llms.txt | 本项目生成的内容说明文件,不代表任何搜索排名或引用保证 |
12.4 当前源码还没有进入正式 SEO 配置
本文所用版本的 site.js 仍包含:
siteUrl: 'http://localhost:5173',
deployment: { mode: 'prototype', productionUrl: '', publishReady: false },
原型模式生成禁止抓取的 robots.txt,HTML 中有 noindex,URL 相关信息仍围绕本地地址。历史上能通过公网域名访问,与源码里的 SEO 是否已准备好,是两回事。
还要区分:robots.txt 阻止抓取,noindex 请求不索引。若爬虫无法抓取页面,可能看不到页面内的 noindex;二者不能简单视为“绝不会被任何搜索结果发现”。相关区别见 Google noindex 说明。
12.5 五个 URL 不代表已经生成五份独立初始页面
当前房间路径是 /、/about、/gallery、/studio、/contact。容器 Nginx 使用 SPA 回退,房间路径直接刷新时仍返回同一入口 HTML;浏览器运行后再根据房间更新 metadata。
因此,读取初始 HTML 而不执行 JavaScript 的分享服务,可能仍看到首页卡片。想让每个房间的初始 HTML 都有独立标题和分享信息,需要进一步实现并验证预渲染或其他服务端输出方案。当前源码没有完成这个改造。
12.6 你可以怎样手动检查
位置:本地预览浏览器,或自己的线上站点。
- “查看网页源代码”检查构建后初始 HTML 的 title、description、canonical 和 JSON-LD。
- F12 → Elements 检查运行后 DOM;它与“查看源代码”不是同一时间点的内容。
- 从首页进入 Gallery,观察地址栏和标题是否变化。
- 直接输入
/gallery并刷新,确认页面能恢复。 - 打开
/robots.txt、/sitemap.xml,核对域名、路径与原型开关。 - 键盘 Tab 导航并检查可访问内容,确认个人信息与 3D 画面一致。
检查实际构建结果,不只看 public 中的旧模板。构建插件可能在输出阶段生成不同内容;本项目旧静态文件和生成逻辑需要这样区分。
正式开放收录前,要一起确认身份与内容、素材来源、真实域名、canonical、分享图、表单允许域名,以及 HTML 和 HTTP 响应中的索引指令。当前未完成的个人化与授权问题也应先处理。
从能够访问,到允许抓取,再到被索引和获得排名,中间还有不同的条件。部署完成后,继续核对内容、元信息和访问体验,才算把作品集真正交给读者。
发布与排错速查
13.1 一张文字速查图
【在本地】
打开正确目录 → 看 Git 状态 → 改数据/代码 → build + 浏览器预览
↓
【保存版本】
看 diff → add → commit → push main
↓
【GitHub CI】
装依赖 → lint 记录 → 构建 → Docker HTTP 冒烟检查
↓ 人工核对要发布的版本
【Publish ACR Image】
手动运行 → 再验证/构建 → 登录 ACR → 推送 SHA/main 镜像标签
↓ Publish 成功触发
【Deploy Server】
解析同一 SHA → SSH → 登录 ACR → pull → 调服务器固定脚本
↓
【服务器】
启动候选 → 健康检查 → 改 backend → nginx -t → reload
→ 当前脚本:本机 HTTPS 检查 → 标记成功 → 停旧
↓
【Runner 与浏览器】
公网 HTTP 检查 → 人工页面与交互验收
↓ 有问题
Document/代理异常:查 Nginx 与容器
HTML/JS 已到但白屏:查 Console → 本地 production preview → 最小修复
13.2 六个关键区别
git push上传的是代码版本,ACR 保存的是镜像,服务器运行的是容器。- CI 检查通过,只能证明当前检查覆盖的内容通过。
- 本项目由人批准 Publish,Publish 成功后才自动 Deploy。
- 宿主机 Nginx 决定请求去哪,容器 Nginx 决定返回哪些文件。
- 代码、部署脚本、服务器配置和线上镜像各有自己的版本与生效方式。
- 先观察现象,再提出假设;用同样的复现方法证明修复。
13.3 给下一次修改留下可查的证据
| 环节 | 操作范围 | 留下什么证据 |
|---|---|---|
| 找内容 | 本地只读搜索 | 找出姓名、一个项目标题和一个社交动作的数据源 |
| 改内容 | 本地改一处文案 | 修改前后差异、本地构建与浏览器验证结果 |
| 读流水线 | GitHub 只读 | 对应 SHA、三个工作流的职责、第一条允许失败的 lint 配置 |
| 排错 | 本地或脱敏历史记录 | 症状、证据、至少两个假设、下一步验证与理由 |
保存这些证据后,即使隔了一段时间再回头看,也能知道某个改动来自哪里、经过哪些验证、还有哪些问题没有解决。首次部署和回滚则可以先在隔离环境中演练。
13.4 排错记录模板
把下面内容填写完整,通常比“报错了,怎么办”更容易推动问题解决:
时间与版本:
操作位置:本地 / Runner / 服务器 / 浏览器
复现步骤:
期望结果:
实际结果:
第一条明确错误:
已确认的事实:
假设 1 与验证办法:
假设 2 与验证办法:
本次只改了什么:
同一复现步骤下的新结果:
仍未验证的内容:
分享日志前移除私钥、密码、Token、Cookie 和不应公开的站点信息。保留能定位问题的错误原文、步骤名称和文件位置。
13.5 发布前后可以核对的清单
- 我知道自己当前是在本地、Runner 还是服务器。
- 我能从一个页面元素找到配置、数据与素材来源。
- 我能解释工作区、暂存区、commit、push。
- 我知道 GitHub、ACR、服务器、OSS 各自放什么。
- 我能准确指出本项目的人工发布入口。
- 我不会把当前 CI 绿色描述成全量 lint 和浏览器测试全部通过。
- 我知道服务器固定脚本不会随前端镜像自动更新。
- 我能解释 8080/8081 与容器 80 的关系。
- 我会验证代理实际命中哪个容器。
- 我能用 Console、Network、Elements 和 preview 排查白屏。
- 我知道恢复访问不等于完成长期修复,也不等于完成 SEO。
参考资料
文中的实现以这个项目为例。涉及工作流触发、构建分包、代理和索引规则时,可以结合使用的版本查阅以下官方资料。
- GitHub Pages 的托管职责可查 GitHub Pages 简介。
- Actions 触发与手动按钮可查 工作流事件 和 手动运行。
- 镜像构建阶段分工可查 Docker 多阶段构建。
- 配置、代理与 reload 可查 Nginx 初学者指南。
- 构建分包与环境变量可查 Rollup manualChunks 和 Vite 环境变量。
- 浏览器请求检查可查 Chrome DevTools Network。
- 抓取与不索引的区别可查 Google noindex。
最后,用三张图把整条路线连起来
总结图一:一次发布的完整闭环

本地改动先经过 build 和预览,Git 保存确定的版本,CI 做自动检查,人触发发布,ACR 保存镜像,服务器运行它,最后由浏览器验收。每一步都留下对应证据:提交 SHA、工作流结果、镜像引用、实际容器和用户体验。
总结图二:服务器上的两层 Nginx

排查时沿请求方向走:域名解析到服务器,HTTPS 到宿主机 Nginx,站点引用 backend,选中 8080 或 8081,再映射到容器 80,由容器 Nginx 返回 dist。宿主机配置、镜像内配置和服务器部署脚本是三种不同的文件,不会因为修改了其中一个就全部同步。
图中最右侧的大盒子表示选中容器的内部放大结构,不是又增加了第三个容器。
总结图三:出问题时先查哪一层

连不上先查连接入口,502 先查代理与容器,HTML 到达后白屏先看浏览器运行,Actions 失败先找对应 SHA 的第一个失败步骤。始终遵循“观察 → 假设 → 验证 → 最小修复 → 复测”,用同样的复现步骤证明修复有效。