resin-blog
首页归档简历规划关于
...
赣ICP备2026011201号-1

作品集

2026-09-14· 前端, docker, CICD

从开源 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 第一次走查:先熟悉原来的交互

按“入口 → 走廊 → 四个房间”走一遍:

  1. About:看首屏、三张卡片和详情弹层。
  2. Gallery:悬停项目纸卡,观察上色;点击翻面,再看链接。
  3. Studio:检查内容卡片,区分可跳转与仅展示的信息。
  4. Contact:区分复制账号、外链和仅展示账号。
  5. 尝试地图跳转、返回、浏览器前进后退与键盘操作。

这一步的价值是建立基线:知道原来怎样工作,之后才知道自己的修改有没有破坏功能。

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 把开源作品集改成自己的内容

内容由统一配置供给 3D 场景、无障碍 HTML 和 SEO

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 用一个小修改学会完整流程

目标:更改个人摘要。位置:本地 · 编辑器。

  1. 打开 src/config/site.js,找到 seo.about。
  2. 把原摘要改成自己真实的一句话,不改变对象结构。
  3. 用编辑器全局搜索 siteConfig.seo.about,观察谁在读取它。
  4. 保存后查看对应页面和语义内容。
  5. 检查源代码管理中的差异,确认只改了预期字段。

如果 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 与网站部署

Git 的四个位置:工作区、暂存区、本地仓库、GitHub

4.1 修改过文件,不等于已经有一个版本

位置 里面是什么 下一步
工作区 编辑器当前看到的文件 选择要记录的改动
暂存区 准备放入下一次提交的内容 创建提交
本地仓库 已提交的版本历史 推送远程
GitHub 远程仓库 别人和 Actions 能获取的版本 检查、构建、发布

一次提交会生成一个提交编号,通常称为 Git SHA。界面常显示前几位便于阅读;部署脚本要求的是完整 40 位编号。

4.2 先用界面做一次,再认识对应命令

位置:本地 · 编辑器。 在 VS Code 的源代码管理中:

  1. 点击改动文件,查看修改前后差异。
  2. 只暂存本次要记录的文件。
  3. 输入能说明目的的提交信息。
  4. 创建 Commit。
  5. 确认远程与分支后 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 · 网页。

  1. 打开仓库,进入 Actions。
  2. 选择 CI,打开与你本次提交 SHA 对应的运行记录。
  3. 点击任务 Validate site and container。
  4. 查看第一个失败步骤,展开它最后的明确错误。
  5. 记录步骤名称、错误原文、提交编号。不要只记“有个红叉”。
失败位置 先看什么
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,到底传走了什么

Node 和 Vite 构建 dist,再把 Nginx 与 dist 组成镜像并启动容器

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

排查路径是:

  1. 在 Actions 展开这个失败步骤,确认不是前面的 lint 日志。
  2. 核对 registry 主机是否是目标实例的镜像入口。
  3. 在 ACR 控制台核对镜像访问用户名、凭证类型与仓库权限。
  4. 在 GitHub 更新对应的 Secrets。
  5. 再运行失败任务,观察登录和 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 与蓝绿部署,怎样把流量交给新版本

双层 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 页面白屏时,开发者怎样自己找到原因

白屏先看浏览器运行,502 先看代理与容器,按观察、假设、验证推进

配图用于理解排查方向,里面的示例文件名和配置不是当前服务器的配置模板。具体判断以 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,点开入口脚本与报错脚本。

  1. 看状态码:404 说明路径/文件缺失,5xx 说明服务链出错。
  2. 看 Content-Type:JS 请求不能收到 text/html 的错误回退页。
  3. 看 Response:即使 200,也要确认返回的是 JavaScript。
  4. 看文件名哈希: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 修复后怎么验收

  1. 本地重新 build,确认原警告/错误变化与预期一致。
  2. 新开浏览器上下文检查 production preview。
  3. Console 无原来的阻断错误,关键 JS/纹理请求正常。
  4. 首页可见,Gallery 翻面、房间导航等关键交互可用。
  5. 保存新提交,经过 CI、Publish、Deploy。
  6. 在线上重复同一套验收,记录对应 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 与无障碍

