블로그로 돌아가기
8월 19, 2026가이드

VPS에서 Gunicorn과 Nginx로 Python 앱을 실행하는 방법

Ubuntu에서 Flask 또는 FastAPI 애플리케이션을 virtualenv, Gunicorn systemd 서비스, Nginx 리버스 프록시, TLS, 환경 파일, 로깅, 502 오류와 워커 규모 체크리스트와 함께 배포합니다.

VPS에서 Gunicorn과 Nginx로 Python 앱을 실행하는 방법

내장 Flask 서버와 uvicorn --reload는 개발용입니다. 공용 VPS에서는 워커를 재시작하고 localhost에 바인딩하며 TLS와 느린 클라이언트를 처리하는 리버스 프록시 뒤에 앉는 프로세스 관리자가 필요합니다. Gunicorn은 Flask와 Django의 흔한 WSGI 선택입니다. FastAPI는 Uvicorn 워커 클래스와 함께 Gunicorn 아래에서 돌 수 있습니다. Nginx(또는 Caddy)가 HTTPS를 종료하고 127.0.0.1:8000으로 전달합니다.

이 가이드는 재부팅을 견디는 배치를 따라갑니다. 프로젝트는 /srv/app, virtualenv, 비밀이 있는 .env, gunicorn.service 유닛, Nginx server 블록, Let's Encrypt, 실제로 grep할 수 있는 로그 파일입니다. 워커 수, 타임아웃, 왜 502 Bad Gateway가 거의 결코 'Nginx가 고장'이 아닌지 — 거의 항상 Gunicorn이 안 돌거나, 잘못된 소켓에 바인딩하거나, import에서 충돌하는지도 이야기합니다. 예제는 Flask이며 명령이 다른 곳에는 FastAPI 메모가 있습니다.

이 스택을 쓰는 이유

Gunicorn은 워커 프로세스를 prefork합니다. 다른 워커 클래스를 쓰지 않으면 각 워커는 한 번에 요청 하나를 처리합니다. Nginx는 느린 클라이언트를 버퍼해서 워커가 모바일 네트워크로 바이트를 보내며 막히지 않게 합니다. systemd는 죽으면 앱을 재시작합니다. 함께면 지루합니다. 새벽 3시에 원하는 것입니다.

  • Gunicorn: 안정적인 WSGI/ASGI 프로세스 모델
  • systemd: 부팅 시 시작, 충돌 시 재시작, journald 로그
  • Nginx: TLS, 정적 파일, 요청 크기 제한, gzip
  • venv: 시스템 Python을 깨끗하게 유지
  • localhost 바인드: 앱은 Nginx를 통해서만 도달

요구 사항

Ubuntu 22.04/24.04의 Python 3.10+면 충분합니다. root로 시스템 site-packages에 pip하지 마세요. TLS에는 도메인이 필요합니다. 프록시로 Caddy를 선호하면 이 글의 Gunicorn 유닛은 그대로입니다. 프론트엔드 설정만 바뀝니다(Caddy 글 참고).

  • Ubuntu 22.04 또는 24.04 VPS
  • requirements.txt 또는 동등한 파일이 있는 애플리케이션
  • WSGI 엔트리포인트(Flask: app:app) 또는 ASGI(FastAPI: uvicorn 워커와 함께 app:app)
  • HTTPS용 도메인 A 레코드

1단계: 시스템 패키지, 사용자, 프로젝트 디렉터리

대화형으로 로그인할 수 없는 시스템 사용자를 만들어 코드를 소유하고 Gunicorn을 실행하게 하세요. python3-venv와 빌드 도구를 설치하면 아직 C 확장을 컴파일하는 패키지에서 pip 실패를 피할 수 있습니다.

bash
ssh root@YOUR_VPS_IP
apt update && apt -y upgrade
apt -y install python3 python3-venv python3-pip python3-dev build-essential nginx curl

adduser --system --group --home /srv/app appuser
mkdir -p /srv/app
chown appuser:appuser /srv/app

# Copy your code (example):
# rsync -a --delete ./myproject/ appuser@YOUR_VPS_IP:/srv/app/

