Django - Docker(도커) & Github Actions(깃헙액션) 배포

julia·2021년 10월 4일
6
post-thumbnail

동아리 운영진을 하며 이번에 장고를 도커와 깃헙액션으로 배포하는 스터디를 진행했다. 처음에는 내 지식도 많이 부족한 상태로 진행했지만 팀원 분들의 열정넘치는 질문들 덕분에 내가 몰랐던 부분들까지 같이 배워나간 느낌이 들었고, 배포 과정에 대해 벨로그를 오랜만에 작성해보려 한다. 폴더 구조와 코드는 여기에서 볼 수 있다.

개발 환경

우리의 개발 환경을 요약하자면 이렇다.

1. docker
2. docker-compose
3. nginx
4. gunicorn 
5. mysql
6. python3.8
7. django(>=3.0)


이 중 nginx 와 gunicorn에 대해 간략하게 정리하고 넘어가자면, nginx는 여러 요청을 한번에 효율적으로 받을 수 있는 웹 서버이다. 클라이언트로부터 요청이 왔을 때 html css와 같은 정적 파일들은 바로 돌려주고, 만약 동적인 데이터가 들어왔다면 우리의 장고 프로젝트로 요청을 넘겨준다. 이때 중간다리 역할을 해주는 것이 gunicorn이라는 인터페이스이다. gunicorn은 wsgi(web server gateway interface)의 한 종류로 웹 서버로 들어온 요청을 파이썬이 알아들을 수 있도록 통역해주는 역할을 한다.

Docker & docker-compose

도커 개념

도커란 리눅스의 응용 프로그램들을 컨테이너로 실행 및 관리하는 오픈소스 가상화 플랫폼을 말한다. 다양한 프로그램과 실행환경을 컨테이너로 추상화하고 동일한 인터페이스를 제공함으로써 프로그램의 배포와 관리를 단순화해준다. 여기서 도커 컨테이너는 프로세스 격리 방식으로 운영된다. 단순히 프로세스를 격리시키기 때문에 가볍고 빠르게 동작한다는 점, CPU나 메모리를 프로세스가 필요한 만큼만 추가로 사용한다는 점, 그리고 성능적으로도 거의 손실이 없다는 점이 컨테이너의 장점이다.

참고 : 
가상 컴퓨터와 컨테이너 차이 -- 가상 컴퓨터는 호스트 OS 위에 게스트 OS 전체를 가상화하는 반면 컨테이너는 프로세스를 격리하는 방식
이미지와 컨테이너 차이 -- 이미지는 컨테이너 실행에 필요한 파일과 설정 값 등을 포함하고 있는 것으로 상태 값을 가지지 않고 변하지 않는 반면 컨테이너는 추가, 변경된 값들을 저장

Dockerfile과 docker-compose.yml 파일의 차이

도커 파일은 하나의 도커 이미지를 생성하기 위한 파일로 이 파일 이미지만 작성을 했다면 다른 컴퓨터에서도 동일한 환경을 올릴 수 있다. Dockerfile에서 run에 대한 정의가 있을 수 있지만 실제 run은 하지 않는다.
그리고 도커 컴포즈는 여러 이미지들을 빌드하고 / 컨데이너끼리의 네트워크를 연결해주고 / 파일 시스템 공유 방식을 결정해주는 등 환경을 제어해준다. 또한 빌드 뿐만 아니라 run 즉 실행을 시켜준다.
Dockerfile만 작성했다면 docker run이라는 명령어로 실행을 따로 시켜줘야 하는데 이때 이 커맨드 뒤에 설정 값들이 꽤나 많이 올 수 있다. 이 수고를 덜기 위해서도 한 번에 docker-compose로 정의하고 관리하는 것이다.

(다음은 배포할 때 사용했던 실제 파일의 코드인데 이 중 팀원 분들과 새롭게 공부한 내용을 공유하고자 한다.)

1. Dockerfile

... 윗부분 생략

# 의존성 패키지 설치 및 삭제
RUN apk add --no-cache mariadb-connector-c-dev
RUN apk update && apk add python3 python3-dev mariadb-dev build-base && pip3 install mysqlclient && apk del python3-dev mariadb-dev build-base


# COPY 과정에서 도커는 캐시를 거치고, RUN 과정에서 설치를 진행하는데 이미 설치된 패키지들은 재설치한다
COPY requirements.txt /app/requirements.txt
RUN pip install -r requirements.txt