同一份内容提供 3D 体验和语义 HTML,访问成功不代表已经收录

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 你可以怎样手动检查

位置:本地预览浏览器,或自己的线上站点。

  1. “查看网页源代码”检查构建后初始 HTML 的 title、description、canonical 和 JSON-LD。
  2. F12 → Elements 检查运行后 DOM;它与“查看源代码”不是同一时间点的内容。
  3. 从首页进入 Gallery,观察地址栏和标题是否变化。
  4. 直接输入 /gallery 并刷新,确认页面能恢复。
  5. 打开 /robots.txt、/sitemap.xml,核对域名、路径与原型开关。
  6. 键盘 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 六个关键区别

  1. git push 上传的是代码版本,ACR 保存的是镜像,服务器运行的是容器。
  2. CI 检查通过,只能证明当前检查覆盖的内容通过。
  3. 本项目由人批准 Publish,Publish 成功后才自动 Deploy。
  4. 宿主机 Nginx 决定请求去哪,容器 Nginx 决定返回哪些文件。
  5. 代码、部署脚本、服务器配置和线上镜像各有自己的版本与生效方式。
  6. 先观察现象,再提出假设;用同样的复现方法证明修复。

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。

最后,用三张图把整条路线连起来

总结图一:一次发布的完整闭环

发布闭环:本地验证、Git 保存、CI、人工发布、自动部署、浏览器验收

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

总结图二:服务器上的两层 Nginx

服务器速查:浏览器经过宿主机 Nginx 和 backend,进入 Blue 或 Green 的容器 Nginx

排查时沿请求方向走:域名解析到服务器,HTTPS 到宿主机 Nginx,站点引用 backend,选中 8080 或 8081,再映射到容器 80,由容器 Nginx 返回 dist。宿主机配置、镜像内配置和服务器部署脚本是三种不同的文件,不会因为修改了其中一个就全部同步。

图中最右侧的大盒子表示选中容器的内部放大结构,不是又增加了第三个容器。

总结图三:出问题时先查哪一层

排错速查:域名连接、502、HTML 200 白屏和 Actions 失败分别对应不同证据入口

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

评论

On this page

  • 从开源 3D 作品集到自动上线:把开发、部署和排错串起来
  • 从本地页面,到一个可以持续更新的网站
  • 第一部分:让作品集属于你
  • 01 先认识你正在操作的几样东西
  • 02 先让原项目在本地跑起来
  • 03 把开源作品集改成自己的内容
  • 第二部分:让改动有版本、能构建
  • 04 用 Git 保存版本,分清 GitHub 与网站部署
  • 05 CI 是怎样自动检查项目的
  • 06 Docker 镜像与 ACR,到底传走了什么
  • 第三部分:把网站交给服务器
  • 07 首次准备服务器,哪些手动做,哪些只做一次
  • 08 以后每次发布,按这个顺序操作
  • 09 Nginx 与蓝绿部署,怎样把流量交给新版本
  • 第四部分:处理上线后的问题
  • 10 页面白屏时,开发者怎样自己找到原因
  • 11 502、Nginx 配置丢失和“明明改了却没生效”
  • 12 网站能访问以后,还要理解 SEO 与无障碍
  • 发布与排错速查
  • 13.1 一张文字速查图
  • 13.2 六个关键区别
  • 13.3 给下一次修改留下可查的证据
  • 13.4 排错记录模板
  • 13.5 发布前后可以核对的清单
  • 参考资料
  • 最后,用三张图把整条路线连起来
  • 总结图一:一次发布的完整闭环
  • 总结图二:服务器上的两层 Nginx
  • 总结图三:出问题时先查哪一层
← React 核心原理:渲染流程 · Fiber · Hook 链表 · Hooks个人博客上线 →