From 5887837407a327d8fd90e4017b25f36fff62e470 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 21:44:56 +0800 Subject: [PATCH] feat: add production blue-green frontend deployment --- docker-compose.production.yml | 8 +- scripts/deploy-production.sh | 113 +++++++++++++++----- src/deployment/blueGreenDeployment.test.mjs | 24 +++++ src/deployment/nginxConfig.test.mjs | 4 +- 4 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 src/deployment/blueGreenDeployment.test.mjs diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 167b779..d721c62 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -8,12 +8,16 @@ services: ports: - "127.0.0.1:${HOST_PORT:-9002}:80" environment: - API_UPSTREAM: "http://gateway:9000" - OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" + API_UPSTREAM: "http://gateway-active:9000" + OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300" networks: - infra-network + - routing-network networks: infra-network: external: true name: firstleg-prod-infra-network + routing-network: + external: true + name: firstleg-production-routing diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index 821a711..e3499ef 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -59,35 +59,92 @@ compose() { docker compose --project-name "$project_name" -f "$compose_file" "$@" } -container_id="$(compose ps --all --quiet "$service" | head -n 1)" -previous_image="" -if [ -n "$container_id" ]; then - previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" -fi - -if compose up -d --no-deps --pull never "$service" && \ - compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then - echo "生产前端部署成功: $image_repository:$image_tag" - compose ps "$service" - exit 0 -fi - -echo "生产前端健康检查失败,准备回滚" >&2 -compose logs --tail=100 "$service" >&2 || true -case "$previous_image" in - "$image_repository:"*) ;; - '') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;; - *) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;; +case "$service" in + admin-web) + base_port=9001 + nginx_config=/www/server/panel/vhost/nginx/admin.buggoglobal.com.conf + legacy_container=firstleg-prod-admin-web + ;; + client-web) + base_port=9002 + nginx_config=/www/server/panel/vhost/nginx/app.buggoglobal.com.conf + legacy_container=firstleg-prod-client-web + ;; + *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;; esac -previous_tag="${previous_image#"$image_repository:"}" -if [ "$previous_tag" = "$image_tag" ]; then - echo "旧镜像与新镜像一致,无法回滚" >&2 + +state_root=/data/firstleg/deploy/blue-green-state +state_file="$state_root/frontend-$service.active" +routing_network=firstleg-production-routing +mkdir -p "$state_root" +chmod 750 "$state_root" +docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null + +for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do + alias_name="${pair%%:*}" + container="${pair#*:}" + if docker inspect "$container" >/dev/null 2>&1; then + docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \ + || docker network connect --alias "$alias_name" "$routing_network" "$container" + fi +done + +active_slot="" +active_container="" +active_port="" +if [ -s "$state_file" ]; then + IFS='|' read -r active_slot active_container active_port active_image < "$state_file" +elif docker inspect "$legacy_container" >/dev/null 2>&1; then + active_slot=legacy + active_container="$legacy_container" + active_port="$base_port" +fi + +if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi +[ "$active_slot" = legacy ] && target_slot=green +if [ "$target_slot" = blue ]; then target_port="$base_port"; else target_port=$((base_port + 10000)); fi +candidate="${legacy_container}-${target_slot}" +docker rm -f "$candidate" >/dev/null 2>&1 || true + +if ! compose run -d --no-deps --name "$candidate" \ + -p "127.0.0.1:$target_port:80" "$service" >/dev/null; then + docker rm -f "$candidate" >/dev/null 2>&1 || true exit 1 fi -docker pull "$previous_image" -export IMAGE_TAG="$previous_tag" -compose up -d --no-deps --pull never "$service" -compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true -echo "已回滚至: $previous_image" >&2 -exit 1 + +healthy=false +elapsed=0 +while [ "$elapsed" -lt 120 ]; do + if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then + healthy=true + break + fi + sleep 2 + elapsed=$((elapsed + 2)) +done +if [ "$healthy" != true ]; then + docker logs --tail 100 "$candidate" >&2 || true + docker rm -f "$candidate" >/dev/null 2>&1 || true + exit 1 +fi + +backup="$nginx_config.blue-green-backup" +cp -f "$nginx_config" "$backup" +if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \ + || cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then + cp -f "$backup" "$nginx_config" + nginx -t && nginx -s reload || true + docker rm -f "$candidate" >/dev/null 2>&1 || true + exit 1 +fi +rm -f "$backup" + +if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then + docker stop --time 30 "$active_container" >/dev/null + docker rm "$active_container" >/dev/null +fi +printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp" +chmod 640 "$state_file.tmp" +mv -f "$state_file.tmp" "$state_file" +echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag" REMOTE_DEPLOY diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs new file mode 100644 index 0000000..8576a08 --- /dev/null +++ b/src/deployment/blueGreenDeployment.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const compose = (await readFile(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +test('client production deployment switches healthy blue-green slots atomically', () => { + const start = script.indexOf('compose run -d --no-deps --name "$candidate"') + const health = script.indexOf('docker exec "$candidate" wget') + const nginx = script.indexOf('nginx -t || ! nginx -s reload') + const stop = script.indexOf('docker stop --time 30 "$active_container"') + assert.ok(start >= 0 && start < health && health < nginx && nginx < stop) + assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/) + assert.match(script, /cp -f "\$backup" "\$nginx_config"/) +}) + +test('client uses stable active gateway aliases on the routing network', () => { + assert.match(compose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/) + assert.match(compose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/) + assert.match(compose, /name: firstleg-production-routing/) +}) diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 2a1ced2..1934f09 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -49,7 +49,9 @@ test('keeps local environments on target nodes and uses credential-managed SSH f assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/) assert.match(jenkinsfile, /git rev-parse origin\/main/) assert.match(productionCompose, /127\.0\.0\.1:\$\{HOST_PORT:-9002\}:80/) - assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/) + assert.match(productionCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/) + assert.match(productionCompose, /name: firstleg-production-routing/) assert.match(productionCompose, /name: firstleg-prod-infra-network/) })