# 코드를 앱 디렉토리로 전부 복사한다
COPY . /app/

도커 이미지 경량화

여기서 "의존성 패키지 설치 및 삭제" 부분을 보면 < python3-dev mariadb-dev build-base > 를 설치해놓고 바로 삭제해주는 걸 보고 의문이 생길 수 있다. 일단 설치한 후에 지우는 저 패키지들은 빌드할 때만 사용되는 것들이고 실행될 때는 사용하지 않는 패키지들이다. 실제로 리눅스에서 -dev 패키지는 실행보다는 컴파일을 위한 헤더 패키지라고 한다.
그렇다면 왜 "바로" 지워도 되는걸까? 그 이유는 단지 mysqlclient이라는 특정 pip를 설치하기 위한 dependency 패키지들이었기 때문이다.
이렇게 의존성 패키지들을 삭제해주는 작업은, 도커 실행이미지를 최대한 라이트하게 만들어서 가벼운 도커의 장점을 극대화시키기 위한 작업이라고 볼 수 있다. 그리고 또 한가지! 멘토님 설명에 따르면 보통 배포 과정을 보면 이미지를 빌드할 때 도커허브와 같은 이미지 저장소에 이미지를 올리고 그 이후에 필요한 서버에서 이미지를 다운받아 쓰도록 되어있는데 이때 네트워크 비용을 줄이기 위함이라고 한다.

2. docker-compose.yml

version: '3'
services:

  db:
    container_name: db
    image: mariadb:latest
    restart: always
    environment:
      MYSQL_ROOT_HOST: '%'
      MYSQL_ROOT_PASSWORD: mysql
    expose:
      - 3306
    ports: # 포트포워딩 - 로컬의 호스트가 3306포트를 사용 중일 수 있으므로 3307 포트를 도커 컨테이너의 3306 포트로 포워딩해줌
      - "3307:3306"
    env_file: # 설정은 .env 파일에 의존
      - .env
    volumes: # 파일 시스템 정의
      - dbdata:/var/lib/mysql

  web:
    container_name: web
    build: . # . 은 디폴트 -> 프로젝트 내의 "Dockerfile"이라는 이름을 알아서 찾아 빌드해줌
    command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" # 최종 런서버 - 브라우저에서 확인 가능
    environment:
      MYSQL_ROOT_PASSWORD: mysql
      DATABASE_NAME: mysql
      DATABASE_USER: 'root'
      DATABASE_PASSWORD: mysql
      DATABASE_PORT: 3306
      DATABASE_HOST: db
      DJANGO_SETTINGS_MODULE: django-rest-framework-14th.settings.dev # dev.py > .env 파일에 있는 값들을 환경변수로 설정
    restart: always
    ports:
      - "8000:8000"
    volumes: # 파일 시스템 정의
      - .:/app
    depends_on: # db 컨테이너로 연결
      - db
volumes:
  app:
  dbdata:

여기서는 web의 environment에서 두 가지 의문이 들 수가 있다. 첫째 나는 mysql이라는 비번을 가진 root 사용자를 생성한 적이 없다. 둘째 디비 정보 값들이 노출이 되어도 되는걸까?
우선 이런 값을 가진 유저를 만든 적이 없어도 로컬에서(지금 보는 파일은 배포용 docker-compose 파일이 아닌 로컬 테스트용 파일) 잘 돌아가는 이유는 도커가 빌드되고 실행될 때 이 값들을 초기 설정으로 해서 디비 설정을 이대로 만들어주기 때문이다. 그리고 이는 RDS 실제 디비에 대한 정보가 아니며 이미지를 삭제해버리면 없어지는 설정들이기 때문에 노출이 되어도 상관없는것!

3. docker-compose.prod.yml

그렇다면 실제 배포용 도커 컴포즈 파일과 비교해보자.

