포스트

[이제와서 시작하는 Docker 마스터하기 - 고급편 #3] Docker로 Python 애플리케이션 배포

[이제와서 시작하는 Docker 마스터하기 - 고급편 #3] Docker로 Python 애플리케이션 배포

“이제와서 시작하는 Docker 마스터하기” Python은 Docker와 함께 사용하기 좋은 언어입니다. 이번 편에서는 Flask와 Django 애플리케이션을 Docker로 배포하는 방법을 실습과 함께 알아보겠습니다.

Python Docker 배포 전략 비교

프레임워크 특징 사용 시나리오 컨테이너 크기 성능
Flask 경량, 유연함 마이크로서비스, API 50-100MB 빠름
Django 풀스택, 기능 풍부 대규모 웹앱 150-300MB 중간
FastAPI 비동기, 타입 힌트 고성능 API 60-120MB 매우 빠름
Streamlit 데이터 앱 대시보드, ML 데모 200-400MB 중간
Celery 비동기 작업 백그라운드 처리 100-200MB 해당 없음

Flask 애플리케이션 컨테이너화

1. Flask 애플리케이션 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# app.py
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
import prometheus_client
from prometheus_client import Counter, Histogram, Gauge
import time
import os
import logging

# 앱 초기화
app = Flask(__name__)

# 설정
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv(
    'DATABASE_URL', 
    'postgresql://user:password@postgres:5432/myapp'
)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['REDIS_URL'] = os.getenv('REDIS_URL', 'redis://redis:6379/0')

# 확장 초기화
db = SQLAlchemy(app)
redis_client = FlaskRedis(app)

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# Prometheus 메트릭
REQUEST_COUNT = Counter(
    'app_request_count', 
    'Application Request Count',
    ['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
    'app_request_latency_seconds',
    'Application Request Latency'
)
ACTIVE_USERS = Gauge(
    'app_active_users',
    'Active Users'
)

# 모델 정의
class Item(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    description = db.Column(db.Text)
    created_at = db.Column(db.DateTime, default=db.func.current_timestamp())

    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'created_at': self.created_at.isoformat()
        }

# 미들웨어
@app.before_request
def before_request():
    request.start_time = time.time()

@app.after_request
def after_request(response):
    if hasattr(request, 'start_time'):
        latency = time.time() - request.start_time
        REQUEST_LATENCY.observe(latency)
    
    REQUEST_COUNT.labels(
        method=request.method,
        endpoint=request.endpoint or 'unknown',
        status=response.status_code
    ).inc()
    
    return response

# 라우트
@app.route('/health')
def health_check():
    """헬스체크 엔드포인트"""
    try:
        # DB 연결 확인
        db.session.execute('SELECT 1')
        # Redis 연결 확인
        redis_client.ping()
        return jsonify({'status': 'healthy'}), 200
    except Exception as e:
        logger.error(f'Health check failed: {str(e)}')
        return jsonify({'status': 'unhealthy', 'error': str(e)}), 503

@app.route('/api/items', methods=['GET'])
def get_items():
    """아이템 목록 조회"""
    # Redis 캐시 확인
    cached = redis_client.get('items_list')
    if cached:
        logger.info('Cache hit for items list')
        return cached
    
    # DB에서 조회
    items = Item.query.order_by(Item.created_at.desc()).all()
    result = jsonify([item.to_dict() for item in items])
    
    # 캐시 저장 (60초)
    redis_client.setex('items_list', 60, result.data)
    
    return result

@app.route('/api/items', methods=['POST'])
def create_item():
    """아이템 생성"""
    data = request.get_json()
    
    item = Item(
        name=data['name'],
        description=data.get('description', '')
    )
    
    db.session.add(item)
    db.session.commit()
    
    # 캐시 무효화
    redis_client.delete('items_list')
    
    logger.info(f'Created item: {item.id}')
    return jsonify(item.to_dict()), 201

@app.route('/metrics')
def metrics():
    """Prometheus 메트릭 엔드포인트"""
    return prometheus_client.generate_latest()

# 데이터베이스 초기화
def init_db():
    with app.app_context():
        db.create_all()
        logger.info('Database initialized')

if __name__ == '__main__':
    init_db()
    app.run(host='0.0.0.0', port=5000)

2. requirements.txt

Flask==2.3.3
Flask-SQLAlchemy==3.0.5
Flask-Redis==0.4.0
psycopg2-binary==2.9.7
prometheus-client==0.17.1
gunicorn==21.2.0

3. Flask Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
FROM python:3.11-slim as builder

# 빌드 의존성 설치
RUN apt-get update && apt-get install -y \
    gcc \
    python3-dev \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 의존성 설치
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# 런타임 스테이지
FROM python:3.11-slim

# 런타임 의존성
RUN apt-get update && apt-get install -y \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

# 사용자 생성
RUN useradd -m -u 1000 appuser

WORKDIR /app

# 빌더에서 패키지 복사
COPY --from=builder /root/.local /home/appuser/.local

# 애플리케이션 코드 복사
COPY --chown=appuser:appuser . .

# PATH 업데이트
ENV PATH=/home/appuser/.local/bin:$PATH

USER appuser

EXPOSE 5000

# Gunicorn으로 실행
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "app:app"]

Django 애플리케이션 컨테이너화

1. Django 프로젝트 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
django-app/
├── myproject/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── myapp/
│   ├── models.py
│   ├── views.py
│   └── urls.py
├── requirements.txt
├── manage.py
├── Dockerfile
└── docker-compose.yml

2. Django settings.py (Docker 설정)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# settings.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.getenv('SECRET_KEY', 'your-secret-key')

DEBUG = os.getenv('DEBUG', 'False') == 'True'

ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost').split(',')

# 데이터베이스
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME', 'djangodb'),
        'USER': os.getenv('DB_USER', 'postgres'),
        'PASSWORD': os.getenv('DB_PASSWORD', 'password'),
        'HOST': os.getenv('DB_HOST', 'postgres'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

# Redis 캐시
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': os.getenv('REDIS_URL', 'redis://redis:6379/1'),
    }
}

# Celery 설정
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://redis:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://redis:6379/0')

# 정적 파일
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles')

# 로깅
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'verbose',
        },
    },
    'root': {
        'handlers': ['console'],
        'level': 'INFO',
    },
}

