1dao 开发测试环境搭建工作总结
完整技术文档合集
目录
第1卷
1dao 开发测试环境搭建工作总结(一):项目背景与虚拟机环境搭建
第一章:项目背景与需求分析
1.1 1dao.cc 站群概述
1dao.cc 是一家仪器公司的对外站群,以一个主域名 1dao.cc 为核心,通过子域名的方式横向扩展出多个功能各异的业务站点。整个站群并非单一应用,而是由若干独立部署、技术栈各异的子系统组合而成,这些子系统共享同一个根域,在对外呈现上构成一个统一的品牌矩阵。理解站群的组成结构,是后续做环境复刻、域名解耦、数据同步等所有工作的前提。
站群共包含以下六个核心站点,每个站点承担不同的业务职能,且各自基于不同的技术实现:
| 站点 | 域名 | 技术栈 | 业务职能 |
|---|---|---|---|
| 主站 | `1dao.cc` | 静态 HTML + 少量 PHP | 公司门户、产品展示、品牌入口 |
| 导航站 | `nav.1dao.cc` | 静态 HTML | 站群导航、流量分发 |
| 博客 | `blog.1dao.cc` | emlog(轻量博客系统) | 技术文章、行业资讯、SEO 内容 |
| 商城 | `shop.1dao.cc` | CRMEB(开源电商框架) | 仪器产品在线销售、订单管理 |
| 禅道 | `zentao.1dao.cc` | 禅道开源版(PHP) | 项目管理、Bug 跟踪、文档协作 |
| 部署系统 | `walle.1dao.cc` / `deploy.1dao.cc` | walle(部署工具) | 代码部署、发布管理、回滚 |
主站(1dao.cc) 是整个站群的门面,采用静态 HTML 加少量 PHP 脚本的形态,首页有中英文及简繁体多语言版本(zh-hans、zh-hant、en 目录),还包含一个 counter.php 计数器脚本和 about-site、releases 等子页面。主站的代码结构简单,但页面内硬编码了大量指向其他子站的链接,这些链接以绝对 URL(包含 1dao.cc)的形式存在,是后续域名替换工作需要重点处理的耦合点之一。
导航站(nav.1dao.cc) 是一个纯粹的静态页面,作用是为整个站群提供统一的入口和导航,把访客引导到各个子站。它的代码量小,但其中的导航链接同样以绝对 URL 形式指向各子站。
博客(blog.1dao.cc) 基于 emlog——一个国产的轻量级 PHP 博客系统。emlog 的特点是部署简单、资源占用低,适合中小型内容站点。博客站点的域名耦合点主要在两个地方:一是数据库 emlog_options 表里的 blogurl 配置项,它决定了系统生成链接时使用的基础域名;二是博客文章内容(emlog_blog 表)和评论(emlog_comment 表)中可能包含的绝对 URL。此外,emlog 的模板文件(header.php、style.css、showcase.php、export_article.php)中也硬编码了域名。
商城(shop.1dao.cc) 基于 CRMEB——一个基于 ThinkPHP 的开源电商系统,功能涵盖商品管理、订单、支付、DIY 装修、营销活动等。CRMEB 是整个站群中最复杂、数据量最大的子系统,它的域名耦合点也最多:数据库 crmeb 中的 eb_system_config 表存储了 site_url 等配置,商品图片、分类图片、DIY 页面、主题配置等表中都大量存储了以 1dao.cc 开头的绝对 URL。据初步统计,仅 crmeb 一个库就有约 300+ 处包含域名的记录。
禅道(zentao.1dao.cc) 是项目管理和 Bug 跟踪系统,基于禅道开源版。禅道的域名耦合相对较轻——运行时支持自动检测当前访问域名,但数据库中的项目名(zt_project)、任务描述(zt_task)、文档内容(zt_doccontent)和历史记录(zt_history)等表中仍可能存在硬编码的域名引用。
部署系统(walle.1dao.cc / deploy.1dao.cc) 基于 walle——一个支持 Git 的代码部署工具,提供 Web 界面管理发布流程。walle 的域名耦合点主要在数据库 project 表中存储的 repo_url(代码仓库地址)和 release_to(发布目标路径),以及 record、task 表中的部署记录。值得注意的是,walle 有两个访问域名(walle.1dao.cc 和 deploy.1dao.cc),在 Nginx 配置和 hosts 映射中都需要同时处理。
整个站群共涉及 4 个数据库(crmeb、emlog、walle、zentao),经排查,数据库中含 1dao.cc 字样的记录总数超过 400 处,分布在上述各库的多张表中。代码层面,排除 vendor、runtime、cache 等依赖和运行时目录后,仍有 24 个文件包含硬编码域名。这些数字直观地说明了"域名耦合"的严重程度,也是后续设计环境切换工具链(switch-env、change-domain)的核心动因。
站群的整体拓扑关系如下图所示:
┌──────────────────────────────────────┐
│ 1dao.cc 站群拓扑 │
└──────────────────────────────────────┘
┌─────────────┐
│ 1dao.cc │ (主站, 静态HTML+PHP)
│ 公司门户 │
└──────┬──────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ nav.1dao.cc │ │blog.1dao.cc │ │shop.1dao.cc │
│ 导航站(静态) │ │ 博客(emlog) │ │ 商城(CRMEB) │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌────────────────┐ ┌────────────────────┐
│zentao.1dao.cc │ │walle.1dao.cc │
│ 禅道(项目管理) │ │deploy.1dao.cc │
└────────────────┘ │ walle(部署系统) │
└────────────────────┘
共享基础设施: Nginx + PHP-FPM + MariaDB + Redis
域名耦合: 数据库 400+ 处, 代码 24 个文件
1.2 线上环境
线上服务器是一台部署在阿里云的云服务器,以下是经过确认的环境信息:
| 项目 | 线上值 |
|---|---|
| IP 地址 | `47.113.148.3` |
| 操作系统 | Debian(Linux) |
| Nginx 版本 | `1.26.3` |
| PHP 版本 | `8.4.24`(PHP-FPM 方式运行) |
| 数据库 | MariaDB `11.8` |
| 缓存 | Redis |
| HTTPS 证书 | Let's Encrypt(有效期至 2026-11-07) |
这是一个相对现代且统一的技术栈:Nginx 作为反向代理和静态文件服务,PHP 8.4 以 FPM(FastCGI Process Manager)方式运行处理动态请求,MariaDB 11.8 作为关系型数据库(MySQL 的社区分支,兼容 MySQL 协议),Redis 负责缓存和会话存储。Let's Encrypt 提供免费 HTTPS 证书。
值得注意的是几个版本选择的技术含义:
Nginx 1.26.3 是 Nginx stable 分支的一个较新版本(1.26 系列于 2024 年发布)。在测试环境复刻时,必须保持版本一致,因为不同版本的 Nginx 在配置指令(如 http2 指令的写法在 1.25+ 改为 http2 on;)、SSL 行为、默认值上可能存在差异,版本不一致会导致配置不兼容或行为偏差。
PHP 8.4.24 是 PHP 8.4 系列的较新 patch 版本。PHP 8.4 相比 8.3 引入了一些新特性(如属性钩子、不对称可见性等),同时对一些旧行为做了废弃和移除。emlog、CRMEB、禅道、walle 这些应用是否完全兼容 PHP 8.4 是一个需要验证的风险点——尤其是一些老旧应用可能使用了被移除的函数或特性。版本必须严格对齐线上,否则测试环境验证通过的功能在线上可能因 PHP 版本差异而行为不同。
MariaDB 11.8 是 MariaDB 的较新 LTS 版本。MariaDB 与 MySQL 在大多数场景下兼容,但在某些 SQL 语法、字符集排序规则、函数行为上存在细微差异。测试环境必须使用 MariaDB 而非 MySQL,以保证数据导入导出、SQL 执行行为的一致性。
Redis 用于缓存和会话。CRMEB 等应用依赖 Redis 存储缓存数据和 session,在环境切换时需要清空 Redis(FLUSHALL),否则旧的缓存(包含旧域名)会干扰新环境的表现。
这些版本信息构成了测试环境搭建的"基准线"——所有软件版本必须与线上对齐,这是"环境一致性"原则的基本要求。环境一致性保证了"在测试环境验证通过的行为,在线上也会一致"这一推论的成立前提。
1.3 接手需求
本次工作的起因是用户(1dao 站群的所有者)需要将站群的开发和维护工作交接给新的开发人员(本机操作者)。交接涉及两个层面的需求:
第一,代码访问权。线上代码需要纳入版本控制,以便新开发人员获取、修改、提交代码。经了解,代码托管在 Gitee(码云)上,组织名为 huaqidao,仓库为私有仓库。新开发人员需要被加入该组织并获得仓库的读写权限,或者通过部署公钥的方式获得拉取权限。
第二,服务器访问权。新开发人员需要 SSH 登录线上服务器,以便直接查看线上运行状态、排查问题、执行部署。SSH 访问通常通过公钥认证实现——将开发人员的公钥加入服务器对应用户的 ~/.ssh/authorized_keys 文件。
这两项授权都需要线上服务器管理员(或用户本人)操作,新开发人员自身无法完成。授权流程的依赖性是后续方案转变的重要诱因之一。
接手一个在运行中的生产站群,与传统新项目开发有本质区别:它要求在不影响线上服务的前提下,建立一套可以安全试错、快速迭代的本地工作环境。这就引出了"测试环境"的必要性。
1.4 授权问题
授权环节涉及两个目标:服务器 SSH 和 Gitee 仓库。新开发人员的 SSH 密钥对标识为 zxy@openclaw.ai(通常反映生成密钥时的 -C 注释字段),公钥需要分发到两处。
SSH 公钥加到服务器:
公钥需要加入线上服务器(47.113.148.3)目标用户的 ~/.ssh/authorized_keys。这一步必须由已有服务器访问权限的人(管理员或用户)完成,操作方式通常是:
## 第二章:VMware 虚拟机创建
### 2.1 VMware Fusion 环境检查
macOS 上的虚拟化方案选择:主流选项有 VMware Fusion、Parallels Desktop、Oracle VirtualBox。本机已安装 VMware Fusion,因此直接采用。VMware Fusion 的优势在于与 macOS 集成良好、性能优秀、命令行工具(`vmrun`、`vmware-vdiskmanager`)完善,便于脚本化操作;Parallels 商业收费且命令行支持较弱;VirtualBox 免费但性能和稳定性略逊。
在创建虚机前,首先检查 VMware Fusion 提供的命令行工具是否可用,这是后续脚本化创建虚机的前提:
第三章:Debian 系统配置
虚机安装完成后,得到的是一个最小化的 Debian 系统。接下来需要进行一系列配置,使其成为可用的开发测试环境的基础底座。这些配置分为网络、SSH、系统基础、用户权限、防火墙五个方面。
3.1 网络配置
虚机的网络配置是主机能否访问虚机的前提。本次采用 Bridge(桥接)模式(原因详见 4.4 节分析),虚机直接接入物理局域网,获得一个独立的局域网 IP。
确认虚机 IP:
虚机启动后,通过控制台或 DHCP 分配的 IP 登录,查看网络配置:
## 第四章:macOS 主机网络配置
虚机就绪后,还需要在 macOS 主机端做一系列配置,才能方便地通过域名访问虚机、通过 SSH 连接虚机。这一章聚焦于主机侧的配置,涉及域名解析、DNS 缓存、SSH 别名、网络模式理解和 WiFi 行为等。
### 4.1 /etc/hosts 机制详解
`/etc/hosts` 是操作系统级的域名解析文件,它允许在不依赖 DNS 服务器的情况下,手工指定域名到 IP 的映射。这是实现"用线上同域名访问测试环境"的核心机制——把 `1dao.cc` 在本机解析到虚机 IP,而非真实的线上 IP。
**域名解析优先级(nsswitch.conf)**:
操作系统解析域名时,会按 `/etc/nsswitch.conf`(macOS 上功能等价的是 `mDNSResponder`)指定的顺序查询各个来源。典型的 `hosts` 行:
第五章:经验教训
5.1 安全隔离原则
本次工作最根本的经验,是确立了"测试环境与线上完全隔离"的原则,并以此指导整个方案设计。
为何必须隔离:
生产环境承载着真实业务和用户数据,任何直接操作都蕴含风险。一个常见的误区是"我小心点就行"——但失误并非源于"不小心",而是源于:
- 认知盲区:不熟悉系统时,无法预见某个操作的连锁影响
- 疲劳失误:长时间工作中,概率性失误不可避免
- 环境差异:测试环境与线上的细微差异,导致本应安全的操作在线上引发问题
- 数据污染:在真实数据上做测试,测试数据与真实数据混杂,难以分离
隔离原则要求:测试环境在物理(不同机器/虚机)、网络(不同 IP/网段)、数据(独立数据库副本)三个层面都与生产环境分离。任何变更先在测试环境验证,确认无误后才通过受控流程部署到线上。
隔离的边界:隔离不等于"完全不接触线上"。合理的隔离是:
- 日常开发、调试、测试 → 在测试环境
- 部署经过验证的变更 → 通过部署系统(walle)接触线上,且部署过程是脚本化、可回滚的
- 查看线上状态 → 只读访问,不做修改
这次方案从"直连线上"转向"本机虚拟机",正是把"日常开发"从生产环境中剥离,放到隔离的测试环境。线上服务器只在"部署"这个受控环节被触及。
隔离的成本与收益:
隔离有成本:搭建测试环境需要时间和资源、保持环境与线上一致需要持续维护、数据同步需要流程。但这些成本远低于一次线上事故的损失——线上事故影响真实用户、损害业务、可能造成数据无法恢复。隔离是用确定的小成本,规避不确定的大损失。
5.2 虚拟机方案的优势
选择 VMware 虚拟机作为测试环境的载体,在实践中体现了多重优势:
零风险调试:
虚机是独立于主机的完整系统,在虚机内的任何操作——删除文件、修改配置、甚至搞坏系统——都不会影响主机和线上。这种"怎么折腾都行"的自由度,是调试和学习所需要的。可以大胆尝试可能有问题的操作,观察结果,而不必担心后果。
快速回滚(快照):
VMware 支持虚机快照——某一时刻的完整状态(内存+磁盘)冻结。搞砸了可以一键恢复到快照点:
## 附录:关键命令速查
### A.1 VMware 虚机管理
总结
本文档系统记录了 2026-08-21 当天围绕 1dao 站群开发测试环境搭建所做的前端基础工作,覆盖从项目背景调研到虚拟机创建再到系统与网络配置的完整链条。
核心成果:
- 明确了项目背景:梳理了 1dao 站群的六个子站点(主站、导航、博客、商城、禅道、walle)、线上环境信息(IP、OS、软件版本)、接手需求和授权流程,理清了域名耦合的规模(数据库 400+ 处、代码 24 个文件)。
- 完成了方案决策:基于安全隔离、风险控制、授权依赖等考量,从"直连线上"转向"本机虚拟机"方案,确立了"测试环境与生产完全隔离"的根本原则。
- 创建了 VMware 虚拟机:检查了 VMware Fusion 工具链,配置了 2GB 内存/40GB 磁盘/Bridge 网络的虚机,选用 Debian 13 trixie netinst ISO,编写了 preseed 自动应答文件并用
pycdlib重新打包 ISO(解决了 boot info table 的坑),实现了全自动安装。
- 完成了 Debian 系统配置:配置了静态网络(虚机 IP
192.168.207.130)、SSH 密钥认证、清华 apt 源、Asia/Shanghai 时区、英文 locale、dev 用户 sudo 权限、ufw 防火墙(开放 22/80/443)。
- 打通了 macOS 主机网络:深入理解了
/etc/hosts机制和 macOS DNS 缓存(mDNSResponder),编写了dev-on/dev-off/which-env主机脚本实现 hosts 切换,配置了 SSH config 别名1dao-dev,分析了 Bridge 网络模式的选择理由,解决了 WiFi 断开时 hosts 不解析的降级方案(IP+端口直连)。
关键技术决策的回顾:
| 决策点 | 选择 | 核心理由 |
|---|---|---|
| 虚拟化方案 | VMware Fusion(完整虚机) | 零风险、快照回滚、环境一致性 |
| 操作系统 | Debian 13 trixie | 与线上 Debian 一致,软件版本够新 |
| 安装方式 | preseed 自动安装 | 可重复、无人值守、配置即代码 |
| 网络模式 | Bridge 桥接 | 虚机独立 IP,无端口冲突,多域名访问清晰 |
| 软件源 | 清华 TUNA 镜像 | 国内速度快,同步及时 |
| 访问模型 | hosts 域名映射 + SSH 别名 | 业务命名与系统命名分层,各得其所 |
| 降级方案 | IP+端口直连 | 应对 WiFi 断开时 hosts 不解析 |
遗留与后续:
本文档覆盖的是环境搭建的"基础底座"部分。后续工作(将在本系列总结的第二、第三部分展开)包括:
- 在虚机内安装 LNMP 软件栈(nginx 1.26.3、php 8.4.24、mariadb 11.8、redis),版本与线上一一对齐
- 从线上迁移站点代码和数据库到虚机,恢复 letsencrypt 证书
- 设计和实现域名切换工具链(
switch-env、change-domain),处理 400+ 处域名耦合,实现 prod/dev 模式无损切换 - 编写完整的技术文档(
1dao-dev-env-tech-doc.md),沉淀整套环境的架构和使用方法
本次工作的整体架构,用一张图总结:
┌──────────────────────────────────────────────────────────────────┐
│ 1dao 开发测试环境 - 基础底座 │
└──────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ macOS 宿主机 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ /etc/hosts │ │ ~/.ssh/config│ │ dev-on/off 脚本 │ │
│ │ 1dao.cc→虚机 │ │ 1dao-dev别名 │ │ which-env │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬────────┘ │
│ │ │ │ │
│ └─────────────────┼───────────────────┘ │
│ │ │
└───────────────────────────┼───────────────────────────────┘
│ Bridge 桥接网络
│ 192.168.207.130
┌───────────────────────────┼───────────────────────────────┐
│ Debian 13 虚机 │
│ │ │
│ ┌────────────────────────┴────────────────────────────┐ │
│ │ 系统配置: 清华源 / Asia/Shanghai / en_US.UTF-8 │ │
│ │ 用户: dev (sudo NOPASSWD) / SSH 密钥认证 │ │
│ │ 防火墙: ufw (22/80/443) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 待安装 (后续工作): │ │
│ │ Nginx 1.26.3 + PHP-FPM 8.4 + MariaDB 11.8 + Redis │ │
│ │ 站点代码 (1dao.cc/nav/blog/shop/zentao/walle) │ │
│ │ 环境切换工具链 (switch-env / change-domain) │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
本次工作为后续的软件栈部署、代码迁移、工具链开发奠定了坚实的基础。虚机已就绪、网络已打通、访问通道已建立,可以进入下一阶段的具体环境搭建工作。
文档版本:1.0 | 编写日期:2026-08-21 | 本系列第一部分,共三部分
第2卷
1dao 开发测试环境搭建工作总结(二):软件栈安装与数据恢复
第一章 软件栈安装
1.1 总体策略与版本对齐原则
在开始软件栈安装前,需要明确一个核心原则:开发测试环境的软件版本必须与线上一致。这并非出于强迫症,而是为了规避"在我机器上能跑"的经典问题。PHP 8.4 与 PHP 8.1 在某些扩展接口上存在差异,例如 mb_ 函数的默认编码探测行为、preg_ 函数对 Unicode 属性转义的支持;MySQL 8.4 与 MariaDB 11.8 在 SQL_MODE 默认值、json_table 函数行为、RETURNING 子句支持上也有细微差别。如果开发环境用了 PHP 8.1 而线上是 PHP 8.4,某些 deprecation warning 在开发期不会暴露,到上线时才暴雷。
版本对齐的另一个隐性收益是排障效率。线上某天出现一个诡异的现象(例如某个 SQL 在 MariaDB 11.8 下走了全表扫描),如果测试环境也是 11.8,可以直接复现、调试、验证修复方案,而不必凭空猜测"是不是版本差异导致的"。
因此本节所有安装都以"与线上版本号完全一致"为目标,具体版本如下表:
| 软件组件 | 线上版本 | 目标版本 | 安装方式 | 状态 |
|---|---|---|---|---|
| nginx | 1.26.3 | 1.26.3 | 官方 apt 源 | 对齐 |
| PHP | 8.4.24 | 8.4.24 | sury.org 源 | 对齐 |
| MariaDB | 11.8.x | 11.8.x | MariaDB 官方源 | 对齐 |
| Redis | 7.x | 7.x | Debian 仓库 | 对齐 |
| certbot | 4.0.0 | 4.0.0 | pip 安装 | 对齐 |
| goaccess | 1.x | 1.x | Debian 仓库 | 对齐 |
| fail2ban | 1.x | 1.x | Debian 仓库 | 对齐 |
备选方案说明:也可以全部使用 Debian 13 默认仓库的版本(更省事),但 Debian trixie 默认 PHP 是 8.4,nginx 是 1.26,MariaDB 是 11.8,基本能满足要求。不过为了精确控制小版本号(如 PHP 8.4.24 而非 8.4.20),还是选择官方源更稳妥。源码编译也是一个选项,但维护成本高(每次安全更新都要重新编译),且容易遗漏编译参数导致扩展不兼容,故不采用。
1.2 nginx 1.26.3 安装
#### 1.2.1 添加官方源
Debian 13 默认仓库的 nginx 版本可能与线上小版本号不完全一致。为精确对齐 1.26.3,添加 nginx 官方仓库:
## 第二章 备份文件解密
### 2.1 备份文件清单
从线上服务器拷贝过来的备份包共 5 个,采用两种加密方式:
| 文件名 | 大小 | 加密方式 | 用途 |
|---------------------------------|-------|-------------------------|---------------------|
| db-all-20261107.sql.gz.gpg | 2.2M | GPG 对称加密 (AES256) | 全库 SQL 备份 |
| sys-config-20261107.tar.gz.gpg | 1.4M | GPG 对称加密 (AES256) | /etc 系统配置 |
| site-data-20261107.tar.gz.gpg | 30M | GPG 对称加密 (AES256) | 各站代码与上传文件 |
| openclaw-20261107.tar.gz.gpg | 39M | GPG 对称加密 (AES256) | OpenClaw 状态数据 |
| secrets-20261107.enc | 4K | openssl AES-256-CBC | 凭据/证书私钥 |
**为什么用两种加密方式**:GPG 对称加密适合大文件批处理,口令管理简单;而 secrets 包用 openssl AES-256-CBC,是历史原因——早期 secrets 加密脚本是 openssl 写的,后来其他备份统一迁移到 GPG,但 secrets 因为密钥轮换不便,沿用 openssl。这种混合策略虽然不优雅,但运行稳定,改造成本高,所以保留。
**为什么 secrets 只有 4K**:secrets 包里只有文本凭据(数据库密码、API key、letsencrypt 账户私钥的 .pem),没有大文件。压缩后 4K 合理。
### 2.2 GPG 加密原理详解
GPG (GNU Privacy Guard) 是 OpenPGP 标准(RFC 4880)的实现,支持两种加密模式:
#### 2.2.1 对称加密 (Symmetric)
发送方和接收方共享同一个口令。加密时:
1. 用户输入口令(passphrase)
2. GPG 用 S2K (String-to-Key) 函数从口令派生出密钥,涉及哈希迭代(默认 SHA1,可配置 SHA256)和盐(salt),防止彩虹表攻击
3. 用派生的密钥加密文件内容(对称加密算法,默认 AES256)
4. 加密后的文件包含 S2K 参数(哈希算法、迭代次数、盐)和密文
解密时输入同一口令,GPG 用相同的 S2K 参数重新派生密钥,解密文件。适合单人备份场景,无需管理密钥对。
#### 2.2.2 非对称加密 (Asymmetric)
用接收方的公钥加密,只有持有私钥的人能解密。流程:
1. 接收方生成密钥对(公钥+私钥),公钥可公开分发
2. 发送方用接收方公钥加密文件
3. 加密时还可以签名(用发送方私钥),接收方用发送方公钥验签
4. 接收方收到后用私钥解密
适合多方通信,但需要事先分发公钥,且私钥本身又需要加密保护(陷入"鸡生蛋"问题)。
本环境的备份用的是对称加密,因为备份脚本自动化运行,非对称加密需要管理私钥文件,而私钥文件本身又需要加密保护。对称加密只需把口令存在安全的 secrets 包里(用另一种加密),口令本身用 openssl 加密,形成两层防护。
#### 2.2.3 AES256 加密算法
AES (Advanced Encryption Standard) 是 NIST 标准的对称加密算法,密钥长度 128/192/256 位。AES256 用 256 位密钥,14 轮加密,理论安全强度 2^128( birthday attack 下限),目前无已知实际攻击方法。
GPG 对称加密默认用 AES256,可以通过 `--cipher-algo` 修改。本环境用默认值。
#### 2.2.4 gpg 命令详解
GPG 对称加密解密的关键参数:
- `--batch`:批处理模式,不进入交互菜单
- `--passphrase <口令>`:直接传入口令(脚本自动化必需)
- `--pinentry-mode loopback`:绕过 pinentry 弹窗(否则在 SSH 非交互终端会卡住)
- `-d` 或 `--decrypt`:解密
- `-c` 或 `--symmetric`:对称加密
- `-o <文件>`:输出到指定文件
- `--yes`:覆盖已存在文件不询问
**坑点**:在某些 GPG 版本中,即使指定 `--passphrase`,如果没有 `--pinentry-mode loopback`,仍会尝试调用 pinentry 弹窗,在 SSH 会话中表现为"hang 住不动"。所以脚本中必须同时指定这两个参数。
### 2.3 GPG 解密执行
第三章 数据库恢复
3.1 创建数据库
db-all.sql.gz 是全库备份,包含 CRMEB、emlog、Walle、禅道四个应用的数据库。但备份文件里的 CREATE DATABASE 语句可能用了不存在的字符集(如 utf8 而非 utf8mb4),或者没有 IF NOT EXISTS,直接导入可能报错或字符集不对。因此先手动创建数据库,确保字符集正确:
mysql -uroot -p <<'EOF'
CREATE DATABASE IF NOT EXISTS crmeb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS emlog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS walle CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS zentao CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 验证
SHOW DATABASES;
EOF
为什么每个应用独立数据库:应用隔离,避免表名冲突(虽然不同应用表名一般不冲突,但万一);便于按应用备份恢复;权限隔离更精细。备选方案是共用一个数据库加表前缀,但 CRMEB、emlog、禅道的表前缀约定不一致(CRMEB 用 eb_,emlog 用 emlog_,禅道用 zt_,Walle 用 walle_),管理混乱,所以还是独立数据库更清晰。
关于 COLLATE 的选择:utf8mb4_unicode_ci 和 utf8mb4_general_ci 都是 utf8mb4 的排序规则:
general_ci:旧版,基于 MySQL 自定义规则,速度快但中文排序不准unicode_ci:基于 Unicode 标准,中文排序更准确,但稍慢
线上用 unicode_ci,跟随。如果用 general_ci,中文按拼音排序会乱(如"啊"排在"波"后面),影响禅道的任务列表展示。
3.2 导入数据
#### 3.2.1 解压并导入
db-all.sql.gz 是 gzip 压缩的 SQL 文本,不需要先解压到磁盘,可以用 zcat 管道直接喂给 mysql,节省磁盘空间:
zcat /root/restore/db/db-all.sql.gz | mysql -uroot -p
## 第四章 站点代码恢复
### 4.1 解压 site-data 包
第五章 HTTPS证书恢复
5.1 letsencrypt 证书目录结构
letsencrypt(certbot)的证书在 /etc/letsencrypt/ 下,结构如下:
/etc/letsencrypt/
├── accounts/ # ACME 账户信息
│ └── acme-v02.api.letsencrypt.org/
│ └── directory/
│ └── <account_id>/
│ └── regr.json, meta.json, private_key.json
├── archive/ # 历史证书版本(每次续期保留旧版)
│ ├── 1dao.cc/
│ │ ├── cert1.pem, chain1.pem, fullchain1.pem, privkey1.pem
│ │ └── cert2.pem, ... (续期后递增)
│ └── ...
├── live/ # 当前生效证书(软链接到 archive)
│ ├── 1dao.cc/
│ │ ├── cert.pem -> ../../archive/1dao.cc/cert2.pem
│ │ ├── chain.pem -> ../../archive/1dao.cc/chain2.pem
│ │ ├── fullchain.pem -> ../../archive/1dao.cc/fullchain2.pem
│ │ └── privkey.pem -> ../../archive/1dao.cc/privkey2.pem
│ └── ...
├── renewal/ # 续期配置
│ └── 1dao.cc.conf
└── renewal-hooks/ # 续期后执行的钩子
关于 archive 和 live 的设计:certbot 每次续期都会生成新版本(cer1, cert2, ...),保留旧版本便于回滚(新证书有问题时可以改软链接指回旧版)。live/ 目录下的文件都是软链接,指向 archive/ 里的最新版本,这样 nginx 配置里写 live/1dao.cc/fullchain.pem 永远指向当前生效证书,续期后无需改 nginx 配置。
5.2 恢复的 9 个域名证书
| 域名 | 用途 | 证书目录 |
|---|---|---|
| 1dao.cc | 主站 | /etc/letsencrypt/live/1dao.cc |
| www.1dao.cc | 主站 www | /etc/letsencrypt/live/www.1dao.cc |
| openclaw.1dao.cc | OpenClaw 服务 | /etc/letsencrypt/live/openclaw.1dao.cc |
| nav.1dao.cc | 导航站 | /etc/letsencrypt/live/nav.1dao.cc |
| blog.1dao.cc | 博客 | /etc/letsencrypt/live/blog.1dao.cc |
| shop.1dao.cc | CRMEB 商城 | /etc/letsencrypt/live/shop.1dao.cc |
| zentao.1dao.cc | 禅道 | /etc/letsencrypt/live/zentao.1dao.cc |
| walle.1dao.cc | Walle 部署系统 | /etc/letsencrypt/live/walle.1dao.cc |
| deploy.1dao.cc | 部署系统备用域名 | /etc/letsencrypt/live/deploy.1dao.cc |
恢复方式是从 sys-config 备份解压:
sudo tar -xzf /root/restore/sys-config/sys-config.tar.gz -C / etc/letsencrypt
ls /etc/letsencrypt/live/
## 第六章 站点测试与修复
### 6.1 测试结果总览
所有站点恢复后,逐个 curl 测试:
for domain in 1dao.cc www.1dao.cc nav.1dao.cc blog.1dao.cc shop.1dao.cc zentao.1dao.cc walle.1dao.cc deploy.1dao.cc; do
code=$(curl -sk -o /dev/null -w "%{http_code}" https://$domain/)
echo "$domain: $code"
done
结果:
| 域名 | HTTP 状态 | 状态说明 |
|------------------|-----------|----------------|
| 1dao.cc | 200 | ✅ 主站正常 |
| www.1dao.cc | 200 | ✅ 正常 |
| nav.1dao.cc | 200 | ✅ 导航站正常 |
| blog.1dao.cc | 500 | ❌ 博客报错 |
| shop.1dao.cc | 500 | ❌ 商城报错 |
| zentao.1dao.cc | 200 | ✅ 禅道正常 |
| walle.1dao.cc | 500 | ❌ 部署系统报错|
| deploy.1dao.cc | 500 | ❌ 部署备用报错|
5 个核心站点恢复成功,3 个返回 500。下面逐个排查。
### 6.2 blog 修复(emlog)
#### 6.2.1 现象
curl -sk https://blog.1dao.cc/
第七章 经验教训
7.1 版本对齐的重要性
#### 7.1.1 案例
线上 PHP 8.4.24 修复了某个 redis 扩展的内存泄漏 bug,如果开发环境用 PHP 8.4.20,在长时间运行压测时内存占用持续上涨,误以为是应用代码问题,排查很久才发现是扩展 bug 已在 8.4.24 修复。
另一个案例:MariaDB 11.4 和 11.8 的默认 sql_mode 不同,11.8 增加了 ERROR_FOR_DIVISION_BY_ZERO(除零报错而非返回 NULL),如果测试环境用 11.4,某些 SQL(如 SELECT 1/0)在测试时不报错,上线后才报错,导致生产事故。
#### 7.1.2 教训
- 软件版本号必须精确到小版本(8.4.24,不是 8.4)
- 扩展版本也要对齐(php-redis 6.x vs 5.x 行为有差异)
- 数据库 SQL_MODE 默认值不同版本不同,会导致相同 SQL 行为不同
- 备份时记录"环境快照"(软件版本表),恢复时逐项核对
- 版本对齐不是一次性的,线上升级时测试环境必须同步升级
7.2 备份加密策略
#### 7.2.1 双重加密的合理性
GPG 对称加密 + openssl AES-256-CBC 双重策略,表面看复杂,实际是分层防护:
- GPG 层:保护大文件批处理(SQL、代码),口令易管理
- openssl 层:保护 secrets 包(凭据),密钥是二进制文件,暴力破解难度高于口令
即使 GPG 口令泄露,secrets 包仍受 openssl 密钥保护,攻击者拿不到凭据。这是"defense in depth"(纵深防御)原则的体现:假设某一层被攻破,下一层仍能提供保护。
#### 7.2.2 教训
- 备份脚本要记录加密方式(GPG 还是 openssl),恢复时不会混淆
.backup-enc-key文件必须单独保护,不进备份包- GPG 解密时务必加
--pinentry-mode loopback,否则 SSH 卡死 - openssl 加密务必加
-pbkdf2,否则安全警告 - 口令和密钥要定期轮换(虽然成本高,但长期不轮换风险累积)
7.3 文件权限陷阱
#### 7.3.1 案例
emlog 500 错误,根因是 content/cache/ 目录不存在。这反映两个问题:
- 备份脚本遗漏空目录
- 应用启动时没有"目录不存在则创建"的容错
另一个权限陷阱:Redis socket 访问。即使 Redis 启动了、socket 存在了、PHP redis 扩展装了,如果 www-data 不在 redis 组,PHP 仍连不上 Redis,报 "Connection refused"(实际是 permission denied,但 PHP 的错误信息不准确)。而且 usermod 加组后必须重启 PHP-FPM,否则不生效,这一点容易遗漏。
#### 7.3.2 教训
- 备份用
tar时不要排除空目录,或者恢复后跑一遍"目录自检"脚本 - 部署文档要列出所有必需的可写目录(emlog 的 cache、CRMEB 的 runtime、Walle 的 storage)
chown -R www-data:www-data是恢复后的标配操作,不能省- Redis socket 访问权限:www-data 必须在 redis 组里,否则 PHP 连不上 Redis
- 用户组变更后必须重启依赖该组的服务(PHP-FPM),否则不生效
- 权限错误的表现可能误导排查方向(如 "Connection refused" 实际是权限问题)
7.4 证书恢复的限制
#### 7.4.1 letsencrypt 在内网的困境
letsencrypt 的 HTTP-01 验证需要公网可达,内网虚机无法自动续期。这是 letsencrypt 设计哲学(自动化、短期证书)与内网环境(隔离、无公网)的根本冲突。
letsencrypt 设计 90 天有效期是为了:(1) 限制证书泄露的影响窗口;(2) 推动 ACME 自动化(短证书逼迫用户实现自动续期)。这种设计在公网环境很好,但在内网环境就成了障碍。
#### 7.4.2 教训
- 内网环境优先用自签证书或内部 CA,而非 letsencrypt
- 如果必须用 letsencrypt,选 DNS-01 验证(需 DNS API)
- 从线上拷贝证书是过渡方案,需建立流程(到期前 30 天提醒)
- certbot.timer 在内网要禁用,避免刷失败日志
- 证书恢复后要立即验证有效期,避免用着用着过期
7.5 多应用共存的资源隔离
#### 7.5.1 案例
如果不给 CRMEB 独立 PHP-FPM pool,商城压测时会耗尽共享 pool 的 max_children,导致博客、禅道也响应缓慢。在共享 pool 模式下,某个应用的一个慢请求(如 CRMEB 商品列表查询慢)会占用一个 worker 进程,如果多个慢请求堆积,worker 被耗尽,其他应用的请求排队等待,表现为全站变慢。
#### 7.5.2 教训
- 高负载应用独立 pool(独立 socket、独立进程数、独立慢日志)
- 数据库用户隔离(每应用一个低权用户)
- Redis 不同应用用不同 db(SELECT 0/1/2...),或者独立 Redis 实例
- 日志分应用存放,便于排查
- nginx 站点配置用
include复用公共片段,减少重复 - 资源隔离的边界要清晰:进程隔离(PHP-FPM pool)、连接隔离(数据库用户)、缓存隔离(Redis db)、日志隔离(分文件)
7.6 灾难恢复演练的价值
本次从备份恢复到可用环境,本质是一次灾难恢复(disaster recovery)演练。过程中暴露的多个问题(目录缺失、组权限未生效、密码字段映射、证书续期无解),如果不演练,平时根本发现不了,真到线上数据丢失时才慌乱排查,代价巨大。
演练揭示的盲点:
- 备份完整性未校验——空目录被遗漏,直到恢复时才发现
- 加密元数据未文档化——secrets 用 openssl 还是 GPG,恢复时靠记忆
- 配置依赖未梳理——Walle 密码字段分散,secrets 与配置文件映射关系不明
- 续期流程未覆盖内网场景——letsencrypt 自动续期在内网失效
改进方向:
- 每月做一次"从备份恢复到可用"的全流程演练,计时并记录遇到的问题
- 备份脚本增加完整性自检(目录清单、SHA256、加密方式元数据文件)
- 维护一份"恢复 checklist",列出每个应用的必需目录、配置文件、密码字段映射
- 证书续期建立日历提醒(到期前 30 天),而非依赖 certbot.timer
关于"备份≠可恢复"的认知:有备份不等于能恢复。备份只是把数据存起来,恢复需要完整的上下文(软件版本、配置、密钥、依赖关系)。本环境花大量篇幅对齐版本、解密、配权限,正是为了让备份"可恢复"。一次成功的恢复演练,比一百次成功的备份更有价值——它证明备份真的能用。定期演练还能发现备份脚本自身的退化(如依赖路径变更导致脚本失效),形成闭环改进。
架构总图:
┌─────────────────────────────────────────────────────────────────┐
│ Debian 13 trixie 虚机 │
│ 192.168.207.130 │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ nginx 1.26.3 │ │
│ │ (443 HTTPS + 80 HTTP) │ │
│ └────────────┬─────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────┼───────────┬──────────┬──────────┐ │
│ ▼ ▼ ▼ ▼ ▼ │
│ 1dao.cc blog shop zentao walle │
│ (静态) (emlog) (CRMEB) │
│ │ │ │ │ │ │
│ │ ┌────┴────┐ ┌───┴────┐ │ │ │
│ │ │ www pool│ │crmeb │ │ │ │
│ │ │ default │ │pool │ │ │ │
│ │ │ sock │ │sock │ │ │ │
│ │ └────┬────┘ └───┬────┘ │ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │
│ │ ┌──────────────────────────────────────────┐ │
│ │ │ MariaDB 11.8 (utf8mb4) │ │
│ │ │ crmeb | emlog | walle | zentao │ │
│ │ └──────────────────────────────────────────┘ │
│ │ │
│ │ ┌──────────────────────────────────────────┐ │
│ │ │ Redis 7.x (unix socket) │ │
│ │ │ /run/redis/redis-server.sock │ │
│ │ └──────────────────────────────────────────┘ │
│ │ │
│ │ ┌──────────────────────────────────────────┐ │
│ │ │ /etc/letsencrypt (9 域名, 2026-11-07) │ │
│ │ └──────────────────────────────────────────┘ │
│ │ │
│ │ ┌──────────────────────────────────────────┐ │
│ │ │ OpenClaw /root/.openclaw (root 运行) │ │
│ │ └──────────────────────────────────────────┘ │
│ │
│ fail2ban | goaccess | certbot(仅查看) │
└────────────────────────────────────────────────────────────────┘
附录:操作命令速查
A.1 软件栈安装
## 附录:版本信息表
| 组件 | 版本 | 安装命令 | 配置文件路径 |
|---------------|-----------|----------------------|---------------------------------------|
| nginx | 1.26.3 | apt install nginx | /etc/nginx/nginx.conf, /etc/nginx/conf.d/*.conf |
| PHP-FPM | 8.4.24 | apt install php8.4-fpm | /etc/php/8.4/fpm/php.ini, /etc/php/8.4/fpm/pool.d/*.conf |
| MariaDB | 11.8.x | apt install mariadb-server | /etc/mysql/mariadb.conf.d/50-server.cnf |
| Redis | 7.x | apt install redis-server | /etc/redis/redis.conf |
| certbot | 4.0.0 | pip install certbot==4.0.0 | /etc/letsencrypt/ |
| goaccess | 1.x | apt install goaccess | (无配置文件,命令行参数) |
| fail2ban | 1.x | apt install fail2ban | /etc/fail2ban/jail.local |
---
## 附录:数据库用户与权限表
| 数据库 | 用户 | 主机 | 权限 | 用途 |
|--------|---------|-----------|-------------------|--------------|
| crmeb | crmeb | localhost | ALL ON crmeb.* | CRMEB 商城 |
| emlog | emlog | localhost | ALL ON emlog.* | emlog 博客 |
| walle | walle | localhost | ALL ON walle.* | Walle 部署 |
| zentao | zentao | localhost | ALL ON zentao.* | 禅道 |
---
## 附录:站点与 PHP-FPM pool 映射
| 站点 | root | PHP-FPM socket | pool 名 |
|-------------------|-------------------------|---------------------------------|---------|
| 1dao.cc | /var/www/1dao.cc | (无,静态 HTML) | - |
| www.1dao.cc | /var/www/1dao.cc | (无,静态 HTML) | - |
| nav.1dao.cc | /var/www/nav | (无,静态 HTML) | - |
| blog.1dao.cc | /var/www/blog | /run/php/php8.4-fpm.sock | www |
| shop.1dao.cc | /var/www/crmeb/public | /run/php/crmeb.sock | crmeb |
| zentao.1dao.cc | /var/www/zentao | /run/php/php8.4-fpm.sock | www |
| walle.1dao.cc | /var/www/walle | /run/php/php8.4-fpm.sock | www |
| deploy.1dao.cc | /var/www/walle | /run/php/php8.4-fpm.sock | www |
| openclaw.1dao.cc | (反代到 OpenClaw 服务) | - | - |
---
## 附录:备份文件清单与解密方式
| 文件 | 加密方式 | 解密命令关键参数 |
|-------------------------------|------------------|----------------------------------------------------|
| db-all-20261107.sql.gz.gpg | GPG AES256 | gpg --passphrase 'xxx' --pinentry-mode loopback |
| sys-config-20261107.tar.gz.gpg| GPG AES256 | 同上 |
| site-data-20261107.tar.gz.gpg | GPG AES256 | 同上 |
| openclaw-20261107.tar.gz.gpg | GPG AES256 | 同上 |
| secrets-20261107.enc | openssl AES-256-CBC | openssl enc -d -aes-256-cbc -pass file:/root/.backup-enc-key -pbkdf2 -iter 10000 |
---
## 附录:常见问题排查速查
| 现象 | 可能原因 | 排查命令 |
|---------------------------|---------------------------------|---------------------------------------------------------|
| 站点 500 | PHP 报错 | tail /var/log/nginx/error.log |
| 站点 502 | PHP-FPM 未启动或 socket 不存在 | systemctl status php8.4-fpm; ls /run/php/ |
| 站点 404 | root 路径错误 | nginx -T \| grep root |
| 数据库连接失败 | 密码错/用户无权/服务未启动 | mysql -u<user> -p<db>; systemctl status mariadb |
| Redis 连接失败 | 服务未启动/socket 权限 | redis-cli -s /run/redis/redis-server.sock ping; groups www-data |
| 证书过期 | 忘记续期/拷贝 | openssl x509 -enddate -in /etc/letsencrypt/live/<domain>/cert.pem |
| 静态资源 403 | 文件权限不对 | ls -l <file>; chown www-data:www-data <path> |
---
## 结语
本文档记录了从备份文件到可运行的开发测试环境的完整恢复过程,涵盖软件栈安装、备份解密、数据库恢复、站点代码恢复、HTTPS证书恢复、站点测试修复六大环节,并总结了五个方面的经验教训。
核心成果:
- 7 个软件组件版本与线上完全对齐
- 4 个数据库(crmeb/emlog/walle/zentao)数据恢复,表数量验证通过
- 6 个站点代码恢复到 /var/www/,文件权限正确
- 9 个域名 HTTPS 证书恢复,有效期至 2026-11-07
- 5 个核心站点恢复成功,2 个(walle/deploy)待修复密码配置后完成
未尽事项:
- walle/deploy 的数据库密码字段映射,需对照 Walle 配置文档逐项核对
- 证书续期流程建立(到期前 30 天从线上拷贝)
- 备份脚本改进:确保空目录打包、记录加密方式元数据
下一篇工作总结将聚焦于应用功能验证、性能基线测试与监控告警体系的搭建。
---
*文档版本: v1.0*
*完成日期: 2026-08-21*
*作者: 1dao 运维*
第3卷
1dao 开发测试环境搭建工作总结(三):域名管理方案演变与 dev-on/off 实现
第一章:域名问题的本质
1.1 多站点系统域名架构
1dao 站群并不是一个单体应用,而是由一个主域名 1dao.cc 加五个子域名横向扩展出的多站点系统。整个站群对外呈现为一个统一的品牌矩阵,但内部各站点独立部署、技术栈各异、共享同一个根域。这个"主域 + 子域"的结构,是后续所有域名方案演变的起点,理解它才能理解为什么域名切换如此复杂。
站群的域名结构如下:
1dao.cc (根域)
│
┌───────────────────┼───────────────────┐
│ │ │
主站 1dao.cc nav.1dao.cc blog.1dao.cc
(静态HTML+PHP) (导航, 静态) (emlog 博客)
│ │
│ │
shop.1dao.cc zentao.1dao.cc walle.1dao.cc
(CRMEB 商城) (禅道项目管理) deploy.1dao.cc
(walle 部署)
| 站点 | 域名 | 技术栈 | 业务职能 |
|---|---|---|---|
| 主站 | `1dao.cc` | 静态 HTML + 少量 PHP | 公司门户、产品展示 |
| 导航 | `nav.1dao.cc` | 静态 HTML | 站群导航 |
| 博客 | `blog.1dao.cc` | emlog | 技术文章 |
| 商城 | `shop.1dao.cc` | CRMEB | 在线销售 |
| 禅道 | `zentao.1dao.cc` | 禅道开源版 | 项目管理 |
| 部署 | `walle.1dao.cc` / `deploy.1dao.cc` | walle | 代码部署 |
需要特别强调的是,所有子域名都是 1dao.cc 的真实子域——它们在 DNS 层级上隶属于 1dao.cc,在证书 SAN(Subject Alternative Name)上通常需要被单独覆盖(除非用通配符证书),在浏览器同源策略下与 1dao.cc 主域同属一个 eTLD+1(有效顶级域 + 1)。这个"同根不同子"的关系,是后续浏览器数据隔离、Cookie 共享、HSTS 缓存等所有问题的共同根源。
1.2 测试环境访问的核心矛盾
当虚机环境搭建完成、站点代码和数据库都恢复到虚机上之后,一个看似简单却极其棘手的问题浮现出来:用什么域名访问虚机上的站点?
线上服务器的域名 1dao.cc 在公网 DNS 上解析到 47.113.148.3(线上服务器 IP)。而虚机的 IP 是 192.168.207.130——一个 VMware NAT 网段下的私有地址,公网 DNS 根本不知道它的存在。如果直接在浏览器输入 http://1dao.cc,浏览器会走公网 DNS 解析到线上服务器,访问的是线上而不是虚机。
要让浏览器访问虚机上的站点,有几条路可走:
- 改 DNS:在公网 DNS 上把
1dao.cc解析到192.168.207.130——绝不可能,会直接把线上站搞挂。 - 改本地 hosts:在本机
/etc/hosts里把1dao.cc指向192.168.207.130——可行,但影响整台 Mac 的所有应用。 - 换一个测试域名:给虚机配一个新域名(如
dev.1dao.cc、1dao.test),hosts 指向虚机——可行,但数据库里存的是1dao.cc,域名不匹配。 - IP + 端口直连:用
http://192.168.207.130:81直接访问——可行,但绝对 URL 会失效,且不优雅。
这四条路每一条都有代价,构成了测试环境访问的核心矛盾:既要访问到虚机,又要域名与数据库一致,还要能同时访问线上做对比,还要浏览器数据不互相污染。这些诉求彼此冲突,不可能用一个方案同时满足,这也是后续五次迭代的根本驱动力。
1.3 域名耦合的深层问题
为什么"换一个测试域名"这么难?根本原因在于站群的代码和数据深度耦合了 1dao.cc 这个域名。这种耦合不是一处两处,而是渗透在数据库、代码、资源引用的每一个角落。
数据库层面的耦合:经过排查,4 个数据库(crmeb、emlog、walle、zentao)中共有 400+ 处记录包含 1dao.cc 字样。典型例子:
- CRMEB 的
eb_system_config表存了site_url = https://shop.1dao.cc,CRMEB 生成所有商品链接、图片路径、跳转地址时都以这个site_url为前缀。 - CRMEB 的
eb_store_product表的商品图片字段存的是https://shop.1dao.cc/upload/2024/xxx.jpg这样的绝对 URL。 - CRMEB 的 DIY 装修页面
eb_diy表存的是 JSON,里面的图片、链接全是https://shop.1dao.cc/...。 - emlog 的
emlog_options表存了blogurl = https://blog.1dao.cc/,emlog 生成所有文章链接、RSS、分页都用它。 - emlog 的
emlog_blog表的文章内容里直接嵌入了https://blog.1dao.cc/...的图片和链接。 - 禅道的
zt_doccontent文档内容、zt_history操作历史里也有硬编码域名。
如果测试环境改用 dev.1dao.cc,数据库里这些 1dao.cc 就全都不匹配了——商品图片显示不出、跳转链接 404、DIY 页面加载失败。要让 dev 域名可用,必须把数据库里所有 1dao.cc 替换成 dev.1dao.cc(这正是 change-domain.sh 工具存在的意义);而一旦测试环境改了,同步回线上时又得替换回来,数据同步的冲突由此产生。
代码层面的耦合:排除 vendor、runtime、cache 等依赖和运行时目录,仍有 24 个文件硬编码了域名:
## 第二章:域名方案演变历程(5 次迭代)
域名方案不是一开始就想清楚的,而是经过五次迭代,每次都是"为什么尝试 → 发现什么问题 → 为什么放弃/保留"的循环。这一章按时间顺序还原演变过程,把每一次决策的依据、踩的坑、最终去留讲清楚。
演变总览如下:
方案1 同域名+hosts ──┐ 优点: DB零修改 缺点: 不能同时访问
│ → 最终保留为 prod 模式
方案2 dev.1dao.cc │ 问题: .dev TLD HSTS强制HTTPS, 无证书
(.dev TLD) │ → 放弃
方案3 1dao.test │ 问题: 非子域, DB需替换, 同步冲突
(.test TLD) │ → 放弃
方案4 dev.1dao.cc │ 优点: 子域名无HSTS 问题: DB需替换
(dev前缀子域名) │ → 保留为 dev 模式
方案5 回到同域名 │ 两种方案都保留
(dev-on/off) ──┘ prod模式 + dev模式 并存
### 2.1 方案 1:同域名 + hosts 覆盖(1dao.cc → 虚机 IP)
**为什么尝试**:这是最直觉的方案。既然数据库里存的是 `1dao.cc`,那就用 `1dao.cc` 访问,只是把 `1dao.cc` 在本机解析到虚机 IP 而非线上 IP。macOS 的 `/etc/hosts` 文件正是为此而生——它优先级高于 DNS,可以覆盖任意域名的解析。
**原理**:在 `/etc/hosts` 加入一行,把 `1dao.cc` 及其子域全部指向虚机 `192.168.207.130`:
第三章:HSTS 与 .dev TLD 的坑(深度解析)
方案 2 的放弃源于 HSTS 与 .dev TLD 的交互,这个坑值得单独深度解析,因为它是整个演变过程中最隐蔽、最难排查的一个问题——浏览器没有明显的错误提示,只是"连不上",排查到根因花了不少时间。
3.1 HSTS 机制
HSTS(HTTP Strict Transport Security)是 RFC 6797 定义的安全机制,目的是防止 SSL 剥离攻击(SSL stripping)。原理是:服务器通过 HTTP 响应头 Strict-Transport-Security 告诉浏览器"在接下来 max-age 秒内,所有到本域的请求都必须用 HTTPS"。
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age=31536000:有效期一年(单位秒)。includeSubDomains:对所有子域生效。preload:允许浏览器把这个域加入 preload list(需要单独申请)。
浏览器收到这个头后,会在本地缓存这条策略。在 max-age 期内,即使用户输入 http://example.com,浏览器也会在发请求前内部重写为 https://example.com,不发出明文 HTTP 请求。这是为了防止中间人在明文 HTTP 阶段劫持请求。
HSTS 的关键特性是不可逆性:一旦浏览器缓存了 HSTS 策略,在 max-age 期内,用户无法通过"输入 http://"绕过——浏览器会强制升级到 HTTPS。这意味着,如果一个域名的 HSTS 被浏览器缓存了,而你又没有有效的 HTTPS 证书,浏览器就会卡在"连接不是私密连接"的警告页,用户必须手动点"继续前往"才能访问,体验极差。
3.2 HSTS preload list
HSTS 的一个局限是"首次访问"问题:用户第一次访问 example.com 时,浏览器还没收到 HSTS 头,如果这次访问被中间人剥离到 HTTP,HSTS 就不生效。为解决这个问题,浏览器厂商维护了 HSTS preload list——一个内置在浏览器源码里的域名清单,这些域名的 HSTS 策略在浏览器出厂时就已生效,不需要等服务器返回 HSTS 头。
各大浏览器的 preload list 是共享的,维护在 https://hstspreload.org ,源头是 Chromium 项目的 transport_security_state_static.json。Firefox、Safari、Edge 都会同步这个列表。
preload list 不仅包含具体域名(如 example.com),还包含整个 TLD。Google 把自己拥有的若干 TLD 加入了 preload list,这些 TLD 下所有域名默认启用 HSTS,强制 HTTPS:
.dev(Google 拥有).app(Google 拥有).foo(Google 拥有).page(Google 拥有).chrome(Google 拥有).google(Google 拥有).android(Google 拥有)- 等等
这意味着,任何 .dev 结尾的域名——myproject.dev、test.dev、dev.1dao.dev——在主流浏览器里都默认强制 HTTPS,无论服务器有没有配 HSTS 头、无论用户输入的是 http 还是 https。
3.3 为什么 .dev 会触发 HSTS
方案 2 用的 dev.1dao.cc 不是 .dev TLD——它的 TLD 是 .cc,1dao.cc 是 eTLD+1,dev.1dao.cc 是 1dao.cc 的子域。所以严格说,方案 2 最初设想的"dev 子域名"如果写成 dev.1dao.cc,其实不会触发 .dev 的 HSTS(详见 3.5 节)。
但方案 2 在最初命名时,考虑过用 1dao.dev 这种形式(把 .dev 作为 TLD),这才是真正触发 HSTS 的写法。实测:
## 第四章:dev-on / dev-off / which-env 命令实现
方案 5 确定后,需要把"切换 hosts"这个操作封装成易用的命令,避免每次都手动编辑 `/etc/hosts`。最终实现了三个脚本:`dev-on`(开)、`dev-off`(关)、`which-env`(判断当前状态),安装到 `/usr/local/bin/` 全局可用。
### 4.1 dev-on 脚本
**原理**:`dev-on` 向 `/etc/hosts` 追加两类条目:
1. `1dao.cc` 系列(主域 + 子域)→ 虚机 IP,这是 prod 模式的访问入口。
2. `dev.*` 系列(`dev.1dao.cc`、`shop.dev.1dao.cc` 等)→ 虚机 IP,这是 dev 模式的访问入口,**永久指向虚机**,`dev-off` 不移除。
为了防止重复追加(多次运行 `dev-on` 会让 hosts 越来越长),用标记注释 `# 1dao-dev-vm` 和 `# 1dao-dev-domains` 来标识这两段。`grep` 检查标记存在则跳过。
**完整实现**(`/usr/local/bin/dev-on`):
#!/bin/bash
第五章:blog / shop 访问问题排查
域名方案定了,dev-on 也开了,本以为 http://1dao.cc 能顺利打开。实测发现:1dao.cc 主站正常,但 blog.1dao.cc 和 shop.1dao.cc 浏览器报"连接被重置"(ERR_CONNECTION_RESET)。这一章复盘这个排查过程,它揭示了 nginx 444 兜底与 HSTS 缓存的交互,是整个工作中最隐蔽的一个坑。
5.1 问题现象
dev-on 后,浏览器访问:
| 域名 | 现象 |
|---|---|
| `http://1dao.cc` | 正常(主站静态页) |
| `http://nav.1dao.cc` | 正常(导航静态页) |
| `http://blog.1dao.cc` | **连接被重置** (ERR_CONNECTION_RESET) |
| `http://shop.1dao.cc` | **连接被重置** (ERR_CONNECTION_RESET) |
| `http://zentao.1dao.cc` | 正常 |
奇怪的是,都是子域名,有的通有的不通,且不通的没有任何 HTTP 状态码——浏览器直接报"连接被重置",意味着 TCP 连接在收到 HTTP 响应前就被对端断开了。
5.2 排查过程
第一步:curl 检查 HTTP 80:
curl -sI http://blog.1dao.cc/ --max-time 5
## 第六章:浏览器数据隔离问题
方案 1(prod 模式,同域名)有一个绕不开的副作用:**线上和测试环境同域名,浏览器数据共享**。这一章深度解析浏览器存储的隔离机制,以及为什么这是个问题,如何缓解。
### 6.1 问题背景
`dev-on` 后,浏览器访问 `https://1dao.cc` 指向虚机(测试环境);`dev-off` 后,访问同一个 `https://1dao.cc` 指向线上。**域名没变,只是背后的 IP 变了**。
浏览器的存储机制(Cookie、localStorage、Service Worker、HSTS 缓存)都是**按域名**索引的,不分端口、不分 IP。这意味着:
- 在测试环境(`dev-on`)登录了 shop,浏览器存下 `shop.1dao.cc` 的登录 Cookie。
- `dev-off` 切到线上,浏览器访问线上 `shop.1dao.cc`,会把刚才测试环境的登录 Cookie 带上去。
- 线上服务器收到一个"测试环境签发的 Cookie",校验失败(因为 session 数据不同),要么报错要么登录态异常。
- 反过来,在线上登录后,`dev-on` 切到测试环境,线上的 Cookie 又被带到测试环境,可能误触发线上身份的操作。
这就是"浏览器数据共享"的痛点——**同域名无法隔离 Cookie**。
### 6.2 Cookie 机制详解
Cookie 是浏览器存储的键值对,通过 HTTP 头 `Set-Cookie` 下发,后续请求通过 `Cookie` 头回传。Cookie 的关键属性:
**domain 属性**:Cookie 按 domain 索引。`Set-Cookie: foo=bar; domain=1dao.cc` 下发的 Cookie,**所有 `1dao.cc` 的子域**(包括 `shop.1dao.cc`、`blog.1dao.cc`、`1dao.cc` 本身)都会在请求时带上。这是"根域 Cookie"。
Set-Cookie: session=abc; domain=1dao.cc; path=/; HttpOnly; Secure
→ 浏览器存储后, 以下请求都会带上这个Cookie:
https://1dao.cc/ ✓
https://shop.1dao.cc/ ✓
https://blog.1dao.cc/ ✓
https://dev.1dao.cc/ ✓ (dev.1dao.cc 也是1dao.cc子域!)
注意最后一条——`dev.1dao.cc` 也是 `1dao.cc` 的子域,所以 `domain=1dao.cc` 的 Cookie 在 dev 模式下也会被带上。这是方案 4(dev 子域名)的一个隐性副作用:虽然 Cookie 在 `1dao.cc` 和 `dev.1dao.cc` 之间隔离(因为 eTLD+1 不同),但 `domain=1dao.cc` 的根域 Cookie 会覆盖所有子域包括 `dev.1dao.cc`。要真正隔离,必须让应用下发 `domain=shop.1dao.cc`(具体子域)而不是 `domain=1dao.cc`(根域)。1dao 的应用是否这么做,取决于各应用代码,不一定统一。
**Cookie 不分端口**:Cookie 的 domain 只看域名,不看端口。`http://1dao.cc:80` 和 `http://1dao.cc:81` 共享 Cookie。所以"用不同端口隔离 Cookie"不可行。
**HttpOnly**:设置后 Cookie 不能被 JavaScript(`document.cookie`)访问,防止 XSS 窃取。但不影响"同域名共享"。
**Secure**:设置后 Cookie 只在 HTTPS 请求时回传。但同域名 HTTPS 还是共享。
**SameSite**:控制跨站请求是否带 Cookie(`Strict`/`Lax`/`None`)。防 CSRF,但不影响同站(同 eTLD+1)的共享。
综上,**同域名的 Cookie 无法通过任何属性配置隔离**——这是浏览器同源策略的固有行为。要隔离 Cookie,只能用不同的 eTLD+1(即不同根域或不同子域作为 eTLD+1)。
### 6.3 localStorage 机制
localStorage 是 HTML5 引入的本地存储,按 **origin** 索引。origin = 协议 + 域名 + 端口。
origin 1: https://shop.1dao.cc (443)
origin 2: http://shop.1dao.cc (80) ← 与origin 1不同(协议不同)
origin 3: https://shop.1dao.cc:8443 ← 与origin 1不同(端口不同)
prod 模式下,线上 `https://shop.1dao.cc` 和测试 `https://shop.1dao.cc` 是**同一个 origin**(协议域名端口全同),localStorage 完全共享。测试环境写入的购物车数据,`dev-off` 后线上访问能看到——数据串台。
dev 模式下,`https://shop.1dao.cc`(线上)和 `http://shop.dev.1dao.cc`(测试)是不同 origin(eTLD+1 不同、协议可能不同),localStorage 天然隔离。
### 6.4 Service Worker
Service Worker 是浏览器后台运行的脚本,用于离线缓存、推送等。它按 **origin** 注册,一个 origin 只能有一个 Service Worker。
prod 模式下,线上和测试同 origin,Service Worker 共享。如果测试环境注册了一个 Service Worker 缓存了测试数据,`dev-off` 后线上访问可能命中测试环境的缓存,显示测试内容——非常难排查的"幽灵缓存"。排查时要在浏览器 `chrome://serviceworker-internals/` 或 `about:debugging` 手动注销。
dev 模式下不同 origin,Service Worker 各自独立,无此问题。
### 6.5 HSTS 缓存
如第三章所述,HSTS 缓存按域名,跨端口、跨协议共享。prod 模式下,线上 nginx 返回的 HSTS 头会被浏览器缓存,即使 `dev-on` 切到测试环境(测试 nginx 可能没配 HSTS),浏览器仍会强制 HTTPS——这通常不是坏事(测试也有 HTTPS),但要注意"线上配的 HSTS 在测试环境也生效"这个副作用。
### 6.6 隔离方案对比
既然 prod 模式有浏览器数据共享问题,有几种缓解方案:
**方案 A:双浏览器**
用一个浏览器访问线上(如 Chrome),另一个访问测试(如 Firefox)。两个浏览器的存储完全独立,Cookie/localStorage/SW/HSTS 都不串。
Chrome → 线上 (https://1dao.cc)
Firefox → 测试 (https://1dao.cc, dev-on)
优点:彻底隔离,无任何串台。缺点:要维护两个浏览器的书签、插件、登录态,日常用哪个浏览器就要取舍。
**方案 B:同浏览器独立 Profile**
一个浏览器开多个 Profile,每个 Profile 的存储独立。Firefox 用 `-P` 启动 Profile 管理器,Chrome 用 `--user-data-dir` 指定独立数据目录:
第七章:WiFi 断开时的域名访问问题
在移动办公场景(咖啡馆、高铁、无 WiFi 的会议室),出现了一个新问题:WiFi 断开后,dev.1dao.cc 等域名访问不了。这一章解析根因和应对方案。
7.1 问题现象
WiFi 断开(或连了一个无 Internet 的热点)后:
http://1dao.cc(prod 模式,hosts 指向虚机)→ 无法访问。http://dev.1dao.cc(dev 模式,hosts 指向虚机)→ 无法访问。ssh 1dao-dev(SSH 到虚机)→ 正常(因为 SSH config 用的是 IP 直连)。ping 192.168.207.130(虚机 IP)→ 正常。
虚机明明能 ping 通,SSH 能连,为什么域名访问不了?
7.2 根因:mDNSResponder 行为
根因在于 macOS 的 DNS 解析守护进程 mDNSResponder 的行为:当没有活动网络接口(无 WiFi/以太网连接)时,mDNSResponder 不响应 /etc/hosts 的查询。
正常情况(WiFi 连接):
应用请求 dev.1dao.cc
│
▼
mDNSResponder 查 /etc/hosts → 命中 → 返回 192.168.207.130
│
▼
应用连接 192.168.207.130 ✓
WiFi 断开:
应用请求 dev.1dao.cc
│
▼
mDNSResponder 检测到无活动网络 → 拒绝查询 → 返回"无解析"
│
▼
应用报错: 服务器DNS解析失败
mDNSResponder 的逻辑是"没网络就没必要解析域名"——它不知道 /etc/hosts 里有静态映射,也不区分"hosts 解析"和"DNS 服务器解析"。WiFi 断开后,它直接拒绝所有域名查询,包括本应走 hosts 的。
这是 macOS 的一个历史行为(至少到 macOS 14 仍如此)。Linux 的 nsswitch.conf 机制会优先查 hosts 再查 DNS,不受网络状态影响;但 macOS 的解析栈不同,mDNSResponder 是统一入口,行为更"激进"。
7.3 虚机网络独立性
讽刺的是,虚机本身网络是通的——VMware 的 bridge/NAT 网络是虚拟接口,不依赖宿主机的 WiFi:
宿主机 (macOS)
├── en0 (WiFi) ← WiFi 断开, 此接口 down
├── vmnet8 (NAT) ← VMware 虚拟接口, 仍 up
│ └── 192.168.207.x 网段
└── 虚机 192.168.207.130 通过 vmnet8 可达
ping 192.168.207.130 通,证明虚机网络接口和路由都正常。问题只在"域名→IP"这一步,被 mDNSResponder 卡住了。
7.4 解决方案:IP + 端口直连
绕过 DNS 的办法是用 IP + 端口直连,不经过域名解析:
http://192.168.207.130 (主站)
http://192.168.207.130:81 (导航)
http://192.168.207.130:82 (博客)
http://192.168.207.130:83 (商城)
http://192.168.207.130:84 (禅道)
但默认 nginx 只监听 80/443,且 server_name 是按域名匹配的——直接 IP 访问会落到 default_server(可能 444)。要让 IP+端口可用,需要 nginx 为每个站配一个端口监听。
7.5 nginx 端口配置
在 nginx 配置里给每个站加一个基于端口的 server 块(或修改现有 server 加 listen 81 等):
## 第八章:经验教训
回顾整个域名方案演变过程,有若干经验教训值得沉淀,供后续类似工作参考。
### 8.1 .dev TLD 的坑:永远不要用 .dev 做开发域名
这是最具体也最容易被忽视的一条。`.dev` 看起来语义清晰("dev = development"),但它是 Google 拥有的真实 TLD,在 HSTS preload list 里,所有主流浏览器对 `.dev` 下所有域名强制 HTTPS。开发环境往往没有有效证书(letsencrypt 签不下来),浏览器报"连接不是私密连接",体验崩溃。
**替代选择**:
- 用 RFC 2606 保留的 `.test`、`.example`、`.localhost`(不触发 HSTS,但可能引入独立根域的同步问题)。
- 用真实根域的子域名,如 `dev.example.com`(eTLD+1 是 example.com,不触发任何 TLD 的 HSTS)。这是 1dao 最终采用的方案。
**通用原则**:做开发域名前,先查 https://hstspreload.org 看该 TLD 是否在 preload list。在的话,绕道。
### 8.2 HSTS 的不可逆性
HSTS 一旦被浏览器缓存,在 max-age 期内持续生效,无法通过"输入 http://"绕过。即使服务器关掉 HSTS 头,缓存仍生效到过期。这对开发调试是隐患——如果某次测试让浏览器缓存了 HSTS,后续没法用 HTTP 访问。
**应对**:
- 开发环境 nginx **不要**配 `Strict-Transport-Security` 头,避免给浏览器缓存 HSTS。
- 如果已经缓存,用 `chrome://net-internals/#hsts` 的 Delete 清除(对 preload TLD 无效)。
- preload list 的 TLD(如 `.dev`)无法清除,只能换域名。
### 8.3 浏览器 DNS 缓存独立于系统
改了 `/etc/hosts` 并刷新 `mDNSResponder` 后,**浏览器仍有自己的 DNS 缓存**,可能继续用旧 IP。Chrome 的 DNS 缓存在 `chrome://net-internals/#dns`,可手动 "Clear host cache"。Firefox 类似。
**实践建议**:`dev-on/off` 后,用 `which-env`(curl 命令行,不走浏览器缓存)验证状态,而不是直接看浏览器。浏览器验证时用无痕窗口(每次新开无缓存)。
### 8.4 444 兜底的副作用
nginx 的 `return 444`(或 `ssl_reject_handshake on`)作为 default_server 兜底,能挡扫描器,但**会误伤未配 443 的子域名 + HSTS 强制 HTTPS 的组合**。线上遗留的 `000-default-444.conf` 在测试环境就触发了 blog/shop 的"连接被重置"。
**应对**:
- 兜底配置要审计:确认所有需要访问的 server_name 都有对应的 `listen 443` server 块。
- `ssl_reject_handshake on` 比 `return 444` 更明确(TLS 握手阶段就拒绝),但效果类似。
- 排查"连接被重置"时,优先检查是否有 default_server 兜底未匹配的请求。
### 8.5 证书 SAN 的重要性
一张证书只覆盖 SAN 里列出的域名(或通配符 `*.example.com` 覆盖的子域)。`1dao.cc` 证书的 SAN 只有 `1dao.cc`、`www.1dao.cc`、`openclaw.1dao.cc`,给 `blog.1dao.cc` 用会报"证书域名不匹配"。
**应对**:
- 多子域场景,优先用**通配符证书**(`*.1dao.cc`),一张证书覆盖所有子域,省心。
- 没有通配符时,每个子域独立签证书(letsencrypt 免费,`certbot certonly -d blog.1dao.cc`),nginx 各自指向。
- 迁移/恢复证书时,核对 SAN 与 nginx `server_name` 一一对应,避免"用错证书"。
### 8.6 Cookie 隔离的局限性
同域名(eTLD+1 相同)无法隔离 Cookie——这是浏览器同源策略的固有行为,任何 Cookie 属性(domain/path/HttpOnly/Secure/SameSite)都不能绕过。要隔离 Cookie,只能用不同的 eTLD+1。
**应对**:
- 需要浏览器数据隔离的场景(同时访问线上+测试、避免登录态串台),用不同 eTLD+1(dev 模式子域名)或不同浏览器/Profile。
- 不要试图通过 Cookie 属性"在同域名下隔离",行不通。
### 8.7 DNS 解析依赖活动网络
macOS 的 `mDNSResponder` 在无活动网络时拒绝响应 hosts 查询,导致 WiFi 断开时域名访问失效(尽管虚机本身可达)。这是 macOS 特有的行为,Linux 的 `nsswitch` 不受影响。
**应对**:
- 应急方案:IP+端口直连,绕过 DNS。
- 长期方案:给虚机配不依赖宿主 WiFi 的网络(Host-Only),或接受"WiFi 断开时只能 SSH,不能域名访问"的限制。
### 8.8 双模式并存的价值
最终采用"prod 模式 + dev 模式并存"的方案,看似复杂,实则各司其职:
- prod 模式(同域名):数据零修改、数据同步零冲突,适合排查线上 bug。
- dev 模式(子域名):浏览器数据隔离、可同时访问,适合功能开发。
两者通过 `dev-on/off`(本机 hosts)和 `switch-env`(虚机数据库)分别控制,互不干扰。这种"按场景选择方案"的思路,比强求一个"万能方案"更务实——没有方案能同时满足所有诉求,双模式让每个场景都有最优解。
### 8.9 工具化的必要性
`dev-on`/`dev-off`/`which-env` 三个脚本看似简单,却是日常工作的倍增器。没有它们,每次切换要手动编辑 hosts、手动刷新缓存、手动 curl 验证——流程长、易出错。封装成命令后,一条 `dev-on` 完成所有操作,`which-env` 一眼看清状态。
**通用经验**:任何需要反复执行的多步操作,都值得封装成脚本/命令,即使只有三五步。封装的收益不只是省时间,更是"减少出错面"——脚本每次执行一致,不会像手动操作那样漏掉一步(比如忘了 `killall -HUP mDNSResponder` 导致缓存残留)。
### 8.10 排查复合根因的耐心
blog/shop 的"连接被重置"问题,根因是"444 兜底 + 子域 443 未配 + HSTS 强制 HTTPS"三者叠加。任一单独看都"没问题",组合才出问题。这种复合根因排查最耗时,因为容易盯着单一环节死磕(比如一直改证书),却看不到全局。
**应对策略**:
- 分层验证:网络层(curl 80/443)、TLS 层(openssl s_client)、应用层(curl 响应头),逐层缩小。
- 用最简工具(curl/openssl)而非浏览器排查——浏览器有缓存、有 HSTS、有重定向,干扰多。
- 画出请求流程图,标出每一步可能的失败点,逐一排除。
---
## 结语
域名管理是整个 1dao 开发测试环境搭建中最曲折的部分。从最初"用 1dao.cc 访问"的直觉,到 `.dev` 的 HSTS 坑、`.test` 的同步冲突、`dev.1dao.cc` 的子域折衷,最终沉淀出"prod + dev 双模式"的方案,配套 `dev-on`/`dev-off`/`which-env`/`switch-env`/`change-domain` 工具链。每一版方案都不是白费——它们各自排除了一个错误方向,最终合力逼近了"兼顾数据一致、浏览器隔离、可同时访问、无证书警告"的综合最优解。
这个过程也是对"环境搭建"这类工作本质的印证:它不是一次性工程,而是反复试错、逐步逼近的过程。提前规划重要,但更重要的是遇到问题时能快速定位根因、调整方案、不固执于单一思路。HSTS、Cookie、证书 SAN、nginx 兜底、DNS 解析这些底层机制,平时隐于幕后,一旦交互出问题就是最隐蔽的坑。理解它们的运作,是高效排障的前提。
下一部分(工作总结四)将聚焦工具链的工程化实现——`switch-env` 的快照法、`change-domain` 的占位符法、`env.conf` 的配置管理,以及如何把这些脚本组织成可维护、可扩展的工具集。
第4卷
1dao 开发测试环境搭建工作总结(四):换域名工具链开发
第一章 域名耦合点调查
1.1 调查方法:数据库全量扫描 + 代码 grep 扫描
1dao 这套站点经过多年累积,域名 1dao.cc(以及 shop.1dao.cc、blog.1dao.cc、nav.1dao.cc 等子域)散落在数据库表的内容字段、PHP 文件、HTML 文件、JSON 配置、CSS 文件等多个位置。要做一次"无损、可逆、可预览"的整站换域名,第一步必须把所有耦合点摸清楚,否则后续替换必然遗漏,导致站点静态资源 404、接口跨域、DIY 页错乱等问题。
调查分两条腿走:
- 数据库全量扫描:利用 MySQL 的
information_schema.columns元数据,把所有可能的字符串类型列(char/varchar/text/mediumtext/longtext/tinytext/json)枚举出来,再逐列用LIKE '%1dao.cc%'判断是否含目标域名。 - 代码 grep 扫描:用
grep -rl在站点根目录下递归搜索1dao\.cc,通过--include白名单限定文件扩展名,通过--exclude-dir黑名单剔除依赖目录。
两条腿缺一不可:数据库侧负责"用户内容"中的耦合(文章正文、DIY 页配置、商品图、主题、配置项值等),代码侧负责"应用代码"中的耦合(模板硬编码、CSS 背景图、PHP 中的绝对 URL、HTML 中的 <a href> 等)。
1.2 数据库调查结果
四个业务库中均存在 1dao.cc 字样,具体分布如下:
#### 1.2.1 crmeb 库(CRMEB 商城)
CRMEB 的耦合点最多,主要集中在以下几个表:
| 表名 | 关键列 | 说明 |
|---|---|---|
| `eb_system_config` | `value` | `site_url` 配置项,整站根 URL,影响支付回调、商品分享、H5 跳转 |
| `eb_store_product` | `image`、`slider_image`、`description` | 商品主图、轮播图、详情图通常存的是完整 URL |
| `eb_store_category` | `pic` | 分类图标 |
| `eb_diy` | `value` | DIY 可视化页面的 JSON 配置,内部含图片、跳转链接 |
| `eb_theme` | `config` | 主题模板的配置 JSON |
| `eb_system_group_data` | `value` | 组合数据(广告位、首页 banner) |
| `eb_article` | `content`、`image_input` | 文章正文与封面 |
#### 1.2.2 emlog 库(博客)
emlog 的耦合点相对集中:
emlog_options:blogurl选项,博客整站根 URL,影响 RSS、sitemap、文章分享emlog_blog:content字段中存在相对链接被历史编辑器自动转成的绝对 URLemlog_comment:url字段,评论者填写的个人站点(部分含1dao.cc的自评)emlog_twitter:碎语内容中可能的内部链接
#### 1.2.3 walle 库(上线部署)
walle 是上线部署系统,耦合点在项目配置:
project表:level、server_id之外的配置列中含目标机器上的部署路径,历史项目记录中绝对路径含1dao.ccrecord:发布记录中的注释task:任务执行上下文
#### 1.2.4 zentao 库(禅道)
zentao 是项目管理系统,耦合点比较多但量级小:
zt_project:项目描述中的链接zt_task:任务描述zt_doccontent:文档正文(富文本,含绝对 URL)zt_history:操作历史中的变更前后值
#### 1.2.5 汇总
四个库合并扫描后,含 1dao.cc 的列行数总计约 400+ 处。这个量级决定了不可能手工逐条 UPDATE,必须脚本化。其中 CRMEB 占六成以上,emlog 与 zentao 各占一成多,walle 较少。
1.3 代码调查结果
在 /var/www 下递归 grep 1dao\.cc,排除 node_modules、vendor、runtime、cache、.git 等依赖与缓存目录后,共在 24 个文件中发现硬编码。按站点归档:
- 主站
/var/www/1dao.cc/:
- index.html:首页中的导航、footer 链接
- counter.php:计数器脚本中的回调 URL
- about-site/index.html:关于页
- releases/index.html:发布日志页
- 导航站
/var/www/nav/:
- index.html:所有导航项的 href
- 博客
/var/www/blog/:
- header.php:博客头部模板中的 RSS、logo 链接
- style.css:CSS 中 background-image 的绝对 URL
- showcase.php:作品展示页
- export_article.php:导出文章时的链接处理
- walle
/var/www/walle/:
- Project.php:项目控制器中的硬编码示例
- help.php:帮助文档
- 其他:crmeb 的
.env、zentao 的config/my.php等配置文件
1.4 应用配置层面的耦合
除了"看得见的字符串",还有几处"运行时才生效"的耦合点必须单独处理:
- CRMEB
site_url:写在eb_system_config表中,CRMEB 启动时会读入并拼装到支付回调、商品分享等位置。换域名时必须先改它,否则即使其他位置都改了,支付回调依然指向旧域名。 - emlog
blogurl:同上,写在emlog_options表中,emlog 在渲染 RSS / sitemap / 文章链接时用它做 base。 - zentao 运行时检测:zentao 不把域名写在配置文件,而是从 HTTP 请求头
Host中实时推断,所以只要 nginx 把对应 server_name 配好、Host 头正确,zentao 会自动适配。这一项不需要在数据库或代码中改,但必须保证 nginx 配置先于 zentao 生效。 - walle 数据库
project表:walle 把项目部署路径写入project表,这条路径是服务器本机路径而非 URL,但如果在 walle 的"项目设置"中填写过完整回调 URL,就需要替换。
1.5 调查脚本
数据库扫描的核心脚本是借助 information_schema.columns 获取所有字符串类型列,再逐列 LIKE 检查:
#!/usr/bin/env bash
## 第二章 `change-domain.sh` 设计
### 2.1 需求
`change-domain.sh` 的核心需求只有一句话:**整站域名替换**,覆盖数据库、代码、缓存三大块。但这一句话拆开看,要求极其严苛:
1. **整站**:不能只改某一站,必须 4 个库 + 24 个代码文件全覆盖。
2. **原子性**:要么全改,要么全不改。不能改到一半挂掉留下"半残站点"。
3. **可预览**:`--dry-run` 必须先告诉用户"我将要动这些表、这些文件",用户确认后再执行。
4. **可细分**:`--db-only` / `--files-only` 让用户能分步操作,先改代码再改数据库,反之亦可。
5. **可逆**:配合 `switch-env.sh` 的快照机制,改完能回滚。
6. **不误伤**:这是最难的,详见 2.2。
### 2.2 核心问题:短域名误伤长域名
这是整条工具链最难的一个问题,需要专门讲清楚。
#### 2.2.1 错误示例
假设要执行 prod → dev 切换,映射关系如下:
| 旧域名 | 新域名 |
|--------|--------|
| `shop.1dao.cc` | `dev.shop.1dao.cc` |
| `1dao.cc` | `dev.1dao.cc` |
如果直接顺序执行 `REPLACE`:
1. 先把数据库中所有 `shop.1dao.cc` 替换成 `dev.shop.1dao.cc`。
2. 再把所有 `1dao.cc` 替换成 `dev.1dao.cc`。
第 2 步会**误伤**第 1 步已经替换好的 `dev.shop.1dao.cc`:因为它内部仍然包含 `1dao.cc` 这个子串,会被替换成 `dev.shop.dev.1dao.cc`,变成了一坨垃圾。
#### 2.2.2 误伤结果
最终结果:
| 原值 | 错误结果 | 正确结果 |
|------|----------|----------|
| `shop.1dao.cc` | `dev.shop.dev.1dao.cc` ❌ | `dev.shop.1dao.cc` ✓ |
| `1dao.cc` | `dev.1dao.cc` ✓ | `dev.1dao.cc` ✓ |
短域名永远会误伤长域名,因为短域名是长子域名的子串。这不是排序能解决的——无论先做长还是先做短,总会出问题:
- 先长后短:长替换的结果里含短,被短再次命中。
- 先短后长:短替换的结果里不含长(因为短还没把长的前缀补上),但短替换之后所有出现 `1dao.cc` 的地方都变成 `dev.1dao.cc` 了,等再做长替换 `shop.1dao.cc → dev.shop.1dao.cc` 时,文本里已经没有 `shop.1dao.cc` 了,长替换完全失效,得到 `shop.dev.1dao.cc` 这种错乱结果。
### 2.3 占位符法
解决上述误伤的标准技术是**占位符法**(placeholder pass),分两阶段:
#### 2.3.1 阶段 1:所有旧域名 → 唯一占位符
为每个旧域名分配一个**不可能出现在任何真实文本中**的占位符串,本工具用 `@@DMNCHG_<idx>_@@` 的格式:
| 旧域名 | 占位符 |
|--------|--------|
| `shop.1dao.cc` | `@@DMNCHG_0_@@` |
| `1dao.cc` | `@@DMNCHG_1_@@` |
**关键点**:占位符不能含任何域名片段,也不能含任何被替换域名的子串。这样后续替换绝不会命中它自己。
阶段 1 按**旧域名长度降序**执行(长的先替换):
UPDATE table SET col = REPLACE(col, 'shop.1dao.cc', '@@DMNCHG_0_@@');
UPDATE table SET col = REPLACE(col, '1dao.cc', '@@DMNCHG_1_@@');
- 第一条把 `shop.1dao.cc` 换成 `@@DMNCHG_0_@@`,此时 `1dao.cc` 这个子串**仅**出现在不是 `shop.1dao.cc` 的位置。
- 第二条把剩下的 `1dao.cc` 换成 `@@DMNCHG_1_@@`。**注意**:已经被换成 `@@DMNCHG_0_@@` 的位置不含 `1dao.cc`,不会被误命中。
阶段 1 结束后,数据库里没有任何 `1dao.cc` 字样了,全部变成了占位符。
#### 2.3.2 阶段 2:占位符 → 新域名
占位符不含任何域名,所以阶段 2 的替换互不影响:
UPDATE table SET col = REPLACE(col, '@@DMNCHG_0_@@', 'dev.shop.1dao.cc');
UPDATE table SET col = REPLACE(col, '@@DMNCHG_1_@@', 'dev.1dao.cc');
无论执行顺序如何,结果都正确。
#### 2.3.3 优势
占位符法的本质是**中转一层**,把"多对多替换"降为"多次一对一替换":
- 多对多:`{shop.1dao.cc, 1dao.cc}` → `{dev.shop.1dao.cc, dev.1dao.cc}`,元素互为子串,无法顺序执行。
- 中转后:`{shop.1dao.cc, 1dao.cc}` → `{占位符0, 占位符1}` → `{dev.shop.1dao.cc, dev.1dao.cc}`,每个箭头都是"原值不含目标值的子串"的安全替换。
这个思路同样适用于代码文件、JSON 配置等所有文本替换场景。
### 2.4 多对映射排序
占位符法阶段 1 必须按旧域名长度降序排序,否则短域名先命中长域名的子串。在 shell 中实现:
第三章 change-domain.sh 实现与调试
3.1 第一版实现
第一版使用经典的 find + xargs + grep 组合搜索文件:
find "${WWW_ROOT}" -type f \
-not -path '*/node_modules/*' \
-not -path '*/vendor/*' \
-not -path '*/runtime/*' \
-not -path '*/.git/*' \
-print0 | xargs -0 grep -l '1dao\.cc'
#### bug1:搜索范围错误
现象:dry-run 输出的命中文件清单里出现了若干路径异常:
/root/backup/www-2024-01/index.html
/root/.openclaw/session/xxxx/context.txt
/var/www/...
dry-run 阶段还好,但实际执行时,脚本会按这些路径去 sed -i 修改文件,意味着会改到 /root/backup/ 下的备份文件、改到 agent session 上下文记录文件,留下脏数据。
排查:在脚本里 set -x 跟踪 find 的起始目录参数,发现 WWW_ROOT 在某次手误中被设置为 /(根目录),find 就从根开始扫,自然把所有用户家目录、agent 上下文全扫了一遍。
根因:find 接受任意路径作为起点,且对路径无任何安全校验,即使给 / 也照扫不误。这违反了"严格限定在站点目录内"的安全前提。
修复:改用 grep -rl --include --exclude-dir 直接在 WWW_ROOT 下搜索,把"搜索根目录"参数固定为 WWW_ROOT,且强制校验非空:
WWW_ROOT="${WWW_ROOT:-/var/www}"
[ -d "${WWW_ROOT}" ] || { echo "WWW_ROOT not a dir: ${WWW_ROOT}"; exit 1; }
grep -rl --include='*.php' --include='*.html' --include='*.js' \
--include='*.css' --include='*.json' --include='*.md' \
--include='*.tpl' --include='*.env' --include='*.env.*' \
--exclude-dir=node_modules --exclude-dir=vendor \
--exclude-dir=runtime --exclude-dir=cache --exclude-dir=.git \
"${OLD}" "${WWW_ROOT}" 2>/dev/null
grep 只在第一个非选项参数(此处是 ${WWW_ROOT})下递归,绝不会"越界"。同时 2>/dev/null 吞掉权限报错(如某些 root-only 文件),避免误报。
3.2 数据库替换循环
数据库侧的替换循环骨架:
for DB in ${DB_LIST}; do
# 取出该库下所有字符串列
mysql -sN -e "SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema='${DB}'
AND data_type IN ('char','varchar','text','mediumtext','longtext','tinytext','json')" \
| while read -r tbl col; do
# 阶段1: old -> placeholder (按长度降序)
for i in "${!OLDS[@]}"; do
mysql "${DB}" -e "UPDATE \`${tbl}\` SET \`${col}\`=REPLACE(\`${col}\`, '${OLDS[i]}', '${PHS[i]}') WHERE \`${col}\` LIKE '%${OLDS[i]}%'"
done
# 阶段2: placeholder -> new
for i in "${!OLDS[@]}"; do
mysql "${DB}" -e "UPDATE \`${tbl}\` SET \`${col}\`=REPLACE(\`${col}\`, '${PHS[i]}', '${NEWS[i]}') WHERE \`${col}\` LIKE '%${PHS[i]}%'"
done
done
done
#### bug2:CONCAT(table_name,"\t",column_name) 输出字面量 \t 而非制表符
现象:第一版中,我把两列拼成一列输出,期望 \t 是制表符,再用 read tbl col 解析:
mysql -sN -e "SELECT CONCAT(table_name,'\t',column_name) FROM information_schema.columns WHERE ..."
执行后,while read -r tbl col 循环里 tbl 的值变成了类似 eb_system_config\tvalue 的整串,col 永远为空。后续的 UPDATE 语句拼出来的表名是 eb_system_config\tvalue,MySQL 报"表不存在",但被外层 || true 吞掉,导致替换实际上从未执行。
排查:在循环里 echo "tbl=[${tbl}] col=[${col}]",输出:
tbl=[eb_system_config\tvalue] col=[]
确认 \t 没被解析成制表符,而是字面两个字符 \ 和 t。
根因:MySQL 的 SQL 字符串中,转义序列 \t 不是制表符。在 MySQL 默认 SQL 模式下,字符串里的 \t 是两个字符:反斜杠 + t。要让 MySQL 输出真正的制表符,需要 CHAR(9):
SELECT CONCAT(table_name, CHAR(9), column_name) ...
这与 Bash、C、Python 等语言不同——Bash 的 echo -e 和 C 的 "\t" 都把 \t 解释成制表符,但 SQL 字面量字符串中 \ 只是字面反斜杠(NO_BACKSLASH_ESCAPES 模式下尤其明显)。
修复:不拼接,直接 SELECT 两列,让 mysql -sN 的默认列分隔符(制表符)来分隔:
mysql -sN -e "SELECT table_name, column_name FROM information_schema.columns WHERE ..."
mysql 客户端在 -s(silent,无表头框线)和 -N(naked,无列名)模式下,默认列分隔符就是 \t 制表符,完美适配 read -r tbl col 的 IFS 分割。同时省去一个 CONCAT 调用,性能略好。
3.3 dry-run 测试验证
--dry-run 模式下的核心代码:
if [ "${DRY_RUN}" = "1" ]; then
echo "=== 将受影响的数据库表/列 ==="
for DB in ${DB_LIST}; do
mysql -sN -e "SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema='${DB}'
AND data_type IN ('char','varchar','text','mediumtext','longtext','tinytext','json')" \
| while read -r tbl col; do
cnt=$(mysql -sN "${DB}" -e "SELECT COUNT(*) FROM \`${tbl}\` WHERE \`${col}\` LIKE '%${OLD}%'" 2>/dev/null)
[ -n "${cnt}" ] && [ "${cnt}" -gt 0 ] 2>/dev/null && \
printf ' %s.%s.%s : %s rows\n' "${DB}" "${tbl}" "${col}" "${cnt}"
done
done
echo "=== 将受影响的代码文件 ==="
grep -rl --include="${INCLUDES[@]}" --exclude-dir=node_modules \
--exclude-dir=vendor --exclude-dir=runtime --exclude-dir=cache \
"${OLD}" "${WWW_ROOT}" 2>/dev/null | sed 's/^/ /'
fi
测试用例:把 1dao.cc → dev.1dao.cc(单对映射,不含子域,只验证流程)。
输出:
=== 将受影响的数据库表/列 ===
crmeb.eb_system_config.value : 1 rows
crmeb.eb_store_product.image : 18 rows
crmeb.eb_store_product.slider_image : 18 rows
crmeb.eb_store_product.description : 7 rows
crmeb.eb_diy.value : 4 rows
...
=== 将受影响的代码文件 ===
/var/www/1dao.cc/index.html
/var/www/1dao.cc/counter.php
/var/www/nav/index.html
...
清单数量与第一章调查结果一致,dry-run 通过。
3.4 实际执行测试
去掉 --dry-run,加 --apply,执行 1dao.cc → dev.1dao.cc 单对替换:
./change-domain.sh --apply mappings/single.txt
执行后,验证三处:
eb_system_config表中menu=site_url的value:
SELECT value FROM eb_system_config WHERE menu='site_url';
-- 改前:https://shop.1dao.cc
-- 改后:https://dev.1dao.cc ← 这里因为只映射 1dao.cc,shop.1dao.cc 中的 1dao.cc 也被换了,得到 dev.shop → dev.1dao.cc,验证占位符法的必要性
(实际生产用例会同时映射 shop.1dao.cc,此处单对测试目的是验证主流程能跑通。)
- 代码文件:
grep -l dev.1dao.cc /var/www/1dao.cc/index.html命中。 - Redis 中 site_url 缓存:
FLUSHALL后再次访问,返回新域名。
主流程验证通过。
3.5 set -euo pipefail 问题
#### bug3:数据库循环中 mysql 对视图表 UPDATE 失败导致脚本退出
现象:脚本头部加了 set -euo pipefail(Bash 严格模式的标准做法, supposedly 防御性编程)。执行到数据库循环时,脚本在某条 UPDATE 后突然退出,后续库与文件均未处理。退出码非零。
排查:set -x 跟踪,发现是某个 UPDATE 语句报错:
ERROR 1356 (HY000): View 'crmeb.V_xxx' references invalid table(s) or column(s) or function(s) or definer/invoker of view lack privilege to use them
原来 information_schema.columns 把视图的列也列出来了。视图本身不能 UPDATE,MySQL 直接报错返回非零退出码。pipefail 把这个非零通过管道传给外层 while,触发 set -e 让整个脚本退出。
根因:set -euo pipefail 是"防御性编程"的常用组合,但在循环体内可能合理失败的场景下会过度敏感。本场景下,视图不能 UPDATE 是预期内的非错误,应该被容忍。
修复:
- 把
set -euo pipefail改为set -u:-u保留(防变量未定义,这是真错误);-e去掉(循环中允许失败);-o pipefail去掉(同理)。 - 在每个可能失败的
mysql命令后加|| true,显式声明"这里允许失败":
set -u # 注意:不再用 -e 和 -o pipefail
mysql "${DB}" -e "UPDATE \`${tbl}\` ..." || true
这是"防御性编程"与"容错性编程"的权衡:-e 适合线性脚本(每步都关键),循环 + 数据库这种"可能合理失败"的场景必须显式容错。
修改后,脚本能跑完所有库、所有表,视图报错被 || true 吞掉,不影响真实表的替换。
第四章 switch-env.sh 设计
4.1 需求
switch-env.sh 是在 change-domain.sh 之上构建的"模式管理层",需求:
- 配置驱动:域名、库列表、快照目录等参数都写在配置文件,不在脚本里硬编码。
- prod / dev 双向无损切换:从 prod 切到 dev 后,prod 的业务数据(订单、文章、评论)要能保留;切回来时这些数据回来。
- 首次自动生成:第一次切到 dev 时没有 dev 快照,要用 prod 快照 + 域名替换自动生成。
- 状态可查:
switch-env status显示当前模式、各快照大小与时间。 - 快照可更新:
switch-env snapshot把当前模式的最新业务数据固化到快照,避免每次切回都丢失变更。
4.2 设计思路:数据库快照法
#### 4.2.1 为什么不能直接"原地替换"
change-domain.sh 是"原地替换":直接修改当前数据库。它解决的是"换域名",但不解决"切换后如何保留上一模式的数据"。如果 prod → dev 直接原地替换,prod 的业务数据(此时已经被改成了 dev 域名)就丢了 prod 的形态;再切回 prod 时,要么再次原地替换(业务数据已是 dev 期间的新数据,无法回到 prod 形态),要么需要外存。
#### 4.2.2 快照法
为每个模式保存一份数据库快照:
prod.sql.gz:prod 模式下的数据库全量导出dev.sql.gz:dev 模式下的数据库全量导出
切换流程:
保存当前模式快照 → 恢复目标模式快照 → 代码文件替换 → 清缓存 → 更新配置
#### 4.2.3 优势
- 业务数据不丢失:每次切走前都把当前数据存盘,切回来时从盘恢复。
- 双向可逆:从 prod 切到 dev 后,dev 期间产生的数据进入
dev.sql.gz;再切回 prod 时,这些 dev 数据被存盘,prod 数据从prod.sql.gz恢复,dev 数据不丢。 - 首次自适应:第一次切到 dev 没有
dev.sql.gz,自动用prod.sql.gz+ 域名替换生成。
4.3 配置文件 env.conf
env.conf 是整个双模式系统的"事实源"(single source of truth):
## 第五章 `switch-env.sh` 实现与调试
### 5.1 第一版实现
第一版直接复制了 `change-domain.sh` 的代码作为内部函数,在 `switch-env dev` 流程中调用。结果首跑就崩了。
#### bug1:`set -euo pipefail` 导致数据库替换循环提前退出
**现象**:`switch-env dev` 执行到数据库替换阶段(从 prod 快照生成 dev 时)直接退出,后续代码替换、清缓存均未执行,留下"数据库已切到 dev 但代码仍是 prod 域名"的半残状态。
**排查**:在脚本头 `set -x` 跟踪,发现退出点在某条 `UPDATE`:
- mysql crmeb -e 'UPDATE
v_xxxSET ...'
ERROR 1356 (HY000): View 'crmeb.v_xxx' references invalid table(s) or column(s)...
- ... ← 此处脚本退出
**根因**:`set -euo pipefail` 中,`pipefail` 把管道中任一命令的非零退出码传给最后,`-e` 又捕获这个非零让整个脚本退出。视图 UPDATE 失败是预期内的,但被 `pipefail` 当作真错误。
这跟第三章 3.5 的 bug3 同根,但因为 `switch-env.sh` 直接复用 `change-domain.sh` 内部代码,问题再次出现。
**修复**:同 3.5,把脚本头改为 `set -u`,在 `mysql` 调用后加 `|| true`:
set -u # 不用 -e -o pipefail
mysql "${DB}" -e "UPDATE ..." || true
修复后,`switch-env dev` 能跑完整流程。
### 5.2 数据库替换的 CONCAT 问题
#### bug2:同 3.2 的 CONCAT 问题在 switch-env 内复发
`switch-env.sh` 内部那段"取所有字符串列"的 SQL,最初也写成了 `CONCAT(table_name,"\t",column_name)`,同样输出字面量 `\t`。
**现象**:循环里 `tbl` 拿到 `eb_system_config\tvalue`,`col` 永远空。
**根因**:同 3.2,SQL 字符串中 `\t` 不是制表符。
**修复**:同 3.2,改为 `SELECT table_name, column_name` 两列,让 `mysql -sN` 的默认 tab 分隔符工作。
此处复现完全是因为代码复用时没把 `change-domain.sh` 已修复的版本同步过来。教训:**复制粘贴是 bug 传播的最快路径**。后续把共享的数据库替换逻辑抽成 `lib-domain.sh` 库,两边都 `source` 它,避免再次出现"修了一边忘另一边"。
### 5.3 首次切换测试
测试流程:
1. 在 prod 模式下执行 `switch-env init`。
2. 验证 `prod.sql.gz` 已生成。
3. 执行 `switch-env dev`。
#### 5.3.1 `switch-env init`
第六章 nginx dev 域名配置
6.1 dev-domains.conf
为所有 dev.* 子域配置 nginx server block,文件位置 /etc/nginx/conf.d/dev-domains.conf。这个文件与 prod 的 server block 互斥,不能同时启用同一 server_name,否则 nginx 会随机挑一个 server block 响应。
设计上,prod server block 用 1dao.cc 及其子域 shop.1dao.cc 等,dev server block 用 dev.1dao.cc 及其子域 shop.dev.1dao.cc 等,子域前缀加 dev.,完全分离。
6.2 端口选择:HTTP 80
dev 是测试环境,不需要 HTTPS,直接用 80 端口。这避免了证书申请、续期的运维负担。如果后续 dev 也需要 HTTPS(如测试支付回调),可以加 listen 443 ssl + 自签证书或 let's encrypt staging。
6.3 X-Environment 头
所有 dev server block 加:
add_header X-Environment dev always;
always 关键字让 nginx 在错误响应(4xx、5xx)时也加上这个头,方便从浏览器开发者工具一眼分辨当前访问的是 prod 还是 dev。
6.4 各站 root 配置
完整 dev-domains.conf:
## 第七章 双模式架构总结
### 7.1 prod 模式
- **域名**:`1dao.cc` 及其子域 `shop.1dao.cc`、`blog.1dao.cc`、`nav.1dao.cc`、`zentao.1dao.cc`。
- **HTTP**:`dev-on/off` 机制(本系列第三部分详述)+ letsencrypt 证书,443 端口。
- **业务数据**:真实用户访问的真实数据,严禁破坏。
### 7.2 dev 模式
- **域名**:`dev.1dao.cc` 及其子域 `shop.dev.1dao.cc`、`blog.dev.1dao.cc`、`nav.dev.1dao.cc`、`zentao.dev.1dao.cc`。
- **HTTP**:`switch-env` 切换 + 纯 HTTP 80 端口,无证书。
- **业务数据**:从 prod 快照生成,可在 dev 模式下任意修改测试,不影响 prod。
### 7.3 两种模式的关系
**互斥**:同一份数据库(同一份业务数据)在同一时刻只能用一种模式。因为业务数据中的 URL 字段已经被替换为对应模式的域名,无法"同时支持两种模式"。这是数据库快照法的固有约束。
### 7.4 切换:`switch-env prod/dev`
切换命令:
switch-env dev # prod → dev
switch-env prod # dev → prod
切换耗时主要在 `mysqldump` 与 `mysql` 导入,4 个库共 1.8M 压缩数据,本机 SSD 上总耗时约 5-8 秒,可接受。
### 7.5 文件位置
| 文件 | 路径 | 作用 |
|------|------|------|
| 配置文件 | `/etc/1dao/env.conf` | 模式、域名、库列表、快照目录 |
| 快照目录 | `/var/lib/1dao-env/` | 存放 `prod.sql.gz`、`dev.sql.gz` |
| 工具脚本 | `/usr/local/bin/change-domain.sh` | 单次域名替换 |
| 工具脚本 | `/usr/local/bin/switch-env.sh` | 模式切换封装 |
| nginx 配置 | `/etc/nginx/conf.d/dev-domains.conf` | dev 子域 server block |
| 共享库 | `/usr/local/lib/lib-domain.sh` | 数据库替换的共享函数(待重构) |
完整架构图:
+--------------------------------------------------------+
| 用户命令 |
|---|
| switch-env dev/prod/status/init |
+----------------------------+---------------------------+
v
+--------------------------------------------------------+
| switch-env.sh |
|---|
| - 读 env.conf |
| - snapshot_save / snapshot_restore |
| - 调 change-domain.sh --files-only |
| - clear_cache_and_reload |
| - update env.conf CURRENT |
+----------------------------+---------------------------+
+-------------------+-------------------+
v v
+--------------------+ +--------------------------+
| /var/lib/1dao-env/ | change-domain.sh | |
|---|---|---|
| prod.sql.gz | <----恢复--- | - 占位符法 |
| dev.sql.gz | <----保存--- | - 数据库全类型覆盖 |
+--------------------+ | - 文件扩展名白名单 |
| - 排除目录黑名单 |
|---|
+-----------+--------------+
v
+--------------------------+
| /var/www/* |
|---|
| 代码文件 (24 个) |
+--------------------------+
v
+--------------------------+
| 缓存清理 |
|---|
| redis FLUSHALL |
| rm runtime/* cache/* |
| reload php-fpm nginx |
+--------------------------+
v
+--------------------------+
| nginx dev-domains.conf |
|---|
| listen 80 |
| X-Environment: dev |
+--------------------------+
---
## 第八章 经验教训
### 8.1 占位符法是批量替换的核心技术
字符串批量替换的核心难题是"互为子串的多对映射"。占位符法通过"中转一层"把多对多降为多次一对一,从根本上消除了误伤。这个思路不仅适用于域名替换,还适用于:
- API 版本号替换:`v1 → v2`
- 数据库表前缀替换:`eb_ → mall_`
- 文件路径迁移:`/var/www/old → /var/data/new`
只要满足"原值集合与目标值集合存在子串关系",就该用占位符法。**直接顺序替换必翻车**,这是工程经验,不是理论推导。
### 8.2 `CONCAT` 的 `\t` 字面量陷阱
SQL 字符串中 `\t` 是字面量两个字符,不是制表符。这与 Bash、C、Python 截然不同:
| 语言 | `"\t"` 的含义 |
|------|---------------|
| Bash | 制表符(经 `echo -e` 或 `$'...'`) |
| C | 制表符 |
| Python | 制表符 |
| **MySQL SQL 字符串** | **字面量 `\` + `t`**(默认模式) |
要在 MySQL 中输出真正的制表符:
- `CHAR(9)`:显式字符函数,无歧义。
- 让 `mysql -sN` 客户端用默认列分隔符:直接 `SELECT col1, col2`,客户端自动用 `\t` 分隔多列输出。
后者更简洁,且省一次 `CONCAT` 调用。本工具采用后者。
延伸陷阱:同理,`\n`、`\r` 在 SQL 字符串里也是字面量,不是换行。要从 SQL 输出换行,用 `CHAR(10)`、`CHAR(13)`。
### 8.3 `set -euo pipefail` 在循环中的陷阱
`set -euo pipefail` 是"严格模式"标准做法,适合**线性脚本**:
- `-u`:变量未定义即报错。绝大多数场景都该开,防止拼错变量名静默通过。
- `-e`:命令失败即退出。适合"每步都关键"的线性流程,失败应该立刻终止避免后续在错误状态上继续。
- `-o pipefail`:管道中任一非零退出码传到最后。配合 `-e`,让 `cmd1 | cmd2` 中 cmd1 失败也能被捕获。
但在**循环体内可能合理失败**的场景,这套组合会过度敏感:
set -euo pipefail
for x in $items; do
cmd $x # 某些 x 会让 cmd 失败,但这是预期的
done
附录 A:change-domain.sh 关键函数完整代码
#!/usr/bin/env bash
set -u
CONF_FILE="${CONF_FILE:-/etc/1dao/env.conf}"
WWW_ROOT="${WWW_ROOT:-/var/www}"
DB_LIST="${DB_LIST:-crmeb emlog walle zentao}"
DRY_RUN=0
DB_ONLY=0
FILES_ONLY=0
APPLY=0
MAP_FILE=""
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--db-only) DB_ONLY=1; shift ;;
--files-only) FILES_ONLY=1; shift ;;
--apply) APPLY=1; shift ;;
--www-root) WWW_ROOT="$2"; shift 2 ;;
--db-list) DB_LIST="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) MAP_FILE="$1"; shift ;;
esac
done
[ -n "${MAP_FILE:-}" ] || { echo "need MAPPING_FILE"; exit 1; }
}
## 附录 C:常用命令速查
附录 D:故障排查
D.1 切换后访问 dev 域名仍跳到 prod
原因:nginx 没把 dev server block 加载,或 prod server block 的 server_name 与 dev 冲突。
排查:
nginx -T 2>&1 | grep -A2 server_name
curl -sI dev.1dao.cc | grep -i 'x-environment\|server:'
修复:nginx -t 验证配置,systemctl reload nginx 重载。
D.2 切换后站点访问 500
原因:数据库切换成功但代码文件没切,或反之。
排查:
switch-env status # 看 CURRENT
grep -l dev.1dao.cc /var/www/1dao.cc/index.html # 看代码是否切
mysql crmeb -e "SELECT value FROM eb_system_config WHERE menu='site_url'" # 看数据库是否切
修复:用 change-domain.sh --apply 把对应方向补一次。
D.3 切换后 Redis 数据仍指向旧域名
原因:clear_cache 没执行或 Redis 没连上。
排查:
redis-cli ping # 应返回 PONG
redis-cli keys '*site_url*' # 看是否还有旧值
修复:redis-cli FLUSHALL,然后 systemctl reload php-fpm。
结语
change-domain.sh 与 switch-env.sh 是整个开发测试环境方案的执行核心。前者解决"如何做单次无损替换",后者解决"如何在两种模式间无损切换"。两者结合,加上 dev-on/off(prod 模式下临时开 dev 入口)与 nginx 配置,构成了完整的"prod / dev 双模式架构"。
关键设计决策:
- 占位符法:消除多对映射的子串误伤。
- 数据库快照法:实现业务数据的双向无损切换。
- 配置驱动:
env.conf让方案可迁移、可审计、可测试。 grep -rl --include/--exclude-dir:比find + xargs更可靠的代码扫描。
关键 bug 教训:
- SQL
CONCAT的\t是字面量,不是制表符。 set -euo pipefail在循环中过度敏感,需set -u+|| true。- 复制粘贴传播 bug,共享逻辑应抽库
source。
本系列后续部分将围绕这个双模式架构展开日常运维、监控、CI/CD 集成等内容。
第5卷
1dao 开发测试环境搭建工作总结(五):经验教训与最佳实践
第一章:环境隔离的教训
环境隔离不是"锦上添花"的工程规范,而是"非做不可"的生存底线。1dao 的域名迁移涉及数据库、配置文件、Nginx 站点等多处耦合点,任何一个误操作都可能污染线上数据。本章从线上操作的风险出发,推演到虚拟机隔离的价值,再到 VMware/Docker/Vagrant 三种隔离手段的取舍,最后给出"测试先行"的硬性纪律。
1.1 线上服务器操作的风险
现象描述:在最初评估域名迁移可行性时,曾短暂考虑过直接在生产服务器上跑 change-domain.sh。理由看似合理——"反正改完就能立即生效,免去环境切换的麻烦"。但一旦把 REPLACE 语句、sed -i、Nginx default_server 改动叠加起来,任何一步出错都会立即落到真实业务数据上。
原因分析:线上服务器是"单点且不可逆"的环境。具体风险包括:
- 数据库批量
REPLACE一旦方向写反(例如把1dao.cc替换为dev.1dao.cc,然后又把刚替换出来的dev.dev.1dao.cc再替换一遍),会引发雪崩式脏数据; mysqldump备份如果只做了表结构没做数据,回滚时业务数据彻底丢失;- Nginx 配置改错后
systemctl reload失败,整个站点 502,影响真实用户; - SSH 会话中断时,正在执行的脚本可能留下半完成的中间状态(例如部分表已替换、部分未替换),且无快照可回滚。
解决方案:线上服务器只允许"读"操作和经过测试环境验证过的"写"操作。任何修改类动作,先在镜像测试环境跑一遍完整流程,确认无误后再以"最小变更集"的形式推到线上。
最佳实践:把线上服务器视为只读副本,任何破坏性操作都必须先在隔离环境完成 dry-run 与真实 run 两轮验证。线上操作的每一条命令都应能在测试环境的 shell history 中找到对应记录,确保"已验证"。
1.2 虚拟机隔离的价值
现象描述:在 VMware 虚拟机中搭建的镜像测试环境,在整个迁移过程中被反复 save/restore 了二十多次。每一次"改坏了"都能瞬间回滚到上一个已知良好状态,而宿主机和线上服务器毫发无损。
原因分析:虚拟机隔离提供了四重保障:
- 零风险:虚机的磁盘是宿主机上的一个文件,任何破坏都局限在该文件内,不会触及宿主系统;
- 快照回滚:VMware 的 Snapshot 功能可在秒级恢复到任意时间点,等同"游戏存档";
- 环境一致性:虚机的 OS、PHP、MySQL、Nginx 版本可与线上严格对齐,避免"在我机器上能跑"的悖论;
- 可移植:虚机文件可整体复制到另一台宿主机或交付给同事,环境随文件迁移。
解决方案:为每一类高风险操作(域名迁移、版本升级、依赖变更)建立独立虚机,操作前打快照,操作失败即回滚。
最佳实践:虚机不是"备选方案",而是高风险操作的"默认载体"。把"先打快照再动手"写成肌肉记忆,成本几乎为零,收益则是规避了不可估量的回滚成本。
1.3 VMware vs Docker vs Vagrant 的选择
现象描述:在选型阶段,曾对比过三种隔离方案,各自的取舍如下表:
| 维度 | VMware Fusion/Workstation | Docker | Vagrant (VirtualBox) |
|---|---|---|---|
| 隔离层级 | 完整硬件级虚拟化 | 进程级容器 | 完整虚拟化(封装 VirtualBox) |
| 资源开销 | 高(独立内核+完整OS) | 低(共享宿主内核) | 高(同 VMware) |
| 网络模式 | NAT/Bridge/Host-only 灵活 | bridge/overlay 较复杂 | 同 VirtualBox |
| 与物理机相似度 | 最高 | 最低(无独立内核) | 中 |
| 快照能力 | 强(秒级) | 弱(需 commit/image) | 中(VirtualBox snapshot) |
| 学习曲线 | 低(GUI 友好) | 中(需懂 Dockerfile) | 中(需写 Vagrantfile) |
| 适合场景 | 完整镜像测试环境 | 微服务/单一进程隔离 | 团队统一环境分发 |
原因分析:1dao 是一台运行 Nginx+PHP-FPM+MySQL 的传统 LEMP 单机,迁移操作涉及系统级服务(systemd)、网络配置(bridge IP)、多端口监听。Docker 的容器模型在"系统服务编排"上反而不如完整虚机直观;Vagrant 本质是 VirtualBox 的封装,性能与 VMware 相当但 GUI 体验弱;VMware 在"最接近物理机"这一点上无可替代。
解决方案:主测试环境用 VMware(bridge 模式固定 IP);轻量级的脚本调试(如 change-domain.sh 的纯逻辑测试)可用 Docker 起一个 MySQL 容器快速验证 SQL;Vagrant 仅在需要向团队分发统一环境时考虑。
最佳实践:隔离手段的选择服从于"被隔离对象"的形态——整机关联操作用完整虚机(VMware),单一服务用容器(Docker),团队分发用 Vagrant。不要用一种方案硬套所有场景。
1.4 最佳实践:永远先在测试环境验证,再推线上
把前三节凝结成一条铁律:任何对线上有潜在影响的变更,必须经过"测试环境 dry-run → 测试环境真实执行 → 线上 dry-run → 线上真实执行"四阶段。其中前三个阶段任一失败都不得进入下一阶段。这条规则看似冗长,实则把"不可逆"压缩成了"可逆",是整个迁移工作零事故的根本原因。
第二章:域名管理的教训
域名是 1dao 迁移的核心对象,也是踩坑最密集的区域。从 TLD 选择到 HSTS 不可逆性,再到证书 SAN 的覆盖范围,每一条教训都来自真实的故障现场。
2.1 .dev TLD 的陷阱
现象描述:最初计划把开发域名定为 1dao.dev,理由是".dev 一看就是开发环境"。结果 Chrome 一访问就强制跳转到 https://1dao.dev/,并报证书错误——而本地根本没配 .dev 的证书。
原因分析:.dev 是 Google 在 2015 年购买的 gTLD,并于 2017 年起将其加入 Chrome 的 HSTS Preload List,强制所有 .dev 域名走 HTTPS。这意味着:
- 即使在
/etc/hosts里把1dao.dev指向127.0.0.1,浏览器仍会强制 HTTPS; - 本地自签证书无法通过 Chrome 的验证(除非手动导入并信任);
- 这个强制行为在浏览器层面,无法通过服务器配置绕过。
## 第三章:浏览器行为的教训
浏览器是最后一个"坑",因为它在用户和服务器之间插入了多层缓存:DNS 缓存、HSTS 缓存、Socket 连接池、Cookie 存储。这些缓存各自独立,任何一层没清干净都会让 hosts 切换"看似失效"。
### 3.1 浏览器 DNS 缓存独立于系统
**现象描述**:改完 `/etc/hosts` 后 `ping dev.1dao.cc` 已经指向新 IP,但 Chrome 仍然访问旧 IP。`dscacheutil -flushcache`、`sudo killall -HUP mDNSResponder` 都试过,无效。
**原因分析**:Chrome 和 Firefox 都有**独立的**应用级 DNS 缓存,与系统 DNS 缓存互不相干。Chrome 的 DNS 缓存默认 TTL 约 1 分钟,但在某些场景下会保留更久(例如已建立的 keep-alive 连接)。
**解决方案**:在 Chrome 地址栏访问 `chrome://net-internals/#dns`,点击 "Clear host cache"。
**最佳实践**:改完 hosts 后,**系统级**和**浏览器级** DNS 缓存都要清,二者缺一不可。
### 3.2 清除浏览器 DNS 缓存
Chrome:
地址栏 → chrome://net-internals/#dns → Clear host cache
Firefox:
地址栏 → about:networking#dns → Clear DNS Cache
Safari:
无独立入口,需重启浏览器或清空全部缓存
**最佳实践**:把 `chrome://net-internals/#dns` 加入书签,作为域名切换后的标准动作之一。
### 3.3 HSTS 缓存
**现象描述**:在清理完 DNS 缓存后,Chrome 仍然把 `http://dev.1dao.cc/` 307 重定向到 `https://`。这是 HSTS 缓存在起作用。
**原因分析**:HSTS 策略存储在浏览器本地的"传输安全状态"表中,与 DNS 缓存是两套独立机制。清 DNS 不会清 HSTS。
**解决方案**:`chrome://net-internals/#hsts` → 在 "Delete domain security policies" 输入 `1dao.cc`(注意是根域名,会连带清除子域名的策略)→ Delete。
**最佳实践**:凡是涉及 HTTPS 切换、域名迁移的场景,清理顺序应是:**DNS 缓存 → HSTS 缓存 → Socket 连接池**,三者依次执行。
### 3.4 Socket 连接池
**现象描述**:DNS 和 HSTS 都清完,Chrome 偶尔还是会"间歇性"访问到旧 IP。抓包发现是某些 keep-alive 连接仍保持着旧的 TCP 会话。
**原因分析**:Chrome 为提升性能,会复用已建立的 TCP/TLS 连接(连接池),这些连接在空闲一段时间内不会关闭。即使 DNS 已经解析到新 IP,旧连接仍走旧 IP。
**解决方案**:`chrome://net-internals/#sockets` → "Flush socket pools"。
**最佳实践**:完整的浏览器缓存清理三件套:`#dns`(Clear host cache)→ `#hsts`(Delete domain security policies)→ `#sockets`(Flush socket pools)。
### 3.5 无痕窗口的价值
**现象描述**:在反复清理缓存的疲惫中,发现一个更省事的方法——直接开无痕窗口验证 hosts 切换。
**原因分析**:无痕窗口启动时使用一个全新的、临时的 profile,DNS/HSTS/Socket/Cookie 缓存均为空。它对 hosts 的解析完全依赖系统当前状态,是"最干净"的验证环境。
**解决方案**:hosts 切换后,优先用无痕窗口验证;确认无误后再处理常规窗口的缓存。
**最佳实践**:**无痕窗口是 hosts 验证的首选工具**。它把"清理缓存"这一易忘步骤直接绕过,是最低成本的验证手段。
### 3.6 Cookie 共享机制
**现象描述**:在 `dev.1dao.cc`(测试)和 `1dao.cc`(线上)同时登录后,发现测试环境的登录态偶尔"串"到线上,或反之。退出一个,另一个也跟着退出。
**原因分析**:Cookie 的作用域由 `Domain` 属性决定,且**不区分端口**:
- 若 Cookie 的 `Domain=.1dao.cc`,则 `1dao.cc`、`dev.1dao.cc`、`1dao.cc:8080`、`1dao.cc:443` **全部共享**;
- 端口不同不会隔离 Cookie(这是 RFC 6265 的设计,出于历史兼容);
- 测试环境与线上同根域名,必然共享 `.1dao.cc` 作用域的 Cookie。
Set-Cookie: session=abc123; Domain=.1dao.cc; Path=/; HttpOnly
第四章:Shell 脚本编程的教训
迁移过程中写了大量 shell 脚本(dev-on.sh、dev-off.sh、switch-env.sh、change-domain.sh),每一个坑都来自真实的调试现场。
4.1 set -euo pipefail 的陷阱
现象描述:change-domain.sh 早期版本头部写着 set -euo pipefail,在循环遍历数据库表做 UPDATE 时,某张视图表报错,整个脚本立即退出,导致后半部分表未替换,数据半残。
原因分析:
-e:任一命令非零退出即终止脚本。在for循环中,一个表的UPDATE失败(例如视图不可写)会终止整个循环,后续表全部跳过;-u:引用未定义变量报错。有助于发现拼写错误,但在某些动态构造变量名的场景会误伤;-o pipefail:管道中任一环节非零则整个管道非零。mysql ... | grep ... | awk ...中 mysql 对视图 UPDATE 失败会被 pipefail 传播,导致 grep/awk 误判。
## 第五章:数据库操作的教训
数据库是 1dao 域名迁移的主战场,所有教训都围绕"如何在不丢数据、不脏数据的前提下完成批量替换"。
### 5.1 mysqldump --databases 的区别
**现象描述**:迁移前用 `mysqldump db1 db2 > backup.sql` 备份,恢复时 `mysql < backup.sql` 报错 `Unknown database 'db1'`。
**原因分析**:`mysqldump` 是否加 `--databases` 选项,导出内容差异巨大:
| 选项 | 导出内容 | 恢复方式 |
| --- | --- | --- |
| `mysqldump db1 db2 > x.sql` | 仅表结构和数据 | 需手动 `CREATE DATABASE` 后再恢复 |
| `mysqldump --databases db1 db2 > x.sql` | 含 `CREATE DATABASE` 和 `USE` | 恢复时自动建库 |
| `mysqldump --all-databases > x.sql` | 所有库 + 建库语句 | 自动恢复所有库 |
第六章:网络配置的教训
网络是虚机与宿主、测试与线上之间的桥梁,任何一处配置不当都会让"明明改对了"的方案"怎么都不生效"。
6.1 VMware 网络模式
现象描述:虚机最初用 NAT 模式,IP 是 192.168.x.x 动态分配,每次重启可能变化,导致 /etc/hosts 和 Nginx 配置经常失效。
原因分析:VMware 三种网络模式的本质差异:
| 模式 | 虚机 IP | 与外部网络 | 与宿主机 | 适用场景 |
|---|---|---|---|---|
| NAT | 动态(vmnet8 DHCP) | 经宿主 NAT 出网 | 可互访 | 隔离开发,不需固定 IP |
| Bridge | 与宿主同网段独立 IP | 直接在局域网 | 可互访 | 需固定 IP、需被其他设备访问 |
| Host-only | 动态(vmnet1 DHCP) | 不可上网 | 仅宿主可访 | 纯本地隔离测试 |
解决方案:开发环境改用 Bridge 模式,在路由器 DHCP 里给虚机 MAC 绑定固定 IP(如 192.168.1.100),从此 IP 永不变。
最佳实践:开发环境用 Bridge 模式 + DHCP 静态绑定,获得固定 IP;NAT 适合临时调试;Host-only 适合纯隔离的安全测试。
6.2 /etc/hosts 与 DNS 解析
现象描述:改完 /etc/hosts 后,ping 立即生效,但 curl 偶尔不生效。
原因分析:域名解析顺序由 /etc/nsswitch.conf(Linux)或 /etc/hosts 优先级(macOS 内置)决定:
## 第七章:版本控制与协作的教训
1dao 的迁移脚本通过 Gitee 组织仓库管理,涉及多账号、权限、敏感信息等典型协作问题。
### 7.1 SSH key 与 Gitee 账号的绑定
**现象描述**:用个人 Gitee 账号的 SSH key push 组织仓库,报 `Permission denied (publickey)`。
**原因分析**:Gitee 的 SSH key 是**账号级**绑定的,一个 key 只能绑定到一个账号。当个人账号未被添加为组织仓库协作者时,push 会被拒。
**解决方案**:多账号场景,用 `~/.ssh/config` 管理多个 IdentityFile:
第八章:文档化的重要性
迁移工作能顺利完成并复用,关键在于每一步都留有文档。本章阐述文档化的具体形态与价值。
8.1 技术文档的价值
现象描述:半年后再次需要切换环境时,凭记忆已经记不清 switch-env.sh 的参数顺序,只能翻 shell history。
原因分析:人脑对操作细节的记忆衰减极快,而迁移类操作低频但高风险,正是"最需要文档"的场景。
解决方案:为每个方案写技术文档,包含三大要素:
- 架构图:整体结构与数据流;
- 使用步骤:从零到可用的可复现命令序列;
- 故障排查:常见错误的诊断与解决。
最佳实践:文档不是"写完就完",而是"有人能用它独立复现方案"——以这个标准衡量文档的完备度。
8.2 文档结构
经过多次迭代,沉淀出以下固定结构:
1. 概述 ← 这个方案解决什么问题
2. 架构 ← ASCII 架构图 + 组件说明
3. 使用步骤 ← 从零到可用的命令序列
4. 命令速查 ← 常用命令一行说明
5. 故障排查 ← 现象 → 原因 → 解决 三段式
6. 维护事项 ← 定期清理、证书续期等
最佳实践:固定文档结构,让读者形成"去第几节找什么"的预期,降低检索成本。
8.3 ASCII 架构图的优势
现象描述:早期文档用图片画架构图,结果图片丢失、版本不一致、无法 diff。
原因分析:图片是二进制文件,版本控制无法 diff,且依赖外部工具渲染。
解决方案:用 ASCII 画架构图:
+-----------------+
| 宿主机(macOS) |
| |
浏览器 ──────────►| /etc/hosts: |
(Chrome) | 192.168.1.100 |
| dev.1dao.cc |
+--------+--------+
│ bridge
+--------▼--------+
| VMware 虚机 |
| 192.168.1.100 |
| |
| Nginx:443 ─────► PHP-FPM
| │ │
| ▼ ▼
| MySQL ←── 业务数据
+-----------------+
优势:
- 纯文本,版本可控,可 diff;
- 无需任何工具渲染,任何编辑器可读;
- 修改成本极低。
最佳实践:架构图优先用 ASCII;仅在需要表达复杂视觉关系(如时序图)时才用专业工具,并导出文本版本一并入库。
8.4 脚本即文档
现象描述:某些脚本写完后,使用者仍需翻 README 才知道参数。
原因分析:脚本本身没有 --help,所有信息都在外部文档,二者容易脱节。
解决方案:每个脚本内置 --help:
#!/usr/bin/env bash
## 第九章:工作方法论
前八章是"具体教训",本章提炼为"通用方法论",可迁移到任何类似的系统工程任务。
### 9.1 先调查后动手
**教训**:域名替换前若不先全量扫描耦合点,必然漏改。
**方法论**:任何"批量修改"类任务,第一步是**穷举式调查**——用 `information_schema` 扫所有字符串列、用 `grep -rl` 扫所有配置文件、用 `find` 扫所有脚本,把"影响面"先摸清,再动手。
第十章:最佳实践速查表
以下是全书所有最佳实践的分类汇总,可作为日常工作的 checklist。
10.1 环境隔离
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 1 | 线上服务器视为只读副本,破坏性操作先在隔离环境验证 | 1.1 |
| 2 | 高风险操作默认在虚机进行,先打快照再动手 | 1.2 |
| 3 | 整机关联操作用 VMware,单一服务用 Docker,团队分发用 Vagrant | 1.3 |
| 4 | 变更走"测试 dry-run → 测试执行 → 线上 dry-run → 线上执行"四阶段 | 1.4 |
10.2 域名管理
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 5 | 永远不用 `.dev/.app/.page` 等已 preload HSTS 的 TLD 做开发域名 | 2.1 |
| 6 | 开发域名安全池:RFC 2606 保留 TLD(`.test/.example/.invalid/.localhost`)或真实域名子域名 | 2.2 |
| 7 | 优先用"现有域名 + 子域名前缀"(`dev./test./local.`),复用通配符证书 | 2.3 |
| 8 | 开发环境绝不下发 HSTS 头,或 `max-age=0` 主动撤销 | 2.4 |
| 9 | 证书务必把所有需要的域名写入 SAN,优先用通配符证书覆盖子域名 | 2.5 |
10.3 浏览器
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 10 | 改完 hosts 清两遍缓存:系统级(`dscacheutil`/`mDNSResponder`)+ 浏览器级(`chrome://net-internals/#dns`) | 3.1/3.2 |
| 11 | HSTS 缓存独立于 DNS,需在 `#hsts` 单独清除 | 3.3 |
| 12 | 完整清理三件套:`#dns` → `#hsts` → `#sockets` | 3.4 |
| 13 | hosts 验证首选无痕窗口,绕过所有缓存 | 3.5 |
| 14 | 测试与线上同根域名时,用双浏览器或独立 Profile 隔离 Cookie | 3.6 |
10.4 Shell 脚本
| # | 最佳实践 | 来源章节 | |||
|---|---|---|---|---|---|
| 15 | 顶层脚本用 `set -euo pipefail`,容错循环用 `set -u` + `\ | \ | true` | 4.1 | |
| 16 | 不要用 `CONCAT` 拼 tab,`mysql -sN` 多列输出天然 tab 分隔 | 4.2 | |||
| 17 | 跨平台文件内替换用 `perl -pi -e`,避开 `sed -i` 差异 | 4.3 | |||
| 18 | 文件内容搜索优先用 `grep -r --include --exclude-dir` | 4.4 | |||
| 19 | 数组按属性排序:`printf \ | awk 加前缀 \ | sort \ | cut 去前缀` | 4.5 |
10.5 数据库
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 20 | 多库备份必须加 `--databases`,恢复时自动建库 | 5.1 |
| 21 | 频繁切换的多环境用"快照 save/restore"代替 dump/restore | 5.2 |
| 22 | 批量替换始终用占位符法,即使单向替换也要用 | 5.3 |
| 23 | 不要手动列举表,用 `information_schema.columns` 自动发现字符串列 | 5.4 |
10.6 网络
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 24 | 开发环境用 Bridge 模式 + DHCP 静态绑定,获得固定 IP | 6.1 |
| 25 | hosts 修改后 flush 系统 DNS 缓存,再清浏览器缓存 | 6.2 |
| 26 | 断网时 hosts 解析可能失效,用 IP + `Host` 头直连 | 6.3 |
| 27 | `default_server` 影响所有未匹配请求,排查"莫名 444"时优先检查 | 6.4 |
10.7 版本控制
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 28 | 多账号用 `~/.ssh/config` 的 Host 别名 + `IdentityFile` 隔离 | 7.1 |
| 29 | push 失败先确认:① SSH key 绑定的账号;② 该账号是否在协作者列表 | 7.2 |
| 30 | 组织仓库用 `git config user.email` 覆盖全局身份 | 7.3 |
| 31 | `.gitignore` 第一条是 `.env*`;token 泄露先撤销再清理历史 | 7.4 |
10.8 文档
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 32 | 每个方案写技术文档,标准结构:概述→架构→步骤→速查→排障→维护 | 8.1/8.2 |
| 33 | 架构图优先用 ASCII,纯文本可 diff | 8.3 |
| 34 | 每个脚本内置 `--help`,注释解释"为什么"而非"是什么" | 8.4 |
10.9 方法论
| # | 最佳实践 | 来源章节 |
|---|---|---|
| 35 | 先调查后动手:穷举式扫描影响面,产出"影响面清单"再修改 | 9.1 |
| 36 | 先 dry-run 后执行:破坏性脚本必须支持 `--dry-run` | 9.2 |
| 37 | 先备份后修改:`mysqldump --databases` + 虚机快照双保险 | 9.3 |
| 38 | 小步快跑:改一处→验证→存档,二分定位出错点 | 9.4 |
| 39 | 重复三次以上的操作写成脚本 | 9.5 |
| 40 | 配置驱动:可变参数抽到 `env.conf`,脚本读配置 | 9.6 |
| 41 | 双模式共存:不强求统一,按场景选择 | 9.7 |
结语
1dao 的开发测试环境搭建,表面是"搭个虚机、改个域名"的工程任务,实质是一次完整的"系统工程方法论"演练。从线上风险的敬畏,到缓存层次的清剿,从 shell 的边界陷阱,到数据库的占位符哲学,每一条教训都来自真实的故障现场,每一条最佳实践都经过反复验证。
这份总结的价值不在于"1dao 这一次怎么做的",而在于它提炼出的 41 条最佳实践可以迁移到任何类似的系统工程任务——无论是下一次域名迁移、版本升级,还是全新项目的环境搭建。把它们作为 checklist,把"先调查、先 dry-run、先备份、小步快跑、工具化、配置驱动"作为肌肉记忆,工程风险就能被压到最低,工程效率就能被拉到最高。
工程能力的本质,不是知道多少命令,而是知道每个命令背后有多少坑,以及如何在动手之前就把这些坑填平。这正是本系列总结想要传达的核心。
第6卷
1dao 开发测试环境搭建工作总结(六):技术原理解析与故障排查手册
第一章:DNS 解析机制深度解析
1.1 DNS 解析层级
当用户在浏览器地址栏输入 dev.1dao.cc 并按下回车,浏览器并不会直接发起 DNS 查询,而是按以下层级顺序逐级查找。理解这一顺序,是排查"为什么改了 hosts 还是不生效"这类问题的前提。
浏览器自身 DNS 缓存
↓ (未命中)
操作系统级 DNS 缓存 (macOS 上为 mDNSResponder 维护的缓存)
↓ (未命中)
/etc/hosts 文件 (本地静态映射)
↓ (未命中)
系统配置的 DNS 服务器 (路由器下发 / 手动设置)
↓ (未命中)
递归向上查询根 → 顶级域 → 权威服务器
各层级的特性:
| 层级 | 位置 | TTL 控制方 | 清除方式 |
|---|---|---|---|
| 浏览器 DNS 缓存 | 进程内存 | 浏览器策略 | `chrome://net-internals/#dns` 清除 |
| 系统 DNS 缓存 | mDNSResponder 进程 | mDNSResponder 内部 | `sudo dscacheutil -flushcache` |
| `/etc/hosts` | 文件 | 手动 | 直接编辑文件 |
| DNS 服务器 | 网络侧 | zone 文件 TTL | 由 DNS 管理员修改 |
一个常见误区是认为"修改了 /etc/hosts 立刻生效"。事实上,如果系统 DNS 缓存中仍有该域名的旧记录,且 mDNSResponder 没有主动失效该条目,那么 /etc/hosts 的修改可能不会被立即查询到。这与 nsswitch.conf 的解析顺序共同决定了实际行为。
1.2 nsswitch.conf:host 配置行决定解析顺序
macOS 的名字解析服务配置文件位于 /etc/nsswitch.conf(部分 macOS 版本没有该文件,此时采用系统默认顺序)。其中 hosts 行决定了名字解析的来源顺序:
hosts: files dns mdns
含义逐项解析:
files:优先读取/etc/hosts文件。如果命中,直接返回结果,不再向 DNS 服务器查询。dns:在files未命中后,向/etc/resolv.conf中配置的 DNS 服务器发起递归查询。mdns:Multicast DNS,主要用于.local域名和局域网服务发现(如打印机、AirPlay 设备)。
将顺序改为 dns files mdns 会导致 DNS 服务器响应优先于本地 hosts 文件。这在某些场景下是灾难性的:当你想在本地把 1dao.cc 指向虚机 IP,但公网 DNS 中 1dao.cc 指向线上服务器,且 DNS 服务器返回的 TTL 较长,那么你的 hosts 修改将完全失效。
macOS 默认没有 /etc/nsswitch.conf 文件,但其内部解析模块遵循 files dns 的默认顺序。可通过 scutil --dns 查看实际生效的 DNS 配置上下文:
scutil --dns
## 第二章:HSTS 与 HTTPS 深度解析
### 2.1 HSTS 机制:HTTP Strict Transport Security(RFC 6797)
HSTS 是浏览器端的 HTTPS 强制策略,通过 HTTP 响应头声明:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
各字段含义:
- **max-age**:策略有效期(秒)。浏览器在此期间内,对该域名的所有 HTTP 请求会被自动重写为 HTTPS。本项目中典型值为 31536000(1 年)。
- **includeSubDomains**:策略扩展到当前域名的所有子域名。例如对 `1dao.cc` 设置,则 `dev.1dao.cc`、`admin.1dao.cc`、`api.1dao.cc` 都会受影响。
- **preload**:声明该域名愿意被加入浏览器的 preload list(详见 2.2)。这不是立即生效的指令,需要站长主动到 [hstspreload.org](https://hstspreload.org/) 提交申请。
HSTS 的核心陷阱:**首次访问不生效**。浏览器必须先以 HTTPS 访问过该域名、并收到了带 HSTS 头的响应,才会缓存策略。这就是为什么 HSTS 不能完全替代 301 重定向——首次 HTTP 请求仍可能被中间人劫持。
工作机制时序:
用户访问 https://1dao.cc
↓
服务器返回 200 OK + Strict-Transport-Security: max-age=31536000
↓
浏览器记录该策略,存储在 profile 目录下
↓
后续 1 年内,任何 http://1dao.cc 链接
↓
浏览器内部直接改为 https://1dao.cc (不发起 HTTP 请求)
↓
服务器收到 HTTPS 请求
清除浏览器 HSTS 策略的方法(Chrome):
1. 打开 `chrome://net-internals/#hsts`
2. 在 "Delete domain security policies for domain" 输入框中填入域名(如 `1dao.cc`)
3. 点击 "Delete" 按钮
4. 在 "Query HSTS/PKP domain" 输入框验证是否已清除
### 2.2 HSTS preload list
HSTS preload list 是浏览器厂商维护的、内置在浏览器二进制文件中的永久 HSTS 域名列表。一旦被加入,即使站长删除了 HSTS 响应头,浏览器仍会强制 HTTPS。
**Google 维护的 preload list** 包含两类域名:
1. **站长主动提交的域名**:如 `github.com`、`paypal.com` 等,站长向 hstspreload.org 提交并满足条件(有效 HTTPS、所有子域名支持 HTTPS、max-age ≥ 18 周)。
2. **Google 自有的 TLD**:如 `.dev`、`.app`、`.foo`、`.page`、`.google` 等。这些 TLD 由 Google 注册,默认全部在 preload list 中,强制 HTTPS。
**关键行为**:
- `.dev` 域名在所有主流浏览器中被永久强制 HTTPS。
- 即使用户访问 `http://1dao.dev`,浏览器也会内部跳转为 `https://1dao.dev`。
- 如果该域名没有 HTTPS 服务,连接失败,无法用任何方式绕过。
- **退出 preload list 极其困难**:需要站长主动提交移除申请,Google 审核期可能长达数月,且需等到下次浏览器版本发布后,才会从 list 中移除。
这就是为什么本项目要避免使用 `.dev` 作为测试域名:`dev.1dao.cc`(eTLD 是 `.cc`)不会触发 `.dev` 的 HSTS,可以自由使用 HTTP。
### 2.3 eTLD+1(Effective Top-Level Domain plus one)
eTLD+1 是 Cookie 和 HSTS 策略计算的基础概念,定义如下:
- **eTLD(Effective Top-Level Domain)**:由 [publicsuffix list](https://publicsuffix.org/) 定义的有效顶级域名。例如 `.com`、`.org`、`.cc`、`.dev` 都是 eTLD。
- **eTLD+1**:eTLD 加上一级,通常是"可注册的域名"。例如 `1dao.cc` 是 `.cc` 的 eTLD+1,`1dao.dev` 是 `.dev` 的 eTLD+1。
**特殊情形**:有些"看起来像 TLD"的实际上是 eTLD+1。例如:
- `1dao.cc` → eTLD 是 `.cc`,eTLD+1 是 `1dao.cc`
- `1dao.dev` → eTLD 是 `.dev`,eTLD+1 是 `1dao.dev`
- `dev.1dao.cc` → eTLD 是 `.cc`,eTLD+1 是 `1dao.cc`(因为 `dev.1dao.cc` 是 `1dao.cc` 的子域名)
**HSTS includeSubDomains 的边界**:HSTS 头中的 `includeSubDomains` 只会扩展到与设置域名的 **eTLD+1 相同** 的子域名。例如对 `1dao.cc` 设置 `includeSubDomains`,扩展到 `dev.1dao.cc`、`admin.1dao.cc`,但**不会**扩展到 `1dao.cc` 之外的其他 eTLD+1(如 `1dao.dev`)。
### 2.4 为什么 dev.1dao.cc 不触发 .dev 的 HSTS
这是本项目中一个核心的设计决策点。许多人会问:"既然 `.dev` 在 preload list 中,那 `dev.1dao.cc` 这个域名中包含 `.dev`,会不会被强制 HTTPS?"
答案是**不会**。原因:
1. **eTLD 计算规则**:publicsuffix list 中,`.dev` 是一个 eTLD(顶级域),而 `.cc` 是另一个 eTLD。
2. **域名解析结构**:`dev.1dao.cc` 的层次是 `cc` → `1dao` → `dev`,eTLD 是 `cc`,eTLD+1 是 `1dao.cc`,`dev` 是 `1dao.cc` 的子域名。
3. **HSTS 触发条件**:浏览器判断一个域名是否在 preload list 时,是按"完整 eTLD+1"匹配的。`dev.1dao.cc` 的 eTLD+1 是 `1dao.cc`,不在 preload list 中;preload list 中的 `dev` 是作为 eTLD 存在的,影响的是 `xxx.dev` 这类域名(其 eTLD+1 形如 `1dao.dev`),而不是子串 `dev` 出现在其他域名中。
**对比表**:
| 域名 | eTLD | eTLD+1 | 是否受 .dev preload 影响 |
|------|------|--------|------------------------|
| `dev.1dao.cc` | `.cc` | `1dao.cc` | 否 |
| `1dao.dev` | `.dev` | `1dao.dev` | 是,强制 HTTPS |
| `admin.1dao.dev` | `.dev` | `1dao.dev` | 是,通过 includeSubDomains |
| `1dao.cc` | `.cc` | `1dao.cc` | 否 |
所以本项目中使用 `dev.1dao.cc` 作为测试域名是安全的,可以使用 HTTP;而如果改用 `1dao.dev` 作为测试域名,就必须提供有效 HTTPS 证书。
**eTLD+1 在 Cookie 域设置中的作用**:publicsuffix list 的存在意义之一,是防止"恶意 Cookie 注入"。考虑场景:某个公网站点 `evil.com` 试图设置 Domain 为 `.com` 的 Cookie。如果浏览器允许,这个 Cookie 会被发送到所有 `.com` 域名(包括 `bank.com`、`google.com` 等),造成严重的隐私泄露和身份伪造风险。publicsuffix 限制了"可注册的 eTLD+1 边界",让 Cookie 只能在同一个 eTLD+1 内共享,跨 eTLD+1 不可传播。
同理,HSTS 的 includeSubDomains 也受 eTLD+1 边界限制:对 `1dao.cc` 设置 includeSubDomains,只影响 `1dao.cc` 自己的子域名(`dev.1dao.cc`、`admin.1dao.cc`),不会影响 `2dao.cc` 或其他 eTLD+1。这保证了 HSTS 策略的隔离性,防止恶意站点通过设置 HSTS 影响其他站点的访问。
### 2.5 TLS 握手与 SNI
TLS 握手是 HTTPS 建立加密通道的过程。简化流程:
客户端 服务器
| --- ClientHello --------------> | (含 SNI:期望访问的域名) |
| <-- ServerHello ---------------- | (选定 TLS 版本、密码套件) |
| <-- Certificate ---------------- | (服务器据此域名选证书返回) |
| <-- ServerHelloDone ------------ | |
| --- ClientKeyExchange ---------> | |
| --- ChangeCipherSpec ----------> | |
| --- Finished ------------------> | |
| <-- ChangeCipherSpec ----------- | |
| <-- Finished ------------------- | |
| === 加密应用数据传输 ========= |
**SNI(Server Name Indication)**:在 ClientHello 阶段,客户端通过 TLS 扩展字段 `server_name` 明文告知服务器"我想访问哪个域名"。服务器收到后,从配置的多个证书中,选择与该域名匹配的证书返回。
SNI 的必要性:同一台服务器(同一 IP)的 443 端口可能服务多个域名(如 `1dao.cc` 和 `dev.1dao.cc`),每个域名有自己的证书。如果没有 SNI,服务器无法在 TLS 握手前知道客户端想访问哪个域名,只能返回默认证书,导致浏览器报证书不匹配错误。
**SNI 不匹配时的行为**:
- 服务器返回 `default_server` 配置的证书(nginx 概念)。
- 如果该证书的 SAN 中不包含客户端访问的域名,浏览器报"证书不受信任"。
- 如果 `default_server` 返回 nginx 的 444 状态码(详见 2.7),连接会被直接关闭。
诊断 SNI 的命令:
第三章:Cookie 与浏览器存储深度解析
3.1 Cookie 机制
Cookie 是 HTTP 协议中客户端保存少量状态数据的机制。服务器通过 Set-Cookie 响应头下发,浏览器在后续请求中通过 Cookie 请求头回传。
Set-Cookie 头的属性:
Set-Cookie: sessionid=abc123; Domain=.1dao.cc; Path=/; Max-Age=86400; Secure; HttpOnly; SameSite=Lax
| 属性 | 作用 | 默认行为 |
|---|---|---|
| `Domain` | Cookie 所属域名 | 默认为响应服务器域名,不带前导点 |
| `Path` | 生效路径 | 默认为响应的 URL 路径 |
| `Expires` | 过期时间(绝对时间) | 不设则为 Session Cookie,关闭浏览器即失效 |
| `Max-Age` | 过期时间(相对秒数) | 优先级高于 Expires |
| `Secure` | 仅 HTTPS 传输 | 不设则 HTTP 也会发送 |
| `HttpOnly` | JS 不可访问 | 防止 XSS 读取 Cookie |
| `SameSite` | 跨站发送策略 | 现代浏览器默认 Lax |
Domain 属性的关键行为:
- 不写 Domain:Cookie 只属于当前精确域名。
dev.1dao.cc设置的 Cookie,不会发送到1dao.cc或admin.1dao.cc。 - 写 Domain=
.1dao.cc(注意前导点,现代浏览器忽略前导点,但语义是"覆盖所有 1dao.cc 的子域名和 1dao.cc 本身"):Cookie 会发送到1dao.cc、dev.1dao.cc、admin.1dao.cc等所有 eTLD+1 一致的域名。
Cookie 不分端口:http://1dao.cc:8080 和 http://1dao.cc:80 共享同一份 Cookie。这是 Cookie 的设计——端口不参与 Cookie 的隔离。
HttpOnly 的意义:设置了 HttpOnly 的 Cookie,document.cookie 取不到,也修改不了。这能防止 XSS 攻击者通过 JS 偷取登录态。本项目登录 Cookie 必须设 HttpOnly。
SameSite 三种取值:
Strict:跨站请求完全不发送 Cookie。例如从google.com点链接到1dao.cc,1dao.cc 不会收到 Cookie,用户需要重新登录。这是最严格的策略,但用户体验差(从外站进入总是未登录状态)。Lax:跨站导航(顶层 GET)发送,其他跨站请求(子资源、POST)不发送。这是 Chrome 80+ 默认值。Lax 模式在安全性和易用性之间取得平衡,大多数普通链接跳转可携带登录态,但 POST 请求(典型如 CSRF 攻击)不会带上 Cookie,从根本上缓解了 CSRF 攻击。None:完全发送,但必须配合Secure(即仅 HTTPS)。旧版本默认 None。在 Chrome 80 之后,如果不显式声明 SameSite=None,会被当作 Lax 处理,这给许多旧系统带来兼容性问题——特别是依赖跨站 Cookie 的第三方嵌入式应用(如支付回调、SSO 跳转)。
SameSite 在跨站判定上的逻辑:浏览器判断"是否跨站",看的是顶级域名是否变化。dev.1dao.cc 跳转到 1dao.cc 算"同站"(eTLD+1 都是 1dao.cc),Cookie 会发送;而 dev.1dao.cc 跳转到 crmeb.example.com 算"跨站",在 Strict 模式下不发送 Cookie。这也提醒我们:Cookie 的同站判定基于 eTLD+1,而 localStorage 的同源判定基于完整 origin,两者不是一回事。
3.2 同域名 Cookie 共享问题
本项目最初的错误设计是 dev 和线上环境共用域名 1dao.cc(仅通过不同路径区分),导致了严重的 Cookie 串扰:
访问 dev.1dao.cc → 写入 Cookie: domain=.1dao.cc, name=login_token, value=dev_xxx
访问 1dao.cc → 浏览器自动附带 login_token=dev_xxx
→ 服务器以为是 dev 环境的登录态
→ 线上环境收到 dev 的 Cookie,数据混乱
解决方案:
- 使用不同子域名:dev 用
dev.1dao.cc,线上用1dao.cc,admin 用admin.1dao.cc。Cookie 设 Domain 时只用精确域名,不用.1dao.cc:
setcookie('login_token', $token, [
'domain' => 'dev.1dao.cc', // 注意:不带前导点
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
- 使用不同浏览器:开发用 Chrome,线上用 Firefox,各自独立的 Cookie 存储。这是临时方案,治标不治本。
- 使用不同浏览器 Profile:Chrome 支持多 Profile,各 Profile 独立 Cookie。比"不同浏览器"更优雅。
最终本项目采用方案 1,通过 dev.1dao.cc / 1dao.cc 子域名隔离,彻底消除 Cookie 串扰。
3.3 localStorage
localStorage 是 HTML5 引入的客户端持久化存储,5-10MB 容量,按 origin 隔离:
- origin = scheme + host + port
http://1dao.cc和https://1dao.cc是不同 origin,localStorage 不共享http://1dao.cc:80和http://1dao.cc:8080是不同 origin,localStorage 不共享http://1dao.cc和http://dev.1dao.cc是不同 origin,localStorage 不共享
关键差异(对比 Cookie):Cookie 按"domain"匹配,不分端口;localStorage 按"origin"隔离,分端口。这意味着:
- 如果 dev 和线上环境共用域名(仅不同端口),Cookie 会串扰,但 localStorage 不会。
- 如果 dev 和线上环境共用域名同端口(仅不同路径),Cookie 和 localStorage 都会串扰。
localStorage API:
localStorage.setItem('user', JSON.stringify({id: 1, name: 'admin'}));
const user = JSON.parse(localStorage.getItem('user'));
localStorage.removeItem('user');
localStorage.clear(); // 清空所有
localStorage 不能跨 origin 访问,即使是父域名的 JS 也无法读取子域名的 localStorage,反之亦然。这是与 Cookie 的关键区别:Cookie 可以"父域名写,子域名读",localStorage 严格按 origin 隔离。
3.4 Service Worker
Service Worker(SW)是浏览器在页面之外运行的 JavaScript 工作线程,可拦截其注册 origin 下的所有请求。典型用途:PWA 离线缓存、推送通知、后台同步。
关键陷阱:SW 一旦注册,会持续存在,即使关闭浏览器、重启电脑,只要在 SW 的有效期(默认 24 小时检查更新)和存储清理前未被注销,就会继续拦截请求。
测试环境中的具体问题:
1. 开发者在 dev 环境注册了 SW(用于测试 PWA)
2. SW 缓存了 dev 环境的 index.html
3. 开发者切换到线上环境访问
4. SW 仍存在,拦截请求,返回 dev 的缓存页面
5. 表现:明明 hosts 已切到线上,但页面内容还是 dev 的
排查方法:
## 第四章:Shell 脚本技术原理
### 4.1 set 命令详解
`set` 命令用于修改 shell 的运行时选项。最常用的三个是:
set -e # errexit:命令失败立即退出
set -u # nounset:未定义变量报错
set -o pipefail # 管道中任一环节失败即报错
**`set -u` 的实际作用**:任何对未定义变量的引用都会触发错误并退出脚本。这是防止"幽灵变量"导致逻辑错误的重要保障。例如:
#!/bin/bash
set -u
echo "Hello, $NAME!" # 如果 NAME 未定义,直接报错退出
如果不加 `set -u`,脚本会继续执行,把 `$NAME` 当作空字符串,导致输出"Hello, !",看似无害,但在生产场景中可能引发数据写入错误(例如把空值写入数据库)。`set -u` 的代价是脚本中需要确保所有变量都已定义,有时需要预先赋默认值:`NAME=${NAME:-default}`。
**`set -e` 的"陷阱清单"**:
1. **命令在 `if`/`while`/`&&`/`||` 条件中失败,不触发退出**。这是 bash 的设计——条件判断的失败是"预期行为",不视为错误。
2. **管道中除最后一条命令失败,默认不触发退出**。需要 `pipefail` 才能让中间命令的失败也生效。
3. **函数内部失败,默认会传播到调用点**。但如果函数被用在 `if` 中,失败同样被忽略。
4. **子 shell 中的失败不传播**。`(false; echo "still alive")` 会打印 "still alive"。
5. **`! cmd` 反转退出码后,失败也不触发退出**。这是逻辑取反的副作用。
这些特性使得 `set -e` 的实际行为经常与直觉相悖。因此,在生产脚本中,关键操作往往手动检查退出码,而不是完全依赖 `set -e`:
if ! mysql -uroot 1dao_db -e "REPLACE INTO ..."; then
echo "ERROR: 替换失败" >&2
exit 1
fi
组合使用 `set -euo pipefail` 是 shell 脚本的"最佳实践",但在循环和管道场景中有陷阱:
**陷阱示例**:本项目早期版本中,switch-env 脚本循环处理多张表:
#!/bin/bash
set -euo pipefail
while IFS=$'\t' read -r table col; do
mysql -uroot 1dao_db -e "REPLACE INTO $table ($col) VALUES ('dev_value')"
done < <(mysql -uroot 1dao_db -sN -e "SELECT table_name, column_name FROM ...")
如果某张表暂时不存在(例如某次循环中 mysql 报错),`set -e` 会让整个脚本立即退出,**剩余的表不会被处理**,但前面的表已经修改,导致数据库处于"半切换"状态。
**替代方案**:在循环中临时关闭 errexit:
while IFS=$'\t' read -r table col; do
set +e
mysql -uroot 1dao_db -e "REPLACE INTO $table ($col) VALUES ('dev_value')"
set -e
done < <(...)
或更简洁,使用 `|| true`:
while IFS=$'\t' read -r table col; do
mysql -uroot 1dao_db -e "REPLACE INTO ... " || true
done < <(...)
`|| true` 的语义是:无论 mysql 成功还是失败,该复合命令的退出码都是 0,因此不会触发 `set -e`。
**pipefail 的陷阱**:
mysql -uroot 1dao_db -e "SELECT ..." | grep "something"
如果 mysql 失败但 grep 成功(因为 grep 找到了匹配),那么 `pipefail` 让整个管道的退出码反映 mysql 的失败,触发 `set -e`。如果这是在循环中,会终止整个循环。
### 4.2 shell 数组与排序
bash 的数组语法:
第五章:数据库快照原理
5.1 mysqldump 机制
mysqldump 是 MySQL 自带的逻辑备份工具,通过执行 SQL 语句重建数据库。它与 mysqlbinlog(基于 binlog 的物理备份)、Percona XtraBackup(物理热备份)相比,优势在于通用性强、跨版本兼容性好;劣势是备份和恢复较慢,因为需要执行大量 SQL 语句重建数据。
逻辑备份 vs 物理备份对比:
| 维度 | 逻辑备份(mysqldump) | 物理备份(XtraBackup) |
|---|---|---|
| 实现方式 | 执行 SELECT 拿数据 + 生成 SQL | 直接拷贝数据文件 |
| 速度 | 慢(受 SQL 解析开销) | 快(直接 IO) |
| 恢复速度 | 慢(逐条执行 SQL) | 快(直接拷贝) |
| 跨版本兼容 | 好 | 差(文件格式可能不兼容) |
| 锁表影响 | InnoDB 用一致性读不锁表 | 几乎无锁 |
| 备份文件大小 | 较大(纯文本 SQL) | 较小(二进制) |
| 单表恢复 | 支持(从备份文件提取) | 不支持(整体恢复) |
关键参数:
mysqldump -uroot \
--single-transaction \
--databases 1dao_db \
--routines \
--triggers \
--default-character-set=utf8mb4 \
| gzip > /var/lib/1dao-env/prod.sql.gz
各参数含义:
--single-transaction:使用 InnoDB 的 MVCC 机制,在一致性读视图中导出。不锁表,但要求所有表是 InnoDB,且导出期间不能执行 DDL(ALTER/ DROP 等)。--databases:导出内容包含CREATE DATABASE和USE语句。恢复时mysql < dump.sql即可自动创建库。如果不加此参数,导出不含建库语句,需手动CREATE DATABASE。--routines:导出存储过程和函数。--triggers:导出触发器(默认开启,显式声明便于阅读)。--default-character-set=utf8mb4:确保 UTF-8 4 字节字符(如表情符号)正确导出。
管道的应用:
## 第六章:nginx 配置原理
### 6.1 server_name 匹配优先级
nginx 收到 HTTP 请求后,按以下顺序匹配 `server` 块:
1. **精确匹配**:`server_name 1dao.cc;` 严格等于 Host 头。
2. **通配符开头**:`server_name *.1dao.cc;` 匹配 `dev.1dao.cc`、`admin.1dao.cc` 等。
3. **通配符结尾**:`server_name 1dao.*;` 匹配 `1dao.com`、`1dao.net` 等。
4. **正则匹配**:`server_name ~^dev\d+\.1dao\.cc$;` 匹配 `dev1.1dao.cc`、`dev2.1dao.cc` 等。正则需以 `~` 开头。
5. **default_server**:`listen 80 default_server;` 标记的服务器,处理所有未匹配的请求。
**示例**:
第七章:故障排查手册
7.1 "您的连接不是私密连接" 排查流程
浏览器报"您的连接不是私密连接"(NET::ERR_CERT_AUTHORITY_INVALID 等),意味着 TLS 证书校验失败。
常见的错误代码及其含义:
| 错误代码 | 含义 | 常见原因 |
|---|---|---|
| `NET::ERR_CERT_AUTHORITY_INVALID` | 证书颁发机构不受信任 | 自签名证书未导入信任 |
| `NET::ERR_CERT_COMMON_NAME_INVALID` | 证书 CN/SAN 不匹配 | 访问的域名不在证书 SAN 中 |
| `NET::ERR_CERT_DATE_INVALID` | 证书过期或未生效 | 系统时间错误,或证书到期 |
| `NET::ERR_CERT_AUTHORITY_INVALID` | 证书链不完整 | 中间证书未配置 |
| `NET::ERR_SSL_PROTOCOL_ERROR` | SSL 握手失败 | TLS 版本不匹配,或 SNI 配置错误 |
排查步骤:
- 检查 URL 是 HTTP 还是 HTTPS
- 如果地址栏显示 http:// 但页面错误,检查是否被 HSTS 强制改写。
- 在 chrome://net-internals/#hsts 查询该域名是否在 HSTS 缓存中。
- 检查证书是否匹配域名
openssl s_client -connect 192.168.207.130:443 -servername dev.1dao.cc < /dev/null 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltName
输出示例:
subject=CN = 1dao.cc
X509v3 Subject Alternative Name:
DNS:1dao.cc, DNS:*.1dao.cc
- 如果访问的 dev.1dao.cc 不在 SAN 中,浏览器报错。
- 如果是自签名证书,需要手动添加到系统钥匙串信任。
- 检查是否在 HSTS preload list
- 访问 hstspreload.org 查询域名。
- 如果用了 .dev / .app 等 Google TLD,会被强制 HTTPS。即使提供 HTTP,浏览器仍会改写为 HTTPS。
- 检查浏览器 HSTS 缓存
- chrome://net-internals/#hsts
- 在 "Query HSTS/PKP domain" 输入域名,查询是否被缓存。
- 在 "Delete domain security policies for domain" 删除该域名。
- 检查系统时间
- 证书有效期是绝对时间。如果系统时间不对(如 1970 年或 2030 年),所有证书都会被认为过期。
- date 命令检查时间是否正确。
完整流程图:
"您的连接不是私密连接"
|
v
是 HTTP 还是 HTTPS?
| |
HTTP HTTPS
| |
v v
检查是否被 HSTS 检查证书 SAN 是否匹配
强制改写为 HTTPS |
| v
v 证书匹配?
在 HSTS 缓存? | |
| 是 否
v | |
是 v v
删除 HSTS 完成 证书是否自签名?
缓存 | |
| 是 否
v | |
重新访问 v v
添加信任 检查证书
到钥匙串 链是否完整
7.2 "站点无法访问" 排查流程
浏览器报"站点无法访问"(ERR_CONNECTION_REFUSED、ERR_CONNECTION_TIMED_OUT 等)。
排查步骤:
- ping 域名,检查 DNS 解析
ping dev.1dao.cc
# 如果 ping 不通域名,但 ping IP 通,是 DNS 问题
# 如果 ping IP 也不通,是网络层问题
- curl -v 检查连接
curl -v http://dev.1dao.cc/
# 输出:
# * Trying 192.168.207.130:80...
# * connect to 192.168.207.130 port 80 failed: Connection refused
- Connection refused:虚机在线但 80 端口未监听(nginx 未启动)。
- Connection timed out:虚机不在线或防火墙拦截。
- Trying 1.2.3.4(非虚机 IP):DNS 解析错误,检查 hosts。
- ssh 虚机,检查服务状态
ssh vagrant@192.168.207.130
sudo systemctl status nginx
sudo systemctl status php8.1-fpm
- 检查 nginx error.log
sudo tail -50 /var/log/nginx/error.log
# 看是否有 "host not found in upstream"(后端域名解析失败)
# 或 "permission denied"(文件权限)
# 或 "no live upstreams"(后端全挂)
- 检查虚机防火墙
sudo ufw status
# 如果 80/443 未开放:
sudo ufw allow 80
sudo ufw allow 443
- 检查 macOS hosts 文件
grep 1dao.cc /etc/hosts
# 应输出:192.168.207.130 1dao.cc dev.1dao.cc ...
7.3 "页面空白/500" 排查流程
页面打开但空白,或返回 500(Internal Server Error)。
排查步骤:
- curl -I 检查状态码
curl -I http://dev.1dao.cc/
# HTTP/1.1 500 Internal Server Error
# HTTP/1.1 200 OK 但内容空白
- 查 nginx error.log
sudo tail -50 /var/log/nginx/error.log
# PHP message: PHP Fatal error: Uncaught ...
# 或:FastCGI sent in stderr: "Primary script unknown"
- 查 PHP-FPM 错误日志
sudo tail -50 /var/log/php8.1-fpm.log
- 查应用日志
sudo tail -50 /var/www/1dao/storage/logs/laravel.log
# 或 crmeb:
sudo tail -50 /var/www/crmeb/runtime/log/$(date +%Y%m)/$(date +%d).log
- 检查文件权限
ls -la /var/www/1dao/storage/
# storage/ 和 bootstrap/cache/ 必须 www-data 可写
sudo chown -R www-data:www-data /var/www/1dao/storage
sudo chown -R www-data:www-data /var/www/1dao/bootstrap/cache
- 检查数据库连接
# 在虚机上测试 mysql 连接
mysql -u1dao -p$(grep DB_PASSWORD /var/www/1dao/.env | cut -d= -f2) 1dao_db -e "SELECT 1"
# 如果连接失败:检查 .env 中 DB_HOST/DB_PORT/DB_PASSWORD
- 清缓存
# Laravel
cd /var/www/1dao
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear
# Redis 缓存
redis-cli FLUSHALL
# crmeb runtime
rm -rf /var/www/crmeb/runtime/cache/*
rm -rf /var/www/crmeb/runtime/temp/*
7.4 "切换 hosts 后浏览器仍访问旧环境" 排查
明明 which-env 显示当前是 dev 模式,但浏览器仍访问线上环境。
排查步骤:
- which-env 确认系统层面
which-env
# 应输出: Current environment: dev (pointing to 192.168.207.130)
# 同时 ping 1dao.cc 应解析到 192.168.207.130
ping -c 1 1dao.cc
- 清浏览器 DNS 缓存
- Chrome:chrome://net-internals/#dns → "Clear host resolver cache"
- Firefox:about:networking#dns → "Clear DNS Cache"
- 清 Socket 连接池
- 浏览器会复用已建立的 TCP 连接(keepalive)。如果旧连接还在,即使 DNS 已切换,仍走旧 IP。
- Chrome:chrome://net-internals/#sockets → "Flush socket pools"
- 或关闭所有浏览器窗口重开。
- 用无痕窗口验证
- 无痕窗口有独立的 DNS 缓存和 socket 池。
- 如果无痕窗口正常而普通窗口异常,确认是浏览器缓存问题。
- 检查 Service Worker
- SW 拦截请求可能返回旧环境的缓存。
- chrome://serviceworker-internals/ 注销所有 SW。
- 检查 HSTS 缓存
- 如果之前访问过 https://1dao.cc,可能被 HSTS 强制 HTTPS,导致访问 dev 也走 HTTPS,但 dev 环境只有 HTTP,连接失败或回退到线上。
- chrome://net-internals/#hsts 删除 1dao.cc 的 HSTS 策略。
7.5 "switch-env 切换后数据没变" 排查
执行了 switch-env dev,但访问页面发现域名、数据仍是 prod 状态。
排查步骤:
- 检查 env.conf 的 CURRENT
cat /var/lib/1dao-env/env.conf
# 应输出: CURRENT=dev
# 如果仍是 CURRENT=prod,说明脚本未执行到最后一步
- 检查快照是否存在
ls -la /var/lib/1dao-env/
# dev.sql.gz 应存在,且修改时间是最近
# 如果 dev.sql.gz 不存在或时间过早,restore 失败但未报错
- 检查数据库实际值
mysql -uroot 1dao_db -e "SELECT value FROM tb_config WHERE name='site_url'"
# 应输出 dev.1dao.cc
# 如果仍是 1dao.cc,数据库未切换成功
- 检查 set -euo pipefail 是否导致脚本提前退出
# 临时修改脚本,在每步后加 echo 调试
bash -x /usr/local/bin/switch-env dev
# -x 选项打印每条命令,查看哪一步退出
- 检查 CONCAT 的 \t 问题
# 如果脚本中有:
# mysql -e "SELECT CONCAT(a, '\t', b) FROM ..."
# 实际输出不是 a<TAB>b,而是 a\tb
# read 命令拿不到正确的两个字段
# 改为 SELECT a, b 解决
- 检查 nginx 配置是否 reload
sudo nginx -t
sudo systemctl reload nginx
# nginx 配置改了但不 reload,旧配置仍生效
- 检查 .env 文件
grep -E 'APP_URL|DOMAIN' /var/www/1dao/.env
# 应为 dev.1dao.cc
7.6 "WiFi 断开时域名无法访问" 排查
关闭 WiFi 后,即使 hosts 配置正确,虚机在线,仍无法访问 dev.1dao.cc。
排查步骤:
- 确认虚机 IP 可达
ping 192.168.207.130
# 应能 ping 通(虚机在线)
- 用 IP+端口直连绕过 DNS
curl -H "Host: dev.1dao.cc" http://192.168.207.130/
# 如果能返回页面,说明 nginx 和 PHP 都正常
# 问题在 DNS 解析层
- 确认 hosts 条目存在
grep 1dao /etc/hosts
# 应输出:192.168.207.130 1dao.cc dev.1dao.cc ...
- 理解 macOS 无网络时不解析 hosts
- 这是 macOS 的已知行为。WiFi 断开时,mDNSResponder 没有活动网络接口,即使 hosts 文件有配置也不解析。
- 验证:ping 1dao.cc 会报 Unknown host,但 ping 192.168.207.130 正常。
- 解决方案
- 重新连接 WiFi(哪怕 WiFi 不通外网,只要接口 active 即可)。
- 创建虚拟网络接口:
sudo ifconfig lo0 alias 127.0.0.2 up
# 这会让系统认为有活动接口,mDNSResponder 开始工作
- 用 IP+Host 头直连,绕过 DNS。
第八章:完整命令速查手册
8.1 macOS 主机命令
## 第九章:项目完整时间线
以下是本次开发测试环境搭建的关键操作时间线,按顺序记录每个决策点和遇到的坑。
### 阶段一:环境规划(上午 09:00-10:00)
- 09:00 评估需求:dev 环境与 prod 环境共用数据库,导致数据串扰严重,需物理隔离。
- 09:15 决定方案:用 VirtualBox + Vagrant 搭建独立虚机,在虚机上运行 dev 环境。
- 09:30 选定虚机配置:Ubuntu 22.04 LTS、2GB 内存、2 CPU、IP 192.168.207.130。
- 09:45 决定域名策略:dev 环境用 `dev.1dao.cc` 子域名,避免与 `1dao.cc` 共用 Cookie。
### 阶段二:虚机初始化(上午 10:00-11:00)
- 10:00 `vagrant init ubuntu/jammy64` 创建 Vagrantfile。
- 10:15 配置 Vagrantfile:私有网络 IP、 forwarded_port、synced_folder。
- 10:30 `vagrant up` 启动虚机,遇到 VirtualBox 内核模块加载失败。
- 排查:`kextstat | grep -v com.apple` 查看扩展加载情况。
- 解决:macOS 系统设置允许 Oracle 加载扩展。
- 10:45 `vagrant ssh` 进入虚机,基础环境就绪。
### 阶段三:服务安装(上午 11:00-下午 13:00)
- 11:00 安装 nginx:`apt install nginx`。
- 11:15 安装 PHP-FPM 8.1 + 常用扩展:`apt install php8.1-fpm php8.1-mysql php8.1-redis php8.1-gd php8.1-mbstring`。
- 11:30 安装 MySQL 8.0:`apt install mysql-server`,运行 `mysql_secure_installation`。
- 11:45 安装 Redis:`apt install redis-server`。
- 12:00 配置 PHP-FPM:修改 `www.conf`,调整 `pm.max_children = 30`。
- 12:15 配置 MySQL:修改 `mysqld.cnf`,设置 `character-set-server = utf8mb4`。
- 12:30 测试各服务:`systemctl status nginx/php8.1-fpm/mysql/redis`。
### 阶段四:应用部署(下午 13:00-15:00)
- 13:00 拉取代码:`git clone` 到 `/var/www/1dao`。
- 13:15 `composer install` 安装 PHP 依赖。
- 13:30 配置 .env 文件:数据库连接、Redis 连接、APP_URL 设为 `http://dev.1dao.cc`。
- 13:45 `php artisan key:generate` 生成应用密钥。
- 14:00 `php artisan migrate` 执行数据库迁移。
- 14:15 `php artisan db:seed` 填充测试数据。
- 14:30 导入 prod 数据库快照:`gunzip -c prod.sql.gz | mysql 1dao_db`。
- 14:45 测试访问 `curl http://localhost/`,确认应用可访问。
### 阶段五:nginx 配置(下午 15:00-16:00)
- 15:00 创建 `/etc/nginx/conf.d/1dao.conf`:配置 server_name `dev.1dao.cc`、root 指向 `/var/www/1dao/public`、try_files 重写、fastcgi_pass 到 PHP-FPM socket。
- 15:15 创建 `/etc/nginx/conf.d/000-default-444.conf`:default_server + return 444,拦截未配置的请求。
- 15:30 `nginx -t` 测试配置通过,`systemctl reload nginx`。
- 15:45 配置 X-Environment 头:`add_header X-Environment "dev" always;`。
- 16:00 在 macOS hosts 中写入 `192.168.207.130 dev.1dao.cc`,测试访问成功。
### 阶段六:DNS 与 HTTPS(下午 16:00-17:00)
- 16:00 尝试访问 `https://dev.1dao.cc`,报"您的连接不是私密连接"。
- 排查:浏览器 HSTS 缓存了 1dao.cc 的 HTTPS 策略。
- 解决:`chrome://net-internals/#hsts` 删除 1dao.cc 的 HSTS 缓存。
- 16:15 测试 `http://1dao.dev`(误用),报错强制 HTTPS。
- 排查:`.dev` 在 HSTS preload list,无法用 HTTP。
- 解决:改用 `dev.1dao.cc`(eTLD 是 `.cc`,不受 `.dev` 影响)。
- 16:30 生成自签名证书:`openssl req -x509 -newkey rsa:2048 -nodes ... -config openssl.cnf`。
- 16:45 配置 HTTPS server,但测试发现不需要 HTTPS 也能用,最终决定 dev 用 HTTP。
### 阶段七:Cookie 串扰(下午 17:00-18:00)
- 17:00 切换 dev-on 后,访问 `1dao.cc` 发现登录态是 dev 的。
- 17:15 排查:Cookie 的 Domain 设为 `.1dao.cc`,导致 dev 和 prod 共享 Cookie。
- 17:30 修改 PHP 代码:setcookie 的 domain 改为精确域名 `dev.1dao.cc`。
- 17:45 测试:dev 和 prod 的 Cookie 隔离,登录态独立。
### 阶段八:Shell 脚本(下午 18:00-19:00)
- 18:00 编写 `dev-on`、`dev-off`、`which-env` 脚本。
- 18:15 编写 `switch-env`、`change-domain` 脚本。
- 18:30 测试 switch-env,发现数据库没切换。
- 排查:`set -euo pipefail` 导致循环中 mysql 失败即终止。
- 解决:循环中加 `|| true`。
- 18:45 测试 change-domain,发现输出格式错误。
- 排查:`CONCAT(table, '\t', col)` 输出的不是制表符。
- 解决:改用 `SELECT table, col`(mysql -sN 自动用 tab 分隔)。
- 19:00 所有脚本测试通过。
### 阶段九:故障排查(下午 19:00-20:00)
- 19:00 浏览器仍访问旧环境。
- 排查:Chrome DNS 缓存未清。
- 解决:`chrome://net-internals/#dns` 清缓存。
- 19:15 WiFi 断开后无法访问 dev 域名。
- 排查:macOS 无活动接口时,mDNSResponder 不解析 hosts。
- 解决:重新连接 WiFi。
- 19:30 Service Worker 拦截请求。
- 排查:`chrome://serviceworker-internals/` 看到 dev 注册的 SW。
- 解决:Unregister 所有 SW。
- 19:45 完整流程跑通,提交代码。
### 阶段十:文档编写(晚上 20:00-22:00)
- 编写本系列工作总结文档,共六部分。
- 本部分(第六部分)聚焦技术原理与故障排查。
---
## 第十章:总结与展望
### 10.1 完成的工作总结
本次开发测试环境搭建工作,完成了以下核心成果。整个工作从最初的"数据串扰问题"识别,到最终的"完整工具链交付",历时约一整个工作日,过程中既验证了已有的网络、DNS、TLS、浏览器存储等知识,也通过踩坑补齐了 Shell 脚本陷阱、SQL 转义、macOS 特殊行为等实战盲区。
**架构层面**:
- 在 macOS 主机上搭建独立的 VirtualBox 虚机作为 dev 环境,与线上 prod 环境物理隔离。
- 通过子域名(`dev.1dao.cc`、`admin.1dao.cc`、`api.1dao.cc`)实现 dev/prod 的 Cookie 隔离。
- 通过 nginx 的 default_server + return 444 拦截寄生域名和未配置请求。
**工具链层面**:
- 在 macOS 主机上提供 `dev-on`、`dev-off`、`which-env` 三个命令,一键切换开发态/线上态。
- 在虚机上提供 `switch-env`、`change-domain` 命令,支持数据库快照切换和域名批量替换。
- 建立了完整的环境标识体系(env.conf、X-Environment 头、应用 .env 文件)。
**数据层面**:
- 实现了 dev 和 prod 模式的双快照备份/恢复,数据相互隔离。
- 在切换环境时保留业务数据,确保开发进度不丢失。
- 通过 mysqldump --single-transaction 实现一致性快照,避免锁表。
**文档层面**:
- 本系列工作总结六部分,覆盖背景、架构、脚本、nginx、数据库、原理排错。
- 提供完整的命令速查手册和故障排查流程。
- 记录项目时间线和决策点,便于后续工程师理解。
**遇到并解决的关键问题**:
1. **HSTS 误用 .dev 域名**:认识到 `.dev` 在 preload list,改用 `dev.1dao.cc`。
2. **Cookie 跨子域共享**:理解 eTLD+1 和 Cookie Domain 的关系,改用精确域名。
3. **macOS DNS 缓存**:理解 mDNSResponder 的工作机制,正确刷新缓存。
4. **Shell 脚本陷阱**:理解 `set -euo pipefail` 在循环中的副作用,用 `|| true` 规避。
5. **SQL 转义陷阱**:理解 MySQL 字符串中 `\t` 不是制表符,改用 `SELECT col1, col2`。
6. **浏览器多级缓存**:理解 Memory/Disk/SW/HSTS 四层缓存,逐一清理。
7. **WiFi 断开不解析 hosts**:理解 macOS 无网络时的特殊行为,提供绕过方案。
### 10.2 待解决的问题
1. **walle / deploy 的 secrets 包**:目前 walle 部署系统中存在 secrets 包管理问题,部署时部分敏感配置(数据库密码、API key)的注入流程不够顺畅。需要研究 walle 的 secrets 集成机制,或改用 Ansible Vault。
2. **证书续期**:目前使用自签名证书,浏览器报警告。需考虑:
- 申请 Let's Encrypt 免费证书(但 dev 环境在内网,无法完成 HTTP-01 验证)。
- 使用 mkcert 生成被本机信任的本地证书。
- 把根证书安装到系统钥匙串,使所有由 mkcert 签发的证书都被信任。
3. **Service Worker 的清理自动化**:目前每次切换环境都要手动 `chrome://serviceworker-internals/` 注销 SW。需要研究是否能在应用层面,通过版本号变化触发 SW 自动注销。
4. **数据库快照的版本管理**:目前 dev.sql.gz 和 prod.sql.gz 是单文件覆盖式,无版本历史。需考虑:
- 引入 git-lfs 或 git-annex 管理快照版本。
- 或保留最近 N 个快照(`dev-20240101.sql.gz`、`dev-20240102.sql.gz`),定期清理。
5. **跨主机部署**:目前 dev 环境绑定在单台虚机上,如果多人协作开发,需要每人各自部署。后续考虑:
- 用 Docker 容器化,通过 docker-compose 一键启动。
- 或用 Vagrant 的多机配置,支持多人共享一台虚机。
### 10.3 未来改进方向
**短期(1-2 周)**:
1. **自动化部署脚本完善**:
- 编写 `deploy.sh`,一键完成代码更新、依赖安装、缓存清理、服务重启。
- 集成 `git pull`、`composer install`、`php artisan migrate`、`systemctl reload` 等步骤。
- 加入回滚机制:部署失败时自动回滚到上一版本。
- 部署前自动备份当前数据库,出错可手工恢复。备份命名规则:`1dao_db_$(date +%Y%m%d_%H%M%S).sql.gz`,定期清理超过 7 天的旧备份。
2. **CI/CD 流水线**:
- 在 GitLab CI / Jenkins 中配置流水线,代码 push 触发自动测试 + 部署到 dev 环境。
- 测试通过后人工审核,部署到 prod 环境。
- 集成 PHP_CodeSniffer、PHPStan 等静态检查工具。
3. **监控告警**:
- 在 dev 虚机上部署 Prometheus + Grafana,监控 CPU、内存、磁盘、网络。
- 在 nginx 上启用 stub_status,监控请求量、响应时间。
- 在 MySQL 上启用 performance_schema,监控慢查询、连接数。
- 配置告警:磁盘 >80%、服务宕机、慢查询激增时邮件 / 飞书通知。
**中期(1-2 月)**:
1. **容器化改造**:
- 将 1dao 应用、crmeb 应用、MySQL、Redis 各自容器化。
- 用 docker-compose 编排,实现一键启停。
- 通过 volume 挂载实现数据持久化。
- 支持多套环境并行(dev、staging、test),各自独立容器组。
2. **日志集中化**:
- 部署 ELK(Elasticsearch + Logstash + Kibana)或 Loki + Grafana。
- 收集 nginx、PHP-FPM、MySQL、应用日志。
- 支持按域名、按环境、按时间维度检索。
3. **域名管理的进一步自动化**:
- 编写一个 watch 守护进程,监控 hosts 文件变化,自动触发 DNS 缓存刷新。
- 或开发 macOS 系统扩展(System Extension),提供更稳定的 dev/prod 切换。
**长期(3-6 月)**:
1. **多环境支持**:
- 支持 feature branch 模式:每个功能分支自动部署到独立子域名(`feature-xyz.1dao.cc`)。
- 用 PR 合并后自动销毁对应环境,节省资源。
2. **测试自动化**:
- 引入 PHPUnit + Laravel Dusk,编写单元测试和浏览器测试。
- 部署后自动运行测试,失败则回滚。
- 集成到 CI/CD 流水线,作为部署门禁。
3. **性能基线**:
- 在 dev 环境运行 k6 或 JMeter 压测,建立性能基线。
- 每次代码变更后跑一次,对比基线,发现性能回归。
- 报告响应时间、QPS、错误率等指标。
4. **安全加固**:
- 定期扫描依赖漏洞:`composer audit`、`npm audit`。
- 配置 WAF(ModSecurity),拦截常见攻击(SQL 注入、XSS)。
- 启用 nginx 的 limit_req,防 CC 攻击。
- 数据库定期审计,删除无用的测试账号。
### 结语
本次开发测试环境搭建工作,虽然过程曲折(踩了 DNS、HSTS、Cookie、Shell 脚本、SQL 转义等多个坑),但最终交付了一套稳定可用、文档完善、可扩展的环境管理工具。过程中积累的底层知识(DNS 解析栈、TLS 握手、Cookie 隔离、浏览器缓存层级),对后续运维和排错都有长远价值。
本系列文档(六部分)既是工作总结,也是知识库。后续工程师遇到类似问题,可直接对照原理章节和故障排查手册,快速定位和解决问题。文档中的命令示例均经过实际验证,可直接复制执行;流程图和表格可用于团队培训和新成员上手;故障排查章节可作为值班排错的参考手册。
如读者发现文档中的疏漏或错误,欢迎指正。技术演进日新月异,本手册描述的命令行为、配置语法,基于 2026 年的 macOS Sonoma、Ubuntu 22.04、nginx 1.22、PHP 8.1、MySQL 8.0,后续版本可能有差异,使用时请对照官方文档。建议每次大版本升级后,对文档中受影响的章节做一次回测和修订,保持内容与实际环境同步。
---
*文档版本:v1.0*
*完成日期:2026-08-21*
*适用环境:macOS Sonoma + VirtualBox 7.0 + Ubuntu 22.04 + nginx 1.22 + PHP 8.1 + MySQL 8.0*