← 返回文章列表 专栏:Linux 与系统安全

更新于

Linux 容器无人值守运维:故障自愈、Logrotate 日志轮转与原子发布脚本

配置 Docker 容器开机自启与健康检查,使用宿主机 logrotate 配合 USR1 信号按日切分 Nginx 日志,并编写支持秒级回滚的 Bash 发布脚本。

容器故障自愈、JSON 日志轮转与 Bash 自动化发布脚本架构
容器故障自愈、JSON 日志轮转与 Bash 自动化发布脚本架构

把容器跑起来通常只需要几分钟,但要让站点长期稳定运行,还需要处理好三件事:宿主机重启后容器自动恢复、访问日志定期轮转压缩避免占满磁盘,以及发版脚本遇到错误时自动中止并回滚。

本文记录 Docker 重启策略配置、logrotate 日志切分,以及基于 ln -sfn 软链接切换的发布脚本。

容器自愈:restart: unless-stopped 与 docker.service 开机自启

在 compose.yaml 中配置 restart: unless-stopped 后,要让容器在云主机重启后自动恢复,还需要确认两点:

  1. 宿主机 docker.service 守护进程已设为开机自启(而不是仅靠 docker.socket 等待客户端命令触发):
1
2
3
# 启用 docker.service 开机自启
sudo systemctl enable --now docker.service
sudo systemctl is-enabled docker.service
  1. Docker 四种重启策略的区别:
策略值 容器内进程异常退出 宿主机重启后 手动 docker stop 后宿主机重启 适用场景
no 不重启 不拉起 不拉起 一次性构建或临时调试容器
on-failure[:max-retries] 仅退出码非 0 时重启 视退出状态而定 不拉起 批处理任务或队列 Worker
always 自动重启 总是拉起 仍会拉起 常驻基础代理服务
unless-stopped 自动重启 自动拉起 保持人工停机状态,不拉起 常规生产 Web 容器
Docker Nginx 容器自愈恢复、日志轮转与原子发布架构图
通过 restart: unless-stopped 保障容器异常退出后自动拉起,通过宿主机 logrotate 发送 USR1 信号切分日志,通过软链接切换完成发布与回退。

宿主机日志轮转:切分文件后向 Nginx 发送 USR1 信号

由于容器内的 /var/log/nginx 已经挂载到宿主机的 /srv/blog/logs,可以直接使用宿主机自带的 logrotate 按日切分并压缩日志,无需在 nginx:alpine 容器内额外运行 cron。

需要注意的是,Linux 进程是通过已打开的文件描述符(File Descriptor)向底层 Inode 写入数据的,而不是每次写日志时重新按路径查找文件。如果 logrotate 只是把 access.log 重命名为 access.log.1 并新建一个空的 access.log,容器内的 Nginx Worker 进程仍然持有旧文件的 Inode,新产生的日志还会继续写进 access.log.1。

因此,在轮转配置的 postrotate 阶段,需要执行 docker kill --signal="USR1" blog-web,通知容器内的 Nginx Master 进程重新打开日志文件描述符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 创建宿主机日志轮转配置:/etc/logrotate.d/blog-nginx
sudo tee /etc/logrotate.d/blog-nginx >/dev/null <<'EOF'
/srv/blog/logs/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 root adm
sharedscripts
postrotate
if docker ps --format '{{.Names}}' | grep -qx 'blog-web'; then
docker kill --signal="USR1" blog-web >/dev/null 2>&1 || true
fi
endscript
}
EOF

# 使用 -d (dry-run) 检查语法,再用 -f 手动执行一次测试轮转
sudo logrotate -d /etc/logrotate.d/blog-nginx
sudo logrotate -f /etc/logrotate.d/blog-nginx
ls -lh /srv/blog/logs/

编写支持自动回滚的 Bash 发布脚本

如果发版脚本缺少错误处理,遇到磁盘写满或同步中断时可能会把残缺目录直接暴露到线上。

