Deploy Minecraft by Docker

시작

언젠가 현실 친구 채팅방에서 ‘우리 다같이 한 것이 뭐가 있었냐?’ 하면서 이야기가 있었는데, 갑자기 마인크래프트 이야기가 나오게 되어 구축하게 되었다.

…는 마인크래프트 서버 구축이 처음은 아니었는데, 지금으로부터 10개월 전에 EC2라는 모드를 넣고 구축한 적이 있었다. 다만, 오래 못 갈 뿐이었다.

하지만, 이번에는 바닐라 기반으로 하자는 이야기가 나왔고, 약 6일 정도 운영하게 되었다.

6일 정도 운영하다보니 나름대로 문제와 이를 해결하기 위한 여러 방안을 구축해서 사용했고, 그 결과 현재는 다음과 같은 구조를 가지게 되었다.

Nodap-MC 서버 구성도

먼저, 마인크래프트 서버라고 해서 단순히 마인크래프트만 돌린다는 것은 전혀 아니다. 그럴 수도 있지만, 가령 아래와 같은 문제가 발생하게 된다.

  • 서버 관리자가 바빠서, 문제가 생겼을 때 복구할 수 없음
  • 오랜 시간 키고 있으면 반응이 느려지는 일이 있고, 서버 컨테이너를 재시작할 필요가 있음

따라서, minecraft 컨테이너 외에 여러 컨테이너들을 동시에 운영하게 되었고, 각각의 역할은 다음과 같다.

  • minecraft: 마인크래프트 서버. 좀 더 나은 성능을 위해 PaperMC 1.15.2 와 adoptOpenJdk11 버전을 사용.
  • rcon: minecraft 서버의 콘솔 접근에 사용되는데, 이는 op 권한을 아무도 갖고 있지 않기 때문이다.
  • Caddy: 외부에서 80, 443 으로 들어온 접근에 대해 각 컨테이너로 연결시킨다.
    • Nginx를 사용할 수도 있었지만, 이번에는 좀 더 간단하다는 Caddy를 사용했다. 제일 마음에 드는 점은, 각 연결 통로마다 tls [email protected] 라고 적어두면 Let’s Encrypt로 통해 인증서를 받아와서 Signing한다는 것이다.
  • Portainer: 웹 UI로 컨테이너나 이미지들에 대해 관리할 수 있고, 로그를 볼 수 있게 하는 도구이다. 서버 관리자(= 필자 본인)이 바쁘기 때문에, 문제가 생겼을 때 누군가가 빠르게 서버를 재부팅해서 사용할 수 있도록 구축했다.
  • Grafana, Prometheus, cAdvisor: cAdvisor는 각 컨테이너에 대한 metrics를 제공하고, Prometheus는 cAdvisor가 수집한 metrics를 저장하는 역할을 맡고, Grafana는 이를 그래프로 수치화해서 보여주는 역할이다. 여기서, Grafana Alerts를 이용하여 메모리 사용량이 일정량 이상일 때 텔레그램으로 경고 메세지를 보낸다.

구축 정리

구축에 사용된 서버는 Vultr 4vCPU 8GB 사양이다.

기본 과정

Ubuntu 기준이며, https://docs.docker.com/install/linux/docker-ce/ubuntu/ 를 참고한다.

sudo apt update
sudo apt install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL <a href="https://download.docker.com/linux/ubuntu/gpg">https://download.docker.com/linux/ubuntu/gpg</a> | sudo apt-key add -
sudo apt-key fingerprint 0EBFCD88
sudo add-apt-repository "deb [arch=amd64] <a href="https://download.docker.com/linux/ubuntu">https://download.docker.com/linux/ubuntu</a> bionic stable"
sudo apt update
sudo apt install docker-ce docker-ce-cli <a href="http://containerd.io">containerd.io</a>
sudo curl -L "<a href="https://github.com/docker/compose/releases/download/1.25.4/docker-compose-$">https://github.com/docker/compose/releases/download/1.25.4/docker-compose-$</a>(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

여기까지 9개의 Command를 실행하여 Docker-CE 버전과 docker-compose를 설치할 수 있다.

마인크래프트 서버 (itzg/minecraft-server)

version: '2'

services:
  minecraft:
    image: itzg/minecraft-server:adopt11
    container_name: "minecraft"
    ports:
      - ...
    environment:
      - EULA=TRUE
      - ENABLE_RCON=TRUE
      - RCON_PASSWORD=...
      - MEMORY=7G
      - TYPE=PAPER
      - VERSION=1.15.2
      - SERVER_PORT=...
    restart: always
    
  rcon:
    image: itzg/rcon
    container_name: "rcon"
    links:
      - minecraft
    networks:
      - nodap-mc-network

networks:
  nodap-mc-network:
    external: true

