blog-demo · 运维与踩坑

ECS 静态站搭建与切流记录

一次真实的 Astro 静态站迁移记录,包括镜像转运、TLS 签发、故障回退、安全验收和正式切流。

状态
历史记录
最后核对
源码修订
e0d3baa2

ECS 静态站搭建与切流记录

本文记录 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。9 月 13 日,提前生成的 v1.0.0 核心迁移镜像 46aa21b 上线,独立 doyouhang-edge 接管 80/443;博客 Web 不再发布主机端口。GitHub Pages 同时退出维护,阶段二容器只保留为迁移历史。最终同名版本还需纳入文档库和 Studio 登录闭环后重新完成门禁与 Web 替换。

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。v1 独立 Edge 上线后,证书目录和 hook 改由私有基础设施仓库维护,博客容器不再挂载证书。

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。
  • Microsoft Edge 直接访问公网域名时,桌面和 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 成功。这只记录当时的迁移证据;v1.0.0 起相关工作流和回退方案均已移除。

9.4 v1 公网 Edge 收口(已切换)

AI 子域接入后,博客 Web 容器同时承载了博客、AI 的 80/443 与证书。这样虽然能工作,但完整博客发版会重建 AI 的公网入口。v1 候选已经把职责拆开:私有 ai-infra 提供独立 doyouhang-edge,唯一占用 80/443、TLS 和 ACME;本仓库的生产 Web 不发布端口,只通过外部网络 doyouhang-edge 暴露别名 blog-web。未来 Studio 的 oauth2-proxy 以 studio-auth 接入同一网络。

旧的 production Nginx、Studio Nginx overlay 和博客证书 hook 已从本仓库删除,不再维护两套入口。内容部署器只执行 up -d --no-build --no-deps web。本机多容器演练需证明博客与 AI 路由、安全头、未知 Host/SNI、TLS 协议和 ACME 正常,并在只替换博客后端后确认 Edge 容器 ID 不变。

2026 年 9 月 13 日完成生产入口切换。先用旧镜像建立无主机端口的迁移后端,再把 80/443 交给独立 Edge;Edge 健康后只替换博客 Web。替换前后 Edge 容器 ID 不变,提前上线的博客版本接口返回提交 46aa21b、构建类型 stable。该镜像随后保留为最终 v1.0.0 Web 替换前的生产回滚基线。

公网验收覆盖博客首页、深层文章、搜索、RSS、sitemap、股票页和 404;HTTP 为 308,TLS 1.2/1.3 可用,TLS 1.1、未知 Host 和未知 SNI 被拒绝。AI 健康检查为 200,无鉴权请求为 401,轮换后的凭据完成一次真实 qwen3:32b 推理。8080、18080 仍不可从公网访问,Certbot dry-run 成功,切换后 Edge 与博客均为 healthy、零重启、零新增 5xx。

10. 当前尚未完成

  • 完成切换后的 24 至 48 小时观察,并记录异常或无异常结论。
  • ECS 自动部署、并发锁、失败回滚、到期告警和发布审计;当前仍由人工验收和部署制品。
  • 后端 API、PostgreSQL、数据库备份和恢复演练。

静态生产入口已经完成切流;后端与数据库继续等待真实业务需求,不影响当前内容站运行。