ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [따라하며 배우는 도커와 CI환경] 8. 복잡한 어플을 실제로 배포해보기(개발 환경 부분)
    DevOps/Docker 2022. 6. 22. 21:01

     

    지난 포스트에서는 리액트 컨테이너 하나만 실행하는 싱글 컨테이너 애플리케이션을 만들어보았다.
    이번엔 프론트엔드 뿐만 아니라 백엔드 서버, 데이터베이스까지 사용하도록 다음과 같은 풀스택(멀티 컨테이너) 애플리케이션을 만들 것이다.

     

     

    ✔️ Multi Container 애플리케이션을 위한 설계

     Multi Container 애플리케이션을 설계하는 방법 2가지를 알아보자.

     

    1️⃣ Nginx의 Proxy를 이용한 설계

    장점

    • Request를 보낼 때 URL 부분을 host 이름이 바뀌어도 변경시켜주지 않아도 된다.
    • 포트가 바뀌어도 변경하지 않아도 된다.
    axios.get('/api/values')

    단점

    • nginx 설정, 전체 설계가 다소 복잡하다.

     

    2️⃣ Nginx는 정적파일을 제공만 해주는 설계

    장점

    • 설계가 다소 간단하여 구현이 더 쉽다.

    단점

    • host name이나 포트가 바뀌면 Request URL도 변경해야 한다. 
    axios.get('http://localhost:5000/api/values')
    axios.get('http://abc.com:5000/api/values')

     

     


    좀 더 복잡한 첫 번째 설계 방식으로 풀스택 애플리케이션을 구현해보자!

    지금부터 텍스트를 입력하면 DB에 데이터를 저장하고,
    DB에 저장된 모든 데이터를 조회하여 리스트 형태로 보여주는 애플리케이션을 만들 것이다.

     

     

    ✔️ Node.js 구성하기 (Backend)

    프로젝트(여기서는 DOCKER-FULLSTACK-APP) 폴더를 만들고, 그 안에 backend 폴더를 생성하자.

     

    backend 폴더에서 다음의 명령어를 통해 초기화한다.

    $ npm init

     

    1️⃣ package.json

    package.json을 다음과 같이 수정해준다.

    {
      "name": "backend",
      "version": "1.0.0",
      "description": "",
      "main": "server.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node server.js",
        "dev": "nodemon server.js"
      },
      "author": "",
      "license": "ISC",
      "dependencies": {
        "express": "4.18.1",
        "mysql": "2.18.1",
        "nodemon": "2.0.16"
      }
    }

     

    start

    • express 서버를 시작할 때 사용한다.
    • node server.js

    dev

    • nodemon을 이용하여 express 서버를 시작할 때 사용한다.
    • nodemon: 소스 변경 시, 이를 감지하여 자동으로 서버를 재시작해주는 tool. nodemon을 사용하면, 서버를 내렸다가 올리지 않아도, 변경된 내용을 적용할 수 있다.
    • nodemon server.js

    express

    • 웹 프레임워크 모듈
    • 4.16.0 버전부터는 body-parser를 내장하고 있어, body-parser를 dependencies에 추가할 필요가 없다.

    mysql

    • mysql을 사용하기 위한 모듈

     

    2️⃣ db.js

    mysql을 연결하기 위한 db.js 파일을 다음과 같이 작성한다.

    const mysql = require('mysql');
    const pool = mysql.createPool({
        connectionLimit: 10,
        host: 'mysql',
        user: 'root',
        password: '1234',
        database: 'myapp'
    });
    
    exports.pool = pool;
    •  Host, 유저 이름, 비밀번호, 데이터베이스 이름을 명시해서 Pool을 생성한다. (docker-compose.yml에서 설정한 값 참고)
    • 생성한 pool을 다른 곳에서 쓸 수 있도록 export 한다.

     

    3️⃣ server.js

    server.js 는 앱이 실행할 때, 시작점이 되는 파일이다.
    여기에서 express 서버를 생성하여 api를 만들어준다.

    server.js 전체 코드

    // 필요한 모듈들을 가져오기
    const express = require('express');
    const db = require('./db');
    
    // Express 서버를 생성
    const app = express();
    
    app.use(express.urlencoded({extended: true}));
    app.use(express.json());
    
    // 테이블 생성
    db.pool.query(`
        CREATE TABLE lists (
        id INTEGER AUTO_INCREMENT,
        value TEXT,
        PRIMARY KEY (id)
    )`, (err, results, fields) => {
        console.log('results', results);
    })
    
    
    // lists 테이블의 전체 데이터 조회
    app.get('/api/values', function (req, res) {
        // 데이터베이스에서 모든 정보 가져오기
        db.pool.query('SELECT * FROM lists;',
            (err, results, fields) => {
                if(err) return res.status(500).send(err);
                else return res.json(results);
            }
        );
    })
    
    // lists 테이블에 데이터 추가
    app.post('/api/value', function (req, res, next) {
        // 데이터베이스에 값 추가하기
        db.pool.query(`INSERT INTO lists (value) VALUES("${req.body.value}")`,
            (err, results, fields) => {
                if(err) return res.status(500).send(err);
                else return res.json({success: true, value: req.body.value});
            }
        );
    })
    
    
    app.listen(5000, () => {
        console.log('애플리케이션이 서버 5000번 포트에서 시작되었습니다.')
    });
    • express.urlencoded() : x-www-form-urlencoded 형태의 데이터를 해석해준다.
    • express.json() : JSON 형태의 데이터를 해석해준다.

     

    2가지 api를 제공한다.

    1. GET /api/values : lists 테이블의 모든 데이터를 조회한다.

    app.get('/api/values', function (req, res) {
        db.pool.query('SELECT * FROM lists;',
            (err, results, fields) => {
                if(err) return res.status(500).send(err);
                else return res.json(results);
            }
        );
    })

     

    2. POST /api/values : lists 테이블에 데이터를 추가한다.

    app.post('/api/value', function (req, res, next) {
        db.pool.query(`INSERT INTO lists (value) VALUES("${req.body.value}")`,
            (err, results, fields) => {
                if(err) return res.status(500).send(err);
                else return res.json({success: true, value: req.body.value});
            }
        );
    })

     

     

    ✔️ React.js 구성하기 (Frontend)

    다음의 명령어를 통해 리액트 앱 환경을 세팅한다.

    $ npx create-react-app frontend

    frontend 폴더에 리액트 앱이 생성되었다.

     

    1️⃣ package.json

    다음과 같이 axios를 의존성에 추가해준다.

    {
        ...
        "dependencies": {
            "axios": "0.27.2",
            ...
        }
    }

     

    2️⃣ /src/App.js

    import React, {useState, useEffect} from 'react';
    import logo from './logo.svg';
    import './App.css';
    import axios from 'axios';
    
    function App() {
    
      const [lists, setLists] = useState([]);
      const [value, setValue] = useState("");
    
      // 데이터베이스에 있는 값을 가져온다.
      useEffect(() => {
        // DB 데이터 조회 api
        axios.get('/api/values')
          .then(response => {
            console.log('response', response)
            setLists(response.data)
          })
      }, []);
    
      const changeHandler = (event) => {
        setValue(event.currentTarget.value)
      }
    
      const submitHandler = (event) => {
        event.preventDefault();
    
        // DB 값 추가 api
        axios.post('/api/value', { value: value })
          .then(response => {
            if (response.data.success) {
              console.log('response', response)
              setLists([...lists, response.data])
              setValue("");
            } else {
              alert('값을 DB에 넣는데 실패했습니다.')
            }
          })
      }
    
      return (
        <div className="App">
          <header className="App-header">
            <img src={logo} className="App-logo" alt="logo" />
            <div className="container">
              {lists && lists.map((list, index) => (
                  <li key={index}>{list.value} </li>
              ))}
              <br />
              <form className="example" onSubmit={submitHandler}>
                <input 
                  type="text" 
                  placeholder="입력해주세요..."
                  onChange={changeHandler}
                  value={value} />
                <button type="submit">확인</button>
              </form>
            </div>
          </header>
        </div>
      );
    }
    
    export default App;

     

     

    3️⃣ /src/App.css

    App.css 파일에 다음을 추가한다.

    .container {
      width: 375px;
    }
    
    form.example input {
      padding: 10px;
      font-size: 17px;
      border: 1px solid grey;
      float: left;
      width: 74%;
      background: #f1f1f1;
    }
    
    form.example button {
      float: left;
      width: 20%;
      padding: 10px;
      background: #2196F3;
      color: white;
      font-size: 17px;
      border: 1px solid grey;
      border-left: none;
      cursor: pointer;
    }
    
    form.example button:hover {
      background: #0b7dda;
    }
    
    form.example::after {
      content: "";
      clear: both;
      display: table;
    }

     

    리액트 앱 실행 화면

     

     

    ✔️ 리액트 앱을 위한 도커 파일 만들기

    이제 frontend 폴더 안에 도커 파일을 생성하자!

     

    1️⃣ Dockerfile.dev 작성

    개발 환경을 위한 도커 파일을 다음과 같이 작성해보자.
    지난 포스트에서 작성한 것과 동일하므로 설명은 생략..

    FROM node:alpine
    WORKDIR /app
    COPY package.json ./
    RUN npm install
    COPY ./ ./
    CMD ["npm", "run", "start"]
     

     

    2️⃣ Dockerfile 작성

    이제 운영 환경을 위한 Dockerfile을 작성해보자.

    # Builder Stage
    FROM
    node:alpine as builder

    WORKDIR /app
    COPY package.json ./
    RUN npm install
    COPY ./ ./
    RUN npm run build

    # Run stage
    FROM nginx
    EXPOSE 3000
    COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
    COPY --from=builder /app/build /usr/share/nginx/html

     

    Run stage 부분이 이제까지와 다른 부분이 있다.

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

    • nginx의 설정 파일을 덮어쓴다.
    • /etc/nginx/conf.d/default.conf : 컨테이너 안에 있는 nginx 설정 파일의 경로

     

    COPY --from-builder /app/build /usr/share/nginx/html

    • 빌드 파일을 /usr/share/nginx/html 에 넣어준다.

     

    갑자기 등장한 nginx 파일 경로 때문에 이해가 쉽지 않을 수 있다.
    간단하게 nginx의 설정 파일에 대해 알아보자.

     

    📌 nginx의 configuration file

    nginx의 기본 설정 파일은 /ect/nginx/nginx.conf 이며, 기본적으로 주어지는 nginx.conf 파일은 다음과 같다.

    user www-data;
    worker_processes auto;
    pid /run/nginx.pid;
    include /etc/nginx/modules-enabled/*.conf;

    events {
        worker_connections 768;
        # multi_accept on;
    }

    http {
        ...
        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
    }

     
    http 블록 안에 include /etc/nginx/conf.d/*.conf 를 통해, 해당 경로에 있는 conf(설정) 파일들을 포함한다.

    이렇게 포함되는 conf 파일은 다음과 같은 형태이다.

    server {
        listen [포트 번호]
        server_name [서버 이름]
        location [url] {
            ...
       }
    }

    server {
        listen [포트 번호]
        server_name [서버 이름]
        location [url] {
            ...
       }
    }


    server {}

    • 하나의 웹사이트를 선언할 때 사용한다.
    • conf 파일에는 최소 하나 이상의 server 블록이 존재한다.
    • port 번호 및 server_name으로 구분된다.

     

    location {}

    • server 블록 안에 등장하며, 특정 URL을 처리하는 법을 정의한다.
    • prefix string 이나 정규식 표현을 사용할 수 있다.

     

     

    3️⃣ /nginx/default.conf 작성

    server {
        listen 3000;
    
        location / {
            root /usr/share/nginx/html;
            index index.html index.htm;
            try_files $uri $uri/ /index.html;
        }
    }

     

    listen 3000

    • 리액트 앱의 기본 포트인 3000으로 설정한다.

    root /usr/share/nginx/html

    • html 파일이 위치할 경로를 설정한다.
    • 빌드 파일을 복사한 경로로 설정한다.

    index index.html index.htm

    • 사이트의 index 페이지로 할 파일명을 설정한다.

    try_files $uri $uri/ /index.html

    • React Router를 사용해서 페이지간 이동을 할 때 필요한 부분이다. 
      이 부분이 없다면 index를 제외한 모든 페이지를 찾을 수 없다는 오류가 발생한다.
    • React는 Single Page Application으로, 오직 하나의 정적 파일(index.html)만 가지고 있다.
      /$uri 에 접속하려고 할 때, index.html 파일에 접근해서 라우팅을 해야 한다.

     

     

    ✔️ 노드 앱을 위한 도커 파일 만들기

    이번엔 backend 폴더 안에 도커 파일을 생성하자!

     

    1️⃣ Dockerfile.dev 작성

    개발 환경을 위한 도커 파일을 다음과 같다.

    FROM node:alpine
    WORKDIR /app
    COPY package.json ./
    RUN npm install
    COPY ./ ./
    CMD ["npm", "run", "dev"]

     

    CMD ["npm", "run", "dev"]

    • npm run dev를 실행하면, nodemon server.js 가 실행된다.
    • 개발 환경에서는 nodemon을 이용하여 노드 앱을 실행하도록 이렇게 설정해준다.

     

    2️⃣ Dockerfile 작성

    운영 환경을 위한 Dockerfile은 다음과 같이 작성해준다.
    리액트와는 다르게 nginx 설정을 하지 않아도 된다.

    FROM node:alpine
    WORKDIR /app
    COPY package.json ./
    RUN npm install
    COPY ./ ./
    CMD ["npm", "run", "start"]

     

     

    ✔️ MYSQL을 위한 도커 파일 만들기

    MySQL 데이터베이스는 개발 환경에서만 도커 환경을 이용하고, 운영 환경에서는 AWS RDS 서비스를 이용할 것이다.
    즉, 아키텍처를 보면 다음과 같다.

    데이터베이스에는 중요한 데이터들을 다루기 때문에, 운영 환경에서는 더욱 안정적인 AWS RDS를 따로 두는 것으로 설계하는 것이 실제 실무에서 더 보편적인 방식이다.

     

    그럼 이제 개발 환경을 위한 MySQL의 도커 파일을 작성해보자. 
    먼저, mysql 폴더를 만들고 이 폴더에서 다음의 작업을 진행한다.

     

    1️⃣ my.conf 작성

    도커 파일을 작성하기 전에, mysql 설정 파일을 작성하자.
    한글을 저장할 경우 mysql 의 기본 설정에서는 한글이 깨질 수 있기 때문에 utf8로 설정해주어야 한다.

    [mysqld]
    character-set-server=utf8

    [mysql]
    default-character-set=utf8

    [client]
    default-character-set=utf8


     

    2️⃣ Dockerfile 작성

    FROM mysql:5.7
    ADD ./my.cnf /etc/mysql/conf.d/my.cnf
    • mysql 5.7 버전을 사용한다.
    • mysql 설정 파일인 my.cnf 파일을 컨테이너의 mysql 설정 파일 경로에 덮어쓴다.

     

    3️⃣ 데이터베이스 초기화를 위한 sql 작성

    데이터베이스를 생성(이미 존재한다면 Drop 한 후에)하고, 생성한 데이터베이스에 lists 테이블을 생성하는 sql 파일을 작성하자.

    ./sqls/initialize.sql 파일

    DROP DATABASE IF EXISTS myapp;
    
    CREATE DATABASE myapp;
    USE myapp;
    
    CREATE TABLE lists (
        id INTEGER AUTO_INCREMENT,
        value TEXT, 
        PRIMARY KEY (id)
    );

     

     

    ✔️ NGINX를 위한 도커 파일 만들기 (proxy)

    정적 파일 (index.html, js 파일)을 요청할 때는 React.js로, API를 요청할 때는 Node.js로 요청을 보내주도록 proxy를 설정하자.

    /api 로 시작하는 요청은 Node.js로, 나머지 요청은 React.js로 보내도록 설정할 것이다.

     

    nginx 폴더를 만들고 다음의 작업을 진행한다.

     

    1️⃣ default.conf 작성

    # 3000번 포트에서 frontend 실행됨
    upstream frontend {
        server frontend:3000;
    }
    
    # 5000번 포트에서 backend 실행됨
    upstream backend {
        server backend:5000;
    }
    
    server {
    
        #nginx 서버 포트 80번으로 설정
        listen 80;
    
        # / 경로는 가장 우선순위가 낮다. 
        # /api로 시작하는 경로를 제외한 모든 요청을 http://frontend로 보낸다.
        location / {
            proxy_pass http://frontend;
        }
    
        # /api로 시작하는 요청을 http://backend로 보낸다.
        location /api {
            proxy_pass http://backend;
        }
    
        # 개발 환경에서 react를 위한 설정 
        location /sockjs-node {
            proxy_pass http://frontend;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "Upgrade";
        }
    }

    * 여기서 frontend, backend 의 이름은 뒤에 나오는 Docker Compose에서 설정해준 이름이다.

     

    2️⃣ Dockerfile 작성

    개발 환경과 운영 환경의 nginx를 위한 도커 파일이 같으므로 1개만 작성한다.

    FROM nginx
    COPY ./default.conf /etc/nginx/conf.d/default.conf
    • 베이스 이미지로 nginx를 사용한다.
    • 위에서 작성해준 default.conf 파일을 컨테이너 내부의 nginx 설정 파일에 덮어쓴다.

     

     

    ✔️ Docker Compose 파일 작성하기

    멀티 컨테이너 환경에서 애플리케이션을 실행하기 위해서는 Docker Compose를 사용해야 한다. (https://wisdom-cs.tistory.com/32)
    Docker Compose를 통해 각각의 컨테이너를 연결하고 한 번에 모든 컨테이너를 실행하자.

     docker-compose.yml 파일

    • services에 4가지(frontend, nginx, backend, mysql)를 추가한다. 
    version: "3"
    services:
      frontend:
        build: 
          dockerfile: Dockerfile.dev
          context: ./frontend
        volumes:
          - /app/node_modules
          - ./frontend:/app
        stdin_open: true	#리액트 앱을 종료할때 나오는 버그를 잡아주기 위한 설정
    
      nginx:
        restart: always
        build: 
          dockerfile: Dockerfile
          context: ./nginx
        ports:
          - "3000:80"
    
      backend:
        build: 
          dockerfile: Dockerfile.dev
          context: ./backend
        container_name: app_backend
        volumes:
          - /app/node_modules
          - ./backend:/app
    
      mysql:
        build: ./mysql
        restart: unless-stopped
        container_name: app_mysql
        ports:
          - "3306:3306"
        volumes:
          - ./mysql/mysql_data:/var/lib/mysql
          - ./mysql/sqls/:/docker-entrypoint-initdb.d/
        environment:
          MYSQL_ROOT_PASSWORD: 1234
          MYSQL_DATABASE: myapp


    context

    • 빌드 시, 소스 파일의 (로컬) 위치를 지정한다.

    volumes

    • 컨테이너에서 로컬 머신에 있는 파일을 참조하여, 수정 후 rebuild 없이 바로 수정된 코드가 반영될 수 있도록 한다.
    • node_modules는 참조하지 않도록 설정한다. (frontend, backend)
    • ./mysql/mysql_data:/var/lib/mysql : 호스트 시스템에 데이터 디렉토리(여기서는 mysql_data, 다른 이름이어도 된다.)를 생성하고, MySQL이 기본적으로 데이터 파일을 쓰는 경로인 /var/lib/mysql 에서 참조하도록 한다.
      이렇게 하면, 컨테이너 삭제 후에도 데이터를 잃지 않고 영구적으로 보관할 수 있다.
    • ./mysql/sqls/:/docker-entrypoint-initdb.d/ : 컨테이너가 처음 시작될 때, /docker-entrypoint-initdb.d 위치에 있는 sql 파일들을 실행하게 된다. 컨테이너 시작 시, 데이터베이스와 테이블을 초기화하는 sql 파일들(여기서는 initialize.sql)을 실행하도록 해당 경로에서 /mysql/sqls/ 를 참조하도록 한다.

    restart

    • 컨테이너 종료 시, 재시작 정책을 지정한다.
    • no : 어떠한 상황에서도 재시작하지 않는다.
    • always : 항상 재시작한다.
    • on-failure : on-failure 에러 코드와 함께 컨테이너가 멈췄을 때만 재시작한다.
    • unless-stopped : 개발자가 임의로 멈추려고 할 때 빼고는 항상 재시작한다.

    environment

    • 환경 변수 값을 설정한다.
    • 여기서는 mysql의 root 계정 비밀번호(MYSQL_ROOT_PASSWORD)와 Database의 이름(MYSQL_DATABASE)을 지정해주었다.

     

    📌 mysql 참고
    mysql 관련 설정에 대한 자세한 내용은 공식문서(https://hub.docker.com/_/mysql)에서 확인할 수 있다. 

     

     

     


    그럼 이제 docker-compose를 이용하여 애플리케이션을 실행해보자!

    $ docker-compose up --build

     다음과 같이 성공적으로 실행되는 것을 확인할 수 있다.

     

     

     

    참고

     

     

     

     

     

    728x90

    댓글

Designed by Tistory.