만약 Forge 기반의 모드를 사용하고자 하면 TYPE=FORGE로 통해 Forge 기반 서버로 사용할 필요가 있다. 처음에 Forge 기반으로 사용했었으나 메모리 사용량이나 서버가 급격하게 무거워지는 문제가 있었기에, PaperMC 를 사용하게 되었다.

rcon 에 연결된 networks인 nodap-mc-network 는 후술할 예정이지만 Caddy가 각 컨테이너로 연결할 수 있게 묶어주는 역할을 한다. TCP로 연결되어 따로 네트워크를 묶을 필요가 없는 minecraft는 기재하지 않았다.

Portainer 서버 (portainer/portainer)

Portainer 서버는 상기하였듯이 컨테이너의 관리를 웹 UI로 할 수 있다.

version: ‘2’

services:
  portainer:
    image: portainer/portainer
    restart: always
    container_name: "portainer-app"
    command: -H unix:///var/run/docker.sock
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - nodap-mc-network

networks:
  nodap-mc-network:
    external: true

여기에서도 nodap-mc-network로 묶어주었다.

모니터링 Stack

모니터링 스택은 상기하였듯이 컨테이너의 CPU/메모리/네트워크 사용량을 볼 수 있게 한다.

cAdvisor 서버 (google/cadvisor)

cadvisor: image: google/cadvisor:latest container_name: cadvisor volumes: – /:/rootfs:ro – /var/run:/var/run:ro – /sys:/sys:ro – /var/lib/docker/:/var/lib/docker:ro – /dev/disk/:/dev/disk:ro restart: always networks: – nodap-mc-network

cAdvisor 등이 외부에 노출될 필요는 없으나 필요할 경우, 8080:8080 포트를 추가하여 웹 UI를 볼 수 있다.

Prometheus 서버 (prom/prometheus)

prometheus:
    image: prom/prometheus
    container_name: prometheus
    restart: always
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
    networks:
      - nodap-mc-network

설정에 필요한 prometheus.yml는 다음과 같다.

global:
  scrape_interval: 15s 

  external_labels:
    monitor: 'nodap-mc-monitor'

scrape_configs:
  - job_name: 'cAdvisor'
    scrape_interval: 5s
    static_configs:
    - targets: ['cadvisor:8080']

Grafana 서버 (grafana/grafana)

grafana: image: grafana/grafana container_name: grafana environment: – GF_SECURITY_ADMIN_PASSWORD= volumes: – grafana-storage:/var/lib/grafana depends_on: – prometheus networks: – nodap-mc-network

그리고, Grafana Alerts를 이용하여 알림 기능을 구현하였는데, 위 Memory Usage 의 빨간색 부분이 기준치이다.

알림 룰은 간단하게 진행하였는데, 매 20분마다 5분간의 평균 메모리 사용량이 7301444403 Bytes (= 6.8GiB) 를 넘으면 Notification을 보내주는 규칙이다.

이 세 서버를 모두 정의하면 다음과 같다.

version: ‘2’

services:
  cadvisor:
    image: google/cadvisor:latest
    container_name: cadvisor
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
      - /dev/disk/:/dev/disk:ro
    restart: always
    networks:
      - nodap-mc-network

  prometheus:
    image: prom/prometheus
    container_name: prometheus
    restart: always
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
    networks:
      - nodap-mc-network

  grafana:
    image: grafana/grafana
    container_name: grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=
    volumes:
      - grafana-storage:/var/lib/grafana
    depends_on:
      - prometheus
    networks:
      - nodap-mc-network

volumes:
  grafana-storage:

networks:
  nodap-mc-network:
    external: true

Caddy 서버 (abiosoft/caddy)

Caddy는 Go로 작성된 웹 서버로 간단한 사용법으로 TLS certificate 관리, 리버스 프록시, 정적 파일 호스팅 등의 많은 기능을 제공한다.

version: ‘2’

services:
  caddy:
    image: abiosoft/caddy
    container_name: caddy
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - .Caddyfile:/etc/Caddyfile
      - .caddy:/root/.caddy
    restart: always
    networks:
      - nodap-mc-network

networks:
  nodap-mc-network:
    external: true

그리고, 설정 파일이기도 한 Caddyfile는 다음과 같다.

***1.uzuki.live {
    proxy / portainer:9000
    tls [email protected]
}

***2.uzuki.live {
    proxy / grafana:3000
    tls [email protected]
}

***3.uzuki.live {
    proxy / rcon:4326
    tls [email protected]
}

***[1~3].uzuki.live 가 하나의 사이트로 인식하게 되며, 각 사이트는 컨테이너:포트 로의 proxy 기능과 tls [email protected] 를 가지게 된다.

Flask + Gunicorn with Docker

