Deploy scalable django on ECS (part 1)

박준영
13 min readDec 8, 2022

--

Photo by Faisal on Unsplash

운영환경에서 사용할 수 있는 ECS 서버구축 경험을 공유하려고 합니다. 조금 포괄적인 솔루션이다보니 여러차례 나누어 포스팅하려고 합니다.

Lightsail에서 사용중인 서비스를 확장가능성 있는 서버가 될 수 있도록 필요하게 되었고, ElasticBeanstalk와 ECS 사이에서 고민하다 ECS를 사용하기로 결정하였습니다. 같은 고민을 하고 계시다면 이전 기사를 참고하여 현재 상황에 맞는 서비스를 선택하시길 바랍니다.

ECS 서비스 이해하기

ECS는 태스크 정의, 서비스, 클러스터 크게 세가지로 구분할 수 있습니다.
태스크 정의는 도커파일이 어떻게 구성되어야하는지 정의합니다. 서비스는 태스크 정의를 묶어서 서비스 단위로 관리할 수 있으며 실행되어야 하는 태스크 수, 배포옵션, 서비스 오토스케일링, 작업 배치 등을 설정할 수 있습니다.클러스터는 이 서비스들을 관리할 수 있는 것으로 가장 큰 범위를 가지며 ECS 서비스를 사용하기 위해서 클러스터는 필수로 필요합니다.

ECS 시작유형

ECS를 시작하는 방법에는 EC2 시작유형과 Fargate 유형 두 가지가 있습니다.

1. EC2 유형

서버를 직접 프로비저닝, 관리해야합니다.
ECS 인스턴스는 기존 EC2와 달리 ECS Agent가 도커에 의해 실행됩니다. ECS Agent는 각각의 EC2 인스턴스를 Amazon ECS 서비스와 지정된 ECS 클러스터에 등록합니다. ECS 태스크를 시작하면 자동으로 EC2 인스턴스에 위치합니다.

2. Fargate 유형
서버리스로 관리해야할 EC2 인스턴스가 없습니다.
ECS 클러스터가 있다면 ECS 태스크를 정의하는 태스크 정의만 있다면 AWS가 필요한 CPU나 RAM에 따라 ECS 태스크를 대신 실행니다. 확장하려면 간단히 태스크 수만 늘리면 됩니다.

IAM 역할

EC2 시작유형에서 ECS Agent를 실행하면 ECS Instance Profile을 생성합니다. 이 ECS Instance Profile을 이용해 다양한 API를 호출할 수 있습니다.
그러면 CloudWatch 로그에 API 호출을 해서 컨테이너 로그를 보내고 ECR로부터 도커 이미지를 가져올 수 있습니다. Secrets Manager나 SSM Parameter Store에서 민감 데이터한 데이터를 가져올 수도 있습니다.

시작유형과 상관없이 태스크는 Task Role이라는 것을 가집니다. 역할이 각자 다른 ECS 서비스에 연결할 수 있게 하기 때문에 두 개의 태스크가 있다면 각자에 특정 Task Role을 지정할 수 있습니다.
Instance Profile과 Task Role의 차이를 이해하셔야 합니다.
Intance Profile은 EC2가 가지는 권한이고 Task Role은 각각의 작업들이 가지는 권한이라고 생각할 수 있습니다. Task가 S3의 이미지를 가져오고 Dynamo DB 등에 저장한다고 하면 Task Role에 해당 역할을 추가해주어야 합니다.

프로젝트 구조 & 핵심 파일

project
|
├── settings
│ ├── __init__.py
│ ├── common.py
│ ├── dev.py
│ └── prod.py
├── nginx
│ ├── Dockerfile
│ ├── nginx-entrypoint.sh
│ ├── nginx.conf
│ └── wait-for-it.sh
├── Dockerfile.base
├── Dockerfile.prod
├── docker-compose.django-prod.yml
├── docker-compose.django-beat.yml
├── Dockerfile.prod
├── ecs-params-django.prod
├── ecs-params-beat.prod
├── app-entrypoint.sh
├── manage.py
├── requirements.txt
...

Dockerfile.base

FROM python:version

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

WORKDIR /app

COPY ./requirements.txt /app/requirements.txt

RUN python3 -m pip install -r requirements.txt