3. Django Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
FROM python:3.11-slim as builder

# 빌드 의존성
RUN apt-get update && apt-get install -y \
    gcc \
    python3-dev \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt

# 런타임 스테이지
FROM python:3.11-slim

# 런타임 의존성
RUN apt-get update && apt-get install -y \
    libpq5 \
    netcat-traditional \
    && rm -rf /var/lib/apt/lists/*

# 사용자 생성
RUN useradd -m -u 1000 django

WORKDIR /app

# 휠 파일과 requirements 복사
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .

# 패키지 설치
RUN pip install --no-cache /wheels/*

# 애플리케이션 코드 복사
COPY --chown=django:django . .

# 정적 파일 디렉토리 생성
RUN mkdir -p staticfiles mediafiles
RUN chown -R django:django staticfiles mediafiles

USER django

# 정적 파일 수집
RUN python manage.py collectstatic --noinput

EXPOSE 8000

# entrypoint 스크립트
COPY --chown=django:django docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh

ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "myproject.wsgi:application"]

4. docker-entrypoint.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash
set -e

# PostgreSQL이 준비될 때까지 대기
echo "Waiting for postgres..."
while ! nc -z $DB_HOST $DB_PORT; do
  sleep 0.1
done
echo "PostgreSQL started"

# 마이그레이션 실행
python manage.py migrate --noinput

# 슈퍼유저 생성 (개발 환경)
if [ "$DJANGO_SUPERUSER_USERNAME" ]; then
  python manage.py createsuperuser \
    --noinput \
    --username $DJANGO_SUPERUSER_USERNAME \
    --email $DJANGO_SUPERUSER_EMAIL || true
fi

exec "$@"

Docker Compose 설정

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
version: '3.8'

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DEBUG=False
      - SECRET_KEY=${SECRET_KEY:-your-secret-key}
      - ALLOWED_HOSTS=localhost,127.0.0.1
      - DB_HOST=postgres
      - DB_NAME=djangodb
      - DB_USER=postgres
      - DB_PASSWORD=password
      - REDIS_URL=redis://redis:6379/1
      - CELERY_BROKER_URL=redis://redis:6379/0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - static_volume:/app/staticfiles
      - media_volume:/app/mediafiles
    networks:
      - django-network

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=djangodb
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - django-network

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - django-network

  celery:
    build: .
    command: celery -A myproject worker -l info
    environment:
      - DEBUG=False
      - SECRET_KEY=${SECRET_KEY:-your-secret-key}
      - DB_HOST=postgres
      - DB_NAME=djangodb
      - DB_USER=postgres
      - DB_PASSWORD=password
      - CELERY_BROKER_URL=redis://redis:6379/0
    depends_on:
      - redis
      - postgres
    networks:
      - django-network

  celery-beat:
    build: .
    command: celery -A myproject beat -l info
    environment:
      - DEBUG=False
      - SECRET_KEY=${SECRET_KEY:-your-secret-key}
      - DB_HOST=postgres
      - DB_NAME=djangodb
      - DB_USER=postgres
      - DB_PASSWORD=password
      - CELERY_BROKER_URL=redis://redis:6379/0
    depends_on:
      - redis
      - postgres
    networks:
      - django-network

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - static_volume:/app/staticfiles:ro
      - media_volume:/app/mediafiles:ro
    depends_on:
      - web
    networks:
      - django-network

volumes:
  postgres_data:
  redis_data:
  static_volume:
  media_volume:

networks:
  django-network:

nginx.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    upstream django {
        server web:8000;
    }

    server {
        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://django;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $host;
            proxy_redirect off;
        }

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

        location /media/ {
            alias /app/mediafiles/;
        }
    }
}

Python 특화 최적화

Python 이미지 최적화 비교

베이스 이미지 크기 장점 단점 용도
python:3.11 ~950MB 모든 도구 포함 크기가 큼 개발 환경
python:3.11-slim ~150MB 균형잡힌 크기 일부 도구 누락 일반 프로덕션
python:3.11-alpine ~50MB 매우 작음 빌드 도구 필요 최소 프로덕션
distroless/python3 ~52MB 보안 강화 쉘 없음 고보안 환경
chainguard/python ~45MB 취약점 최소화 제한적 엔터프라이즈

1. 멀티스테이지 빌드 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 의존성 캐싱을 위한 별도 스테이지
FROM python:3.11-slim as python-deps

RUN apt-get update && apt-get install -y \
    gcc \
    python3-dev \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

RUN pip install --upgrade pip setuptools wheel

COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt

# 애플리케이션 스테이지
FROM python:3.11-slim

# 휠 파일 복사 및 설치
COPY --from=python-deps /wheels /wheels
RUN pip install --no-cache /wheels/* && rm -rf /wheels

# 나머지 설정...

2. Poetry 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM python:3.11-slim as builder

RUN pip install poetry==1.6.1

WORKDIR /app
COPY pyproject.toml poetry.lock ./

# 의존성 설치
RUN poetry config virtualenvs.create false \
    && poetry install --no-interaction --no-ansi --no-root --only main

# 런타임 스테이지
FROM python:3.11-slim

COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin

WORKDIR /app
COPY . .

CMD ["gunicorn", "app:app"]

3. 비동기 애플리케이션 (FastAPI)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# main.py
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
import uvicorn

app = FastAPI()

# 비동기 DB 설정
engine = create_async_engine(
    "postgresql+asyncpg://user:password@postgres/db"
)
AsyncSessionLocal = sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)

@app.get("/")
async def root():
    return {"message": "Hello World"}

@app.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(Item))
    items = result.scalars().all()
    return items

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
1
2
3
4
5
6
7
8
9
10
11
# FastAPI Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

개발 환경 설정

docker-compose.dev.yml

1
2
3
4
5
6
7
8
9
10
11
12
version: '3.8'

services:
  web:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/app
    environment:
      - DEBUG=True
    ports:
      - "8000:8000"

성능 모니터링

APM 통합 (New Relic)

1
2
3
# settings.py
if os.getenv('NEW_RELIC_LICENSE_KEY'):
    NEW_RELIC_CONFIG_FILE = 'newrelic.ini'
1
2
3
4
5
# Dockerfile에 추가
RUN pip install newrelic

# CMD 수정
CMD ["newrelic-admin", "run-program", "gunicorn", "myproject.wsgi:application"]

트러블슈팅

일반적인 문제 해결

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 로그 확인
docker compose logs -f web

# 쉘 접속
docker compose exec web bash

# Python 쉘
docker compose exec web python manage.py shell

# 마이그레이션 문제
docker compose exec web python manage.py makemigrations
docker compose exec web python manage.py migrate

# 정적 파일 문제
docker compose exec web python manage.py collectstatic --noinput

보안 고려사항

1. 시크릿 관리

1
2
3
4
5
6
7
8
9
# settings.py
import os
from dotenv import load_dotenv

load_dotenv()

SECRET_KEY = os.getenv('SECRET_KEY')
if not SECRET_KEY:
    raise ValueError("SECRET_KEY environment variable is not set")

2. 프로덕션 체크리스트

1
2
3
4
5
6
# Django 보안 체크
docker compose exec web python manage.py check --deploy

# 취약점 스캔
docker run --rm -v $(pwd):/src \
  pyupio/safety safety check --file /src/requirements.txt

2025년 Python Docker 배포 트렌드

컨테이너화 도구 비교

도구 특징 장점 단점 2025년 트렌드
Docker 표준 컨테이너 범용성 단일 호스트 여전히 주류
Podman Rootless 보안 강화 생태계 작음 성장 중
Buildah OCI 빌더 유연함 복잡함 특수 용도
Kaniko 데몬리스 빌드 CI/CD 최적화 Docker 비호환 K8s 환경
Pack Cloud Native Buildpacks 자동 최적화 제한적 커스터마이징 급성장

Python 앱 배포 아키텍처

graph TB
    subgraph "Development"
        A[로컬 개발] --> B[테스트]
        B --> C[이미지 빌드]
    end
    
    subgraph "CI/CD"
        C --> D[자동 테스트]
        D --> E[보안 스캔]
        E --> F[이미지 푸시]
    end
    
    subgraph "Registry"
        F --> G[Docker Hub]
        F --> H[Private Registry]
        F --> I[Cloud Registry]
    end
    
    subgraph "Deployment"
        G --> J[개발 환경]
        H --> K[스테이징]
        I --> L[프로덕션]
        
        J --> M[모니터링]
        K --> M
        L --> M
    end
    
    M --> N[로그 수집]
    M --> O[메트릭 수집]
    M --> P[알림]

마무리

Python 애플리케이션을 Docker로 배포하면 의존성 관리가 쉬워지고 일관된 환경을 유지할 수 있습니다. Flask의 단순함부터 Django의 풍부한 기능까지, Docker는 모든 Python 프로젝트에 적용할 수 있습니다. 다음 편에서는 Docker로 데이터베이스를 운영하는 방법을 알아보겠습니다.

다음 편 예고

  • 프로덕션 데이터베이스 운영
  • 백업과 복원 전략
  • 고가용성 구성
  • 성능 튜닝

데이터베이스도 Docker로 안전하게 운영해봅시다! 🗄️

📚 Docker 마스터하기 시리즈

🐳 기초편 (입문자용 - 5편)

  1. Docker란 무엇인가?
  2. Docker 설치 및 환경 설정
  3. 첫 번째 컨테이너 실행하기
  4. Docker 이미지 이해하기
  5. Dockerfile 작성하기

💼 실전편 (중급자용 - 6편)

  1. Docker 네트워크 기초
  2. Docker 볼륨과 데이터 관리
  3. Docker Compose 입문
  4. 멀티 컨테이너 애플리케이션
  5. Docker Hub 활용하기
  6. Docker 보안 베스트 프랙티스

🚀 고급편 (전문가용 - 9편)

  1. Docker 로그와 모니터링
  2. Docker로 Node.js 애플리케이션 배포
  3. Docker로 Python 애플리케이션 배포 ← 현재 글
  4. Docker로 데이터베이스 운영
  5. Docker 이미지 최적화
  6. Docker와 CI/CD
  7. Docker Swarm 기초
  8. 문제 해결과 트러블슈팅
  9. Docker 생태계와 미래
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.