Django REST Framework와 React-Redux application을 Docker로 배포하는 방법

Seogyu Gim·2020년 11월 29일
3

Django

목록 보기
1/1

Okan Çakmak의 How to Deploy Django REST Framework and React-Redux application with Docker 에 기반하여 작성한 글 입니다.

목차

  1. 생성할 컨테이너 목록
  2. 백엔드 세팅
  3. 프론트엔드 세팅
  4. Dockerize and Deploy

생성할 컨테이너 목록

  1. Backend Docker Container
    : API 실행
  2. Frontend Docker Container
    : JS코드를 빌드하고 프론트엔드 앱 번들을 Nginx에 전달
  3. Webserver Docker Container
    : static frontend app을 제공하고, API 호출을 backend docker로 route

백엔드 세팅

1. 프로젝트 디렉토리 생성

mkdir article
cd article

2. 가상환경 생성 및 실행

python3 -m venv venv
source venv/bin/activate

3. 패키지 다운로드

pip install django djangorestframework django-cors-headers gunicorn

4. 장고 프로젝트 생성

mkdir backend
django-admin startproject config backend
cd backend

5. 프로젝트 앱 디렉토리 생성 및 앱 생성

mkdir apps && mkdir apps/articles
django-admin startapp articles apps/articles

6. migration 및 server 실행

python manage.py migrate && python manage.py runserver

7. OutPut

System check identified no issues (0 silenced).
XXXX XX, 2020 - XX:XX:XX
Django version 3.X.X, using settings 'config.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

8. 패키지 list 저장

pip freeze > requirements.txt

9. apps/articles/models.py

from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=50)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titles

10. apps/articles/views.py

from rest_framework.permissions import AllowAny
from rest_framework.viewsets import ModelViewSet
from .models import Article
from .serializers import ArticleSerializer


class ArticleViewSet(ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    permission_classes = [AllowAny]

  

11. apps/articles/serializers.py

from rest_framework import serializers
from . import models


class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        fields = ("id", "title", "content", "created_at", "updated_at")

12. config/urls.py

from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("apps.articles.urls")),
]

13. apps/articles/urls.py

from rest_framework.routers import SimpleRouter
from . import views 


router = SimpleRouter()
router.register(r'articles', views.ArticleViewSet, 'articles')

urlpatterns = router.urls

14. config/settings.py

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # UI에서 API로의 호출을 위해 필요한 cors policy 세팅
    "corsheaders",
    # django 기반 rest-api framework == rest_framework
    "rest_framework",
    # 'articles' 추가
    "apps.articles",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    # cors middleware 추가 (순서가 중요함!)
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

# 개발 시에만 허용될 설정
CORS_ORIGIN_ALLOW_ALL = True
  1. Migration
# makemigrations은 django로 하여 model 변화를 인식하게 한다 - migrations 폴더에 파일 생성되는 것 참고
python manage.py makemigrations

# migrate는 변경사항을 데이터베이스에 적용한다
python manage.py migrate
  1. 실행
    지금까지 잘 수행하였다면, Django는 아래의 api endpoint를 제공하며,
http://127.0.0.1:8000/api/articles

endpoint는 POST, GET, DELETE requests에 응답한다.

프론트엔드 세팅

  1. 프로젝트 생성
# 'article' directory
$ npx create-react-app frontend
$ cd frontend
# 개발 서버 실행
$ yarn start

# 현재 디렉토리 상황
article
├── backend
│   ├── apps
│   │   └── articles
│   │       └── migrations
│   └── config
└── frontend
    ├── public
    └── src

패키지 인스톨

  • antd: this is for UI components
  • redux: well, this is obvious
  • axios: for making http requests
  • react-redux: this binds react and redux together
  • react-thunk: this is a middleware for dispatch async events
  • redux-logger: this is a middleware eases debugging.
$ cd .. && yarn add antd redux axios react-redux redux-thunk redux-logger

폴더 생성

$ mkdir frontend/src/Containers frontend/src/Redux

리덕스 파트

frontend/src/Redux/store.js
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";

import logger from "redux-logger";
import reducer from "./reducers.js";
const middlewares = [thunk, logger];

export default function configureStore(initialState) {
  const store = createStore(
    reducer,
    initialState,
    applyMiddleware(...middlewares)
  );
  return store;
}
frontend/src/Redux/types.js
const Types = {
  POSTS_LOADING: "POSTS_LOADING",
  GET_POSTS: "GET_POSTS",
  DELETE_POST: "DELETE_POST",
  CREATE_POST: "CREATE_POST",
};

