DRF 배포[1]: 밑바닥부터 알아보는 Docker, 배포 자동화 AWS VPC, EC2, RDS

YoungJoon Jang
27 min readMay 6, 2024

지난 글에서는 DRF를 이용해서 카카오 소셜로그인을 마무리했다. 이렇게 하나의 기능을 추가하고, 프론트와의 개발 병행을 위해 배포를 먼저 해야겠다고 다짐했다.

배포를 처음 해보며 정말 많은 문제에 부딪혔는데, 본 글에서는 docker와 docker-compose가 어떤 역할을 하는지, 배포 자동화는 어떻게 이루어지는지, AWS 내부에서는 어떤 과정이 이루어지는지 등 밑바닥부터 알아볼 예정이다.

먼저, 내가 프로젝트에서 사용한 인프라 아키텍처는 다음과 같다.

간단하게 설명해보면 다음과 같다.

  1. 코드를 작성하여 깃허브에 업로드하면, GITHUB ACTIONS를 통해 자동으로 VPC 내부의 퍼블릭 서브넷에 위치한 EC2 인스턴스에 업로드된다.
  2. EC2 인스턴스는 2개의 docker container로 이루어져 있으며, 하나는 django web을, 다른 하나는 nginx를 담고 있고, 이들은 gunicorn으로 연결된다.
  3. EC2는 프라이빗 서브넷에 위치한 AWS RDS와 통신하며, 본 프로젝트에서는 Mysql을 기본 데이터베이스로 사용하였다.
  4. 유저의 요청이 들어오면, Route53에 등록된 Elastic Load Balancer를 통해서 로드밸런싱이 이루어지고, 유저의 요청은 VPC로 향하게 되어 요청이 이루어진다.

이제 이 기능들을 어떻게 구현했는지 하나하나 설명해보도록 하겠다. 최종 코드는 링크에서 확인이 가능하다.

1. Docker / Docker-compose

먼저 배포 자동화에 관해 설명하기 전, docker 및 docker-compose를 통해 개발 및 배포 환경을 구성한 것에 대해 설명해보고자 한다.

1–1. Docker는 무엇이고 어떤 역할을 하는가?

Docker를 사용하지 않고 배포하는 상황을 가정해 보자. 먼저 AWS에 접속하여 EC2에서 서버를 만들면 ubuntu OS로 인스턴스가 뜨는데 (ubuntu OS를 사용할 경우), 이때 이 안에는 아무것도 없다. Python이나 Git, Mysql 등등 아무것도 없어서, 서버를 띄우기엔 일일이 설치해줘야 할 것들이 정말 많고, 이것들을 매번 하기도 매우 귀찮다.

또 위에서는 ubuntu를 사용했지만, 만약에 서버를 ubuntu가 아닌 다른 OS로 띄운다고 하면 설치 방법도 달라지니 모두 대응 해줘야 한다. 게다가 서버와 로컬의 환경도 다르니 막상 배포하고 나서 OS가 달라서 생기는 문제도 생기기 마련이다.

이러한 상황을 해결해 주는 것이 Docker다. 어떤 OS에서도 같은 환경을 만들어주는것. 그래서 서버에 Docker만 깔고 배포를 해도 되는것이다.

그럼 Docker와 Docker-compose의 차이는 무엇일까?

Docker가 실행하는 Dockerfile은 하나의 Image를 만들기 위한 과정이다. (여기서 말하는 Image는 내가 구축한 환경을 캡쳐해둔 것이라고 생각하면 편하다.) 이 Image만 있다면 다른 컴퓨터에서도 똑같은 환경을 만들 수 있게 된다.

그리고 이 이미지를 여러 개 띄워서 서로 네트워크도 만들어주고 컨테이너의 밖의 호스트와도 어떻게 연결할지, 파일 시스템은 어떻게 공유할지(volumes) 제어해주는것이 docker-compose이다.

1–2. 이 기술을 프로젝트에 어떻게 활용하는가? (개발 환경 구축)

본래 DRF로 개발 시 “python manage.py runserver”를 터미널에 작성하여 프로세스를 시작하고 개발하지만, 여기서는 Dockerfile을 만들어 환경(Image)를 구축하고, Docker-compose를 통해 이미지를 띄워보자.

먼저 Dockerfile은 다음과 같다.

FROM python:3.10.0-alpine
ENV PYTHONUNBUFFERED 1

RUN mkdir /app
WORKDIR /app

# dependencies for psycopg2-binary
RUN apk add --no-cache mariadb-connector-c-dev libffi-dev gcc musl-dev
RUN apk update && apk add python3 python3-dev mariadb-dev build-base && pip3 install mysqlclient

