Files
LokiStack/scripts/release.sh
T
opsx fdd705c4b4
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
build(release): release.sh 加独占构建锁 + push 重试,杜绝 latest 被并发覆盖
=== 问题 ===
本项目所有清单都指向 `latest`(k8s deployment / docker-compose / prod compose),
两个构建并发时**谁后完成谁生效**,另一个的推送等于白做。
本次实际踩到:一次被中断的构建后来完成,把 latest 推回了旧镜像 64fb231,
而当前正确版本是 a16978d2;若此时 Pod 重启就会跑回旧代码,
表现是"改了但没生效",从页面完全看不出来。

=== 修复 1:flock 独占锁 ===
  LOCK_FILE=${RELEASE_LOCK_FILE:-/var/lock/lokistack-release.lock}
  exec 9>"$LOCK_FILE"; flock -n 9 || { 中文提示 + exit 1 }
- 锁跟随文件描述符,进程退出(含 Ctrl-C / set -e 中断)自动释放,
  不需要手工清理僵尸锁文件
- 失败提示给出:锁路径、查看占用命令(fuser/lsof)、以及"无需手工删锁"的说明
- 支持 RELEASE_LOCK_FILE 换锁路径(多仓库并行发布时用)
- 新增 --dry-run:只做前置检查(docker 是否可用、锁能否获取),不构建不推送,
  用于验证脚本本身和排查"为什么发布失败"

=== 修复 2:push 重试 ===
Docker Hub 偶发 502,实测导致过"v0.1 推成功、latest 失败 → latest 指旧镜像"。
新增 push_with_retry():每个 tag 最多 3 次,间隔 10 秒,全失败才 exit 1。
三个 tag(latest / v0.1 / 不可变 tag)都走它。

=== 实测 ===
  测试1 无人占用        : 🔒 获取锁成功,退出码 0
  测试2 另一进程持有锁   : ❌ 明确拒绝并给出排查命令,退出码 1
  测试3 占用进程退出后   : ✅ 自动恢复可用,退出码 0
  bash -n 语法检查通过
2026-10-30 17:48:20 +00:00