version: '3'
services:

  web:
    container_name: web
    build:
      context: ./
      dockerfile: Dockerfile.prod # "Dockerfile"이 아니라 뒤에 prod가 붙기 때문에 명시적으로 작성
    command: gunicorn django-rest-framework-14th.wsgi:application --bind 0.0.0.0:8000 # 실제 서버에서는 gunicorn으로 웹서버와 장고를 연결해준다
    environment:
      DJANGO_SETTINGS_MODULE: django-rest-framework-14th.settings.prod # prod.py > .env 파일에 있는 값들을 환경변수로 설정
    env_file:
      - .env # .github/workflows/deploy.yml에서 만든 env 파일에 의존
    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 # 이 폴더 안에도 Dockerfile이 있고 이 도커파일에서 설정파일인 nginx.conf 파일로 연결시켜준다
    volumes:
      - static:/home/app/web/static
      - media:/home/app/web/media
    ports: # 웹 서버 포트 80
      - "80:80"
    depends_on: # web 컨테이너로 연결
      - web

volumes:
  static:
  media:

db out, nginx in

docker-compose.yml 파일과는 다르게 db 컨테이너가 없고 대신에 nginx 컨테이너가 있다. db가 없는 이유는! 보안상으로 위험하기도 하고, 다른 서버가 db에 붙지 못하는 상황이 올 수도 있고, ec2 인스턴스 안에 도커 안에 db 컨테이너를 받아주게 되면 인스턴스의 자원을 서버와 디비가 같이 사용하게 되어서 비효율적이기 때문이다.

그럼 nginx로 넘어가보자.

nginx collectstatic

로컬에서 Debug=True 로 두고 runserver을 돌리게 되면 장고의 static 이 자동으로 실행된다. 그러나 Debug=False 로 하면 static 파일들이 적용이 안되는 것을 볼 수가 있다. nginx 를 사용하면 static 파일을 collectstatic 명령으로 수동으로 모아야 하는데 이렇게 웹 서버인 nginx가 정적 파일을 대신 처리하게 되어 더 효율적이고 안정적으로 자원 관리를 할 수 있게 된다. 참고로 우리 플젝의 collectstatic 명령은 config/docker/entrypoint.prod.sh 에 넣어 놓았다.

우리의 폴더 구조는 이렇다. 중요한 것들만 써보았다.

django-rest-framework-14th
|
|___api (앱)
|___config
	|___nginx
    		|___nginx.conf
            
|___django-rest-framework-14th (프로젝트)
	|___settings
    		|___base.py
        |___urls.py
        
|___staticfiles

STATICFILES_DIR는 개발, STATIC_ROOT는 웹 서버

개발 서버에서는 STATICFILES_DIR을 통해 정적파일에 접근하게 되고, 웹 서버에서는 STATIC_ROOT로 접근하게 된다. 그리고 웹 서버는 개발 서버와 달리 정적 파일들의 위치를 모르기 때문에 모든 정적 파일들을 STATIC ROOT 경로에 따로 모아서 관리해야 한다.

우선 base 디렉토리 밑에 'staticfiles' 라는 디렉토리를 만들어 정적 파일들을 넣어보았다. (test.html css txt 등등)

그 후 아래처럼 settings/base.py 에서 STATICFILES_DIRS 를 지정해주게 되면, <폴더 내에 있는 staticfiles들을 모두 찾아 static 디렉토리로 복사해라> 라는 뜻이 된다.

STATIC_URL = '/static/'
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'staticfiles')
]
STATIC_ROOT = os.path.join(BASE_DIR, 'static')

참고: 이때 STATICFILES_DIRS 와 STATIC_ROOT 의 디렉토리 이름은 달라야 한다고 한다.

이렇게 해서 개발서버는 runserver했을 때 "http://127.0.0.1:8000/static/test.html" 로, 배포 후에는 "ex2 dns/static/test.html"로 접속해보면 static이 잘 떠있는걸 볼 수 있고 nginx 컨테이너와 web 컨테이너 안에는 사진과 같이 저장되어 있는 걸 확인할 수 있다.

nginx 에서 static을 읽게끔 하는 세팅은 nginx.conf에서 정의할 수 있다.

upstream django-rest-framework-14th {   # django-rest-framework-14th라는 upstream 서버 정의
  server web:8000;     # docker container 중 web의 8000포트 연결
}

server {   # nginx server 정의

  listen 80;  # 80포트 (http)

  location / {   # "/" 도메인에 도달하면 아래 proxy 수행
    proxy_pass http://django-rest-framework-14th;   # django-rest-framework-14th라는 upstream으로 요청 전달 (정적 데이터가 아니라 동적 데이터라면 우리의 장고 프로젝트로 요청 전달)
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;  # header 설정
    proxy_set_header Host $host;  
    proxy_redirect off;
  }

  location /static/ {  # "/static/" 도메인에 도달하면 아래 alias 수행 (캐싱 용도, 정적 데이터는 nginx에서 바로 처리하게끔)
    alias /home/app/web/static/;   # 디렉토리 (서버의 파일시스템) 맵핑
  }

  location /media/ {
    alias /home/app/web/media/;
  }
}

