Django 그리고 Postgres, Gunicorn, Nginx Dockerizing

Django 그리고 Postgres, Gunicorn, Nginx Dockerizing

Django-Postgres-Gunicorn-Nginx 구성환경을 Docker 에 구현하는 튜토리얼을 참고해 작성한 글입니다.


현재 회사에서 개발중인 학습관리시스템 환경은 도커로 구성되어 있고 시스템과 연결된 클라우드 환경도 도커로 구성되어 있습니다. 도커는 많은 곳에서 사용하고 있고 실제 사용해보면 편리한 점들을 느낄 수 있습니다.

여러 곳에서 사용되는 도커가 왜 필요한지 어떤 점이 좋아서 많이 사용되는지를 설명해보면 우선 도커파일을 한번 잘 구성해두면 환경설정을 다시 할 필요가 없습니다. 원래 환경설정을 다시 할 필요가 없지 않냐고 반문할 수 있겠지만 프로젝트는 혼자서 코딩하는게 아니라 다른 개발자와도 협업을 해야합니다. 다른 개발자가 프로젝트에 참여했을때 쉽게 접근할 수 있어야 하는데 기존에는 환경 셋업을 하는데 정말 많은 시간이 들어갔습니다. 반면 도커환경은 독립된 별도의 공간이어서 도커파일을 실행하는 명령어를 따라하면 쉽게 환경을 구성하고 프로젝트에 착수 할 수 있습니다.

도커 컴포즈를 사용하는 이유

하나의 도커파일은 하나의 환경을 기준으로 합니다. 실제로 환경을 구축해보면 장고 백엔드 환경만으로는 부족합니다. nginx 를 추가해야할 수도 있고 redis 를 추가해야할 수도 있습니다. 구별된 여러개의 도커파일을 한곳에서 제어하는게 도커 컴포즈 파일입니다. 도커 컴포즈 파일에서는 여러개의 도커환경을 서로 연결해서 구성하고 저장공간 그리고 의존관계를 설정할 수 있습니다.

이하 튜토리얼은 Dockerizing Django with Postgres, Gunicorn, and Nginx 해당 튜토리얼을 참고해서 작성했습니다


환경설정

  1. Django v3.0.7
  2. Docker v19.03.8
  3. Python v3.8.3

프로젝트 설정

$ mkdir django-on-docker && cd django-on-docker
$ mkdir app && cd app
$ python3.8 -m venv env
$ source env/bin/activate
(env)$ pip install django==3.0.7
(env)$ django-admin.py startproject hello_django .
(env)$ python manage.py migrate
(env)$ python manage.py runserver

runserver 실행후 http://localhost:8000/ 에 접속하면 장고 화면이 나옴을 확인할 수 있습니다. 위 명령어로 간단히 동작하는 장고 프로젝트를 만든 것입니다.

app 폴더내에 requirements.txt 파일을 생성하고 장고 패키지 정보를 입력합니다.

Django==3.0.7

도커

도커를 설치하고(링크를 따라 Docker Desktop 을 설치할 것을 권장합니다), 역시 app 폴더에 Dockerfile 을 추가합니다.

# python 3.8.3 이미지를 베이스 이미지로 합니다
FROM python:3.8.3-alpine

# 작업용 디렉토리를 지정합니다
WORKDIR /usr/src/app

# 환경 변수를 설정합니다
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# 패키지들을 설치합니다
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# 호스트상의 프로젝트 파일들을 이미지 안에 복사합니다
COPY . .
  1. PYTHONDONTWRITEBYTECODE : 파이썬이 소스 모듈을 임포트 할 때 .pyc 파일을 쓰지 않습니다 (python -B 옵션과 동일)
  2. PYTHONUNBUFFERED : stdout 과 stderr 스트림을 버퍼링하지 않도록 만듭니다 (python -u 옵션과 동일)

다음으로 docker-compose.yml 파일을 프로젝트 경로에 생성합니다

version: '3.7'

