本文记录 2026 年 9 月 8 日完成私有预发布,以及 9 月 12 日完成生产 HTTPS 切流的实际过程。它用于解释环境为何成为现在的样子;重复发布、排障和回滚请看静态站 ECS 运维手册。
服务器地址、私钥内容和未来的仓库令牌不写入仓库。文中的 ECS_HOST、DEPLOY_USER 和 SSH_KEY 需要替换为自己的连接信息。
阶段二交付边界
- GitHub Pages 继续作为公开生产站点。
- ECS 只运行 Astro 静态产物,不包含 Studio、API 或 PostgreSQL。
- ECS Web 服务只绑定
127.0.0.1:8080,通过 SSH Tunnel 预览。 - 未配置公网 80/443、正式域名、HTTPS 或 DNS 切换。
- 当前发布源码版本为
d31c192,镜像为blog-demo-web:d31c192。 - 当前版本目录为
/opt/blog-demo/releases/d31c192,/opt/blog-demo/current指向该目录。 - canonical、RSS 和 sitemap 暂用保留的测试源站
https://migration.example,正式域名确定后必须重新构建镜像。
上述边界是阶段二完成时的历史状态。2026 年 9 月 12 日,生产 HTTPS 已切换到 ECS,规范地址为 https://www.doyouhang.live,首次通过生产健康检查的切流版本为 a75bbc7;阶段二容器继续只绑定 127.0.0.1:8080,作为短期独立预发布入口。
flowchart LR
A[本地已验证提交 d31c192] --> B[构建 amd64 静态镜像]
B --> C[导出镜像并计算 SHA-256]
C --> D[通过 SSH/SCP 传入 ECS]
D --> E[Docker load]
E --> F[Compose 启动 Nginx]
F --> G[127.0.0.1:8080]
H[本机浏览器] -->|SSH Tunnel| G
I[公网访问] -. 不可达 .-> G
1. 初始盘点
先只读检查系统、资源、容器工具、监听端口和 sudo 能力:
uname -a
cat /etc/os-release
free -h
df -h /
docker version
docker compose version
sudo ss -ltnp
sudo ufw status verbose
sudo -n true
实际发现如下:
| 项目 | 结果 | 对部署的影响 |
|---|---|---|
| 系统 | Ubuntu 22.04.5 LTS,x86_64 | 使用 Ubuntu 软件源的 amd64 Docker 包 |
| 内存 | 约 1.6 GiB,可用约 934 MiB | 静态 Nginx 足够;构建时依赖 2 GiB swap 缓冲峰值 |
| Swap | 2 GiB | 保留,不调整 |
| 根盘 | 40 GiB,剩余约 32 GiB | 足够保留若干发布目录和镜像 |
| Docker | 未安装 | 需要先安装 Docker Engine 与 Compose v2 |
| 8080 | 未占用 | 用作私有预发布回环端口 |
| UFW | inactive | 不能把 UFW 状态当成公网隔离证据 |
| sudo | 部署用户可无交互使用 | Docker 命令统一使用 sudo |
盘点还发现服务器已有以下监听,迁移过程没有停止、覆盖或改写它们:
- 系统 Nginx 监听
0.0.0.0:18080。 - frps 监听
*:7000,另有数个回环监听端口。 - hbrclient 使用一个回环临时端口。
因此新站选择 127.0.0.1:8080,而不是复用已有 Nginx 或公开监听地址。
2. 安装 Docker 与 Compose
服务器的阿里云 Ubuntu 镜像源已经提供所需软件包,因此没有添加第三方 APT 仓库:
sudo apt-get install -y docker.io docker-compose-v2
安装后验证:
sudo docker version
sudo docker compose version
systemctl is-active docker
本次安装结果:
- Docker Engine
29.1.3 - Docker Compose
2.40.3 - Docker 服务状态为
active
部署用户没有加入 docker 组。Docker socket 等同于高权限入口,保留 sudo docker ... 能让权限使用更明确。
3. 准备版本化发布目录
本地只打包已经提交的版本,因此不会把 .git/、.zcode/、本地 dist/ 或未提交内容带上服务器:
git archive --format=tar --output=/tmp/blog-demo-d31c192.tar d31c192
sha256sum /tmp/blog-demo-d31c192.tar
scp -i SSH_KEY /tmp/blog-demo-d31c192.tar DEPLOY_USER@ECS_HOST:/tmp/
服务器端先核对同一个 SHA-256,再解压:
sha256sum /tmp/blog-demo-d31c192.tar
sudo install -d -m 0755 -o DEPLOY_USER -g DEPLOY_USER /opt/blog-demo/releases/d31c192
tar -xf /tmp/blog-demo-d31c192.tar -C /opt/blog-demo/releases/d31c192
配置放在发布目录之外,避免换版本时覆盖,也避免进入 Git:
sudo install -d -m 0750 -o root -g DEPLOY_USER /etc/blog-demo
sudo install -m 0640 -o root -g DEPLOY_USER staging.env /etc/blog-demo/staging.env
本次配置的非敏感结构如下:
SITE_URL=https://migration.example
SITE_BASE=/
RELEASE_ID=d31c192
SITE_URL 和 SITE_BASE 是 Astro 构建输入。环境文件必须与镜像构建参数一致;修改环境文件不会改写已经存在的 HTML、RSS 或 sitemap。
4. 检查 Compose 最终配置
启动前先渲染合并结果:
sudo docker compose \
--env-file /etc/blog-demo/staging.env \
-f /opt/blog-demo/releases/d31c192/infra/compose.yaml \
-f /opt/blog-demo/releases/d31c192/infra/compose.staging.yaml \
config
重点人工确认:
- 镜像为
blog-demo-web:d31c192。 SITE_BASE为/。- 唯一端口映射为主机
127.0.0.1:8080到容器80。 read_only: true、no-new-privileges:true和能力限制仍然存在。- 没有数据库、Studio 或额外公开端口。
Compose v2 会提示旧式 version 字段已过时,但会忽略它并正确解析。仓库暂时保留该字段,以兼容已有的 Compose v1 配置测试;这条警告不代表启动失败。
5. 处理 Docker Hub 不可达
服务器直接构建时,访问 registry-1.docker.io:443 超时。失败发生在读取已经锁定摘要的 Node 和 Nginx 镜像元数据阶段,尚未启动或替换容器。
随后验证了候选通道:
ghcr.io/v2/返回 HTTP 401,说明网络可达但需要鉴权。registry.cn-shanghai.aliyuncs.com/v2/返回 HTTP 401,说明网络可达但需要鉴权。- GitHub 主站返回 HTTP 200。
首次部署使用了同为 amd64 的本地可信构建机。镜像已在本地完成构建、路由和 HTTP 行为验收:
docker build \
--build-arg SITE_URL=https://migration.example \
--build-arg SITE_BASE=/ \
-f infra/docker/web.Dockerfile \
-t blog-demo-web:d31c192 .
docker image inspect blog-demo-web:d31c192
docker save --output /tmp/blog-demo-web-d31c192.tar blog-demo-web:d31c192
sha256sum /tmp/blog-demo-web-d31c192.tar
scp -i SSH_KEY /tmp/blog-demo-web-d31c192.tar DEPLOY_USER@ECS_HOST:/tmp/
本地和服务器两端得到相同的镜像归档 SHA-256 后,服务器导入镜像:
sudo docker load --input /tmp/blog-demo-web-d31c192.tar
sudo docker image inspect blog-demo-web:d31c192
导入后的镜像架构为 amd64,镜像 ID 与本地一致。长期发布不继续手工传镜像;下一阶段使用 ECS 已验证可达的镜像仓库。
6. 启动私有预发布
由于镜像已经导入,启动时明确禁止再次构建:
sudo docker compose \
--env-file /etc/blog-demo/staging.env \
-f /opt/blog-demo/releases/d31c192/infra/compose.yaml \
-f /opt/blog-demo/releases/d31c192/infra/compose.staging.yaml \
up -d --no-build
Compose 项目当前由目录名派生为 infra,容器名为 infra-web-1。自动发布阶段应在部署脚本中固定项目名,不能依赖操作者当前目录。
验收通过后建立当前版本指针:
sudo ln -s /opt/blog-demo/releases/d31c192 /opt/blog-demo/current
7. 实际验收结果
服务器本机执行:
sudo docker inspect infra-web-1
curl --fail --show-error --head http://127.0.0.1:8080/
curl --fail --show-error --head http://127.0.0.1:8080/interests/food/
curl --show-error --output /dev/null --write-out '%{http_code}\n' \
http://127.0.0.1:8080/not-a-real-route/
curl --fail --show-error --head http://127.0.0.1:8080/pagefind/pagefind.js
sudo ss -ltnp
结果:
- 容器为
running / healthy。 - 容器根文件系统只读。
- Docker 端口绑定为
127.0.0.1:8080 -> 80/tcp。 - 首页和深层页面返回 200。
- 不存在的页面返回真实 404,并展示站点的“查无此档”页面。
- Pagefind 文件返回 200,缓存策略为一小时。
- 哈希静态资源使用一年 immutable 缓存。
- 页面 canonical 为
https://migration.example/,与构建参数一致。
在本地通过 SSH Tunnel 请求首页同样返回 200:
ssh -i SSH_KEY -L 8080:127.0.0.1:8080 DEPLOY_USER@ECS_HOST
从外部网络直接访问 ECS_HOST:8080 超时。这个结果与回环绑定共同证明预发布没有通过该地址公开,但每次改变端口、安全组或 Compose 配置后都要重新验证。
8. 首次推送与 CI 兼容修正
本地 4 个迁移提交首次推送到 origin/main 后,GitHub Pages Run #51 在 Unit tests 步骤失败,部署任务因此跳过。失败发生在约 34 秒内,站点构建和公开部署均未开始,原 GitHub Pages 站点继续在线。
根因是 tests/container-config.test.mjs 只识别本机 Docker Compose v1 输出的短端口格式:
127.0.0.1:8080:80/tcp
GitHub Runner 使用 Compose v2,同一个安全绑定会输出为 host_ip、published 和 target 三个结构化字段。运行配置仍然正确,测试却因输出表示不同而误报。
修正后,测试在 Compose v2 可用时读取 JSON 配置,并把 v1 与 v2 输出归一为相同的端口对象,再断言唯一绑定为 127.0.0.1:8080 -> 80。同时增加了 Compose v2 结构化输出的独立回归用例,防止只在某一代 Compose 上通过。
修正提交 d6ccea1 推送后,GitHub Pages Run #52 的构建与部署任务均成功,说明本地 Compose v1、ECS Compose v2 和 GitHub Runner Compose v2 的配置检查已经统一,旧生产站恢复正常自动发布。
处理这类 CI 差异时,应先确认失败发生在配置本身还是测试适配层。不能为了让测试通过而放宽端口断言,更不能把预发布端口改成公网绑定。
9. 阶段三和阶段四生产切换
9.1 域名与证书
切换时的公开配置如下:
| 项目 | 实际值 |
|---|---|
| 规范地址 | https://www.doyouhang.live |
| 保留地址 | https://doyouhang.live |
| A 记录 | 两个域名均指向 ECS 公网 IPv4 |
| TTL | 600 秒 |
| AAAA | 未设置;未验证 IPv6 全链路 |
| ICP 备案 | 粤ICP备2026135373号 |
| 公安备案 | 尚未办理,因此页面不展示虚假编号 |
两个 A 记录在执行阶段三前已经指向 ECS,因此证书使用 Certbot webroot 与 HTTP-01 签发,而不是引入 DNS API 凭据。临时宿主机 Nginx 只在 80 提供 /.well-known/acme-challenge/,未知 Host 返回 444,其他路径不提供站点。Let’s Encrypt 证书由 YR2 签发,覆盖根域名和 www,有效期至 2026 年 12 月 10 日。
证书原件由 Certbot 管理,容器读取 /etc/blog-demo/tls 中的受限副本。目录权限为 700,完整证书链为 644,私钥为 600。certbot.timer 为 active,certbot renew --dry-run --no-random-sleep-on-renew 已通过;续期成功后,deploy hook 会原子更新副本并向 blog-demo-production-web-1 发送 HUP。
9.2 生产发布
生产准备提交为 a83488e,固定 Compose 项目名的修正为 c35f381。首次启动 c35f381 时,默认 HTTP server 的 server 级 return 444 抢先处理了 /healthz,容器虽然能提供正式页面,但健康检查返回 444。生产项目随即停止,临时 ACME 配置恢复,阶段二容器未受影响。
提交 a75bbc7 将拒绝逻辑收进 location /,并新增配置层级回归测试。本机验证结果为:Astro check 零错误和警告、115 个单元测试通过、正式域名参数构建 101 个页面并索引 54 条公开记录、浏览器冒烟全部通过、Nginx 配置测试与生产容器健康检查通过。
生产归档在传输前后使用 SHA-256 核对:
源码归档 646f2a87e5afc75a5c96ec5b8312dd39cebbf5178f63a7e131608c0cbc437bc0
镜像归档 ba50861f4fc0248eae3fe293c3b10c3cef0a8dcc093683222346abd77c98522a
镜像为 linux/amd64。Docker Desktop 显示配置摘要 ceb013fc...,ECS 的 containerd image store 显示 OCI manifest 摘要 2c37763a...;归档校验值、镜像配置摘要和 10 个 RootFS 层摘要逐项一致。
生产使用独立项目和环境文件:
Compose 项目 blog-demo-production
生产容器 blog-demo-production-web-1
生产环境文件 /etc/blog-demo/production.env
生产发布目录 /opt/blog-demo/releases/a75bbc7
当前版本指针 /opt/blog-demo/current -> /opt/blog-demo/releases/a75bbc7
生产镜像 blog-demo-web:a75bbc7
阶段二容器 infra-web-1 -> blog-demo-web:d31c192
9.3 切流与验收
安全组先开放 TCP 80 用于 HTTP-01,证书签发和续期演练成功后再开放 TCP 443。生产容器接管端口前,停用 /etc/nginx/sites-enabled/blog-demo-acme 并重载宿主机 Nginx;原有 18080 配置继续运行。生产 Compose 只发布 IPv4 的 80 与 443,主机 UFW 保持 inactive,公网边界由阿里云安全组与 Compose 共同约束。
2026 年 9 月 12 日的 ECS 本机与公网验收结果:
doyouhang.live和www.doyouhang.live的可信 HTTPS 均返回 200,HTTP 返回 308 并保留请求主机名。- 首页、文章、搜索、Pagefind、RSS 和 sitemap 返回 200;不存在的路径返回站点 404。
- Chrome 直接访问公网域名时,桌面和 390px 手机宽度均无横向溢出;首页、文章和搜索成功页面没有脚本或控制台错误。
- canonical、RSS 和 sitemap 均使用
https://www.doyouhang.live,没有 GitHub Pages 项目路径残留。 - HSTS、CSP frame ancestors、Permissions Policy、Referrer Policy、nosniff 和 SAMEORIGIN 响应头存在。
- 未知 HTTP Host 被拒绝,未知 TLS SNI 握手被拒绝;ACME challenge 仍由生产容器提供。
- 生产容器为 healthy、零重启,验收窗口内没有 5xx,内存约 4 MiB;根盘使用率 19%。
- 公网访问 8080 超时;阶段二入口仍只绑定
127.0.0.1:8080。 - SSH 22、frps 7000、AI 网关 18080 和原有回环监听均保持原状。
GitHub Pages 的默认首页和深层文章仍返回 200,提交 a75bbc7 的 Actions Run 34628941849 成功。Pages 在切流后保留 7 至 14 天;生产站先观察 24 至 48 小时。
10. 当前尚未完成
- 完成切流后的 24 至 48 小时观察,并记录异常或无异常结论。
- 在 7 至 14 天回退保留期结束后,决定是否停止 GitHub Pages。
- GitHub Actions 构建并发布生产镜像。
- ECS 使用只读凭据按不可变镜像摘要拉取,以及自动部署、并发锁、失败回滚、到期告警和发布审计。
- 后端 API、PostgreSQL、数据库备份和恢复演练。
静态生产入口已经完成切流;后端与数据库继续等待真实业务需求,不影响当前内容站运行。