feat: add production blue-green frontend deployment
This commit is contained in:
@@ -9,12 +9,16 @@ services:
|
|||||||
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||||
environment:
|
environment:
|
||||||
CLIENT_WEB_URL: "https://app.buggoglobal.com/"
|
CLIENT_WEB_URL: "https://app.buggoglobal.com/"
|
||||||
API_UPSTREAM: "http://gateway:9000"
|
API_UPSTREAM: "http://gateway-active:9000"
|
||||||
OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300"
|
OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
|
||||||
networks:
|
networks:
|
||||||
- infra-network
|
- infra-network
|
||||||
|
- routing-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
infra-network:
|
infra-network:
|
||||||
external: true
|
external: true
|
||||||
name: firstleg-prod-infra-network
|
name: firstleg-prod-infra-network
|
||||||
|
routing-network:
|
||||||
|
external: true
|
||||||
|
name: firstleg-production-routing
|
||||||
|
|||||||
@@ -59,35 +59,92 @@ compose() {
|
|||||||
docker compose --project-name "$project_name" -f "$compose_file" "$@"
|
docker compose --project-name "$project_name" -f "$compose_file" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
container_id="$(compose ps --all --quiet "$service" | head -n 1)"
|
case "$service" in
|
||||||
previous_image=""
|
admin-web)
|
||||||
if [ -n "$container_id" ]; then
|
base_port=9001
|
||||||
previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")"
|
nginx_config=/www/server/panel/vhost/nginx/admin.buggoglobal.com.conf
|
||||||
fi
|
legacy_container=firstleg-prod-admin-web
|
||||||
|
;;
|
||||||
if compose up -d --no-deps --pull never "$service" && \
|
client-web)
|
||||||
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then
|
base_port=9002
|
||||||
echo "生产前端部署成功: $image_repository:$image_tag"
|
nginx_config=/www/server/panel/vhost/nginx/app.buggoglobal.com.conf
|
||||||
compose ps "$service"
|
legacy_container=firstleg-prod-client-web
|
||||||
exit 0
|
;;
|
||||||
fi
|
*) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
|
||||||
|
|
||||||
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 ;;
|
|
||||||
esac
|
esac
|
||||||
previous_tag="${previous_image#"$image_repository:"}"
|
|
||||||
if [ "$previous_tag" = "$image_tag" ]; then
|
state_root=/data/firstleg/deploy/blue-green-state
|
||||||
echo "旧镜像与新镜像一致,无法回滚" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
docker pull "$previous_image"
|
|
||||||
export IMAGE_TAG="$previous_tag"
|
healthy=false
|
||||||
compose up -d --no-deps --pull never "$service"
|
elapsed=0
|
||||||
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true
|
while [ "$elapsed" -lt 120 ]; do
|
||||||
echo "已回滚至: $previous_image" >&2
|
if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||||
exit 1
|
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
|
REMOTE_DEPLOY
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
test('production frontend starts and validates the candidate before switching nginx', () => {
|
||||||
|
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"')
|
||||||
|
const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"')
|
||||||
|
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('production frontend supports both slots and restores nginx on failure', () => {
|
||||||
|
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
|
||||||
|
assert.match(script, /cp -f "\$nginx_config" "\$backup"/)
|
||||||
|
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
|
||||||
|
assert.match(script, /docker rm -f "\$candidate"/)
|
||||||
|
assert.match(script, /gateway-active:firstleg-production-gateway/)
|
||||||
|
})
|
||||||
@@ -60,7 +60,9 @@ describe('admin nginx deployment config', () => {
|
|||||||
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
|
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
|
||||||
expect(jenkinsfile).toContain('git rev-parse origin/main')
|
expect(jenkinsfile).toContain('git rev-parse origin/main')
|
||||||
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
|
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
|
||||||
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway:9000"')
|
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"')
|
||||||
|
expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"')
|
||||||
|
expect(productionCompose).toContain('name: firstleg-production-routing')
|
||||||
expect(productionCompose).toContain('name: firstleg-prod-infra-network')
|
expect(productionCompose).toContain('name: firstleg-prod-infra-network')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user