Files
eagle0/scripts/deploy-blue-green.sh
T
1d98df6cbc Remove obsolete ReloadGames RPC call from deploy script (#5232)
With lazy game loading (merged in #5223), games are loaded on-demand
when users reconnect after nginx switches traffic. No explicit reload
call is needed - the new server reads fresh state from storage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:58:22 -08:00

243 lines
7.8 KiB
Bash
Executable File

#!/bin/bash
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to storage
# 5. Switching nginx to route traffic to green
# 6. Users reconnect, triggering lazy game loading from fresh storage
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
# Example:
# ./deploy-blue-green.sh latest
# ./deploy-blue-green.sh sha-abc123
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
pull_with_retry() {
local image=$1
local max_attempts=${2:-3}
local attempt=1
# Use crane if available (handles OCI format correctly)
if [ -x "${APP_DIR}/crane" ]; then
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
rm -f /tmp/image.tar
log_info "Image pulled and loaded successfully"
return 0
fi
rm -f /tmp/image.tar
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
else
# Fallback to docker pull if crane not available
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
if docker pull "${image}"; then
log_info "Image pulled successfully"
return 0
fi
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
fi
log_error "Failed to pull image after ${max_attempts} attempts"
return 1
}
# Wait for a container to be healthy
wait_for_healthy() {
local container=$1
local max_attempts=${2:-60}
local attempt=1
log_info "Waiting for ${container} to become healthy..."
while [ $attempt -le $max_attempts ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
log_info "${container} is healthy"
return 0
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
echo ""
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
return 1
}
# Main deployment logic
main() {
local new_tag="${1:-latest}"
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
log_info "Starting blue-green deployment"
log_info "New image: ${new_image}"
cd "${APP_DIR}"
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ]; then
staging="green"
else
staging="blue"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
exit 1
fi
# Start staging instance with new image
log_info "Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
else
staging_port=40032
fi
log_info "Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
log_warn "JIT will be cold on first requests"
fi
# Stop the active instance (this flushes state to storage)
log_info "Stopping eagle-${active} (flushing state to storage)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Note: No need to explicitly reload games - Eagle uses lazy loading.
# Games are loaded on-demand when users reconnect after nginx switches traffic.
# This ensures games are always loaded from the freshest storage state.
# Switch nginx upstream
log_info "Switching nginx upstream to eagle-${staging}..."
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Clean up old instance
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
# Update the staging instance's restart policy and image var
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
if [ "$staging" = "blue" ]; then
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
# User should update their .env file
else
log_info "Green is now active. Consider switching to blue on next deployment."
fi
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
log_info ""
log_info "Note: Update your .env file with EAGLE_IMAGE=${new_image}"
log_info " if you want future 'docker compose up' to use this version."
}
# Check for required tools
check_requirements() {
if ! command -v docker &> /dev/null; then
log_error "docker is required but not installed"
exit 1
fi
if ! command -v sed &> /dev/null; then
log_error "sed is required but not installed"
exit 1
fi
if [ ! -f "${NGINX_CONF}" ]; then
log_error "nginx config not found at ${NGINX_CONF}"
exit 1
fi
if [ ! -f "${COMPOSE_FILE}" ]; then
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
}
# Run
check_requirements
main "$@"