참고 - upstream 개념

일반적인 프록시 구조에서 요청을 받는 쪽을 upstream, 응답을 받는 쪽을 downstream이라고 한다. 상대적으로 nginx 입장에선 django가 upstream인 것이다.

Github Actions (깃헙액션)

드디어 깃헙 액션!!
그냥 docker-compose up 하면 되는 것을 왜 굳이 깃헙 액션을 사용할까?
바로 docker-compose.prod를 올리는 명령어로 서버 배포를 진행할 수도 있지만, 푸쉬도 하고 도커컴포즈도 입력하는 반복 작업을 하다보면 생각보다 귀찮은 일이 될 수가 있다. 그래서 푸쉬를 하면 자동으로 배포가 될 수 있게 자동화를 목표로 github actions, travis, jenkins 등의 툴을 이용하는 것이다.

why github actions?

  1. 깃헙액션에서는 푸쉬할때 말고도 다른 원하는 행위나 다른 브랜치 대해 workflow를 다양하게 정의해서 사용할 수도 있다. 예를들어 .github/workflows 밑에 dev.yml과 prod.yml을 나눠서 개발과 운영을 분리한 후 서로 다른 작업을 수행하게끔 정의할 수 있다 (개인적으로 로컬에서의 테스트 말고 개발용 서버와 디비를 따로 또 판 경우에 이렇게 하면 좋을 것 같다)
  2. 또 깃헙 액션만의 장점이 있다면 코드가 배포에 문제가 없는지 확인하고, 에러가 난다면 어디서 나는지 빌드 로그를 볼 수 있는 등 배포 관련 작업들을 외부의 다른 툴 없이 우리가 기존에 사용하던 github이라는 코드 관리 공간에서 같이 할 수 있다는 점이 되겠다.

workflow

  1. .github/workflows/deploy.yml
name: Deploy to EC2
on: [push] # 깃헙 푸쉬했을 때마다 
jobs: # 아래의 jobs를 수행

  build:
    name: Build
    runs-on: ubuntu-latest # 여러 OS 중 우분투 환경 선택
    steps:
    - name: checkout # 1. 마스터 브랜치로 checkout
      uses: actions/checkout@master

    - name: create env file # 2. 깃헙 settings > secrets 에 올려놓은 비밀 값을 복사해 .env 파일로 생성
      run: |
        touch .env
        echo "${{ secrets.ENV_VARS }}" >> .env

    - name: create remote directory # 3. ec2 서버에 접속해 리모트 디렉토리 생성
      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 # 4. ssh key로 현재 푸시된 소스를 서버에 복사 (rsync로 github runners와 ec2 동기화)
      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 # 5. 서버로 접속해 deploy.sh 파일 실행
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }}
        username: ubuntu
        key: ${{ secrets.KEY }}
        script: |
          sh /home/ubuntu/srv/ubuntu/config/scripts/deploy.sh

위 파일은 우리가 커밋 후 master에 푸쉬할 때마다 일어날 배포 과정을 자동화하기 위해 정의한 스텝들이다. 이렇게 스텝들을 나눠 놓으면 빌드 과정 중 어떤 스텝에서 에러가 났는지 (Actions 탭에서) 쉽게 확인 가능하다. 보통은 빌드가 되지 않은 이유까지 자세히 확인할 수 있다.
예를 들어 4번 스텝에서 한번 에러가 났던 적이 있는데, 물론 에러에는 여러 이유들이 있겠지만 나같은 경우에는 pycache가 너무 많이 쌓여서 소스를 복사할 수 없었던 상황이었다. (ubuntu 디렉토리를 삭제하고 다시 빌드시킴으로써 해결했다)

  1. config/scripts/deploy.sh
#!/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