export default Types;
frontend/src/Redux/actions.js
import Types from "./types";
import axios from "axios";

export const getPosts = () => {
  return (dispatch) => {
    dispatch({ type: Types.POSTS_LOADING, payload: true });
    axios
      .get(`${process.env.REACT_APP_HOST_IP_ADDRESS}/api/posts`)
      .then((response) => {
        dispatch({ type: Types.GET_POSTS, payload: response.data });
      })
      .catch((err) => {
        console.log(err);
        dispatch({ type: Types.POSTS_LOADING, payload: false });
      });
  };
};

export const deletePost = (id, cb) => {
  return (dispatch) => {
    dispatch({ type: Types.POSTS_LOADING, payload: true });
    axios
      .delete(`${process.env.REACT_APP_HOST_IP_ADDRESS}/api/posts/${id}/`)
      .then((response) => {
        dispatch({ type: Types.DELETE_POST, payload: id });
        cb();
      })
      .catch((err) => {
        console.log(err);
      });
  };
};

export const createPost = (data, cb) => {
  return (dispatch) => {
    axios
      .post(`${process.env.REACT_APP_HOST_IP_ADDRESS}/api/posts/`, data)
      .then((response) => {
        console.log(response);
        dispatch({ type: Types.CREATE_POST, payload: response.data });
        cb();
      })
      .catch((err) => {
        console.log(err);
        dispatch({ type: Types.POSTS_LOADING, payload: false });
      });
  };
};
frontend/src/Redux/reducers.js
import Types from "./types";
const initialState = {
  posts: [],
  loading: false,
};

const postReducer = (state = initialState, action) => {
  switch (action.type) {
    case Types.POSTS_LOADING: {
      console.log("create_item");
      return { ...state, loading: action.payload };
    }

    case Types.GET_POSTS: {
      return { ...state, posts: action.payload };
    }

    case Types.DELETE_POST: {
      return {
        ...state,
        posts: state.posts.filter((post) => post.id != action.payload),
      };
    }
    default:
      return state;
  }
};

export default postReducer;

컨테이너

  • a page for displaying all posts, (ListPosts.js)
  • a page for creating a post. (CreateNewPost.js)
  • a main page allows user to select between ListPosts and CreateNewPost pages (Blog.js)
frontend/src/Components/Article.js
import React from "react";
import { Layout, Menu } from "antd";
import "antd/dist/antd.css";
import ListPosts from "./ListPosts";
import CreateNewPost from "./CreateNewPost";

class Article extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      selected: 1,
    };
  }
  handleClick = (e) => {
    this.setState({ selected: e });
  };
  render() {
    const { Header, Content } = Layout;

    return (
      <Layout>
        <Header>
          <Menu
            theme="dark"
            mode="horizontal"
            defaultSelectedKeys={[this.state.selected.toString()]}
          >
            <Menu.Item key="1" onClick={() => this.handleClick(1)}>
              {" "}
              Read Posts
            </Menu.Item>
            <Menu.Item key="2" onClick={() => this.handleClick(2)}>
              {" "}
              Create Post
            </Menu.Item>
          </Menu>
        </Header>
        <Content>
          {this.state.selected == 1 ? <ListPosts /> : <CreateNewPost />}
        </Content>
      </Layout>
    );
  }
}

export default Article;

frontend/src/Containers/CreateNewPost.js

import React from "react";
import { connect } from "react-redux";
import { createPost } from "../Redux/actions";
import "antd/dist/antd.css";
import { Form, Input, message, Button } from "antd";

class CreateNewPost extends React.Component {
  onFinish(values) {
    console.log(values);
    this.props.createPost(values, this.info());
  }

  info() {
    message.info("Post Created");
  }

  render() {
    const posts = this.props.posts;
    console.log(posts);

    const layout = {
      labelCol: { span: 5 },
      wrapperCol: { span: 12 },
    };

    const validateMessages = {
      required: "${label} is required!",
      types: {
        email: "${label} is not validate email!",
        number: "${label} is not a validate number!",
      },
      number: {
        range: "${label} must be between ${min} and ${max}",
      },
    };

    return (
      <Form
        {...layout}
        name="nest-messages"
        onFinish={this.onFinish.bind(this)}
        validateMessages={validateMessages}
      >
        <Form.Item name="title" label="Title" rules={[{ required: true }]}>
          <Input />
        </Form.Item>

        <Form.Item name="content" label="Content" rules={[{ required: true }]}>
          <Input.TextArea />
        </Form.Item>

        <Form.Item wrapperCol={{ ...layout.wrapperCol }}>
          <Button type="primary" htmlType="submit">
            Submit
          </Button>
        </Form.Item>
      </Form>
    );
  }
}