base 도커파일이 존재하는 첫 번째 이유는 종속성 모듈을 매번 설치하지 않기위함입니다. 두 번째는 도커 이미지를 줄이기 위함입니다. 이미지를 더 줄이고 싶다면 가벼운 alpine 이미지를사용할 수도 있습니다.

Dockerfile.prod

FROM <???>.dkr.ap-northeast-2.amazonaws.com/base-python-prod

COPY . /app/
COPY app-entrypoint.sh /app-entrypoint.sh

RUN chmod +x /app-entrypoint.sh

위의 base 이미지를 ECR에 저장하고 가져올 것입니다.
ECR은 어디에서나 컨테이너 이미지를 손쉽게 저장, 공유 및 배포할 수 있는 완전관리형 Docker 컨테이너 레지스트리입니다.

app-entrypoint.sh

#!/bin/bash

# Migrate Database
python3 manage.py migrate --noinput

# Run Gunicorn (WSGI Server)
gunicorn --bind 0.0.0.0:8000 settings.wsgi:application

# Collect Staticfiles
python3 manage.py collectstatic --noinput

app-entrypoint.sh 파일을 통해 django 컨테이너가 실행될 때 해당명령을 실행합니다. 컨테이너가 실행될 때 변경되지 않을 명령어에 대해서는 CMD를 사용하는 것보다 ENTRYPOINT를 사용하는 것을 권장드립니다. CMD는 받은 인자로 대체하여 실행될 수도 있기 때문입니다.

Gunicorn 실행 시 옵션으로 지정하는 settings.wsgi는 프로젝트의 wsgi.py파일을 가리키는 경로입니다.

nginx/Dockerfile

FROM nginx:version

# Configure Nginx
COPY nginx.conf /etc/nginx/nginx.conf

# Copy Script to Wait for Gunicorn
COPY wait-for-it.sh /wait-for-it.sh

# Copy Entrypoint Script
COPY nginx-entrypoint.sh /nginx-entrypoint.sh
RUN chmod +x /nginx-entrypoint.sh

/wait-for-it.sh는 Django 컨테이너가 준비될 때까지, 즉 Gunicorn 서버가 실행될 때까지 기다리도록 하는 스크립트입니다. 이 파일은 https://github.com/vishnubob/wait-for-it에서 볼 수 있으며 /nginx-entrypoint.sh는 Nginx 컨테이너가 생성될 때 해당 컨테이너에서 실행될 스크립트입니다.

nginx-entrypoint.sh

#!/bin/bash

# Wait for Gunicorn
chmod +x /wait-for-it.sh
/wait-for-it.sh django:8000 --timeout=0 -- nginx -g 'daemon off;'

Gunicorn(웹 서버와 통신하기 위한 인터페이스)이 실행되기까지 기다리는 스크립트를 실행합니다.

실행되기까지 기다리는 이유는 djagno 컨테이너가 실행된 후 nginx가 실행되도록 depends on 설정을 하더라도 django 컨테이너의 entrypoint 명령어가 모두 완료되기까지 기다리지 않습니다. 즉, gunicorn이 실행되지 않은 상태로 django, nginx 컨테이너가 켜질 수 있으며 이때 nginx로 요청이 들어오더라도 django의 응답을 제대로 처리할 수 없습니다. 그렇기 때문에 Gunicorn이 실행되기까지 기다리는 스크립트를 실행해야합니다.

nginx/nginx.conf

events {
worker_connections 1024;
}

http {
client_max_body_size 0;
upstream django {
server django:8000;
}

server {
listen 80 default_server;

server_name _;

location / {
return 404;
}

location = /health-check {
access_log off;
return 200;
}
}

server {
listen 80;

server_name api.domain.com;

location / {
proxy_pass http://django/;
proxy_set_header Host $http_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 https;
}
}
}

nginx는 비동기 처리 방식을 가지고 있는데 events 블럭은 그 처리에 대한 옵션을 지정합니다. worker_process * worker_connection = 최대 접속자 수가 되는데 worker_process 는 auto로 설정해도 되고 명시적으로 서버에 장착되어 있는 cpu만큼 할당하기도 합니다. 기본값은 1입니다.
worker_connection은 cpu코어수 * 1024 정도를 권장합니다.

