README에 conda/systemd/Apache 설치 문서 추가
- Miniconda(ncue) 기반 설치/실행 및 systemd 예시 추가 - Apache ProxyPass 설정 및 503 트러블슈팅 절차 정리 - Flask DB pool lazy 생성으로 임포트 단계 장애(503) 완화 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
160
README.md
160
README.md
@@ -16,33 +16,166 @@ python3 -m http.server 8000
|
|||||||
|
|
||||||
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
|
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
|
||||||
|
|
||||||
## 서버(Node) + PostgreSQL 사용자 저장
|
## 백엔드(Flask) + PostgreSQL 사용자 저장
|
||||||
|
|
||||||
로그인 후 사용자 정보를 `ncue_user`에 저장하고(Upsert), 로그인/로그아웃 시간을 기록하며,
|
로그인 후 사용자 정보를 `ncue_user`에 저장하고(Upsert), 로그인/로그아웃 시간을 기록하며,
|
||||||
`/api/config/auth`로 Auth0 설정을 공유하려면 백엔드 서버가 필요합니다.
|
`/api/config/auth`로 Auth0 설정을 공유하려면 백엔드 서버가 필요합니다.
|
||||||
|
|
||||||
현재 백엔드는 **Python Flask(기본 포트 8023)** 로 제공합니다. (정적 HTML/JS는 그대로 사용 가능)
|
현재 백엔드는 **Python Flask(기본 포트 8023)** 로 제공합니다. (정적 HTML/JS는 그대로 사용 가능)
|
||||||
|
|
||||||
1) 의존성 설치(Python)
|
### 1) DB 테이블 생성(서버에서 1회)
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2) 테이블 생성
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f db/schema.sql
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f db/schema.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
3) 서버 실행
|
### 2) 실행 방법 A: (로컬/간단) venv로 실행
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName ncue.net
|
||||||
|
|
||||||
|
DocumentRoot /path/to/home
|
||||||
|
<Directory /path/to/home>
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
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
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
적용:
|
||||||
|
|
||||||
|
```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`로 자동 승격됩니다.
|
기본적으로 `.env`의 `ADMIN_EMAILS`에 포함된 이메일은 `can_manage=true`로 자동 승격됩니다.
|
||||||
|
|
||||||
### (선택) 역프록시/분리 배포
|
### (선택) 역프록시/분리 배포
|
||||||
@@ -83,3 +216,6 @@ update ncue_user set can_manage = true where email = 'me@example.com';
|
|||||||
- 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다.
|
- 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다.
|
||||||
- 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다.
|
- 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 설치 방
|
||||||
55
flask_app.py
55
flask_app.py
@@ -24,13 +24,6 @@ def env(name: str, default: str = "") -> str:
|
|||||||
return str(os.getenv(name, default))
|
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_]*$")
|
_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")
|
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_PORT = int(env("DB_PORT", "5432") or "5432")
|
||||||
DB_NAME = must("DB_NAME")
|
DB_NAME = env("DB_NAME", "").strip()
|
||||||
DB_USER = must("DB_USER")
|
DB_USER = env("DB_USER", "").strip()
|
||||||
DB_PASSWORD = must("DB_PASSWORD")
|
DB_PASSWORD = env("DB_PASSWORD", "").strip()
|
||||||
|
|
||||||
TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user")
|
TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user")
|
||||||
CONFIG_TABLE = "ncue_app_config"
|
CONFIG_TABLE = "ncue_app_config"
|
||||||
@@ -71,8 +64,25 @@ AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").strip()
|
|||||||
# Optional CORS (for static on different origin)
|
# Optional CORS (for static on different origin)
|
||||||
CORS_ORIGINS = env("CORS_ORIGINS", "*").strip() or "*"
|
CORS_ORIGINS = env("CORS_ORIGINS", "*").strip() or "*"
|
||||||
|
|
||||||
|
_POOL: Optional[psycopg2.pool.SimpleConnectionPool] = None
|
||||||
|
|
||||||
POOL: psycopg2.pool.SimpleConnectionPool = psycopg2.pool.SimpleConnectionPool(
|
|
||||||
|
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,
|
minconn=1,
|
||||||
maxconn=10,
|
maxconn=10,
|
||||||
host=DB_HOST,
|
host=DB_HOST,
|
||||||
@@ -82,20 +92,23 @@ POOL: psycopg2.pool.SimpleConnectionPool = psycopg2.pool.SimpleConnectionPool(
|
|||||||
password=DB_PASSWORD,
|
password=DB_PASSWORD,
|
||||||
sslmode="disable",
|
sslmode="disable",
|
||||||
)
|
)
|
||||||
|
return _POOL
|
||||||
|
|
||||||
|
|
||||||
def db_exec(sql: str, params: Tuple[Any, ...] = ()) -> None:
|
def db_exec(sql: str, params: Tuple[Any, ...] = ()) -> None:
|
||||||
conn = POOL.getconn()
|
pool = get_pool()
|
||||||
|
conn = pool.getconn()
|
||||||
try:
|
try:
|
||||||
conn.autocommit = True
|
conn.autocommit = True
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
finally:
|
finally:
|
||||||
POOL.putconn(conn)
|
pool.putconn(conn)
|
||||||
|
|
||||||
|
|
||||||
def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]:
|
def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]:
|
||||||
conn = POOL.getconn()
|
pool = get_pool()
|
||||||
|
conn = pool.getconn()
|
||||||
try:
|
try:
|
||||||
conn.autocommit = True
|
conn.autocommit = True
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
@@ -103,7 +116,7 @@ def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return row
|
return row
|
||||||
finally:
|
finally:
|
||||||
POOL.putconn(conn)
|
pool.putconn(conn)
|
||||||
|
|
||||||
|
|
||||||
def ensure_user_table() -> None:
|
def ensure_user_table() -> None:
|
||||||
@@ -248,6 +261,8 @@ def static_files(filename: str) -> Response:
|
|||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz() -> Response:
|
def healthz() -> Response:
|
||||||
try:
|
try:
|
||||||
|
if not db_configured():
|
||||||
|
return jsonify({"ok": False, "error": "db_not_configured"}), 500
|
||||||
row = db_one("select 1 as ok")
|
row = db_one("select 1 as ok")
|
||||||
if not row:
|
if not row:
|
||||||
return jsonify({"ok": False}), 500
|
return jsonify({"ok": False}), 500
|
||||||
@@ -259,6 +274,8 @@ def healthz() -> Response:
|
|||||||
@app.post("/api/auth/sync")
|
@app.post("/api/auth/sync")
|
||||||
def api_auth_sync() -> Response:
|
def api_auth_sync() -> Response:
|
||||||
try:
|
try:
|
||||||
|
if not db_configured():
|
||||||
|
return jsonify({"ok": False, "error": "db_not_configured"}), 500
|
||||||
ensure_user_table()
|
ensure_user_table()
|
||||||
id_token = bearer_token()
|
id_token = bearer_token()
|
||||||
if not id_token:
|
if not id_token:
|
||||||
@@ -318,6 +335,8 @@ def api_auth_sync() -> Response:
|
|||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
def api_auth_logout() -> Response:
|
def api_auth_logout() -> Response:
|
||||||
try:
|
try:
|
||||||
|
if not db_configured():
|
||||||
|
return jsonify({"ok": False, "error": "db_not_configured"}), 500
|
||||||
ensure_user_table()
|
ensure_user_table()
|
||||||
id_token = bearer_token()
|
id_token = bearer_token()
|
||||||
if not id_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()
|
ensure_config_table()
|
||||||
row = db_one(f"select value, updated_at from public.{CONFIG_TABLE} where key = %s", ("auth",))
|
row = db_one(f"select value, updated_at from public.{CONFIG_TABLE} where key = %s", ("auth",))
|
||||||
if not row:
|
if not row:
|
||||||
@@ -384,6 +405,8 @@ def api_config_auth_get() -> Response:
|
|||||||
@app.post("/api/config/auth")
|
@app.post("/api/config/auth")
|
||||||
def api_config_auth_post() -> Response:
|
def api_config_auth_post() -> Response:
|
||||||
try:
|
try:
|
||||||
|
if not db_configured():
|
||||||
|
return jsonify({"ok": False, "error": "db_not_configured"}), 500
|
||||||
ensure_config_table()
|
ensure_config_table()
|
||||||
if not CONFIG_TOKEN:
|
if not CONFIG_TOKEN:
|
||||||
return jsonify({"ok": False, "error": "config_token_not_set"}), 403
|
return jsonify({"ok": False, "error": "config_token_not_set"}), 403
|
||||||
|
|||||||
Reference in New Issue
Block a user