COPY requirements.txt /app/requirements.txt
RUN pip install -r requirements.txt

COPY . /app/

이 dockerfile을 통해 python3.10 을 베이스로 하는 Docker Image 내부의 환경에서 app이라는 폴더가 만들어지고, 거기에 필요한 라이브러리들이 설치된다.

다음은 docker-compose.yml 파일이다.

version: '3'
services:

db:
container_name: db
image: mariadb:latest
restart: always
expose:
- 3306
ports:
- '3307:3306'
env_file:
- .env
volumes:
- dbdata:/var/lib/mysql

web:
container_name: web
build:
context: ./
dockerfile: Dockerfile
command: sh -c "python manage.py makemigrations && python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=resumai.settings.dev"
environment:
DJANGO_ENV: development
env_file:
- .env
restart: always
ports:
- "8000:8000"
volumes:
- .:/app
depends_on:
- db
volumes:
app:
dbdata:

본 프로젝트에서는 데이터베이스를 db라는 컨테이너에, 웹서버를 web이라는 컨테이너로 하여 2개의 컨테이너를 만들었다.

  • web container와 관련하여 작성된 부분을 보면, 이전에 작성한 Dockerfile을 베이스로 한 환경에서
    “python manage.py makemigrations && python manage.py migrate && python manage.py runserver 0.0.0.0:8000 — settings=resumai.settings.dev” 를 실행하는 것을 알 수 있다. 이로써 web container가 8000번 포트를 통해 통신된다.
  • db container와 관련하여 작성된 부분을 보면, mariadb 를 데이터베이스로 사용하며, 3306번 포트를 사용하여 통신된다.

이렇게 작성하고 “docker-compose up — build” 후 “docker-compose up -d” 명령어를 실행하면 2개의 container가 만들어진다. (-d는 detach 명령어로, 백그라운드에서도 떠 있게 하는 명령어이다.)

docker ps를 해보면 다음과 같다.

출력된 화면을 보면, 8000번 포트를 통해 resumai-server_web 이미지가, 3307:3306로 연결된 포트를 통해 mariadb 이미지가 컨테이너화 되어 떠 있다. 이렇게 2개의 container를 띄우는 데 성공했다. 실제로 127.0.0.1:8000 주소로 접속해보면 다음과 같이 화면도 잘 뜬다.

1–3. 배포 환경을 구축해보자!

위와 비슷하게, 배포 시에도 dockerfile과 docker-compose.prod.yml을 작성해주어야 한다. 대신 이 파일들은 내 로컬 컴퓨터가 아닌, AWS VM의 ubuntu 서버에서 container를 띄우고 실행시키는 역할을 한다.

Dockerfile.prod

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

# pull official base image
FROM python:3.10.0-alpine as builder

# set work directory
WORKDIR /usr/src/app


# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install psycopg2 dependencies
RUN apk update && apk add python3 python3-dev mariadb-dev build-base && pip3 install mysqlclient

# install dependencies
COPY ./requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt


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

# pull official base image
FROM python:3.10.0-alpine

# create directory for the app user
RUN mkdir -p /home/app

# create the app user
RUN addgroup -S app && adduser -S app -G app

# create the appropriate directories
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
RUN mkdir $APP_HOME/static
RUN mkdir $APP_HOME/media
WORKDIR $APP_HOME