const mapStateToProps = (state) => ({
  posts: state.posts,
});

const mapDispatchToProps = {
  createPost,
};

export default connect(mapStateToProps, mapDispatchToProps)(CreateNewPost);

frontend/src/Containers/ListPosts.js

import React from "react";
import { connect } from "react-redux";
import { getPosts, deletePost } from "../Redux/actions.js";
import { Card, Row, Col, message } from "antd";
import {
  EditOutlined,
  EllipsisOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import "antd/dist/antd.css";

class ListPosts extends React.Component {
  componentDidMount() {
    this.props.getPosts();
  }

  deletePost = (id) => {
    this.props.deletePost(id, this.info);
  };

  info() {
    message.info("Post Deleted");
  }

  render() {
    const posts = this.props.posts;
    console.log(posts);

    return (
      <Col span={12} offset={6}>
        {posts.map((p) => (
          <Row gutter={[48, 48]}>
            <Col span={24}>
              <Card
                key={p.id}
                title={p.title}
                style={{ width: "100%" }}
                actions={[
                  <DeleteOutlined
                    key="delete"
                    onClick={() => this.deletePost(p.id)}
                  />,
                ]}
              >
                <p>{p.content}</p>
              </Card>
            </Col>
          </Row>
        ))}
      </Col>
    );
  }
}

const mapStateToProps = (state) => ({
  posts: state.posts,
});

const mapDispatchToProps = {
  getPosts,
  deletePost,
};

export default connect(mapStateToProps, mapDispatchToProps)(ListPosts);

frontend/src/Containers/App.js (App.js 를 Containers 디렉토리로 옮김)

import React from "react";
import Article from "../Components/Article";
import { Provider as ReduxProvider } from "react-redux";
import configureStore from "../Redux/store";

const reduxStore = configureStore(window.REDUX_INITIAL_DATA);

function App() {
  return (
    <ReduxProvider store={reduxStore}>
      <div className="App">
        <Article />
      </div>
    </ReduxProvider>
  );
}

export default App;

frontend/src/index.js

// Code above code stays is the same
import App from './Containers/App';
// Code below stays the same

Done!

# API 주소가 환경변수로 설정된 것에 주의
$ REACT_APP_HOST_IP_ADDRESS=http://localhost:8000 yarn start

Dockerize and Deploy

backend/config/settings.py

# Rest of the lines will be remain the same

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get("SECRET_KEY")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ")

# CORS_ORIGIN_ALLOW_ALL = True
CORS_ORIGIN_WHITELIST =os.environ.get("CORS_ORIGIN_WHITELIST").split(" ")

backend/Dockerfile

FROM python:3
ENV PYTHONUNBUFFERED 1

ARG DJANGO_ALLOWED_HOSTS
ARG DJANGO_SECRET_KEY
ARG DJANGO_CORS_ORIGIN_WHITELIST

ENV DJANGO_ALLOWED_HOSTS $DJANGO_ALLOWED_HOSTS
ENV DJANGO_SECRET_KEY $DJANGO_SECRET_KEY
ENV DJANGO_CORS_ORIGIN_WHITELIST $DJANGO_CORS_ORIGIN_WHITELIST

RUN mkdir /backend
WORKDIR /backend
COPY requirements.txt /backend/
EXPOSE 8000
RUN pip install -r requirements.txt
COPY . /backend/
RUN python manage.py makemigrations
RUN python manage.py migrate

frontend/Dockerfile

FROM node:14.4.0-alpine3.10
USER root
WORKDIR /frontend
COPY . /frontend
ARG API_URL
ENV REACT_APP_HOST_IP_ADDRESS $API_URL
RUN yarn
RUN yarn build

create webserver directory

# in the article directory
$ mkdir webserver

article/webserver/nginx-proxy.conf

upstream api {
    server backend:8000;
}

server {
    listen 8080;

    location /api/ {
        proxy_pass http://api$request_uri;
    }

    # ignore cache frontend
    location ~* (service-worker\.js)$ {
        add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
        expires off;
        proxy_no_cache 1;
    }

    location / {
      root /var/www/frontend;
      try_files $uri $uri/ /index.html;
    }

}

article/docker-compose.yml


version: '3'

services:
  backend:
    build:
      context: ./backend
      args:
        DJANGO_ALLOWED_HOSTS: *
        DJANGO_SECRET_KEY: *
        DJANGO_CORS_ORIGIN_WHITELIST: *
    command: gunicorn blog_backend.wsgi --bind 0.0.0.0:8000
    ports:
      - "8000:8000"
  frontend:
    build:
      context: ./frontend
      args:
        API_URL: *
    volumes:
      - build_folder:/frontend/build
  nginx:
    image: nginx:latest
    ports:
      - 80:8080
    volumes:
      - ./webserver/nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
      - build_folder:/var/www/frontend
    depends_on:
      - backend
      - frontend
volumes:
  build_folder:
  • 백엔드 서비스 :
    • 환경 변수로 사용할 인수를 설정합니다.
    • backend 폴더 내에 Dockerfile을 사용하여 백엔드 서비스를 빌드합니다. gunicorn을 실행하고 8000 포트 수신
  • 프런트 엔드 서비스 :
    • API_URL 매개 변수를 통해 백엔드 호출에 대한 환경 변수를 설정합니다. (환경 변수를 docker-compose에서 설정하고 Dockerfile을 통해 빌드 된 앱에 전달되는 방식에 유의)
    • React 코드를 빌드하고 볼륨 기능을 통해 이 번들을 NGINX 서비스와 공유 합니다.
  • 웹서버 서비스 :
    • 호스트 컴퓨터의 구성 파일을 컨테이너에 복사합니다.
    • 최신 NGINX 이미지로 웹 서버 서비스를 만듭니다.

이 docker-compose 파일을 사용하여 배포를 위해해야 할 일은 다음과 같습니다.

  • 호스트 및 요구 사항에 따라 다음과 같은 변수들을 설정
    1. DJANGO_ALLOWED_HOSTS
    2. API_URL
    3. DJANGO_SECRET_KEY
    4. API_URL 매개 변수 등
      예제 값은 다음과 같습니다.
# nginx의 conf에 정의되어 있으므로 허용되는 호스트 변수에 대해 직접 api 를 사용할 수 있습니다 .
DJANGO_ALLOWED_HOSTS : ec2-3-122 ... amazonaws.com, api
DJANGO_SECRET_KEY : $a-fpiay!bdp+g=&df34578fv+$z3yd!!(%*0caqjf((r$2-jvs_
'DJANGO_CORS_ORIGIN_WHITELIST : http : // ec2- 3-122 -... amazonaws.com 
API_URL : http : //ec2-3-122...amazonaws.com

백엔드 및 프런트 엔드 서비스에 다른 호스트를 사용하려면 CORS 화이트리스트 변수를 설정해야 할 수 있습니다.

모든 것이 설정되면 실행

$ docker-compose up
  • 지금까지 모든 것이 잘 진행되었다면 서비스가 하나씩 올라가는 것을 볼 수 있고, 컨테이너를 확인하면
[ec2-user @ ip-172-31-21-251 ~] $ docker container ls

그러면 article_nginx_1 및 article_backend_1 이라는 2개의 활성 컨테이너를 확인할 수 있습니다. 앞서 언급했듯이 프론트엔드 도커는 코드를 빌드하고 웹 서버에 전달합니다. 그 후 종료됩니다.

부족한 글 읽어 주셔서 감사합니다. 틀린 부분 있다면 계속 수정해 나가겠습니다.

profile
의미 있는 일을 하고싶은 개발자

2개의 댓글

comment-user-thumbnail
2021년 3월 13일

좋은 번역글 감사합니다. 다만 사소한 오류가 있어서 글남깁니다.

backend/config/settings.py에서
"SECRET_KEY" 대신 "DJANGO_SECRET_KEY",
"CORS_ORIGIN_WHITELIST" 대신 "DJANGO_CORS_ORIGIN_WHITELIST", (이부분은 원문 댓글에 있던데 저는 일단 이렇게 했습니다)

article/docker-compose.yml 에서
command: gunicorn blog_backend.wsgi --bind 0.0.0.0:8000 대신
command: gunicorn config.wsgi --bind 0.0.0.0:8000로 해서 성공했습니다.

다만, 올려주신 코드 그대로 react/redux 세팅을 하면 글을 post 요청은 할 수 있는데 저장이 되지 않습니다. 제가 react/redux를 모르는 탓에 어떻게 고쳐야할 지 모르겠네요 ㅠ

다시 한번 좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2021년 4월 5일

너무 좋은내용이네요 ! 딱 제가 필요한 포스트입니다 감사합니다

답글 달기