가끔 유지보수 하고 있던 node.js 기반의 텔레그램 봇에서 파이썬을 도입하게 되었다. 그 이유는, 봇의 특정 기능 중 요약문을 제공하는 기능을 필요로 했었고, Komoran + TextRank 알고리즘 기반으로 구성하는 것이 그나마 빠르게 진행할 수 있었기 때문이었다.

다만, 다른 곳에서 해당 기능을 사용하므로 api로 배포할 필요가 있었고, 전체적인 구조는 다음과 같다.

  • 텔레그램 봇 —> Textrank API —> TextRank Analyzer

이 중, API를 사용하기 위해 Flask-RESTFul 를 사용하고, 배포는 Python WSGI 웹 서버인 Gunicorn 를 사용했다.

이 글에서는 TextRank Analyzer 를 API로 expose 하고, Docker로 배포하는 과정을 정리한다.

API 연결

먼저, TextRank 알고리즘 수행을 위해 외부에서 받아 와야 할 정보들에 대해 정리가 필요하다.

  • 요약할 문장 (필수값)
  • 최대 요약할 문장 갯수 (기본값 3)
  • 최대 요약할 키워드 갯수 (기본값 3)
  • PageRankDamping Factor 값 → 사용자가 클릭을 중단할 확률. 기본값 0.85
  • 최상위에 랭크된 문장과 그 다음 문장 간의 최소 코사인 거리. 기본값 0.3
  • 허용 단어 빈도 (최소 n번 사용된 문장을 선택하나 m번 이상 사용된 문장은 사용하지 않음). 기본값 3, 20
  • 최소 문장 단어 수 (최소 n개 단어를 사용한 문장을 사용하나 m개 이상 단어가 사용되면 사용하지 않음). 기본값 10, 80

이제, 이 값들을 body로 받아 보관해야 하는데, Flask-RESTFul 는 reqparse 라는 기능을 제공한다.

이는 argument parsing 에 많이 쓰이는 argparse 와 비슷한 사용법을 가지면서도 사용자의 post body 를 해석하는 기능을 제공한다.