# install dependencies
RUN apk update && apk add libpq
RUN apk update \
&& apk add --virtual build-deps gcc python3-dev musl-dev \
&& apk add --no-cache mariadb-dev mariadb-client
COPY --from=builder /usr/src/app/wheels /wheels
COPY --from=builder /usr/src/app/requirements.txt .
RUN pip install mysqlclient
RUN pip install --no-cache /wheels/*
RUN apk del build-deps

# copy entrypoint-prod.sh
COPY ./config/docker/entrypoint.prod.sh $APP_HOME

# copy project
COPY . $APP_HOME

# change owner of all the files to the app user
RUN chown -R app:app $APP_HOME

# change to the app user
USER app

개발 시의 dockerfile보다는 길지만, 사실상 실행하는 동작은 거의 비슷하다: python3.10-alpine을 이용하여 app 폴더를 만들어 필요한 라이브러리들을 설치한다.

다만 마지막의 “COPY ./config/docker/entrypoint.prod.sh $APP_HOME” 명령어가 추가되었는데, 이 config/docker/entrypoint.prod.sh 파일은 다음과 같이 migration 작업과 static 파일들을 처리해주는 작업을 담당하는 명령어들이 담긴 파일이다.

#!/bin/sh

python manage.py makemigrations --no-input

python manage.py migrate --no-input

python manage.py collectstatic --no-input

exec "$@"

이로써 ubuntu OS를 가진 AWS VM의 /home/app 디렉토리에 web 환경을 구축했다. 이를 docker-compose 파일로 실행시켜보자.

#docker-compose.prod.yml
version: '3'
services:

web:
container_name: web
build:
context: ./
dockerfile: Dockerfile.prod
command: gunicorn resumai.wsgi:application --bind 0.0.0.0:8000 -t 120
environment:
DJANGO_SETTINGS_MODULE: resumai.settings.prod
DJANGO_ENV: production
env_file:
- .env.prod
volumes:
- static:/home/app/web/static
- media:/home/app/web/media
expose:
- 8000
entrypoint:
- sh
- config/docker/entrypoint.prod.sh

nginx:
container_name: nginx
build: ./config/nginx
volumes:
- static:/home/app/web/static
- media:/home/app/web/media
ports:
- "80:80"
depends_on:
- web

volumes:
static:
media:

가장 처음 위 파일을 봤을 때, 개발 시 작성했던 docker-compose.yml 파일과 다른 점은 “db” 컨테이너와 관련된 항목이 없다는 점이다. 왜일까?

  • 배포 환경에서 db 컨테이너가 있으면 위험하다. 데이터가 날아갈/유출될 위험이 있기 때문이다.
  • 보통 서버는 상황에 따라 여러 인스턴스를 띄울수도 있고 지울수도 있고 하는데, 서버에 db를 띄운다면 다른 서버가 db에 붙지도 못하고, 인스턴스를 날리면 데이터도 함께 날아가게 된다.
  • 또, 인스턴스의 자원(메모리, cpu 등)을 서버와 db가 같이 쓰니까 효율적이지도 않다. 만약 서버가 해킹당한다면, 서버의 코드만 털리면 되는 일을 개인정보까지 털리게 되는 일로 커질 수 있게 된다.

그래서 production의 docker-compose에는 db 컨테이너가 없다.

1–4. Nginx는 무엇인가?

그리고 또 달라진 점을 살펴보니, nginx라는 컨테이너가 생겼다. Nginx는 무엇이고 nginx container는 어떻게 구성될까?

일반적으로 우리가 Web Application Server (WAS)라고 부르는 것은 web에서 application을 serving하는 놈들이다. 여기서 중요한 점은 application과 server를 구분해야 한다는 것이다.
결론부터 말하자면, application은 django이고, server가 nginx이다.

일반적으로 백엔드 개발자가 코드를 짠다면, 그건 비즈니스를 구현하는 것이다. 비즈니스는 쉽게 변할 수 있고, 복잡하다. Django는 이를 대신 구현해주고, 이게 곧application이다. 그래서 django를 web server라고 부른다면 엄밀히 말해 틀린 것이다.

Nginx는 이 Django라는 application에 접근하고 요청과 응답을 전달할 수 있게 도와주는 도구이다. 그래서 사용자가 요청을 보내면 해당 요청은 nginx라는 web server를 통해 django로 전달된다. 그림으로 이 전 과정을 표현하면 다음과 같다.

여기서 Nginx와 Django 사이에 Gunicorn이라는 것이 껴 있는 것을 볼 수 있다. Gunicorn은 Django의 가장 흔한 WSGI (Web Server Gateway Interface)이다. Django에서는 WSGI를 python의 표준 웹서버로 사용하는데, 이 웹서버가 따로 필요한 이유는 여러 이유가 있다.

  • Application을 여러 대(process혹은 thread) 띄우고 웹 서버가 이를 적절하게 로드밸런싱 하기 위함
  • 보안상 위험한 요청을 차단하기 위함 등등의 용도가 있다.

그럼 위 docker-compose.prod.yml 파일에서 nginx container의 build 부분에 있는 경로 ‘./config/nginx’ 에는 무엇이 있을까? 바로 다음과 같다.

nginx 설정에 포함될 dockerfile과 nginx.conf 파일
# nginx/Dockerfile
FROM nginx:1.19.0-alpine

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

각각의 명령어에 대한 설명은 다음과 같다.

FROM nginx:1.19.0-alpine

  • nginx의 1.19.0-alpine 버전 이미지를 사용한다. 이 이미지는 이미 만들어진 이미지이며, nginx 구동에 필요한 환경들이 다 포함돼 있다.

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

  • default config 파일을 삭제한다. 아래에서 우리가 원하는 설정파일로 바꿔줄 예정이다.

COPY nginx.conf /etc/nginx/conf.d

  • nginx.conf라는 파일을 삭제한 위치에 옮겨준다.

nginx.conf 파일은 다음과 같다. 설명은 주석을 통해 달아두었다.

# nginx/nginx.conf
upstream resumai { # django_docker라는 upstream 서버를 정의
server web:8000; # web의 8000포트에 연결. web은 docker container임
}

server { # nginx server를 정의

listen 80; # 80포트를 열어줌 (http)

location / { # "/" 도메인에 도달하면 아래 proxy를 수행
proxy_pass http://resumai; # resumai라는 upstream으로 요청을 전달

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # header 설정
proxy_set_header Host $host;
proxy_redirect off;

proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
send_timeout 300s;
}

location /static/ { # "/static/" 도메인에 도달하면 아래 alias를 수행
alias /home/app/web/static/; # 아래 디렉토리 (서버의 파일시스템)을 매핑
}

location /media/ {
alias /home/app/web/media/; # 위와 동일
}
}

Upstream 서버란?

  • 일반적인 프록시 구조에서, 요청을 받는 쪽을 upstream, 응답을 받는 쪽을 downstream이라고 한다. (상대적인 개념임.) nginx 입장에서는 django가 upstream이 된다.

한 컨테이너에서 다른 컨테이너로의 접근?

  • 위와 같이 web이라는 컨테이너의 호스트명을 써주면 된다. 이건 모두 docker-compose를 통해 실행되기 때문에 가능한 것이다.

Static과 media란?

  • 일반적으로 static과 media 파일은 다른 요청에 비해 사이즈가 매우 크다. 그리고 디렉토리 구조가 복잡해서 파일을 찾아나가는 것도 비용이다. 그래서 이를 캐싱하려는 용도로 두 설정은 nginx에서 따로 정의하는 것이다.

1–5. Gunicorn은 무엇인가?

이렇게 nginx의 dockefile과 nginx.conf 파일을 이용해서 docker-compose.yml 파일에 작성된 대로 nginx 라는 container를 만들 수 있다.

그런데 docker-compose.prod.yml 파일을 보면 web container command에 gunicorn과 관련한 명령어가 있는 것을 알 수 있다. nginx를 통과한 후, gunicorn을 통해 django와 통신할 수 있기 때문에 gunicorn과 관련된 설정을 따로 해줘야 하는 것이다. 이를 위해 commandentrypoint를 정의해야 한다.

Gunicorn을 실행시키기 위해 아래 Command를 실행한다.

web:
command: gunicorn resumai.wsgi:application --bind 0.0.0.0:8000

이렇게 Gunicorn이 실행된 후, 아래 entrypoint를 실행한다.

entrypoint:
- sh
- config/docker/entrypoint.prod.sh
#!/bin/sh

python manage.py makemigrations --no-input

python manage.py migrate --no-input

python manage.py collectstatic --no-input

exec "$@"

이는 앞서 개발 환경 구축 시 정의한 entrypoint와 같은 entrypoint인데, 이를 정의함으로써 web container가 실행될 수 있게 된다.

이제 비로소 user의 요청이 nginx와 gunicorn을 거쳐 django application에 도달 할 수 있게 됐다 !!

2. 배포 자동화

이제 유저가 django와 소통하는 방식에 대해서는 알게 됐는데, 배포하는 개발자 입장에서는 아래와 같은 의문이 생긴다.

  1. 어떻게 내 EC2 인스턴스로 들어가 docker를 실행시켜주느냐?
  2. 그리고 방금 푸시한 커밋을 어떻게 복사하는가?

이 과정을 Github Actions가 해준다. 그리고 이 모든 과정을 CD(Continuous Delivery)라고 부른다.

2–1. Setting

이 Github Actions를 다시 간단하게 말하면, 내가 작업하고 푸시한 커밋이 자동으로 VM 에 반영되도록 하는 것이다. 명령어 파일은 다음과 같다.

# .github/workflows/deploy.yml

name: Deploy to EC2

on:
push:
branches:
- dev

jobs:

build:
name: Build
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@master

- name: create env file
run: |
touch .env
echo "${{ secrets.ENV_VARS }}" >> .env.prod

- name: create remote directory
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: mkdir -p /home/ubuntu/srv/ubuntu

- name: copy source via ssh key
uses: burnett01/rsync-deployments@4.1
with:
switches: -avzr --delete
remote_path: /home/ubuntu/srv/ubuntu/
remote_host: ${{ secrets.HOST }}
remote_user: ubuntu
remote_key: ${{ secrets.KEY }}

- name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: |
sh /home/ubuntu/srv/ubuntu/config/scripts/deploy.sh

명령어들을 조각조각 살펴보자.

name: Deploy to EC2

on:
push:
branches:
- dev

먼저 위 명령어는 Github Actions가 작동될 때의 이름과 어느 브랜치에 push 될 때 Github Actions가 활성화되는지를 알려주는 명령어이다. 본인은 기본 브랜치를 dev로 바꿨기 때문에 이렇게 설정했다.

jobs:

build:
name: Build
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@master

- name: create env file
run: |
touch .env
echo "${{ secrets.ENV_VARS }}" >> .env.prod

- name: create remote directory
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: mkdir -p /home/ubuntu/srv/ubuntu

- name: copy source via ssh key
uses: burnett01/rsync-deployments@4.1
with:
switches: -avzr --delete
remote_path: /home/ubuntu/srv/ubuntu/
remote_host: ${{ secrets.HOST }}
remote_user: ubuntu
remote_key: ${{ secrets.KEY }}

- name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: |
sh /home/ubuntu/srv/ubuntu/config/scripts/deploy.sh

이후부터는 쭉 수행하는 작업에 대한 명령어이다.

  1. checkout: 이 작업은 연결된 github repository의 코드를 가져와 VM의 환경에 올려놓는 작업이다. 실제로 적용하면 다음과 같다.

2. create env file: 이름을 통해 직관적으로 알 수 있는 작업이다. 이는 내가 사용하는 env 파일을 생성하는 작업이다. 다만 github secrets에 내 .env 파일들을 모두 복붙해놓아야 이 key들이 반영될 수 있다. 실행하면 다음과 같다.

3. create remote directory: 이는 github actions로 원격 서버 (ec2)에 접속하여 디렉토리를 만드는 작업이다. 당연히 ec2에 접속하기 위해서는 .pem 등의 키체인 파일이 있어야 하기 때문에, 이 또한 github secrets에 저장해 두어야 한다. 이 작업을 수행하면 /home/ubuntu/srv/ubuntu 폴더가 만들어지게 된다.

참고로, appleboy/ssh-action@master는 TCP 프로토콜을 이용해서, 원격 ec2에 접속하고, sh 파일을 수행한다는 내용이다. 수행 후의 결과는 다음과 같다.

4. copy source via ssh key: burnett01/rsync-deployments@4.1 액션을 사용하여 소스 코드나 파일을 원격 서버로 복사하는 과정을 자동화하는 것을 목적으로 한다. rsync 는 파일과 디렉토리를 동기화하는 데 사용되는 효율적인 도구로, 네트워크를 통한 데이터 전송 시 많은 최적화를 제공한다.

간단히 말해 앞서 checkout에서 내 VM 환경에 올려뒀던 코드를 원격 ec2의 코드들과 비교하여 달라진 부분만 효율적으로 전송한다는 내용이다. 실행 화면은 다음과 같다.

이렇게 새로 작업되거나 변경된 내용들만 sync 된다.

4. executing remote ssh commands using password: 마지막으로 appleboy/ssh-action@master 액션을 사용하여 원격 ssh에서 commands를 실행한다. 여기서 실행하는 command는 /home/ubuntu/srv/ubuntu/config/scripts/deploy.sh 경로에 있는 command인데, 이 파일은 다음과 같다.

#!/bin/bash

# Installing docker engine if not exists
if ! type docker > /dev/null
then
echo "docker does not exist"
echo "Start installing docker"
sudo apt-get update
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
sudo apt update
apt-cache policy docker-ce
sudo apt install -y docker-ce
fi

# Installing docker-compose if not exists
if ! type docker-compose > /dev/null
then
echo "docker-compose does not exist"
echo "Start installing docker-compose"
sudo curl -L "https://github.com/docker/compose/releases/download/1.27.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
fi

echo "start docker-compose up: ubuntu"
sudo docker-compose -f /home/ubuntu/srv/ubuntu/docker-compose.prod.yml up --build -d

이 스크립트를 통해 docker가 설치돼 있는지, docker-compose가 설치돼 있는지 확인한 후, 이들을 설치한다. 이후, 최종적으로 docker-compose를 통해 앞서 작업했던Dockerfile과 docker-compose.yml 파일을 실행시킨다. 이렇게 되면 모든 사항이 반영되고 성공적으로 배포가 된다 !!!

이렇게 Docker와 Docker-compose를 어떻게 활용하는지, Github Actions를 통해 배포 자동화를 어떻게 하는지에 대해 알아보았다. 다음 글에서는 AWS 세팅에 대해 다뤄볼 예정이다.

--

--