위에서 두 개의 if 블록들은 docker와 docker-compose를 설치해주는 역할을 한다. 처음 ec2 인스턴스를 생성하면 그 내부는 비어있기 때문에 도커 환경을 만들어 주는 것이다. 결국 우리의 배포 환경은, ec2 안에 -> 도커 안에 -> 도커 컨테이너 이런 식이 된다.
마지막 docker-compose.prod.yml up --build -d 명령어가 결국 하이라이트다! 이 커맨드로 서버가 docker-compose.prod 파일에 의해 최종으로 빌드 및 실행되는 것!

그리고 deploy.yml 파일을 공부하며 팀원 분들이 여러 질문을 해주었다. 그 중 Github runners에 대해 새로 알게 되었는데 이를 정리해보고자 한다.

Github Runners

  1. 2, 3번 스텝에서 두 경우 모두 파일/디렉토리를 생성한다는 점에서 ssh-key 설정이 필요할 것 같다고 생각하는데 그러지 않은 이유가 뭔지 궁금합니다!

env 파일을 만드는 2번 스텝은 github actions가 돌아가는 환경에 파일을 만드는 동작이다. github actions는 Runners 라는 가상 workspace를 이용하는데, 이 Runners에 파일을 먼저 생성해 올려놓는 것이다.
디렉토리를 만드는 3번 스텝은 github actions의 환경이 아닌 remote ec2 서버에 접속해서 만드는 동작이다. 이때 사용하고 있는 'uses' 가 파라미터 'with' 를 받아 실행하고 있기 때문에 이렇게 실행 환경에 차이가 있을 수 있는 것이다.

  1. 3, 4번 스텝 이전에 .env 파일을 먼저 생성한 이유가 있을까요? 그리고 아직 2번째 스텝이면 서버상에 디렉토리가 구축이 안됐다고 생각하는데, 2번째 스텝이후 생성된 .env 파일은 어디에 저장이 되어있나요?

env파일 생성 스텝은 3번 스텝 이전에만 오면 된다. 디렉토리 구축보다는 rsync하는 스텝에 영향을 받을 것이다. rsync할 때 우리가 만든 ubuntu 폴더로 경로가 지정되고 동기화가 되는데, 이 스텝 전에 파일을 만들어 놓아야 github-hosted runners 환경에 만들어진 .env파일이 /home/ubuntu/srv/ubuntu/ 경로와 정상적으로 동기화가 될 것이다.
그렇지 않고 rsync 다음에 env파일을 만든다면 이미 있는 파일들만 동기화를 해놓고 새로운 env 파일을 생성하는 거니 우리 폴더 내에 생성되는 것이 아니라 github-hosted runners 환경에 생성된다.
추가로 runner 인스턴스의 기본 디렉토리 위치는 디폴트 /home/runner/work/my-repo-name/my-repo-name 이다.

도커-깃헙액션 배포 플로우 정리해보기

다시 돌아와 깃헙액션이 어떻게 서버와 연결되는지 정리해보면,

  1. deploy.yml을 통해 Github Actions가 우리의 코드를 서버에 올리고 deploy.sh를 실행한다.
  2. deploy.sh는 docker-compose.prod를 실행한다. (이 sh 파일은 ec2 서버에서 실행)
  3. docker-compose.prod는 web 컨테이너와 nginx 컨테이너를 빌드 및 실행한다.
  4. web 컨테이너는 Dockerfile.prod를 기준으로 빌드되며, 이 도커 이미지는 django를 구동하기 위한 환경이 모두 갖춰져있다. / nginx 컨테이너에 대한 환경은 nginx.conf에서 정의하고 이 환경은 nginx/Dockerfile에서 불러온다.

느낀점

서버 공부를 하며 도커와 깃헙액션으로 배포하는 건 처음이었다. 전에 elastic beanstalk으로 배포한 경험이 있는데 사실 eb가 뚝딱뚝딱 다 해줘서 플로우가 정리가 잘 안되었었다. (엘라스틱 빈스톡에 관한 내용도 언젠가 잘 정리해서 내 것으로 만들 수 있었으면 좋겠다.) 개인적으로 이번에 공부를 해보고 나니 도커가 훨씬 편리하다고 느껴졌고, 자동 배포도 전에는 너무 야매(?)로 이해한 느낌이었다면 지금은 전체적인 플로우를 이해하게 되었다는 점에서 한층 성장했다는 생각이 든다.
아직 너무 부족하지만 앞으로도 도커 관련 공부를 더 해보고 싶고, 여러 배포 툴들을 사용해보고 싶다!!

profile
Move Forward

0개의 댓글