本手册用于维护已经切换到 ECS 的静态生产站,以及短期保留的私有预发布环境。当前生产入口是 https://www.doyouhang.live,GitHub Pages 在观察期作为独立静态回退。若服务器结构与搭建与切流记录不一致,先确认实际状态,不要直接复制执行更新或删除命令。
连接和路径速查
| 项目 | 当前约定 |
|---|---|
| SSH | ssh -i SSH_KEY DEPLOY_USER@ECS_HOST |
| 发布根目录 | /opt/blog-demo/releases/ |
| 当前版本指针 | /opt/blog-demo/current |
| 生产环境配置 | /etc/blog-demo/production.env |
| 预发布环境配置 | /etc/blog-demo/staging.env |
| Compose 基础文件 | /opt/blog-demo/current/infra/compose.yaml |
| Compose 生产覆盖 | /opt/blog-demo/current/infra/compose.production.yaml |
| 生产容器 | blog-demo-production-web-1 |
| 生产入口 | https://www.doyouhang.live/ |
| 预发布容器 | infra-web-1 |
| 预发布入口 | http://127.0.0.1:8080/,仅 ECS 本机或 SSH Tunnel |
| 证书副本 | /etc/blog-demo/tls,只读挂载到容器 |
| Certbot webroot | /var/lib/letsencrypt |
私钥只留在可信电脑。不要把私钥、完整环境文件、访问令牌或带凭据的命令输出粘贴进 Issue、聊天记录和仓库。
每次登录先看什么
readlink /opt/blog-demo/current
sudo docker ps --filter name=blog-demo-production-web-1
sudo docker inspect --format \
'{{.State.Status}} {{.State.Health.Status}} {{.Config.Image}}' \
blog-demo-production-web-1
sudo systemctl is-active certbot.timer
sudo certbot certificates
sudo ss -ltnp
df -h /
free -h
正常状态应满足:
current指向一个明确的发布版本目录。- 容器为
running和healthy。 - 生产镜像标签中的版本与
/etc/blog-demo/production.env的RELEASE_ID一致。 - 博客生产入口只发布 IPv4 的 80 与 443;8080 只出现在
127.0.0.1:8080。 - 原有 18080、7000 等服务仍保持原状态。
- Certbot timer 为 active,证书未接近到期;证书副本权限没有放宽。
- 磁盘和内存没有接近耗尽。
从自己的电脑检查预发布
保持下面的 SSH 会话运行:
ssh -i SSH_KEY \
-L 8080:127.0.0.1:8080 \
DEPLOY_USER@ECS_HOST
浏览器打开 http://127.0.0.1:8080/。如果本机 8080 已被占用,可以只改变左侧端口:
ssh -i SSH_KEY \
-L 18081:127.0.0.1:8080 \
DEPLOY_USER@ECS_HOST
此时打开 http://127.0.0.1:18081/。不要为了省去隧道而把 Compose 改成 0.0.0.0:8080:80。
查看生产状态和日志
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/current/infra/compose.yaml \
-f /opt/blog-demo/current/infra/compose.production.yaml \
ps
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/current/infra/compose.yaml \
-f /opt/blog-demo/current/infra/compose.production.yaml \
logs --tail 200 web
持续跟踪日志时使用 logs --follow web,看完按 Ctrl+C;这不会停止容器。Nginx 静态站正常空闲时日志很少。
手工发布新版本
自动发布工作流完成后,本节只作为应急流程。所有命令都应从干净、已经验证的提交执行。
1. 本地确定版本和构建参数
git status --short
git rev-parse --short HEAD
记下短提交号作为 RELEASE_ID,记下实际规范域名作为 SITE_URL。先完成仓库验证:
SITE_URL=https://YOUR_DOMAIN SITE_BASE=/ npm run check
SITE_URL=https://YOUR_DOMAIN SITE_BASE=/ npm run build
SITE_URL=https://YOUR_DOMAIN SITE_BASE=/ npm run test
然后构建运行镜像:
docker build \
--platform linux/amd64 \
--build-arg SITE_URL=https://YOUR_DOMAIN \
--build-arg SITE_BASE=/ \
-f infra/docker/web.Dockerfile \
-t blog-demo-web:RELEASE_ID .
必须替换 YOUR_DOMAIN 和 RELEASE_ID,不能把占位文本原样用于正式发布。
2. 导出源码和镜像
git archive \
--format=tar \
--output=/tmp/blog-demo-RELEASE_ID.tar \
RELEASE_ID
docker save \
--output=/tmp/blog-demo-web-RELEASE_ID.tar \
blog-demo-web:RELEASE_ID
sha256sum /tmp/blog-demo-RELEASE_ID.tar
sha256sum /tmp/blog-demo-web-RELEASE_ID.tar
上传两个文件:
scp -i SSH_KEY \
/tmp/blog-demo-RELEASE_ID.tar \
/tmp/blog-demo-web-RELEASE_ID.tar \
DEPLOY_USER@ECS_HOST:/tmp/
3. 服务器核验并安装
登录服务器后再次计算两个 SHA-256,必须与本地逐字一致:
sha256sum /tmp/blog-demo-RELEASE_ID.tar
sha256sum /tmp/blog-demo-web-RELEASE_ID.tar
创建新目录并导入镜像:
sudo install -d \
-m 0755 \
-o DEPLOY_USER \
-g DEPLOY_USER \
/opt/blog-demo/releases/RELEASE_ID
tar -xf /tmp/blog-demo-RELEASE_ID.tar \
-C /opt/blog-demo/releases/RELEASE_ID
sudo docker load --input /tmp/blog-demo-web-RELEASE_ID.tar
sudo docker image inspect blog-demo-web:RELEASE_ID
先备份 /etc/blog-demo/production.env,再用 sudoedit 更新 SITE_URL、SITE_BASE=/ 和 RELEASE_ID。这里的值必须与刚才构建镜像时一致;证书与 ACME 目录变量保持不变。
4. 先检查配置,再替换容器
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.yaml \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.production.yaml \
config
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.yaml \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.production.yaml \
run --rm --no-deps web nginx -t
确认镜像标签、80/443 端口和安全选项后启动:
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.yaml \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.production.yaml \
up -d --no-build
--no-build 很关键:镜像已经导入,服务器又可能无法访问 Docker Hub,不能在切换过程中临时重新构建。
5. 验收后再更新 current
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.yaml \
-f /opt/blog-demo/releases/RELEASE_ID/infra/compose.production.yaml \
ps
curl --fail --show-error --head \
--resolve YOUR_DOMAIN:443:127.0.0.1 \
https://YOUR_DOMAIN/
curl --fail --show-error --head \
--resolve YOUR_DOMAIN:443:127.0.0.1 \
https://YOUR_DOMAIN/interests/food/
curl --show-error --output /dev/null --write-out '%{http_code}\n' \
--resolve YOUR_DOMAIN:443:127.0.0.1 \
https://YOUR_DOMAIN/not-a-real-route/
随后从 ECS 外的网络用正式域名检查首页、文章、搜索、静态资源、404、RSS、sitemap、证书链和 HTTP 跳转,并确认公网 8080 不可访问。检查容器健康、重启次数、5xx、内存和磁盘;全部通过后执行:
sudo ln -sfn \
/opt/blog-demo/releases/RELEASE_ID \
/opt/blog-demo/current
临时归档只在确认回滚所需镜像和版本目录都存在后删除。生产上线后观察 24 至 48 小时,GitHub Pages 保留 7 至 14 天。
回滚到上一版本
先找出当前版本和可用旧版本:
readlink /opt/blog-demo/current
find /opt/blog-demo/releases -mindepth 1 -maxdepth 1 -type d -printf '%f\n'
sudo docker image ls blog-demo-web
确定 PREVIOUS_ID 后,检查旧镜像存在:
sudo docker image inspect blog-demo-web:PREVIOUS_ID
备份后使用 sudoedit /etc/blog-demo/production.env 把 RELEASE_ID 改成 PREVIOUS_ID,并确认 SITE_URL、SITE_BASE 与旧镜像的构建配置兼容。渲染配置后启动旧版本:
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/releases/PREVIOUS_ID/infra/compose.yaml \
-f /opt/blog-demo/releases/PREVIOUS_ID/infra/compose.production.yaml \
config
sudo docker compose -p blog-demo-production \
--env-file /etc/blog-demo/production.env \
-f /opt/blog-demo/releases/PREVIOUS_ID/infra/compose.yaml \
-f /opt/blog-demo/releases/PREVIOUS_ID/infra/compose.production.yaml \
up -d --no-build
健康检查和公网验收通过后,把 current 指向旧版本。回滚失败时保留现场,不要清理镜像或发布目录。
a75bbc7 是第一份通过生产健康检查的版本,a83488e 和 c35f381 不能作为生产回滚目标。首次生产版本故障时,优先保留现场并使用已验证的 GitHub Pages 默认地址提供静态回退;若需要释放 80,可停止 blog-demo-production,恢复 /etc/nginx/sites-enabled/blog-demo-acme 指向已保留的站点配置,再执行 nginx -t 和 reload。不要停止 18080 的原有站点。
检查证书续期
Certbot 使用生产容器挂载的 webroot 完成 HTTP-01。生产容器必须继续提供 /.well-known/acme-challenge/,安全组 80 不能只因全站使用 HTTPS 就关闭。
sudo systemctl status certbot.timer
sudo certbot certificates
sudo certbot renew --dry-run --no-random-sleep-on-renew
sudo stat -c '%a %U:%G %n' \
/etc/blog-demo/tls \
/etc/blog-demo/tls/fullchain.pem \
/etc/blog-demo/tls/privkey.pem
续期成功后,/etc/letsencrypt/renewal-hooks/deploy/blog-demo.sh 更新容器证书副本,并向 blog-demo-production-web-1 发送 HUP。随后检查证书有效期、容器健康和公网 HTTPS。deploy hook 不运行时,先检查 RENEWED_LINEAGE、目标目录权限、容器名和 Docker 权限,不要手工放宽私钥权限。
常见故障
permission denied 访问 Docker socket
原因通常是直接运行了 docker ...。本环境没有把部署用户加入 docker 组,请使用:
sudo docker ps
sudo docker compose version
构建停在 registry-1.docker.io 并超时
这是已出现过的网络问题。先确认并非 Docker 服务故障:
systemctl is-active docker
curl --connect-timeout 5 --head https://registry-1.docker.io/v2/
手工应急发布使用已验收镜像的 docker save、SHA-256 校验、SCP 和 docker load 流程。常规发布应使用已验证可达的 GHCR 或阿里云镜像仓库。
容器不断重启或显示 unhealthy
sudo docker ps --filter name=blog-demo-production-web-1
sudo docker logs --tail 200 blog-demo-production-web-1
sudo docker inspect blog-demo-production-web-1
重点检查:
/var/cache/nginx、/var/run和/tmp是否仍配置为 tmpfs。- 只读文件系统和能力限制是否与 Compose 文件一致。
- 镜像是否为当前
RELEASE_ID。 - 生产环境检查 80 和 443,预发布环境检查 8080 是否被其他进程占用。
不要进入容器手改文件;修复源码或配置后重新生成一个版本。
ECS 本机可以访问,SSH Tunnel 不通
先在 ECS 上执行:
curl --fail --head http://127.0.0.1:8080/
如果成功,检查本机隧道命令、私钥、本机端口占用和 SSH 会话是否仍在。左侧本机端口可以换成 18081,右侧仍保持 127.0.0.1:8080。
页面返回 404
- 不存在的内容返回 404 是正确行为,Nginx 不使用 SPA fallback。
- 若所有深层页面都 404,检查镜像中的
/usr/share/nginx/html和infra/nginx/production.conf。 - 若只有一篇文章 404,先确认对应内容是否进入当前提交和构建产物。
- 检查 URL 中是否错误带有 GitHub Pages 的
/blog-demo/前缀;ECS 构建使用SITE_BASE=/。
canonical、RSS 或 sitemap 还是旧域名
SITE_URL 在构建时写入静态文件。修改 /etc/blog-demo/production.env 后继续运行旧镜像不会生效。必须用新 SITE_URL 重建镜像、分配新 RELEASE_ID 并重新发布。
可以快速检查:
curl --silent https://www.doyouhang.live/
curl --silent https://www.doyouhang.live/rss.xml
curl --silent https://www.doyouhang.live/sitemap-index.xml
更新后仍看到旧内容
依次核对:
readlink /opt/blog-demo/current
sudo docker inspect --format '{{.Config.Image}} {{.Image}}' blog-demo-production-web-1
sudo docker image ls blog-demo-web
HTML 使用 no-cache,哈希资源使用长期缓存。若 HTML 已经引用新的资源文件名,浏览器缓存通常不是根因;重点检查容器镜像和发布版本是否真的切换。
磁盘空间持续减少
先看占用,不要直接执行 docker system prune -a:
df -h /
sudo docker system df
sudo docker image ls blog-demo-web
find /opt/blog-demo/releases -mindepth 1 -maxdepth 1 -type d -printf '%f\n'
至少保留当前版本和一个已演练可用的回滚版本。删除前逐项确认镜像标签、发布目录和 current 指针。
备份和恢复范围
当前阶段没有数据库,公开内容的权威副本仍是 Git 仓库。需要保留:
- GitHub 仓库及提交历史。
/etc/blog-demo/production.env与/etc/blog-demo/staging.env的安全备份。- Certbot 配置、账户资料、续期配置和
/etc/blog-demo/tls证书副本;私钥备份必须加密并限制访问。 - 当前和上一个可用发布目录。
- 当前和上一个可用镜像,或能从可信仓库按摘要重新拉取它们的方法。
- ECS 安全组、DNS 和未来证书配置的变更记录。
后续引入 PostgreSQL 后,必须增加独立的数据备份、异地保存和恢复演练;镜像与 Git 不能代替数据库备份。
排障时如何留证据
记录以下信息即可,不要复制整个系统或泄露环境变量:
发生时间和时区:
正在访问的入口:ECS localhost / SSH Tunnel / 正式域名
current 指向版本:
容器镜像标签和镜像 ID:
容器状态与健康状态:
相关 HTTP 状态码:
最近 200 行容器日志:
最近一次部署做了什么:
已有服务是否正常:
先区分连接失败、HTTP 错误、内容版本错误和域名生成错误,再处理对应层。这样能避免为了修一个页面问题去重启 Docker,或为了网络超时去修改 Astro 源码。