사용법은 `parser.add_argument(name, type) 이고, required 여부나 default 도 지정할 수 있다.

이를 반영한 API 클래스는 다음과 같을 것이다.

class TextRank(Resource):
    def post(self):
        try:
            parser = reqparse.RequestParser()
            parser.add_argument('body', type=str, required=True) 
            parser.add_argument('sentenceCount', type=int, default=3, required=False) 
            parser.add_argument('keywordCount', type=int, default=5, required=False)  
            parser.add_argument('beta', type=float, default=0.85, required=False) 
            parser.add_argument('diversity', type=float, default=0.3, required=False)  
            parser.add_argument('minWordCount', type=int, default=3, required=False)  
            parser.add_argument('maxWordCount', type=int, default=20, required=False)  
            parser.add_argument('minSentencePenalty', type=int, default=10, required=False) 
            parser.add_argument('maxSentencePenalty', type=int, default=80, required=False) 
            args = parser.parse_args()

            ...

            return {
                'code': '1',
                'message': 'Success',
                'sentence': sentence,
                'keyword': keywords
            }

        except Exception as e:
            return {'code': -1, "message": 'Failed ' + str(e)}

만들어진 TextRank 클래스를 Flask에 연결하게 되면, 지정한 routing 에 따라 API가 호출된다.

app = Flask(__name__)
api = Api(app)
api.add_resource(TextRank, '/summarize')

if __name__ == '__main__':
    app.run(port=8000)

이와 같이 작성하고 Run을 하여 127.0.0.1:8000/summarize 로 API 요청을 보내면 결과가 나오게 된다.

Gunicorn 배포

python3 server.py 로 서버를 실행시킬 수 있지만, 이는 development 기준으로 배포용도에는 맞지 않다.

실제로, 위 명령어로 배포하게 되면 아래와 같은 메세지가 나오게 된다.

* Serving Flask app "server" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on <http://127.0.0.1:8000/> (Press CTRL+C to quit)

요점은 production WSGI server 를 사용하라는 것이다. 여기서 WSGI 는 PEP-3333 에 정의된 파이썬 웹 서버 게이트웨이로, 파이썬 스크립트가 웹 서버와 통신하기 위해 작성된 인터페이스라고 설명할 수 있다.

여기서는 상기하였듯이 Gunicorn 를 사용했고, 사용법은 다음과 같다.

gunicorn server:app

여기서 server:app 는 각각 Flask를 담고 있는 파이썬 스크립트와 Flask 로컬 변수를 의미한다.

명령어를 입력하면 아래와 같은 로그가 나오게 된다.

% gunicorn server:app
[2020-02-15 13:15:14 +0900] [38332] [INFO] Starting gunicorn 20.0.4
[2020-02-15 13:15:14 +0900] [38332] [INFO] Listening at: <http://127.0.0.1:8000> (38332)
[2020-02-15 13:15:14 +0900] [38332] [INFO] Using worker: sync
[2020-02-15 13:15:14 +0900] [38336] [INFO] Booting worker with pid: 38336

기본적으로 127.0.0.1 (loopback) 주소에만 대응하고 있는데, 이를 도커 등으로 활용하려면 0.0.0.0:8000 등으로 port만 지정할 필요가 있다.

이는 -b 0.0.0.0:8000 으로 지정할 수 있고, 최종 명령어는 다음과 같다.

gunicorn -b 0.0.0.0:8000 server:app

이를 실행하면 다음과 같이 나온다.

% gunicorn -b 0.0.0.0:8000 server:app
[2020-02-15 13:16:04 +0900] [38387] [INFO] Starting gunicorn 20.0.4
[2020-02-15 13:16:04 +0900] [38387] [INFO] Listening at: <http://0.0.0.0:8000> (38387)
[2020-02-15 13:16:04 +0900] [38387] [INFO] Using worker: sync
[2020-02-15 13:16:04 +0900] [38391] [INFO] Booting worker with pid: 38391

Docker 배포

위 gunicorn 을 사용해 Dockerfile로 만들면 다음과 같을 것이다.

FROM ubuntu
WORKDIR /usr/src/app
RUN rm -rf /var/lib/apt/list/* && apt-get update && apt-get install python3 python3-pip -y
ADD . .
RUN pip3 install -r requirements.txt
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "server:app"]

간단히, python 과 python3-pip 를 설치하고 모든 파일을 복사하여 requirements.txt 에 적힌 항목을 전부 설치한다. 마지막으로, 포트 8000를 expose 하여 gunicorn를 실행한다.

Deploy React App by Docker 삽질기

처음 React로 토이 프로젝트를 진행하면서, Docker로 배포하면서 삽질한 기록을 정리해둔다.

1. 개발 환경에서는 npm start로 작동하지만…

RUN npm install
RUN npm run build
ENTRYPOINT ["npm", "start"]

처음에는 위의 단순한 코드로 Docker 컨테이너를 생성하고 올렸지만, Firefox에서 이상한 메세지가 나왔다.

이를 보니 Firefox에서는 https:// 에서 ws:// 로 이동하는 것이 Firefox에서는 불가능하다고 되어있다고 한다. (https://stackoverflow.com/questions/11768221/firefox-websocket-security-issue/12042843#12042843)

라이브러리 상 문제긴 하였지만, DevTools 를 실제 서버에 올리는 것도 문제가 되었기에 아래처럼 개발용일 때에만 DevTools와 Logger를 사용하도록 수정했다.

import rootReducer from "./reducers/RootReducer";

const middlewares = [];
let enhancer: any;

if (process.env.NODE_ENV === "development") {
 const createLogger = require("redux-logger").createLogger;
 const logger = createLogger();
 middlewares.push(logger);
 const compose = composeWithDevTools({
   trace: true,
   traceLimit: 100
});
 enhancer = compose(applyMiddleware(...middlewares));
}

const store = createStore(rootReducer, enhancer);
const rootElement = document.getElementById("root");

2. Production build로 올리기

1번에서 사용하던 Dockerfile로는 개발 빌드로 계속 올라가서, react-scripts build 로 빌드를 시도했을 때, 아래의 메세지가 나온다.

...
You can control this with the homepage field in your package.json.  
For example, add this to build it for GitHub Pages:

"homepage" : "...",

The build folder is ready to be deployed.
You may serve it with a static server:

npm install -g serve
serve -s build

Find out more about deployment here:

bit.ly/CRA-deploy

물론, 정석은 nginx를 올리는 것이지만 이미 서버의 앞단에 nginx가 reverse-proxy로 자리잡고 있어 단순히 static page를 올리는 것에서는 본문에 있는 serve 가 좀 더 적합해 보였다. https://github.com/zeit/serve

이를 Dockerfile로 반영하면 다음과 같았다.

RUN npm install
RUN npm i -S serve
RUN npm run-script build
ENTRYPOINT [ "serve" "-s" "build" ]

단, 이렇게 했을 때 [“serve” 를 path 에서 찾을 수 없다는 에러가 나오게 되는데, 이는 기본적으로 ENTRYPOINT 및 CMD가 /bin/sh 로 작업을 실행하기 때문에 찾을 수 없다는 로그다.

따라서 이를 해결하기 위해 직접 node_modules 에 있는 바이너리를 참조할 필요가 있다.

EXPOSE 5000

RUN npm install
RUN npm i -S serve
RUN npm run-script build
ENTRYPOINT [ "./node_modules/.bin/serve", "-s", "build" ]

이렇게 하고 빌드하니 정상적으로 실행이 되었고, nginx로 reverse proxy 연결해주니 도메인으로 접속할 수 있었다.