services:
  web:
    build: ./app
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - ./app/:/usr/src/app/
    ports:
      - 8000:8000
    env_file:
      - ./.env.dev

settings.py 파일에서 SECRET_KEY, DEBUG, ALLOWED_HOSTS 세개 변수를 아래와 같이 수정합니다

SECRET_KEY = os.environ.get("SECRET_KEY")

DEBUG = int(os.environ.get("DEBUG", default=0))

# 'DJANGO_ALLOWED_HOSTS' 는 허용할 호스트 값의 문자열 값으로 스페이스 간격을 기준으로 자릅니다
# 예를들면, 'DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]'
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ")

.env.dev 파일을 만들어서 위에서 선언한 변수들에 들어갈 값을 입력합니다

DEBUG=1
SECRET_KEY=foo
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]

차례로 이미지를 빌드하고 컨테이너를 실행합니다

$ docker-compose build
$ docker-compose up -d

위 명령어 실행후 http://localhost:8000/ 에 접속하면 장고 화면이 나옴을 확인할 수 있습니다.

Postgres

postgres 설정을 위해선 docker-compose.yml 파일에 db 서비스를 추가해야합니다. Django 셋팅을 업데이트하기 위해선 Psycopg2 해당 패키지를 설치해야 합니다

docker-compose.yml 파일에 db 라는 서비스를 추가했습니다

version: '3.7'

services:
  web:
    build: ./app
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - ./app/:/usr/src/app/
    ports:
      - 8000:8000
    env_file:
      - ./.env.dev
    depends_on:
      - db
  db:
    image: postgres:12.0-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    environment:
      - POSTGRES_USER=hello_django
      - POSTGRES_PASSWORD=hello_django
      - POSTGRES_DB=hello_django_dev

volumes:
  postgres_data:

컨테이너가 종료되어도 db 데이터를 지속하기 위해 volumes 설정을 추가했습니다 해당 설정은 postgres_data 로 명명되어 컨테이너의 /var/lib/postgresql/data/ 해당 경로에 데이터가 저장됩니다

.env.dev 파일에 아래와 같이 web 서비스에서 사용될 새로운 환경변수들을 추가합니다

DEBUG=1
SECRET_KEY=foo
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
## 추가된 부분
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=hello_django_dev
SQL_USER=hello_django
SQL_PASSWORD=hello_django
SQL_HOST=db
SQL_PORT=5432

환경설정을 입력하는 settings.py 의 DATABASES 부분도 알맞게 수정합니다 환경설정 파일에서 해당하는 환경변수 값을 불러오고 해당값이 없을때는 뒤에 있는 디폴트값을 사용합니다.

DATABASES = {
    "default": {
        "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
        "NAME": os.environ.get("SQL_DATABASE", os.path.join(BASE_DIR, "db.sqlite3")),
        "USER": os.environ.get("SQL_USER", "user"),
        "PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
        "HOST": os.environ.get("SQL_HOST", "localhost"),
        "PORT": os.environ.get("SQL_PORT", "5432"),
    }
}

Psycopg2 를 설치할 수있게 추가 패키지들을 설치해야하고 도커파일에 해당부분을 업데이트합니다

# 공식 베이지 이미지를 pull 합니다
FROM python:3.8.3-alpine

# 작업공간을 설정합니다
WORKDIR /usr/src/app

# 환경 변수를 설정합니다
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# psycopg2 dependencies 설치합니다
RUN apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

# requirements 를 설치합니다
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# 프로젝트 소스를 복사합니다
COPY . .

requirements.txt 에 Psycopg2 관련 패키지를 추가합니다

Django==3.0.7
psycopg2-binary==2.8.5

변경된 새로운 이미지를 빌드하고 2개 컨테이너 (services 하위에 있는 web, db)를 실행시킵니다

$ docker-compose up -d --build

web 서비스내에서 migrate 작업을 실행시킵니다

$ docker-compose exec web python manage.py migrate --noinput

아래와 같은 에러가 발생한다면 정상입니다. 데이터베이스 내에 hello_django_dev 라는 데이터베이스가 없기때문입니다