http 블럭은 웹서버 동작에 대한 설정이며 한 개 이상의 서버 블록을 설정할 수 있습니다. client_max_body_size 옵션을 통해 대용량 이미지와 같은 큰 페이로드를 받을 수 있도록 설정할 수 있습니다. 사이즈를 제한하지 않으려면 0으로 설정합니다.

upstrem은 server설정에서 nginx로 부터 받은 요청을 어디로 보내줄지 설정합니다. 즉 nginx는 프록시 서버로 동작되는 것입니다.

첫 번째 server 블록에서는 로드밸런서의 상태 검사 요청에 대한 200 응답을 줄 수 있도록 설정합니다. 두 번째 server 블록에서는 80번 포트로 들어오는 트래픽을 Django 컨테이너에서 8000번 포트로 열려 있는 Gunicorn 서버에 전달하는 역할을 수행하도록 설정합니다.

nginx의 설정에 대해 조금 더 알고 싶으신 분은 해당 레포지토리를 참고하면 좋을 것 같습니다.

docker-compose.django-prod.yml

version: '3'
services:
django:
build:
context: .
dockerfile: Dockerfile.prod
image: <???>.dkr.ecr.ap-northeast-2.amazonaws.com/django-prod:latest
container_name: django-container
entrypoint:
- /app-entrypoint.sh

celery-worker:
build:
context: .
dockerfile: Dockerfile.prod
image: <???>.dkr.ecr.ap-northeast-2.amazonaws.com/django-prod:latest
container_name: worker-container
command: celery -A saladpet worker --loglevel=info

nginx:
build:
context: ./nginx
dockerfile: ./Dockerfile
image: <???>.dkr.ecr.ap-northeast-2.amazonaws.com/nginx-prod:latest
container_name: nginx-container
ports:
- "80"
depends_on:
- django
links:
- django
entrypoint:
- /nginx-entrypoint.sh

docker-compose.beat-prod.yml

version: '3'
services:
celery-beat:
build:
context: .
dockerfile: Dockerfile.prod
image: <???>.dkr.ecr.ap-northeast-2.amazonaws.com/django-prod:latest
container_name: beat-container
command: celery -A saladpet beat -l INFO

Celery를 사용하고 있다면 다음 상황을 고려해야합니다. Celery는 작업을 해야한다고 신호를 보내는 beat와 작업을 처리하는 worker로 구성됩니다. Auto Scaling을 통해 크기가 자동조절될 때 beat 는 하나만 실행되기를 원할 것입니다. 왜냐하면 여러개의 beat가 실행되면 여러개의 신호가 발생하고 중복 작업이 일어나기 때문입니다. Auto Scaling 이 되더라도 beat는 항상 1개만 실행시키기 위해서 beat가 실행되는 환경은 따로 분리할 것입니다.

ecs-params-django-prod.yml

version: 1
task_definition:
task_execution_role: "ecsTaskExecutionRole"
services:
django:
mem_limit: "1024m"
mem_reservation: "512m"
secrets:
- value_from: "arn:aws:secretsmanager:ap-northeast-2:<???>:secret:<???>:ENV1::"
name: "ENV1"
- value_from: "arn:aws:secretsmanager:ap-northeast-2:<???>:secret:<???>:ENV2::"
name: "ENV2"
...
celery-worker:
mem_limit: "300m"
mem_reservation: "256m"
secrets:
...
nginx:
mem_limit: "512m"
mem_reservation: "256m"
cpu: "256"

ecs-params.yml 파일은 컨테이너 설정에 필요한 세팅을 넣을 수 있습니다. task_execution_role은 어떤 역할을 할 수 있는지 정의한 것인지 AWS에 있는 역할 이름을 지정해줍니다. Secrets Manager를 사용해 중요 환경변수를 설정할 수 있고 각 컨테이너에 필요한 메모리 , cpu 할당량 등을 세팅할 수 있습니다.

ECS 서비스, 프로젝트에서 사용된 각 파일들 예시에 대한 설명을 마쳤습니다. 모든 내용을 깊게 다룰순 없지만 최대한 자세히 작성하도록 노력하였습니다.누군가에게 도움이 되기를 바라며 수정될 내용이 있다면 언제든 댓글을 남겨주세요.

--

--