← 返回博客

ECS 静态站搭建与切流记录

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

2026-09-12 约 9 分钟读完

补记于 2026-09-12 · 正式 HTTPS 切流完成,稳定性观察中

本文目录 跳到想读的章节
  1. 阶段二交付边界
  2. 1. 初始盘点
  3. 2. 安装 Docker 与 Compose
  4. 3. 准备版本化发布目录
  5. 4. 检查 Compose 最终配置
  6. 5. 处理 Docker Hub 不可达
  7. 6. 启动私有预发布
  8. 7. 实际验收结果
  9. 8. 首次推送与 CI 兼容修正
  10. 9. 阶段三和阶段四生产切换
  11. 10. 当前尚未完成

本文记录 2026 年 9 月 8 日完成私有预发布,以及 9 月 12 日完成生产 HTTPS 切流的实际过程。它用于解释环境为何成为现在的样子;重复发布、排障和回滚请看静态站 ECS 运维手册

服务器地址、私钥内容和未来的仓库令牌不写入仓库。文中的 ECS_HOSTDEPLOY_USERSSH_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 缓冲峰值
Swap2 GiB保留,不调整
根盘40 GiB,剩余约 32 GiB足够保留若干发布目录和镜像
Docker未安装需要先安装 Docker Engine 与 Compose v2
8080未占用用作私有预发布回环端口
UFWinactive不能把 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_URLSITE_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: trueno-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_ippublishedtarget 三个结构化字段。运行配置仍然正确,测试却因输出表示不同而误报。

修正后,测试在 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
TTL600 秒
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.livewww.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、数据库备份和恢复演练。

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

读到这里,谢谢你的时间。

如果还想继续读下去,
新的文章和闪念会送进 RSS。

订阅这本档案 ↗