From d321fd8f80a68c2eeb13d69427b2e517c5d78f48 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 19 May 2026 12:15:56 +0200 Subject: [PATCH] feat: try new ci/cd blue and green --- .gitea/workflows/deploy.yml | 126 +++++++++++-- docker-compose.prod.yml | 236 ++++++++++--------------- docker/nginx/bemeal-upstream.blue.inc | 4 + docker/nginx/bemeal-upstream.green.inc | 4 + docker/nginx/bemeal.conf | 51 ++++++ 5 files changed, 259 insertions(+), 162 deletions(-) create mode 100644 docker/nginx/bemeal-upstream.blue.inc create mode 100644 docker/nginx/bemeal-upstream.green.inc create mode 100644 docker/nginx/bemeal.conf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6abfbef..4885f21 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -53,9 +53,10 @@ jobs: - name: Build Docker image run: | - IMAGE=$REGISTRY/$IMAGE_NAME:latest - docker build -t $IMAGE . - docker push $IMAGE + IMAGE=$REGISTRY/$IMAGE_NAME + docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:latest" . + docker push "$IMAGE:${{ github.sha }}" + docker push "$IMAGE:latest" # ========================= @@ -69,28 +70,119 @@ jobs: steps: - name: Deploy via SSH (Blue/Green) uses: appleboy/ssh-action@v1.0.3 + env: + IMAGE_TAG: ${{ github.sha }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_PASSWORD: ${{ secrets.TOKEN_GITEA }} with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} + envs: REGISTRY,IMAGE_NAME,IMAGE_TAG,DEPLOY_PATH,REGISTRY_USERNAME,REGISTRY_PASSWORD script: | - set -e + set -eu - echo "📦 Pull latest image" - docker pull gitea.leonmorival.com/daily_meal/bemeal-api:latest + APP_DIR="${DEPLOY_PATH:-/opt/bemeal}" + COMPOSE_FILE="docker-compose.prod.yml" + COMPOSE_ENV_FILE=".env.prod" + UPSTREAM_FILE="/etc/nginx/conf.d/bemeal-upstream.inc" + DRAIN_SECONDS="${DRAIN_SECONDS:-20}" - echo "🟢 Start GREEN container" - docker compose up -d bemeal-api-green + if [ "$(id -u)" -eq 0 ]; then + SUDO="" + else + SUDO="sudo" + fi - echo "⏳ Wait for healthcheck" - sleep 10 + cd "$APP_DIR" - curl -f http://localhost/api/health || exit 1 + if [ -n "${REGISTRY_PASSWORD:-}" ]; then + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin + fi - echo "🔁 Switch traffic (MANUAL STEP OR NPM API)" - sed -i 's/blue/green/g' /etc/nginx/conf.d/bemeal.conf - nginx -s reload + if [ -f "$COMPOSE_ENV_FILE" ]; then + COMPOSE="docker compose --env-file $COMPOSE_ENV_FILE -f $COMPOSE_FILE" + else + COMPOSE="docker compose -f $COMPOSE_FILE" + fi - echo "🧹 Stop BLUE container" - docker stop bemeal-api-blue || true - docker rm bemeal-api-blue || true + IMAGE="$REGISTRY/$IMAGE_NAME:$IMAGE_TAG" + export IMAGE_TAG + + echo "Pull image $IMAGE" + docker pull "$IMAGE" + + echo "Ensure shared services are running" + $COMPOSE up -d pgsql redis meilisearch + + ACTIVE="" + if grep -q "127.0.0.1:8081" "$UPSTREAM_FILE" 2>/dev/null; then + ACTIVE="blue" + elif grep -q "127.0.0.1:8082" "$UPSTREAM_FILE" 2>/dev/null; then + ACTIVE="green" + fi + + if [ "$ACTIVE" = "blue" ]; then + TARGET="green" + TARGET_PORT="8082" + else + TARGET="blue" + TARGET_PORT="8081" + fi + + TARGET_SERVICE="bemeal-api-$TARGET" + + echo "Start $TARGET_SERVICE" + $COMPOSE up -d --force-recreate --no-deps "$TARGET_SERVICE" + + echo "Wait for $TARGET_SERVICE healthcheck" + HEALTH_STATUS="" + for attempt in $(seq 1 60); do + HEALTH_STATUS="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$TARGET_SERVICE" 2>/dev/null || true)" + + if [ "$HEALTH_STATUS" = "healthy" ]; then + break + fi + + sleep 2 + done + + if [ "$HEALTH_STATUS" != "healthy" ]; then + docker logs --tail=200 "$TARGET_SERVICE" || true + exit 1 + fi + + curl -fsS "http://127.0.0.1:$TARGET_PORT/api/health" >/dev/null + + if [ -f "$UPSTREAM_FILE" ]; then + $SUDO cp "$UPSTREAM_FILE" "$UPSTREAM_FILE.bak" + fi + + printf '%s\n' \ + 'upstream bemeal_api {' \ + " server 127.0.0.1:$TARGET_PORT max_fails=3 fail_timeout=10s;" \ + ' keepalive 32;' \ + '}' | $SUDO tee "$UPSTREAM_FILE" >/dev/null + + if ! $SUDO nginx -t; then + if [ -f "$UPSTREAM_FILE.bak" ]; then + $SUDO mv "$UPSTREAM_FILE.bak" "$UPSTREAM_FILE" + fi + + exit 1 + fi + + echo "Switch Nginx traffic to $TARGET" + $SUDO nginx -s reload + $SUDO rm -f "$UPSTREAM_FILE.bak" + + echo "Restart workers on the new image" + $COMPOSE up -d --force-recreate --no-deps horizon scheduler + + if [ -n "$ACTIVE" ] && [ "$ACTIVE" != "$TARGET" ]; then + echo "Drain old $ACTIVE service for $DRAIN_SECONDS seconds" + sleep "$DRAIN_SECONDS" + $COMPOSE stop "bemeal-api-$ACTIVE" || true + $COMPOSE rm -f "bemeal-api-$ACTIVE" || true + fi diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index dca22f9..de9f81f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,101 +1,85 @@ +name: bemeal + +x-app-image: &app-image gitea.leonmorival.com/daily_meal/bemeal-api:${IMAGE_TAG:-latest} + +x-app-environment: &app-environment + APP_LOCALE: "fr" + APP_ENV: "production" + APP_KEY: "${APP_KEY:?APP_KEY is required}" + APP_URL: "${APP_URL:?APP_URL is required}" + SERVER_NAME: ":80" + APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" + APP_DEBUG: "${APP_DEBUG:-false}" + + FILESYSTEM_DISK: "public" + + DB_CONNECTION: "pgsql" + DB_HOST: "pgsql" + DB_PORT: "5432" + DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" + DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" + DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" + + GEMINI_API_KEY: "${GEMINI_API_KEY:?GEMINI_API_KEY is required}" + + AI_MEAL_USER_NAME: "${AI_MEAL_USER_NAME:-Lolo AI}" + AI_MEAL_USER_EMAIL: "${AI_MEAL_USER_EMAIL:-loloai@maily.com}" + AI_MEAL_IMAGE_PATH: "${AI_MEAL_IMAGE_PATH:-meal-posts/ai-generated}" + AI_MEAL_IMAGE_QUALITY: "${AI_MEAL_IMAGE_QUALITY:-medium}" + AI_MEAL_TEXT_TIMEOUT: "${AI_MEAL_TEXT_TIMEOUT:-90}" + AI_MEAL_IMAGE_TIMEOUT: "${AI_MEAL_IMAGE_TIMEOUT:-120}" + + REDIS_HOST: "redis" + REDIS_CLIENT: "predis" + QUEUE_CONNECTION: "redis" + + SCOUT_DRIVER: "meilisearch" + MEILISEARCH_HOST: "http://meilisearch:7700" + MEILISEARCH_KEY: "${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}" + +x-app-volumes: &app-volumes + - storage-data:/var/www/html/storage/app + - storage-public-data:/var/www/html/storage/app/public + +x-app-depends-on: &app-depends-on + pgsql: + condition: service_healthy + redis: + condition: service_started + meilisearch: + condition: service_started + +x-app-healthcheck: &app-healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null 2>&1"] + start_period: 30s + interval: 5s + timeout: 5s + retries: 12 + +x-app-service: &app-service + image: *app-image + restart: unless-stopped + depends_on: *app-depends-on + environment: *app-environment + volumes: *app-volumes + networks: + - internal + stop_grace_period: 30s + services: -bemeal-api-blue: - image: gitea.leonmorival.com/daily_meal/bemeal-api:latest - container_name: bemeal-api-blue - restart: unless-stopped + bemeal-api-blue: + <<: *app-service + container_name: bemeal-api-blue + ports: + - "127.0.0.1:8081:80" + healthcheck: *app-healthcheck - depends_on: - pgsql: - condition: service_healthy - redis: - condition: service_started - meilisearch: - condition: service_started - - environment: - APP_LOCALE: "fr" - APP_ENV: "production" - APP_KEY: "${APP_KEY}" - APP_URL: "${APP_URL}" - SERVER_NAME: ":80" - APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" - APP_DEBUG: "${APP_DEBUG}" - - FILESYSTEM_DISK: "public" - - DB_CONNECTION: "pgsql" - DB_HOST: "pgsql" - DB_PORT: 5432 - DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" - DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" - DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" - - GEMINI_API_KEY: "${GEMINI_API_KEY:?GEMINI_API_KEY is required}" - - REDIS_HOST: "redis" - REDIS_CLIENT: "predis" - QUEUE_CONNECTION: "redis" - - SCOUT_DRIVER: "meilisearch" - MEILISEARCH_HOST: "http://meilisearch:7700" - MEILISEARCH_KEY: "${MEILISEARCH_KEY}" - - volumes: - - storage-data:/var/www/html/storage/app - - storage-public-data:/var/www/html/storage/app/public - - networks: - - nginx - - internal - -bemeal-api-green: - image: gitea.leonmorival.com/daily_meal/bemeal-api:latest - container_name: bemeal-api-green - restart: unless-stopped - - depends_on: - pgsql: - condition: service_healthy - redis: - condition: service_started - meilisearch: - condition: service_started - - environment: - APP_LOCALE: "fr" - APP_ENV: "production" - APP_KEY: "${APP_KEY}" - APP_URL: "${APP_URL}" - SERVER_NAME: ":80" - APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" - APP_DEBUG: "${APP_DEBUG}" - - FILESYSTEM_DISK: "public" - - DB_CONNECTION: "pgsql" - DB_HOST: "pgsql" - DB_PORT: 5432 - DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" - DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" - DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" - - GEMINI_API_KEY: "${GEMINI_API_KEY:?GEMINI_API_KEY is required}" - - REDIS_HOST: "redis" - REDIS_CLIENT: "predis" - QUEUE_CONNECTION: "redis" - - SCOUT_DRIVER: "meilisearch" - MEILISEARCH_HOST: "http://meilisearch:7700" - MEILISEARCH_KEY: "${MEILISEARCH_KEY}" - - volumes: - - storage-data:/var/www/html/storage/app - - storage-public-data:/var/www/html/storage/app/public - - networks: - - nginx - - internal + bemeal-api-green: + <<: *app-service + container_name: bemeal-api-green + ports: + - "127.0.0.1:8082:80" + healthcheck: *app-healthcheck pgsql: image: postgres:15-alpine @@ -108,7 +92,7 @@ bemeal-api-green: volumes: - pgsql-data:/var/lib/postgresql/data healthcheck: - test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432" ] + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432"] start_period: 30s interval: 5s timeout: 5s @@ -126,7 +110,7 @@ bemeal-api-green: - internal horizon: - image: gitea.leonmorival.com/daily_meal/bemeal-api:latest + image: *app-image container_name: bemeal-horizon restart: unless-stopped depends_on: @@ -134,26 +118,15 @@ bemeal-api-green: condition: service_healthy redis: condition: service_started - environment: - APP_ENV: "production" - APP_KEY: "${APP_KEY}" - DB_CONNECTION: "pgsql" - DB_HOST: "pgsql" - DB_PORT: 5432 - DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" - DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" - DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" - REDIS_HOST: "redis" - REDIS_CLIENT: "predis" - QUEUE_CONNECTION: "redis" - volumes: - - storage-data:/var/www/html/storage/app + environment: *app-environment + volumes: *app-volumes command: "php artisan horizon" + stop_grace_period: 60s networks: - internal scheduler: - image: gitea.leonmorival.com/daily_meal/bemeal-api:latest + image: *app-image container_name: bemeal-scheduler restart: unless-stopped depends_on: @@ -161,33 +134,10 @@ bemeal-api-green: condition: service_healthy redis: condition: service_started - environment: - APP_LOCALE: "fr" - APP_ENV: "production" - APP_KEY: "${APP_KEY}" - APP_URL: "${APP_URL}" - APP_DEBUG: "${APP_DEBUG}" - FILESYSTEM_DISK: "public" - DB_CONNECTION: "pgsql" - DB_HOST: "pgsql" - DB_PORT: 5432 - DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" - DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" - DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" - GEMINI_API_KEY: "${GEMINI_API_KEY:?GEMINI_API_KEY is required}" - AI_MEAL_USER_NAME: "${AI_MEAL_USER_NAME:-Lolo AI}" - AI_MEAL_USER_EMAIL: "${AI_MEAL_USER_EMAIL:-loloai@maily.com}" - AI_MEAL_IMAGE_PATH: "${AI_MEAL_IMAGE_PATH:-meal-posts/ai-generated}" - AI_MEAL_IMAGE_QUALITY: "${AI_MEAL_IMAGE_QUALITY:-medium}" - AI_MEAL_TEXT_TIMEOUT: "${AI_MEAL_TEXT_TIMEOUT:-90}" - AI_MEAL_IMAGE_TIMEOUT: "${AI_MEAL_IMAGE_TIMEOUT:-120}" - REDIS_HOST: "redis" - REDIS_CLIENT: "predis" - QUEUE_CONNECTION: "redis" - volumes: - - storage-data:/var/www/html/storage/app - - storage-public-data:/var/www/html/storage/app/public + environment: *app-environment + volumes: *app-volumes command: "php artisan schedule:work" + stop_grace_period: 30s networks: - internal @@ -196,23 +146,21 @@ bemeal-api-green: container_name: bemeal-adminer restart: unless-stopped environment: - # Optionnel : définit PostgreSQL par défaut à la connexion ADMINER_DEFAULT_SERVER: pgsql networks: - internal -# - nginx + meilisearch: image: getmeili/meilisearch:latest container_name: bemeal-meilisearch restart: unless-stopped environment: MEILI_NO_ANALYTICS: "true" - MEILI_MASTER_KEY: "${MEILISEARCH_KEY}" + MEILI_MASTER_KEY: "${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}" volumes: - meilisearch-data:/meili_data networks: - internal -# - nginx volumes: pgsql-data: @@ -228,7 +176,5 @@ volumes: name: bemeal_meilisearch_data networks: - nginx: - external: true internal: driver: bridge diff --git a/docker/nginx/bemeal-upstream.blue.inc b/docker/nginx/bemeal-upstream.blue.inc new file mode 100644 index 0000000..f1f0119 --- /dev/null +++ b/docker/nginx/bemeal-upstream.blue.inc @@ -0,0 +1,4 @@ +upstream bemeal_api { + server 127.0.0.1:8081 max_fails=3 fail_timeout=10s; + keepalive 32; +} diff --git a/docker/nginx/bemeal-upstream.green.inc b/docker/nginx/bemeal-upstream.green.inc new file mode 100644 index 0000000..f46e40b --- /dev/null +++ b/docker/nginx/bemeal-upstream.green.inc @@ -0,0 +1,4 @@ +upstream bemeal_api { + server 127.0.0.1:8082 max_fails=3 fail_timeout=10s; + keepalive 32; +} diff --git a/docker/nginx/bemeal.conf b/docker/nginx/bemeal.conf new file mode 100644 index 0000000..ddd8e45 --- /dev/null +++ b/docker/nginx/bemeal.conf @@ -0,0 +1,51 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +include /etc/nginx/conf.d/bemeal-upstream.inc; + +server { + listen 80; + listen [::]:80; + server_name bemeal.leonmorival.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name bemeal.leonmorival.com; + + ssl_certificate /etc/letsencrypt/live/bemeal.leonmorival.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/bemeal.leonmorival.com/privkey.pem; + + client_max_body_size 50m; + + location / { + proxy_pass http://bemeal_api; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_read_timeout 90s; + proxy_send_timeout 90s; + proxy_redirect off; + } +}