176 lines
7.6 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================
# release.sh — 构建 + 推送(latest / v0.1 / 不可变 tag 三合一)
# ============================================================
# 本项目的发布策略:**清单与容器启动统一使用 latest**。
# kubernetes/deployment.yaml → lghost/lokistack:latest + imagePullPolicy: Always
# docker-compose.yml → lghost/lokistack:latest
# docker-compose.prod.yml → lghost/lokistack:${IMAGE_TAG:-latest}
#
# 所以每次发版必须让 latest 与 v0.1 **同时**指向新镜像,否则会出现
# "k8s 拉到新版、docker 起还是旧版"这种版本漂移。
#
# 本脚本另外额外打一个不可变 tag(<版本>-<日期>-<短commit>):
# - 它不写进任何清单,只用于事后追溯/回滚(仓库里能查到那一刻的确切镜像)
# - 需要"固定版本部署"的生产环境可以手动改成这个 tag
#
# 为什么不在脚本里改 yml 的 tag:
# 上一版脚本会把 deployment.yaml 的 image 改成不可变 tag 并把 pullPolicy
# 改成 IfNotPresent,结果与"统一用 latest"的约定相反,还容易忘记提交
# 导致下次 apply 退回旧 tag。改成默认不动清单。
#
# 用法:
# bash scripts/release.sh # 构建 + 推三个 tag + 滚动重启(拉 latest)
# bash scripts/release.sh --no-deploy # 只构建 + 推送,不碰集群
# bash scripts/release.sh --pin # 额外把 deployment.yaml 钉到不可变 tag(可选)
# bash scripts/release.sh --dry-run # 只检查锁与前置条件,不构建不推送
#
# 并发保护(重要):
# 本项目所有环境都拉 latest,两个构建同时跑会互相覆盖 latest:
# 后完成的那次生效,先完成的那次白推。曾出现"中断的构建后来完成,
# 把 latest 指回旧代码",表现为"改了但没生效",极难排查。
# 因此脚本用 flock 加独占锁,同一时刻只允许一个发布任务。
# flock 在进程退出时自动释放,不会留下需要手工清理的僵尸锁。
# ============================================================
set -euo pipefail
cd "$(dirname "$0")/.."
IMAGE_REPO="${IMAGE_REPO:-lghost/lokistack}"
VERSION="${VERSION:-2.0.0}"
MODE="${1:-}"
# ── 独占锁:同一时刻只允许一个发布任务 ────────────────────────────────
# 为什么必须加:所有清单都指向 latest,两个构建并发时谁后完成谁生效,
# 另一个的推送等于白做;更糟的是完成顺序不可控,可能把 latest 指向更旧的
# 代码。这是"改了但没生效"类问题的根源之一。
# flock 跟随文件描述符,进程退出(含 Ctrl-C / set -e 中断)自动释放。
LOCK_FILE="${RELEASE_LOCK_FILE:-/var/lock/lokistack-release.lock}"
mkdir -p "$(dirname "${LOCK_FILE}")" 2>/dev/null || true
if ! exec 9>"${LOCK_FILE}"; then
echo "❌ 无法创建锁文件 ${LOCK_FILE}(权限不足?)"
exit 1
fi
if ! flock -n 9; then
echo "❌ 已有发布任务正在进行,本次不启动。"
echo " 锁文件 : ${LOCK_FILE}"
echo " 查看占用 : fuser -v ${LOCK_FILE} 2>/dev/null || lsof ${LOCK_FILE}"
echo " 无需手工删锁文件:持有进程退出时 flock 自动释放。"
exit 1
fi
echo "🔒 已获取发布锁:${LOCK_FILE}"
echo "=========================================="
echo " 前置检查"
echo "=========================================="
echo " 仓库 : ${IMAGE_REPO}"
if ! command -v docker >/dev/null 2>&1; then
echo "❌ 未找到 docker 命令"
exit 1
fi
echo " docker : $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo '未知')"
if [ "${MODE}" = "--dry-run" ]; then
echo ""
echo "(--dry-run) 只做前置检查:不构建、不推送、不动集群。"
echo " → 锁获取正常。"
exit 0
fi
SHORT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || echo "nogit")
DIRTY=""
if ! git diff --quiet 2>/dev/null; then
DIRTY="-dirty"
echo "⚠️ 工作区有未提交改动,不可变 tag 会带 -dirty(发布前建议先提交)"
fi
IMMUTABLE_TAG="${VERSION}-$(date +%Y%m%d)-${SHORT_SHA}${DIRTY}"
echo "=========================================="
echo " 构建: ${IMAGE_REPO} → latest / v0.1 / ${IMMUTABLE_TAG}"
echo "=========================================="
docker build \
-t "${IMAGE_REPO}:latest" \
-t "${IMAGE_REPO}:v0.1" \
-t "${IMAGE_REPO}:${IMMUTABLE_TAG}" .
IMAGE_ID=$(docker images --format '{{.ID}}' "${IMAGE_REPO}:latest" | head -1)
echo "✅ 构建完成 ${IMAGE_ID}"
echo ""
echo "推送三个 tag(latest 与 v0.1 必须同 digest,否则环境间会版本漂移)..."
# Docker Hub 偶发 502(实测踩过:v0.1 推成功、latest 失败 →
# latest 指着旧镜像,Pod 重启后跑的是旧代码)。所以每次 push 都重试。
push_with_retry() {
local tag="$1" attempt
for attempt in 1 2 3; do
if docker push "${IMAGE_REPO}:${tag}" 2>&1 | tail -1; then
return 0
fi
echo " ⚠️ push ${tag} 第 ${attempt} 次失败,10 秒后重试..."
sleep 10
done
echo "❌ push ${tag} 连续 3 次失败(镜像仓库异常?)"
return 1
}
push_with_retry latest
push_with_retry v0.1
push_with_retry "${IMMUTABLE_TAG}"
# ── 校验三个 tag 指向同一个镜像 ──
echo ""
echo "校验远端 digest 一致性..."
digest_of() {
curl -s "https://hub.docker.com/v2/repositories/${IMAGE_REPO}/tags/$1" 2>/dev/null \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("digest","?"))' 2>/dev/null \
|| echo "?"
}
D_LATEST=$(digest_of latest)
D_V01=$(digest_of v0.1)
D_IMM=$(digest_of "${IMMUTABLE_TAG}")
echo " latest : ${D_LATEST:0:19}"
echo " v0.1 : ${D_V01:0:19}"
echo " ${IMMUTABLE_TAG}: ${D_IMM:0:19}"
if [ "${D_LATEST:0:19}" != "${D_V01:0:19}" ] || [ "${D_LATEST:0:19}" != "${D_IMM:0:19}" ]; then
echo "❌ 三个 tag digest 不一致,先别继续部署(可能是并发构建覆盖了 latest)"
exit 1
fi
echo " → 一致 ✅"
if [ "${MODE}" = "--pin" ]; then
echo ""
echo "(--pin)把 kubernetes/deployment.yaml 钉到 ${IMMUTABLE_TAG}..."
sed -i "s|image: ${IMAGE_REPO}:.*|image: ${IMAGE_REPO}:${IMMUTABLE_TAG}|" kubernetes/deployment.yaml
sed -i "s|imagePullPolicy: Always|imagePullPolicy: IfNotPresent|" kubernetes/deployment.yaml
echo "⚠️ 记得提交 deployment.yaml,否则下次 apply 会退回 latest"
fi
if [ "${MODE}" != "--no-deploy" ]; then
export KUBECONFIG="${KUBECONFIG:-/root/.kube/config}"
echo ""
echo "应用清单(image: latest + Always → 强制重拉)..."
kubectl apply -f kubernetes/deployment.yaml
# 清单内容没变时 apply 不会触发滚动,这里显式重启一次
kubectl -n log rollout restart deploy/lokistack
kubectl -n log rollout status deploy/lokistack --timeout=300s
POD_DIGEST=$(kubectl -n log get po -l app=lokistack \
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}' 2>/dev/null | sed 's/.*@//')
echo ""
echo "=========================================="
echo " 部署完成,核对 Pod 与远端 latest:"
echo " 远端 latest : ${D_LATEST:0:19}"
echo " Pod 运行中 : ${POD_DIGEST:0:19}"
if [ "${POD_DIGEST:0:19}" = "${D_LATEST:0:19}" ]; then
echo " → 一致 ✅"
else
echo " → 不一致 ❌(Pod 可能还在拉取,或节点镜像缓存异常)"
fi
echo "=========================================="
fi
echo ""
echo "回滚方法(latest 策略下靠不可变 tag 追溯):"
echo " docker pull ${IMAGE_REPO}:<上次的 ${VERSION}-日期-commit>"
echo " docker tag ${IMAGE_REPO}:<该 tag> ${IMAGE_REPO}:latest && docker push ${IMAGE_REPO}:latest"
echo " kubectl -n log rollout restart deploy/lokistack"