Files
LokiStack/scripts/release.sh
T
opsx 63445bd598
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
build(release): 部署后自愈 + 版本戳,堵住"latest 被旧镜像覆盖"这类问题
=== 背景:同类问题已经出现三次 ===
  1) Docker Hub 502 导致 latest 推失败,Pod 仍跑旧镜像
  2) 并发构建互相覆盖 latest(谁后完成谁生效)
  3) 中断的构建后来完成,把 latest 推回旧代码(本次差点中招)

第 2 类已由 flock 独占锁解决;但前两类还有一个共同的后半段:
**没有人核对"部署后 Pod 到底跑的是不是刚推的那版"**。

=== 本次新增 ===
1) 部署后自愈:rollout 完成即核对 Pod digest 与远端 latest;
   不一致自动 `kubectl delete po` 强制重拉(节点缓存了旧 latest 映射是常见原因),
   最多等 4 分钟,仍不一致才 exit 1 —— 不再"报个 ❌ 就结束"。
2) 版本戳:核对通过后给 Deployment 打三个 annotation
     lokistack.io/release-image = <digest>
     lokistack.io/release-tag   = <版本-日期-commit>
     lokistack.io/release-time  = <ISO8601>
   以后排查"线上跑哪版"直接 `kubectl -n log get deploy lokistack -o yaml | grep release-`,
   不用再在 docker images / Hub API / Pod 之间来回比对。

=== 用法 ===
  以后发版统一走 `bash scripts/release.sh`(build+push+deploy+自愈+打戳一次完成)
  不要再手敲 docker build + docker push —— 那样会绕过 flock 锁,回到互相覆盖。
  bash scripts/release.sh --dry-run   只做前置检查
  bash scripts/release.sh --no-deploy 只发镜像不动集群
2026-10-31 07:08:23 +00:00

209 lines
9.2 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 # 只检查锁与前置条件,不构建不推送
#
# 部署后自愈与版本戳:
# 滚动完成后核对 Pod digest 与远端 latest;不一致时自动删 Pod 强制重拉
# (节点镜像缓存/拉取时机问题),仍不一致才 exit 1。
# 核对通过后给 Deployment 打三个 annotation,直接看集群就知道线上跑哪版:
# lokistack.io/release-image / release-tag / release-time
#
# 并发保护(重要):
# 本项目所有环境都拉 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/.*@//'
}
POD_DIGEST=$(pod_digest)
echo ""
echo "=========================================="
echo " 部署完成,核对 Pod 与远端 latest:"
echo " 远端 latest : ${D_LATEST:0:19}"
echo " Pod 运行中 : ${POD_DIGEST:0:19}"
# ── 自愈:digest 不一致时强制重拉一次 ──
# 常见原因:节点缓存了旧的 latest 映射、或 rollout 与 push 完成时间太接近。
# 直接删 Pod 让 kubelet 重新解析 latest 通常就恢复了,不需要人工介入。
if [ "${POD_DIGEST:0:19}" != "${D_LATEST:0:19}" ]; then
echo " ⚠️ 不一致,删除 Pod 强制重新拉取 latest..."
kubectl -n log delete po -l app=lokistack --wait=false >/dev/null 2>&1 || true
for _ in $(seq 1 30); do
sleep 8
POD_DIGEST=$(pod_digest)
[ "${POD_DIGEST:0:19}" = "${D_LATEST:0:19}" ] && break
done
echo " 重拉后 Pod : ${POD_DIGEST:0:19}"
fi
if [ "${POD_DIGEST:0:19}" = "${D_LATEST:0:19}" ]; then
echo " → 一致 ✅"
# ── 版本戳:下次排查"线上到底跑哪版"直接看 annotation ──
kubectl -n log annotate deploy/lokistack --overwrite \
"lokistack.io/release-image=${D_LATEST#sha256:}" \
"lokistack.io/release-tag=${IMMUTABLE_TAG}" \
"lokistack.io/release-time=$(date -Is)" >/dev/null 2>&1 \
&& echo " 已标注 run 版本:${D_LATEST:0:19} (${IMMUTABLE_TAG})"
else
echo " → 不一致 ❌(重拉后仍不符,检查节点镜像缓存 / 网络)"
echo "=========================================="
exit 1
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"