Bash 脚本执行环境、子 Shell 隔离与 set -euo pipefail 控制流图解
发布脚本的关键保护措施:独立子进程执行、set -euo pipefail 错误中断、圆括号子 Shell 隔离目录切换,以及健康检查失败后自动回滚。

在 /srv/blog/bin/deploy-release.sh 中编写发布与回滚脚本,主要包含四步检查:

  1. set -euo pipefail:命令返回非 0(-e)、引用未定义变量(-u)或管道中间环节失败(pipefail)时立即终止脚本。
  2. 发布前检查关键文件:确认新版本目录中包含非空的 index.html、404.html、atom.xml 与 search.json 后,才执行软链接切换。
  3. ln -sfn 切换与本机 HTTP 探活:软链接切换后重启或重载容器,并用 curl 校验首页返回 200、不存在路径返回 404;若任一检查未通过,自动切回上一版本(PREV_TARGET)。
  4. 子 Shell 清理旧版本:在圆括号 ( ... ) 子 Shell 中只保留最近 5 个历史版本目录,避免旧产物长期堆积占满磁盘。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/usr/bin/env bash
# 保存为 /srv/blog/bin/deploy-release.sh && chmod +x /srv/blog/bin/deploy-release.sh
set -euo pipefail

SITE_ROOT="/srv/blog"
RELEASES_DIR="${SITE_ROOT}/releases"
CURRENT_LINK="${RELEASES_DIR}/current"
CONTAINER_NAME="blog-web"

NEW_VERSION="${1:-}"
if [[ -z "${NEW_VERSION}" ]]; then
echo "用法: $0 <新版本号目录名>" >&2
exit 1
fi

TARGET_DIR="${RELEASES_DIR}/${NEW_VERSION}"

# 1. 检查新版本核心静态产物是否齐全且非空
for required in "index.html" "404.html" "atom.xml" "search.json"; do
if [[ ! -s "${TARGET_DIR}/${required}" ]]; then
echo "[ERROR] 缺失或空文件: ${TARGET_DIR}/${required},已中止发布" >&2
exit 1
fi
done

# 2. 记录当前生效版本路径,以便探活失败时回退
PREV_TARGET=""
if [[ -L "${CURRENT_LINK}" ]]; then
PREV_TARGET="$(readlink -f "${CURRENT_LINK}" || true)"
fi

# 3. 切换 current 软链接并重启容器以刷新只读挂载目录
ln -sfn "${TARGET_DIR}" "${CURRENT_LINK}"
docker restart "${CONTAINER_NAME}" >/dev/null

# 4. 本机 HTTP 健康检查与 404 状态码验证
sleep 1
HTTP_HOME="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/)"
HTTP_404="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/__health_404_check__)"

if [[ "${HTTP_HOME}" != "200" || "${HTTP_404}" != "404" ]]; then
echo "[ERROR] 健康检查未通过 (home=${HTTP_HOME}, 404=${HTTP_404}),回退至上一版本" >&2
if [[ -n "${PREV_TARGET}" && -d "${PREV_TARGET}" ]]; then
ln -sfn "${PREV_TARGET}" "${CURRENT_LINK}"
docker restart "${CONTAINER_NAME}" >/dev/null
fi
exit 1
fi

# 5. 在子 Shell 中清理超出 5 个的旧版本目录
(
cd "${RELEASES_DIR}"
ls -1dt 20* 2>/dev/null | tail -n +6 | xargs -r rm -rf --
)

echo "[OK] 版本 ${NEW_VERSION} 已上线并通过健康检查。"

容器恢复与发布脚本自检表

测试项 模拟命令 预期结果
容器进程异常退出恢复 docker kill --signal="SIGKILL" blog-web 几秒内 docker ps 显示容器已由守护进程自动拉起(Up)
日志轮转句柄切换 sudo logrotate -f /etc/logrotate.d/blog-nginx 生成 .log.1 归档文件,新访问请求写入新的 .log
残缺版本拦截 传入一个缺失 index.html 的目录执行 deploy-release.sh 脚本在预检阶段报错退出(Exit 1),线上 current 软链接保持不变