diff --git a/Dockerfile b/Dockerfile index 092f5de..e34a177 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,4 +28,4 @@ ENV GIT_CONFIG_VALUE_0=/repo EXPOSE 8000 ENTRYPOINT ["/app/entrypoint.sh"] -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"] +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "--preload", "wsgi:app"] diff --git a/README.md b/README.md index 9dcbfd0..6c42852 100644 --- a/README.md +++ b/README.md @@ -545,6 +545,28 @@ docker compose restart web docker compose down && docker compose up -d ``` +**502 Bad Gateway (Nginx)** + +Nginx не может достучаться до контейнера `web`. Проверьте: + +```bash +cd ~/fotohost +docker compose ps +docker compose logs --tail=100 web +curl -I http://127.0.0.1:8080/health +``` + +Частые причины после обновления: + +1. Контейнер `photohost-web` не запущен или перезапускается — смотрите логи `docker compose logs web` +2. В Nginx указан неверный порт — должен совпадать с `APP_PORT` из `.env` (по умолчанию `8080`): + +```nginx +proxy_pass http://127.0.0.1:8080; +``` + +3. База данных ещё не готова — подождите 30–60 секунд и выполните `docker compose restart web` + --- ## Технологии diff --git a/app/__init__.py b/app/__init__.py index 123724d..98f34b4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -21,7 +21,7 @@ def load_user(user_id): return db.session.get(User, int(user_id)) -def create_app(): +def create_app(setup_database=True): app = Flask(__name__) app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me") @@ -63,7 +63,10 @@ def create_app(): def inject_banners(): from app.banner_service import get_banners_by_position - return {"site_banners": get_banners_by_position()} + try: + return {"site_banners": get_banners_by_position()} + except Exception: + return {"site_banners": {}} with app.app_context(): from app.models import ( # noqa: F401 @@ -79,23 +82,11 @@ def create_app(): ) db.create_all() - from app.bootstrap import ( - create_first_admin, - ensure_default_group, - ensure_group_limit_columns, - ensure_photo_storage_column, - ensure_schema, - ensure_site_settings, - ) - from app.folders import ensure_folder_schema - ensure_schema() - ensure_default_group(app) - ensure_group_limit_columns() - ensure_folder_schema() - ensure_site_settings(app) - ensure_photo_storage_column() - create_first_admin(app) + if setup_database and os.getenv("SKIP_DB_INIT") != "1": + from app.bootstrap import run_database_setup + + run_database_setup(app) return app diff --git a/app/bootstrap.py b/app/bootstrap.py index 5bf17c1..7f53225 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -12,20 +12,22 @@ def ensure_schema(): tables = inspector.get_table_names() if "photos" in tables: - columns = {col["name"] for col in inspector.get_columns("photos")} - if "user_id" not in columns: - db.session.execute( - text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)") + db.session.execute( + text( + "ALTER TABLE photos ADD COLUMN IF NOT EXISTS " + "user_id INTEGER REFERENCES users(id)" ) - db.session.commit() + ) + db.session.commit() if "users" in tables and "user_groups" in tables: - columns = {col["name"] for col in inspector.get_columns("users")} - if "group_id" not in columns: - db.session.execute( - text("ALTER TABLE users ADD COLUMN group_id INTEGER REFERENCES user_groups(id)") + db.session.execute( + text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS " + "group_id INTEGER REFERENCES user_groups(id)" ) - db.session.commit() + ) + db.session.commit() def ensure_default_group(app): @@ -65,17 +67,19 @@ def ensure_group_limit_columns(): if "user_groups" not in inspector.get_table_names(): return - columns = {col["name"] for col in inspector.get_columns("user_groups")} - if "max_folders" not in columns: - db.session.execute( - text("ALTER TABLE user_groups ADD COLUMN max_folders INTEGER NOT NULL DEFAULT 10") + db.session.execute( + text( + "ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS " + "max_folders INTEGER NOT NULL DEFAULT 10" ) - db.session.commit() - if "max_photos" not in columns: - db.session.execute( - text("ALTER TABLE user_groups ADD COLUMN max_photos INTEGER NOT NULL DEFAULT 500") + ) + db.session.execute( + text( + "ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS " + "max_photos INTEGER NOT NULL DEFAULT 500" ) - db.session.commit() + ) + db.session.commit() def ensure_site_settings(app): @@ -91,10 +95,25 @@ def ensure_photo_storage_column(): inspector = inspect(db.engine) if "photos" not in inspector.get_table_names(): return - columns = {col["name"] for col in inspector.get_columns("photos")} - if "storage_backend" not in columns: - db.session.execute(text("ALTER TABLE photos ADD COLUMN storage_backend VARCHAR(20) DEFAULT 'local'")) - db.session.commit() + db.session.execute( + text( + "ALTER TABLE photos ADD COLUMN IF NOT EXISTS " + "storage_backend VARCHAR(20) DEFAULT 'local'" + ) + ) + db.session.commit() + + +def run_database_setup(app): + ensure_schema() + ensure_default_group(app) + ensure_group_limit_columns() + from app.folders import ensure_folder_schema + + ensure_folder_schema() + ensure_site_settings(app) + ensure_photo_storage_column() + create_first_admin(app) def slugify(name): diff --git a/app/folders.py b/app/folders.py index cbe5de3..0da82af 100644 --- a/app/folders.py +++ b/app/folders.py @@ -319,9 +319,10 @@ def ensure_folder_schema(): tables = inspector.get_table_names() if "photos" in tables: - columns = {col["name"] for col in inspector.get_columns("photos")} - if "folder_id" not in columns: - db.session.execute( - text("ALTER TABLE photos ADD COLUMN folder_id INTEGER REFERENCES folders(id)") + db.session.execute( + text( + "ALTER TABLE photos ADD COLUMN IF NOT EXISTS " + "folder_id INTEGER REFERENCES folders(id)" ) - db.session.commit() + ) + db.session.commit() diff --git a/app/routes.py b/app/routes.py index d36f260..0df843c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -25,6 +25,15 @@ from app.upload_service import process_uploads bp = Blueprint("main", __name__) +@bp.route("/health") +def health(): + try: + db.session.execute(db.text("SELECT 1")) + return {"status": "ok"}, 200 + except Exception as exc: + return {"status": "error", "detail": str(exc)}, 503 + + @bp.route("/") def index(): photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all() diff --git a/docker-compose.yml b/docker-compose.yml index 4700a32..db10083 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,12 @@ services: depends_on: db: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"] + interval: 15s + timeout: 10s + retries: 8 + start_period: 40s volumes: postgres_data: diff --git a/entrypoint.sh b/entrypoint.sh index c537269..a779aac 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,14 +1,18 @@ #!/bin/sh set -e -# Mounted /repo belongs to host user; appuser needs write access for git deploy. +# Git deploy needs write access to mounted /repo; never fail container start on chown errors. if [ "$ALLOW_GIT_DEPLOY" = "true" ] || [ "$ALLOW_GIT_DEPLOY" = "1" ] || [ "$ALLOW_GIT_DEPLOY" = "yes" ]; then if [ -d /repo/.git ]; then - chown -R appuser:appuser /repo - chmod -R u+rwX /repo/.git + chown -R appuser:appuser /repo 2>/dev/null || true + chmod -R u+rwX /repo/.git 2>/dev/null || true elif [ -d /repo ]; then - chown -R appuser:appuser /repo + chown -R appuser:appuser /repo 2>/dev/null || true fi fi +# Run DB migrations once before gunicorn workers start. +gosu appuser python /app/init_db.py + +export SKIP_DB_INIT=1 exec gosu appuser "$@" diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..db888b7 --- /dev/null +++ b/init_db.py @@ -0,0 +1,3 @@ +from app import create_app + +create_app(setup_database=True) diff --git a/wsgi.py b/wsgi.py index 0a23b5a..ea80813 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,3 +1,3 @@ from app import create_app -app = create_app() +app = create_app(setup_database=False)