=== 问题 ===
本项目所有清单都指向 `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 语法检查通过
176 lines
7.6 KiB
Bash
Executable File
176 lines
7.6 KiB
Bash
Executable File
#!/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"
|