From f2119ed8daa6056923b56b5bb2799a1877ed3482 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sun, 8 Feb 2026 09:05:47 +0900 Subject: [PATCH] =?UTF-8?q?README=EC=97=90=20conda/systemd/Apache=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Miniconda(ncue) 기반 설치/실행 및 systemd 예시 추가 - Apache ProxyPass 설정 및 503 트러블슈팅 절차 정리 - Flask DB pool lazy 생성으로 임포트 단계 장애(503) 완화 Co-authored-by: Cursor --- README.md | 160 +++++++++++++++++++++++++++++++++++++++++++++++---- flask_app.py | 73 +++++++++++++++-------- 2 files changed, 196 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0ebeded..56d26bf 100644 --- a/README.md +++ b/README.md @@ -16,33 +16,166 @@ python3 -m http.server 8000 그 후 브라우저에서 `http://localhost:8000`으로 접속합니다. -## 서버(Node) + PostgreSQL 사용자 저장 +## 백엔드(Flask) + PostgreSQL 사용자 저장 로그인 후 사용자 정보를 `ncue_user`에 저장하고(Upsert), 로그인/로그아웃 시간을 기록하며, `/api/config/auth`로 Auth0 설정을 공유하려면 백엔드 서버가 필요합니다. 현재 백엔드는 **Python Flask(기본 포트 8023)** 로 제공합니다. (정적 HTML/JS는 그대로 사용 가능) -1) 의존성 설치(Python) - -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -``` - -2) 테이블 생성 +### 1) DB 테이블 생성(서버에서 1회) ```bash psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f db/schema.sql ``` -3) 서버 실행 +### 2) 실행 방법 A: (로컬/간단) venv로 실행 ```bash -python flask_app.py +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +PORT=8023 python flask_app.py ``` +### 3) 실행 방법 B: (운영/권장) Miniconda 환경 `ncue` + gunicorn + systemd + +#### B-1) 최초 1회 설치 + +```bash +cd /path/to/home +conda activate ncue +python -m pip install -r requirements.txt +python -m pip install gunicorn +``` + +#### B-2) 단독 실행(테스트) + +```bash +conda activate ncue +cd /path/to/home +gunicorn -w 2 -b 127.0.0.1:8023 flask_app:app +``` + +확인: + +```bash +curl -s http://127.0.0.1:8023/healthz +curl -s http://127.0.0.1:8023/api/config/auth +``` + +#### B-3) systemd 서비스(예시) + +`/etc/systemd/system/ncue-flask.service`: + +```ini +[Unit] +Description=NCUE Flask API (gunicorn) +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/path/to/home + +# miniconda 경로는 설치 위치에 맞게 수정하세요. +ExecStart=/bin/bash -lc 'source /opt/miniconda3/etc/profile.d/conda.sh && conda activate ncue && gunicorn -w 2 -b 127.0.0.1:8023 flask_app:app' +Restart=always +RestartSec=3 + +# .env 대신 systemd Environment로 주입(권장) +Environment=DB_HOST=ncue.net +Environment=DB_PORT=5432 +Environment=DB_NAME=ncue +Environment=DB_USER=ncue +Environment=DB_PASSWORD=REPLACE_ME +Environment=TABLE=ncue_user +Environment=AUTH0_DOMAIN=ncue.net +Environment=AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM +Environment=AUTH0_GOOGLE_CONNECTION=google-oauth2 +Environment=ADMIN_EMAILS=dosangyoon@gmail.com,dsyoon@ncue.net +Environment=PORT=8023 + +[Install] +WantedBy=multi-user.target +``` + +적용/재시작: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ncue-flask +sudo systemctl restart ncue-flask +sudo systemctl status ncue-flask --no-pager +``` + +로그 확인: + +```bash +sudo journalctl -u ncue-flask -n 200 --no-pager +``` + +### 4) Apache 설정(정적은 Apache, `/api/*`만 8023 프록시) + +필요 모듈(Ubuntu/Debian): + +```bash +sudo a2enmod proxy proxy_http headers +sudo systemctl reload apache2 +``` + +가상호스트 예시: + +```apacheconf + + ServerName ncue.net + + DocumentRoot /path/to/home + + Require all granted + + + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto "https" + + ProxyPass /api/ http://127.0.0.1:8023/api/ + ProxyPassReverse /api/ http://127.0.0.1:8023/api/ + ProxyPass /healthz http://127.0.0.1:8023/healthz + ProxyPassReverse /healthz http://127.0.0.1:8023/healthz + +``` + +적용: + +```bash +sudo apachectl -t && sudo systemctl reload apache2 +``` + +### 5) 503(Service Unavailable) 트러블슈팅 체크리스트 + +브라우저 콘솔에서 503이 뜨면 대부분 **Apache가 127.0.0.1:8023 백엔드로 프록시했는데 백엔드가 응답을 못하는 상태**입니다. + +- 백엔드가 살아있는지: + +```bash +curl -i http://127.0.0.1:8023/healthz +curl -i http://127.0.0.1:8023/api/config/auth +``` + +- 서비스 로그: + +```bash +sudo journalctl -u ncue-flask -n 200 --no-pager +``` + +- Apache 프록시 로그: + - Ubuntu/Debian: `/var/log/apache2/error.log` + - RHEL/CentOS: `/var/log/httpd/error_log` + +> 참고: `flask_app.py`는 DB가 일시적으로 죽어도 앱 임포트 단계에서 바로 죽지 않도록(DB pool lazy 생성) 개선되어, +> “백엔드 프로세스가 안 떠서 Apache가 503”인 케이스를 줄였습니다. + 기본적으로 `.env`의 `ADMIN_EMAILS`에 포함된 이메일은 `can_manage=true`로 자동 승격됩니다. ### (선택) 역프록시/분리 배포 @@ -83,3 +216,6 @@ update ncue_user set can_manage = true where email = 'me@example.com'; - 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다. - 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다. + + +## 설치 방 \ No newline at end of file diff --git a/flask_app.py b/flask_app.py index 59ba1fb..e731975 100644 --- a/flask_app.py +++ b/flask_app.py @@ -24,13 +24,6 @@ def env(name: str, default: str = "") -> str: return str(os.getenv(name, default)) -def must(name: str) -> str: - v = env(name).strip() - if not v: - raise RuntimeError(f"Missing env: {name}") - return v - - _IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") @@ -51,11 +44,11 @@ def parse_email_csv(s: str) -> list[str]: PORT = int(env("PORT", "8023") or "8023") -DB_HOST = must("DB_HOST") +DB_HOST = env("DB_HOST", "").strip() DB_PORT = int(env("DB_PORT", "5432") or "5432") -DB_NAME = must("DB_NAME") -DB_USER = must("DB_USER") -DB_PASSWORD = must("DB_PASSWORD") +DB_NAME = env("DB_NAME", "").strip() +DB_USER = env("DB_USER", "").strip() +DB_PASSWORD = env("DB_PASSWORD", "").strip() TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user") CONFIG_TABLE = "ncue_app_config" @@ -71,31 +64,51 @@ AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").strip() # Optional CORS (for static on different origin) CORS_ORIGINS = env("CORS_ORIGINS", "*").strip() or "*" +_POOL: Optional[psycopg2.pool.SimpleConnectionPool] = None -POOL: psycopg2.pool.SimpleConnectionPool = psycopg2.pool.SimpleConnectionPool( - minconn=1, - maxconn=10, - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - sslmode="disable", -) + +def db_configured() -> bool: + return bool(DB_HOST and DB_NAME and DB_USER and DB_PASSWORD) + + +def get_pool() -> psycopg2.pool.SimpleConnectionPool: + """ + Lazy DB pool creation. + - prevents app import failure (which causes Apache 503) when DB is temporarily unavailable + - /api/config/auth can still work purely from .env without DB + """ + global _POOL + if _POOL is not None: + return _POOL + if not db_configured(): + raise RuntimeError("db_not_configured") + _POOL = psycopg2.pool.SimpleConnectionPool( + minconn=1, + maxconn=10, + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + sslmode="disable", + ) + return _POOL def db_exec(sql: str, params: Tuple[Any, ...] = ()) -> None: - conn = POOL.getconn() + pool = get_pool() + conn = pool.getconn() try: conn.autocommit = True with conn.cursor() as cur: cur.execute(sql, params) finally: - POOL.putconn(conn) + pool.putconn(conn) def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]: - conn = POOL.getconn() + pool = get_pool() + conn = pool.getconn() try: conn.autocommit = True with conn.cursor() as cur: @@ -103,7 +116,7 @@ def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]: row = cur.fetchone() return row finally: - POOL.putconn(conn) + pool.putconn(conn) def ensure_user_table() -> None: @@ -248,6 +261,8 @@ def static_files(filename: str) -> Response: @app.get("/healthz") def healthz() -> Response: try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 row = db_one("select 1 as ok") if not row: return jsonify({"ok": False}), 500 @@ -259,6 +274,8 @@ def healthz() -> Response: @app.post("/api/auth/sync") def api_auth_sync() -> Response: try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 ensure_user_table() id_token = bearer_token() if not id_token: @@ -318,6 +335,8 @@ def api_auth_sync() -> Response: @app.post("/api/auth/logout") def api_auth_logout() -> Response: try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 ensure_user_table() id_token = bearer_token() if not id_token: @@ -364,6 +383,8 @@ def api_config_auth_get() -> Response: } ) + if not db_configured(): + return jsonify({"ok": False, "error": "not_set"}), 404 ensure_config_table() row = db_one(f"select value, updated_at from public.{CONFIG_TABLE} where key = %s", ("auth",)) if not row: @@ -384,6 +405,8 @@ def api_config_auth_get() -> Response: @app.post("/api/config/auth") def api_config_auth_post() -> Response: try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 ensure_config_table() if not CONFIG_TOKEN: return jsonify({"ok": False, "error": "config_token_not_set"}), 403