2단계: virtualenv와 의존성

파일 소유권이 맞도록 appuser로 venv를 만드세요. 프로덕션에서는 버전을 고정하세요. 설치 후 일회성 gunicorn --check-config 또는 Python import로 앱을 가져올 수 있는지 확인하세요. 여기의 import 오류가 나중에 502가 되는 바로 그 오류입니다.

bash
sudo -u appuser -H bash -lc '
cd /srv/app
python3 -m venv /srv/app/venv
/srv/app/venv/bin/pip install --upgrade pip
/srv/app/venv/bin/pip install -r /srv/app/requirements.txt gunicorn
'

# Flask example check:
sudo -u appuser -H /srv/app/venv/bin/python -c "from app import app; print('import ok')"

3단계: 환경 파일

월드 읽기 가능한 git에 들어가게 SECRET_KEY나 데이터베이스 URL을 systemd 유닛에 하드코딩하지 마세요. EnvironmentFile을 쓰세요. chmod 640, 소유자 root, 그룹 appuser(또는 원하면 소유자 appuser).

bash
cat >/srv/app/.env <<'EOF'
FLASK_ENV=production
SECRET_KEY=replace-with-openssl-rand-hex-32
DATABASE_URL=postgresql://app:password@127.0.0.1:5432/app
EOF
chown appuser:appuser /srv/app/.env
chmod 600 /srv/app/.env

4단계: Gunicorn systemd 서비스

Nginx를 건너뛸 이유가 없으면 0.0.0.0이 아니라 127.0.0.1:8000에 바인딩하세요. 워커 수는 sync 워커에서 종종 (2 x CPU) + 1입니다. 2 vCPU VPS에서는 5인데, 각 워커가 무거운 ML 모델을 로드하면 너무 많을 수 있습니다. 그때는 2–3을 쓰세요. FastAPI에서는 --worker-class uvicorn.workers.UvicornWorker를 설정하고 uvicorn을 설치하세요. 타임아웃은 가장 느린 정직한 요청보다 길어야 합니다. 2분 내보내기가 있으면 30초가 아닙니다.

bash
cat >/etc/systemd/system/gunicorn.service <<'EOF'
[Unit]
Description=Gunicorn for the web app
After=network.target

[Service]
User=appuser
Group=appuser
WorkingDirectory=/srv/app
EnvironmentFile=/srv/app/.env
ExecStart=/srv/app/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 --timeout 60 --access-logfile - --error-logfile - app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now gunicorn
systemctl status gunicorn --no-pager
ss -tulpn | grep 8000

5단계: Nginx 리버스 프록시와 TLS

Nginx는 80/443에서 듣고 Gunicorn으로 프록시합니다. 업로드에는 client_max_body_size가 중요합니다. proxy_read_timeout은 Gunicorn 타임아웃과 같거나 더 길어야 합니다. server 블록이 HTTP에서 동작한 뒤 Certbot으로 인증서를 발급하거나 프론트를 Caddy로 바꾸세요. 아래 스니펫은 테스트용 HTTP만입니다. 그다음 파일을 편집할 수 있는 Certbot을 실행하세요.

bash
cat >/etc/nginx/sites-available/app <<'EOF'
server {
    listen 80;
    server_name app.example.com;
    client_max_body_size 20m;

    location /static/ {
        alias /srv/app/static/;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        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_read_timeout 90s;
    }
}
EOF

ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
nginx -t && systemctl reload nginx

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable

apt -y install certbot python3-certbot-nginx
certbot --nginx -d app.example.com

6단계: 정적 파일, 권한, Flask/FastAPI 세부 사항

가능하면 정적 파일은 Nginx가 제공해야 합니다. Gunicorn보다 빠릅니다. Django collectstatic, 아주 작은 앱의 Flask send_from_directory, 또는 나중의 CDN입니다. appuser는 트리를 읽을 수 있어야 합니다. FastAPI를 쓰면 venv에 uvicorn[standard]를 설치하고 ExecStart를 UvicornWorker로 바꾸세요. TCP 대신 Unix 소켓을 쓰면 proxy_pass를 소켓으로 향하게 하고 www-data가 쓸 수 있도록 권한을 맞추세요.

