#!/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 "?" } # Docker Hub 的 tags API 有传播延迟:刚 push 完立刻查,常常还是旧 digest。 # 实测遇到过 latest 已是新值、v0.1 还停在旧值,如果只查一次就会误判成 # "并发覆盖"并拒绝部署。所以轮询到三者一致为止(最多 60 秒)。 DIGEST_ATTEMPTS=12 DIGEST_SLEEP=5 for attempt in $(seq 1 ${DIGEST_ATTEMPTS}); do D_LATEST=$(digest_of latest) D_V01=$(digest_of v0.1) D_IMM=$(digest_of "${IMMUTABLE_TAG}") if [ "${D_LATEST:0:19}" = "${D_V01:0:19}" ] && [ "${D_LATEST:0:19}" = "${D_IMM:0:19}" ] \ && [ "${D_LATEST:0:19}" != "?" ]; then break fi if [ "${attempt}" -lt "${DIGEST_ATTEMPTS}" ]; then echo " ⏳ 远端 digest 尚未同步(第 ${attempt}/${DIGEST_ATTEMPTS} 次),${DIGEST_SLEEP}s 后重试…" sleep ${DIGEST_SLEEP} fi done 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 不一致(已等待 $((DIGEST_ATTEMPTS * DIGEST_SLEEP)) 秒)" echo " 若 latest 与 v0.1 不同:可能是并发构建覆盖了 latest(检查 flock 锁是否被绕过)" 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"