diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f64e0c7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.gitignore +.env +.env.* +!.env.example +.idea +.vscode +node_modules +vendor +public/build +public/hot +public/storage +bootstrap/cache/*.php +storage/*.key +storage/backups +storage/logs/* +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/views/* +tests +compose*.yaml +README.md diff --git a/README.md b/README.md index acb326c..23c8eee 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,23 @@ chmod +x scripts/deploy-ubuntu.sh ./scripts/deploy-ubuntu.sh --skip-cache ``` +## Production без Sail + +Для production используется отдельный стек `compose.prod.yaml`: + +- `web` — непривилегированный nginx со статикой; +- `app` — PHP-FPM с кодом и Composer-зависимостями внутри образа; +- `mysql` — постоянная база на томе `sail-mysql`. + +Node.js, Playwright, Xdebug, dev-зависимости Composer и bind-mount исходного кода в production-образы не входят. + +```bash +chmod +x scripts/deploy-production.sh +./scripts/deploy-production.sh +``` + +Скрипт сначала собирает и проверяет новый PHP-FPM, выполняет миграции и прогрев кэша, а затем переключает порт с legacy Sail-контейнера на production nginx. Если HTTP-проверка не проходит, legacy-контейнер запускается обратно. + ## Как это связано с AzerothCore По умолчанию Sail-контейнер подключается к auth-базе core-сервера так: diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..57ca99e --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,105 @@ +services: + app: + build: + context: . + dockerfile: docker/production/Dockerfile + target: app + args: + APP_UID: '${WWWUSER:-1000}' + APP_GID: '${WWWGROUP:-1000}' + image: moonwell-web-app:production + restart: unless-stopped + init: true + env_file: + - .env + environment: + APP_ENV: production + APP_DEBUG: 'false' + DB_HOST: mysql + DB_PORT: 3306 + volumes: + - './storage:/var/www/html/storage' + tmpfs: + - '/var/www/html/bootstrap/cache:uid=${WWWUSER:-1000},gid=${WWWGROUP:-1000},mode=0770' + networks: + - sail + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - 'SCRIPT_NAME=/healthz SCRIPT_FILENAME=/healthz REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q pong' + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + web: + build: + context: . + dockerfile: docker/production/Dockerfile + target: web + args: + APP_UID: '${WWWUSER:-1000}' + APP_GID: '${WWWGROUP:-1000}' + image: moonwell-web-nginx:production + restart: unless-stopped + init: true + ports: + - '127.0.0.1:${APP_PORT:-8080}:8080' + volumes: + - './storage/app/public:/var/www/html/public/storage:ro' + networks: + - sail + depends_on: + app: + condition: service_healthy + healthcheck: + test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://127.0.0.1:8080/healthz'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + mysql: + image: mysql:8.4 + restart: unless-stopped + ports: + - '${FORWARD_DB_PORT:-3307}:3306' + environment: + MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' + MYSQL_ROOT_HOST: '%' + MYSQL_DATABASE: '${DB_DATABASE}' + MYSQL_USER: '${DB_USERNAME}' + MYSQL_PASSWORD: '${DB_PASSWORD}' + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS:-}' + volumes: + - 'sail-mysql:/var/lib/mysql' + - './docker/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' + networks: + - sail + healthcheck: + test: + - CMD + - mysqladmin + - ping + - '-p${DB_PASSWORD}' + retries: 3 + timeout: 5s + +networks: + sail: + driver: bridge + +volumes: + sail-mysql: + driver: local diff --git a/compose.yaml b/compose.yaml index afcb138..d029d31 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,12 @@ services: - sail depends_on: - mysql + healthcheck: + test: ['CMD', 'curl', '--fail', '--silent', 'http://127.0.0.1/healthz'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s mysql: image: 'mysql:8.4' restart: unless-stopped diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile new file mode 100644 index 0000000..12f02be --- /dev/null +++ b/docker/production/Dockerfile @@ -0,0 +1,116 @@ +# syntax=docker/dockerfile:1 + +FROM php:8.3-fpm-bookworm AS php-base + +ARG APP_UID=1000 +ARG APP_GID=1000 + +ENV APP_ENV=production \ + APP_DEBUG=false \ + COMPOSER_ALLOW_SUPERUSER=1 + +WORKDIR /var/www/html + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libfcgi-bin \ + libfreetype6-dev \ + libicu-dev \ + libjpeg62-turbo-dev \ + libpng-dev \ + libzip-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" \ + bcmath \ + exif \ + gd \ + intl \ + pcntl \ + pdo_mysql \ + sockets \ + zip \ + && apt-mark manual \ + libfcgi-bin \ + libfreetype6 \ + libicu72 \ + libjpeg62-turbo \ + libpng16-16 \ + libzip4 \ + && apt-get purge -y --auto-remove \ + libfreetype6-dev \ + libicu-dev \ + libjpeg62-turbo-dev \ + libpng-dev \ + libzip-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid "$APP_GID" app \ + && useradd --uid "$APP_UID" --gid "$APP_GID" --create-home --shell /usr/sbin/nologin app + +COPY docker/production/php.ini /usr/local/etc/php/conf.d/99-production.ini +COPY docker/production/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-production.conf + +FROM php-base AS vendor + +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +COPY . . + +RUN composer install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --no-interaction \ + --no-progress \ + && composer clear-cache + +FROM node:24-bookworm-slim AS frontend + +WORKDIR /var/www/html + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY resources ./resources +COPY public ./public +COPY vite.config.* ./ + +ARG VITE_APP_NAME=Moonwell +ENV VITE_APP_NAME="$VITE_APP_NAME" + +RUN npm run build + +FROM php-base AS app + +COPY --from=vendor --chown=app:app /var/www/html /var/www/html +COPY --from=frontend --chown=app:app /var/www/html/public/build /var/www/html/public/build + +RUN mkdir -p \ + bootstrap/cache \ + storage/app/public \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + && chown -R app:app bootstrap/cache storage + +EXPOSE 9000 + +CMD ["php-fpm", "--nodaemonize"] + +FROM nginx:1.27-alpine AS web + +ARG APP_UID=1000 +ARG APP_GID=1000 + +RUN addgroup -S -g "$APP_GID" app \ + && adduser -S -D -H -u "$APP_UID" -G app app + +COPY docker/production/nginx.conf /etc/nginx/nginx.conf +COPY --from=vendor --chown=app:app /var/www/html/public /var/www/html/public +COPY --from=frontend --chown=app:app /var/www/html/public/build /var/www/html/public/build + +USER app + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/production/nginx.conf b/docker/production/nginx.conf new file mode 100644 index 0000000..f53e3da --- /dev/null +++ b/docker/production/nginx.conf @@ -0,0 +1,60 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /dev/stdout; + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + client_max_body_size 100m; + + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + server { + listen 8080 default_server; + server_name _; + root /var/www/html/public; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /healthz { + allow 127.0.0.1; + deny all; + + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /healthz; + fastcgi_pass app:9000; + } + + location = /index.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param HTTP_PROXY ""; + fastcgi_pass app:9000; + fastcgi_read_timeout 60s; + } + + location ~ \.php$ { + return 404; + } + + location ~ /\. { + deny all; + } + } +} diff --git a/docker/production/php-fpm-pool.conf b/docker/production/php-fpm-pool.conf new file mode 100644 index 0000000..922c8ce --- /dev/null +++ b/docker/production/php-fpm-pool.conf @@ -0,0 +1,23 @@ +[www] +user = app +group = app + +listen = 0.0.0.0:9000 + +pm = dynamic +pm.max_children = 8 +pm.start_servers = 2 +pm.min_spare_servers = 1 +pm.max_spare_servers = 4 +pm.max_requests = 500 + +request_terminate_timeout = 60s +request_slowlog_timeout = 10s +slowlog = /proc/self/fd/2 + +ping.path = /healthz +ping.response = pong + +clear_env = no +catch_workers_output = yes +decorate_workers_output = no diff --git a/docker/production/php.ini b/docker/production/php.ini new file mode 100644 index 0000000..a579488 --- /dev/null +++ b/docker/production/php.ini @@ -0,0 +1,22 @@ +[PHP] +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On +memory_limit = 256M +max_execution_time = 60 +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS + +[Date] +date.timezone = UTC + +[opcache] +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.memory_consumption = 128 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 0 +opcache.save_comments = 1 diff --git a/docker/sail/8.3/Dockerfile b/docker/sail/8.3/Dockerfile index 5abb7d0..1b355c3 100644 --- a/docker/sail/8.3/Dockerfile +++ b/docker/sail/8.3/Dockerfile @@ -56,6 +56,11 @@ RUN apt-get update && apt-get upgrade -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +RUN apt-get update \ + && apt-get install -y nginx php8.3-fpm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 RUN userdel -r ubuntu @@ -65,7 +70,10 @@ RUN git config --global --add safe.directory /var/www/html COPY start-container /usr/local/bin/start-container COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY nginx.conf /etc/nginx/nginx.conf +COPY php-fpm-pool.conf /etc/php/8.3/fpm/pool.d/www.conf COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini +COPY php.ini /etc/php/8.3/fpm/conf.d/99-sail.ini RUN chmod +x /usr/local/bin/start-container EXPOSE 80/tcp diff --git a/docker/sail/8.3/nginx.conf b/docker/sail/8.3/nginx.conf new file mode 100644 index 0000000..e6879af --- /dev/null +++ b/docker/sail/8.3/nginx.conf @@ -0,0 +1,52 @@ +user sail; +worker_processes auto; +pid /run/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /dev/stdout; + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + client_max_body_size 100m; + + server { + listen 80 default_server; + server_name _; + root /var/www/html/public; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /healthz { + allow 127.0.0.1; + deny all; + + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /dev/null; + fastcgi_pass 127.0.0.1:9000; + } + + location ~ \.php$ { + try_files $uri =404; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTP_PROXY ""; + fastcgi_pass 127.0.0.1:9000; + fastcgi_read_timeout 60s; + } + + location ~ /\. { + deny all; + } + } +} diff --git a/docker/sail/8.3/php-fpm-pool.conf b/docker/sail/8.3/php-fpm-pool.conf new file mode 100644 index 0000000..3db3537 --- /dev/null +++ b/docker/sail/8.3/php-fpm-pool.conf @@ -0,0 +1,25 @@ +[www] +user = sail +group = sail + +listen = 127.0.0.1:9000 +listen.owner = sail +listen.group = sail + +pm = dynamic +pm.max_children = 8 +pm.start_servers = 2 +pm.min_spare_servers = 1 +pm.max_spare_servers = 4 +pm.max_requests = 500 + +request_terminate_timeout = 60s +request_slowlog_timeout = 10s +slowlog = /proc/self/fd/2 + +ping.path = /healthz +ping.response = pong + +clear_env = no +catch_workers_output = yes +decorate_workers_output = no diff --git a/docker/sail/8.3/supervisord.conf b/docker/sail/8.3/supervisord.conf index 656da8a..c4dad31 100644 --- a/docker/sail/8.3/supervisord.conf +++ b/docker/sail/8.3/supervisord.conf @@ -4,10 +4,29 @@ user=root logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid -[program:php] -command=%(ENV_SUPERVISOR_PHP_COMMAND)s -user=%(ENV_SUPERVISOR_PHP_USER)s -environment=LARAVEL_SAIL="1" +[program:php-fpm] +command=/usr/sbin/php-fpm8.3 --nodaemonize --fpm-config /etc/php/8.3/fpm/php-fpm.conf +user=root +priority=10 +autostart=true +autorestart=true +startsecs=2 +stopasgroup=true +stopsignal=QUIT +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +user=root +priority=20 +autostart=true +autorestart=true +startsecs=2 +stopasgroup=true +stopsignal=QUIT stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100755 index 0000000..a5939fd --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +PROD_COMPOSE="${ROOT_DIR}/compose.prod.yaml" +DEV_COMPOSE="${ROOT_DIR}/compose.yaml" +ENV_FILE="${ROOT_DIR}/.env" +LEGACY_WAS_RUNNING=0 + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" +} + +fail() { + printf '[error] %s\n' "$*" >&2 + exit 1 +} + +env_value() { + local key="$1" + grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- | sed -e 's/^"//' -e 's/"$//' +} + +prod() { + docker compose -f "$PROD_COMPOSE" "$@" +} + +legacy() { + docker compose -f "$DEV_COMPOSE" "$@" +} + +wait_for_health() { + local service="$1" + local container_id + local status + + container_id="$(prod ps -q "$service")" + [[ -n "$container_id" ]] || fail "Контейнер ${service} не создан" + + for _ in $(seq 1 45); do + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" + if [[ "$status" == healthy ]]; then + return 0 + fi + if [[ "$status" == exited || "$status" == dead ]]; then + docker logs --tail 100 "$container_id" >&2 || true + fail "Контейнер ${service} завершился со статусом ${status}" + fi + sleep 2 + done + + docker logs --tail 100 "$container_id" >&2 || true + fail "Контейнер ${service} не перешёл в healthy" +} + +rollback_legacy() { + log "Production web не прошёл проверку, выполняю откат" + prod stop web app >/dev/null 2>&1 || true + if (( LEGACY_WAS_RUNNING )); then + legacy start laravel.test >/dev/null 2>&1 || true + fi +} + +main() { + command -v docker >/dev/null 2>&1 || fail "Не найден Docker" + docker compose version >/dev/null 2>&1 || fail "Не найден Docker Compose Plugin" + [[ -f "$ENV_FILE" ]] || fail "Не найден ${ENV_FILE}" + + cd "$ROOT_DIR" + + local app_uid app_gid app_port legacy_id + app_uid="$(env_value WWWUSER)" + app_gid="$(env_value WWWGROUP)" + app_port="$(env_value APP_PORT)" + app_uid="${app_uid:-1000}" + app_gid="${app_gid:-1000}" + app_port="${app_port:-8080}" + + mkdir -p storage/app/public storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs + if [[ "$(id -u)" -eq 0 ]]; then + chown -R "${app_uid}:${app_gid}" storage + fi + chmod -R ug+rwX storage + + log "Собираю production-образы" + prod build app web + + log "Запускаю новый PHP-FPM" + prod up -d --no-deps app + wait_for_health app + + log "Выполняю миграции и прогреваю кэш" + prod exec -T --user "${app_uid}:${app_gid}" app php artisan migrate --force + prod exec -T --user "${app_uid}:${app_gid}" app php artisan optimize + + legacy_id="$(legacy ps -q laravel.test 2>/dev/null || true)" + if [[ -n "$legacy_id" ]] && [[ "$(docker inspect -f '{{.State.Running}}' "$legacy_id")" == true ]]; then + LEGACY_WAS_RUNNING=1 + log "Останавливаю legacy Sail-контейнер" + legacy stop laravel.test + fi + + log "Переключаю nginx на production web-контейнер" + if ! prod up -d --no-deps web || ! wait_for_health web; then + rollback_legacy + exit 1 + fi + + if ! curl --fail --silent --max-time 15 "http://127.0.0.1:${app_port}/" >/dev/null; then + rollback_legacy + fail "HTTP-проверка production web завершилась ошибкой" + fi + + log "Production-деплой завершён: app и web healthy" + prod ps app web +} + +main "$@"