bash
# FastAPI ExecStart example:
# ExecStart=/srv/app/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 2 --bind 127.0.0.1:8000 app:app

# Unix socket variant:
# --bind unix:/run/gunicorn/app.sock
# Nginx: proxy_pass http://unix:/run/gunicorn/app.sock:

chown -R appuser:appuser /srv/app

워커, 메모리, 무중단 리로드

동기 Gunicorn 워커는 단순하고 CPU가 가벼운 요청/응답 API에는 충분합니다. 동시 느린 I/O 대기가 많으면 gevent나 ASGI 워커를 고려하세요. 추측하지 말고 측정하세요. 각 워커가 앱을 로드하므로 RAM ~= 워커 수 곱하기 앱 RSS입니다. 2 GB VPS에 300 MB 앱 워커 8개는 스왑하고 '무작위로 느리다'고 느껴집니다. 파일을 그 자리에 배포했다면 systemctl reload gunicorn(HUP)으로 새 코드의 워커를 재시작할 수 있습니다. 의존성이 바뀌면 전체 재시작이 더 분명합니다.

bash
# After git pull / rsync of new code:
sudo -u appuser -H /srv/app/venv/bin/pip install -r /srv/app/requirements.txt
systemctl restart gunicorn
curl -I https://app.example.com/health

502와 조용한 충돌 문제 해결

502는 Nginx가 업스트림에서 유효한 응답을 얻지 못했다는 뜻입니다. 첫 명령은 journalctl -u gunicorn -e입니다. 흔한 원인: 잘못된 module:app 이름, 빠진 .env 키, Postgres가 안 돌음, 127.0.0.1에 바인딩했는데 Nginx가 다른 호스트, SELinux(Ubuntu에서는 드묾), 앱이 IPv6만 듣음. 504는 타임아웃입니다. 앱이 HTTP로 리다이렉트하고 X-Forwarded-Proto가 무시되면 301 루프가 납니다.

  • systemctl status gunicorn — active인가?
  • journalctl -u gunicorn -n 100 — ImportError, 빠진 env, 데이터베이스
  • VPS에서 curl -v http://127.0.0.1:8000/ — 이것이 실패하면 Nginx는 무죄
  • nginx -t와 error.log — upstream prematurely closed connection
  • ss -tulpn | grep 8000 — 아무도 듣지 않음
  • 디스크 가득 참 — 워커가 이상한 방식으로 충돌

보안

앱은 공개 바인딩하지 않습니다. 비밀은 .env에 남습니다. venv와 OS를 패치하세요. Gunicorn을 root로 돌리지 마세요. 로그인을 다루면 세션 쿠키에 Secure와 SameSite를 설정하고, 프레임워크가 Nginx의 X-Forwarded-Proto만 신뢰하도록 구성하세요. 로그인 경로는 Nginx 또는 앱에서 속도 제한하세요.

  • 127.0.0.1 또는 unix 소켓에 바인드
  • chmod 600 .env
  • systemd의 User=는 비root
  • TLS는 Certbot 또는 Caddy 경유
  • 프로덕션에서 debug 모드와 자동 리로드 비활성화

  • Compose와 로드 밸런서용으로 DB 연결을 확인하는 /health 경로 추가
  • Nginx에서 정적 자산에 cache-control 헤더
  • 이메일과 무거운 작업은 별도 워커 또는 큐(Redis + systemd)
  • requirements.txt에서 gunicorn과 uvicorn 버전 고정
  • 첫 프로덕션 전환 전에 스냅샷 찍기

VPS의 Python 앱은 Gunicorn이 systemd 서비스로 돌고 localhost에만 바인딩되며 TLS가 있는 Nginx 또는 Caddy를 통해 도달할 때 프로덕션 준비가 됩니다. 비밀은 EnvironmentFile에 두고, 워커는 블로그 공식이 아니라 RAM에 맞추고, 502는 먼저 Gunicorn 저널에서 디버깅하세요. 이 경로가 저장소에 문서화되면 이후 배포는 매번 rsync 또는 git pull, pip install, systemctl restart gunicorn입니다.