django.db.utils.OperationalError: FATAL:  database "hello_django_dev" does not exists

아래 명령어로 모든 서비스 컨테이너를 정지시키고 volume도 함께 삭제합니다


$ docker-compose down -v

db 컨테이너에 dbname 은 hello_django_dev, username 은 hello_django 로 기본 장고 테이블을 설정합니다

$ docker-compose exec db psql --username=hello_django --dbname=hello_django_dev

아래명령어로 volume 이 잘생성되었고 제대로 동작하는지 확인해볼수있습니다

$ docker volume inspect django-on-docker_postgres_data
[
    {
        "CreatedAt": "2020-06-13T18:43:56Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "django-on-docker",
            "com.docker.compose.version": "1.25.4",
            "com.docker.compose.volume": "postgres_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/django-on-docker_postgres_data/_data",
        "Name": "django-on-docker_postgres_data",
        "Options": null,
        "Scope": "local"
    }
]

app 폴더에 아래 entrypoint.sh 을 추가해서 migration을 적용하기 전과 장고 개발서버를 실행시키기 전에 Postgres가 정상인지 확인할 수 있습니다

#!/bin/sh

if [ "$DATABASE" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $SQL_HOST $SQL_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

python manage.py flush --no-input
python manage.py migrate

exec "$@"

해당 스크립트의 권한을 변경해 스크립트가 동작 가능하게 합니다

$ chmod +x app/entrypoint.sh

도커파일 가장아래에 entrypoint 명령어를 사용해 entrypoint.sh 을 실행을 추가합니다

# 공식 베이지 이미지를 pull 합니다
FROM python:3.8.3-alpine

# 작업공간을 설정합니다
WORKDIR /usr/src/app

# 환경 변수를 설정합니다
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# psycopg2 dependencies 설치합니다
RUN apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

# requirements 를 설치합니다
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# 프로젝트 소스를 복사합니다
COPY . .

# run entrypoint.sh
ENTRYPOINT ["/usr/src/app/entrypoint.sh"]

.env.dev 파일에 DATABASE 변수를 추가합니다

DEBUG=1
SECRET_KEY=foo
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=hello_django_dev
SQL_USER=hello_django
SQL_PASSWORD=hello_django
SQL_HOST=db
SQL_PORT=5432
DATABASE=postgres ## 추가한 부분

다시 테스트를 해봅니다

  1. 이미지 재빌드
  2. 컨테이너 실행
  3. http://localhost:800 접속

노트

첫번째, Postgres 를 더했음에도 불구하고, 장고를 위한 독립적인 도커이미지를 생성할 수 있습니다 DATABASE 환경변수를 postgres 로 설정하지 않는한, 테스트를 위해 새로운 이미지를 빌드하고 컨테이너를 실행해봅니다

$ docker build -f ./app/Dockerfile -t hello_django:latest ./app
$ docker run -d \
    -p 8006:8000 \
    -e "SECRET_KEY=please_change_me" -e "DEBUG=1" -e "DJANGO_ALLOWED_HOSTS=*" \
    hello_django python /usr/src/app/manage.py runserver 0.0.0.0:8000

http://localhost:8006 에 접속하면 장고 페이지를 볼수있습니다

두번째, entrypoint.sh 에서 데이터베이스를 flush, migrate 하는 명령어를 주석처리해 컨테이너가 시작 또는 재시작할때마다 해당명령어가 실행되지 않게 합니다

#!/bin/sh

if [ "$DATABASE" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $SQL_HOST $SQL_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

# python manage.py flush --no-input
# python manage.py migrate

exec "$@"

주석처리한 대신에 컨테이너가 실행된 다음에 직접 실행시킬수있습니다

$ docker-compose exec web python manage.py flush --no-input
$ docker-compose exec web python manage.py migrate

Gunicorn

운영환경으로 가기위해선 Gunicorn 을 더해야합니다.

Django==3.0.7
gunicorn==20.0.4
psycopg2-binary==2.8.5

개발용으로 기존 장고 내장서버를 그대로 사용하기를 원한다면 운영용으로 사용할 docker-compose.prod.yml 컴포즈 파일을 만듭니다

version: '3.7'

services:
  web:
    build: ./app
    command: gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000
    ports:
      - 8000:8000
    env_file:
      - ./.env.prod
    depends_on:
      - db
  db:
    image: postgres:12.0-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - ./.env.prod.db

volumes:
  postgres_data:

command 에 주목해보면 이제는 장고 개발서버가 아닌 gunicorn으로 동작하게 됩니다. 운영 환경에서 더이상 필요하지 않기 떄문에 web service에서 volume 을 제거했습니다.

마지막으로 web 과 db에서 구분된 환경변수 파일을 사용해 컨테이너가 run 될때에 각각 다른 환경변수 파일이 사용됩니다. (./.env.prod 그리고 ./.env.prod.db)

.env.prod:

DEBUG=0
SECRET_KEY=change_me
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=hello_django_prod
SQL_USER=hello_django
SQL_PASSWORD=hello_django
SQL_HOST=db
SQL_PORT=5432
DATABASE=postgres

.env.prod.db:

POSTGRES_USER=hello_django
POSTGRES_PASSWORD=hello_django
POSTGRES_DB=hello_django_prod

두개 환경변수 파일은 프로젝트 경로에 위치하고 버전관리에서 제외하고 싶다면 .gitignore 파일에 추가하면 됩니다

-v 플래그를 사용해 개발 컨테이너를 종료시킵니다

$ docker-compose down -v

운영용 이미지를 빌드하고 나서 실행시키는 명령어 입니다

$ docker-compose -f docker-compose.prod.yml up -d --build

장고 기본 테이블로 hello_django_prod 가 제대로 생성되었는지 확인해봅니다. 테스트는 http://localhost:8000/admin 어드민 페이지 에서 확인합니다. 정적 파일들이 아직 로딩되지않았음을 확인할 수 있습니다. 디버그 모드가 꺼져있었기때문에 예상할 수 있었던 부분입니다. 간단히 고쳐보겠습니다.

컨테이너에서 에러가 발생했을때 에러 로그를 통해 어디에서 문제가 발생하는지 체크할 수 있습니다.

$ docker-compose -f docker-compose.prod.yml logs -f

운영용 도커파일

매번 컨테이너를 실행할떄마다 flush 와 migrate 명령어가 동작하던것을 기억하시나요? 개발환경에서는 괜찮지만 운영환경을 위해선 새로운 entrypoint 파일을 생성해야합니다.

entrypoint.prod.sh:

#!/bin/sh

if [ "$DATABASE" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $SQL_HOST $SQL_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

exec "$@"

개발환경때와 마찬가지로 파일 권한을 수정합니다

$ chmod +x app/entrypoint.prod.sh

위 파일을 사용해 운영환경에서 사용될 새로운 도커파일 Dockerfile.prod 를 생성합니다

###########
# BUILDER #
###########

# 공식 베이스 이미지를 pull
FROM python:3.8.3-alpine as builder

# 작업 공간설정
WORKDIR /usr/src/app

# 환경변수 설정
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# psycopg2 디펜던시 설치
RUN apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

# lint
RUN pip install --upgrade pip
RUN pip install flake8
COPY . .
RUN flake8 --ignore=E501,F401 .

# 디펜던시 설치
COPY ./requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt

#########
# FINAL #
#########

# 공식 베이스 이미지를 pull
FROM python:3.8.3-alpine

# app user를 위한 폴더 생성
RUN mkdir -p /home/app

# app user 생성
RUN addgroup -S app && adduser -S app -G app

# 적절한 디렉토리 생성
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
WORKDIR $APP_HOME

# 디펜던시 설치
RUN apk update && apk add libpq
COPY --from=builder /usr/src/app/wheels /wheels
COPY --from=builder /usr/src/app/requirements.txt .
RUN pip install --no-cache /wheels/*

# entrypoint-prod.sh 복사
COPY ./entrypoint.prod.sh $APP_HOME

# 프로젝트 파일 복사
COPY . $APP_HOME

# app user 모든 파일 권한변경
RUN chown -R app:app $APP_HOME

# app user 변경
USER app

# entrypoint.prod.sh 실행
ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"]

여기에서 우리는 최종 이미지 사이즈를 줄이기위해 multi-stage 빌드 도커를 사용합니다. builder 는 Python wheels 가 빌드되는 동안 사용되는 임시적인 이미지 입니다. wheels 는 최종 운영 이미지에 복사되고 나서 builder 이미지는 무시됩니다.

root 가 아닌 유저를 생성한 것을 기억하나요? 기본적으로 도커는 컨테이너 내부에선 root 계정과 같은 권한으로 컨테이너 프로세스를 동작시킵니다. 따라서 공격자가 도커 호스트의 root 권한을 갖을 수 있게 하는 것은 컨테이너 관리를 함에 있어서 좋지 않은 사례입니다. 컨테이너 안에서 root 권한을 갖는다면 호스트 상에서도 root 일 것이기 때문입니다.

docker-compose.prod.yml 파일의 web 서비스 부분에서 사용하는 도커파일을 Dockerfile.prod 로 수정합니다

web:
  build:
    context: ./app
    dockerfile: Dockerfile.prod
  command: gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000
  ports:
    - 8000:8000
  env_file:
    - ./.env.prod
  depends_on:
    - db

실행해봅니다.

$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput

Nginx

예를 들면 static file의 전송과 같은 클라이언트 요청을 제어하는 gunocorn 의 reverse proxy 서버 역할로 nginx를 추가해봅니다.

docker-compose.prod.yml 에 서비스를 더합니다

nginx:
  build: ./nginx
  ports:
    - 1337:80
  depends_on:
    - web

프로젝트 경로에 nginx 폴더를 생성한후 해당 폴더 안에 Dockerfile, nginx.conf 파일을 생성합니다

도커파일은

FROM nginx:1.19.0-alpine

RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d

nginx.conf 는

upstream hello_django {
    server web:8000;
}

server {

    listen 80;

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

}

docker-compose.prod.yml 파일의 web 서비스에서 ports 를 expose 로 교체합니다.

web:
  build:
    context: ./app
    dockerfile: Dockerfile.prod
  command: gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000
  expose:
    - 8000
  env_file:
    - ./.env.prod
  depends_on:
    - db

이제 8000번 포트는 내부적으로만 다른 도커 서비스에 노출됩니다. 포트는 더이상 호스트 머신에 공개되지 않습니다.

다시 테스트 해봅니다.

$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput

web 서비스는 8000 포트에 열려있고 nginx.conf 에서 8000포트는 80번 포트에 맵핑, docker-compose.prod.yml 파일에서 nginx 는 80번 포트가 1337 포트에 맵핑되어 있습니다. 따라서 외부에서 접속할 수 있는 포트는 1337 포트입니다 http://localhost:1337 접속되는걸 확인할 수 있습니다

컨테이너를 종료시킵니다.

$ docker-compose -f docker-compose.prod.yml down -v

Gunicorn은 application 서버이기 때문에 static file을 전송하지 않습니다. 그럼 static, media 파일을 어떻게 다룰 수 있을까요?

Static Files

settings.py 에 아래 환경변수를 추가합니다.

STATIC_URL = "/staticfiles/"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")

개발환경

이제 해당 경로의 어느 요청이든 [http://localhost:8000/staticfiles/](http://localhost:8000/staticfiles/) staticfiles 폴더에서 파일이 서빙됩니다 테스트를 위해 최초의 이미지를 재빌드하고 새로운 컨테이너를 실행시켜봅니다

http://localhost:8000/admin 접속해보면 static 파일이 정확히 서빙되는 것을 확인할 수 있습니다

운영환경

운영환경을 위해 docker-compose.prod.yml 에 있는 web 과 nginx 서비스에 볼륨을 추가합니다. 각 컨테이너는 staticfiles 라는 이름의 디렉토리를 서로 공유할 수 있습니다

version: '3.7'

services:
  web:
    build:
      context: ./app
      dockerfile: Dockerfile.prod
    command: gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000
    volumes:
      - static_volume:/home/app/web/staticfiles # 공유 디렉토리
    expose:
      - 8000
    env_file:
      - ./.env.prod
    depends_on:
      - db
  db:
    image: postgres:12.0-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - ./.env.prod.db
  nginx:
    build: ./nginx
    volumes:
      - static_volume:/home/app/web/staticfiles # 공유 디렉토리
    ports:
      - 1337:80
    depends_on:
      - web

volumes:
  postgres_data:
  static_volume:

동작을 위해선 Dockerfile.prod 파일에 /home/app/web/staticfiles 폴더를 생성해야합니다.

...

# 적절한 경로를 생성합니다
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
RUN mkdir $APP_HOME/staticfiles
WORKDIR $APP_HOME

...

이러한 작업이 필요한 이유는 무엇일까요? 도커 컴포즈는 일반적으로 root 사용자로써 볼륨을 마운트하는데 현재 우리가 사용하고 있는 root 가 아닌 사용자인 경우, 권한문제가 발생해 collectstatic 명령어가 동작하지 않을 수 있습니다

해당 이슈를 해결하려면 아래와 같은 방법을 사용할 수있는데

  1. 도커파일 안에 폴더를 생성한다
  2. 마운트 된 폴더의 권한을 변경한다

여기서는 1번 방법을 사용해 진행해보겠습니다

다음으로 Nginx 설정에서 static file 요청이 라우팅 되는 staticfiles 폴더 설정을 수정합니다

upstream hello_django {
    server web:8000;
}

server {

    listen 80;

    location / {
        proxy_pass http://hello_django;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }
	
	 # static 관련 추가된 부분
    location /staticfiles/ {
        alias /home/app/web/staticfiles/;
    }

}

개발계 컨테이너를 잠시 내리고 테스트를 해봅니다

$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput
$ docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --no-input --clear

다시 [http://localhost:1337/staticfiles/*] 에 접속하면 staticfiles 폴더에서 파일이 서빙됩니다

http://localhost:1337/admin 어드민 페이지에 접속했을때에도 static 파일들이 정확히 로딩 되는 것을 확인할 수 있습니다

또한 docker-compose -f docker-compose.prod.yml logs -f

로그를 통해서도 static 파일들이 Nginx 를 통해 성공적으로 서빙됨을 확인할 수 있습니다

nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /admin/ HTTP/1.1" 302 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /admin/login/?next=/admin/ HTTP/1.1" 200 1928 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /staticfiles/admin/css/base.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /staticfiles/admin/css/login.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /staticfiles/admin/css/responsive.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /staticfiles/admin/css/fonts.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /staticfiles/admin/fonts/Roboto-Regular-webfont.woff HTTP/1.1" 304 0 "http://localhost:1337/staticfiles/admin/css/fonts.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"
nginx_1  | 172.31.0.1 - - [13/Jun/2020:20:35:47 +0000] "GET /staticfiles/admin/fonts/Roboto-Light-webfont.woff HTTP/1.1" 304 0 "http://localhost:1337/staticfiles/admin/css/fonts.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" "-"

컨테이너를 다시 종료합니다

$ docker-compose -f docker-compose.prod.yml down -v

Media Files

media 파일을 다루는걸 테스트하기 위해선 Django 의 새로운 앱을 생성해야합니다

$ docker-compose up -d --build
$ docker-compose exec web python manage.py startapp upload

settings.py 파일에서 INSTALLED_APPS 새롭게 생성한 upload 를 추가합니다

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    "upload",
]

app/upload/views.py 을 아래와 같이 작성합니다

from django.shortcuts import render
from django.core.files.storage import FileSystemStorage

def image_upload(request):
    if request.method == "POST" and request.FILES["image_file"]:
        image_file = request.FILES["image_file"]
        fs = FileSystemStorage()
        filename = fs.save(image_file.name, image_file)
        image_url = fs.url(filename)
        print(image_url)
        return render(request, "upload.html", {
            "image_url": image_url
        })
    return render(request, "upload.html")

“app/upload” 폴더에 “templates” 폴더를 생성하고 새로운 template 파일 upload.html 을 추가합니다

<form action="{% url "upload" %}" method="post" enctype="multipart/form-data">
    {% csrf_token %}
    <input type="file" name="image_file">
    <input type="submit" value="submit" />
  </form>

  {% if image_url %}
    <p>File uploaded at: <a href="{{ image_url }}">{{ image_url }}</a></p>
  {% endif %}

app/hello_django/urls.py urls.py 파일을 아래와 같이 생성합니다

from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static

from upload.views import image_upload

urlpatterns = [
    path("", image_upload, name="upload"),
    path("admin/", admin.site.urls),
]

if bool(settings.DEBUG):
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

app/hello_django/settings.py: settings.py 에 MEDIA 와 관련된 설정을 추가합니다

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

개발환경

테스트를 위해

$ docker-compose up -d --build

이제 http://localhost:8000/ 에서 이미지를 업로드할 수 있고 업로드한 이미지를 http://localhost:8000/mediafiles/IMAGE_FILE_NAME 에서 확인할 수 있습니다

운영환경

운영환경을 위해서 web 과 nginx 서비스에 또다른 볼륨을 추가합니다

version: '3.7'

services:
  web:
    build:
      context: ./app
      dockerfile: Dockerfile.prod
    command: gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000
    volumes:
      - static_volume:/home/app/web/staticfiles
      - media_volume:/home/app/web/mediafiles # 추가된 media 공유 디렉토리
    expose:
      - 8000
    env_file:
      - ./.env.prod
    depends_on:
      - db
  db:
    image: postgres:12.0-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - ./.env.prod.db
  nginx:
    build: ./nginx
    volumes:
      - static_volume:/home/app/web/staticfiles
      - media_volume:/home/app/web/mediafiles # 추가된 media 공유 디렉토리
    ports:
      - 1337:80
    depends_on:
      - web

volumes:
  postgres_data:
  static_volume:
  media_volume:

Dockerfile.prod 파일에 “/home/app/web/mediafiles” 폴더를 생성합니다

...

# 적절한 폴더를 생성합니다
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
RUN mkdir $APP_HOME/staticfiles
RUN mkdir $APP_HOME/mediafiles
WORKDIR $APP_HOME

...

Nginx 설정을 수정합니다

upstream hello_django {
    server web:8000;
}

server {

    listen 80;

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

    location /staticfiles/ {
        alias /home/app/web/staticfiles/;
    }
		
	 # 추가된 부분
    location /mediafiles/ {
        alias /home/app/web/mediafiles/;
    }

}

다시 빌드합니다

$ docker-compose down -v

$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput
$ docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --no-input --clear

최종적으로 확인해보면

  1. http://localhost:1337/ 에서 이미지를 업로드합니다
  2. http://localhost:1337/mediafiles/IMAGE_FILE_NAME 에서 이미지를 확인합니다

혹시 413 Request Entity Too Large 에러를 만나게된다면 Nginx 설정에서 클라이언트 request body 에 허용되는 최대파일의 크기를 변경하면 됩니다

예를 들면, 아래와 같습니다

location / {
    proxy_pass http://hello_django;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_redirect off;
    client_max_body_size 100M; # 추가된 부분
}

이로써 Django 그리고 Postgres, Gunicorn, Nginx 를 도커환경에 구축해보았습니다. 다음 포스팅에서 해당 셋팅을 바탕으로 AWS Elastic beanstalk 을 활용한 Travis CI/CD 환경 구축을 진행해보겠습니다

긴 글 읽어주셔서 감사합니다