노트/Docker

배포 예제 네 가지

작성 수정

프로젝트마다 남겨 둔 배포 설정이다. 어떤 상황에서 어떤 조합을 썼는지 함께 적는다.

1. Cloud Build 로 Cloud Run 에 올리기 (Node 서비스)

커밋 해시를 이미지 태그로 써서 빌드, 푸시, 배포를 Cloud Build 가 한 번에 한다.

FROM node:20

WORKDIR /usr/src/app

# package.json 과 package-lock.json 을 먼저 복사해야 코드만 바뀔 때 npm install 을 다시 하지 않는다
COPY package*.json ./
RUN npm install --omit=dev

COPY ./*.js ./

CMD [ "npm", "start" ]
# cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
  args: [ 'build', '-t', 'gcr.io/my-project/merge-user:$COMMIT_SHA', '.' ]
- name: 'gcr.io/cloud-builders/docker'
  args: [ 'push', 'gcr.io/my-project/merge-user:$COMMIT_SHA' ]
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
  entrypoint: gcloud
  args: [ 'run', 'deploy', 'merge-user',
          '--image', 'gcr.io/my-project/merge-user:$COMMIT_SHA',
          '--region', 'us-central1', '--platform', 'managed',
          '--no-cpu-throttling', '--concurrency', '1000' ]
images:
- gcr.io/my-project/merge-user:$COMMIT_SHA

dev 용 cloudbuild_dev.yaml 은 서비스 이름을 merge-user-dev 로 바꾸고 --update-env-vars NODE_ENV=development 를 더한 것이다. package.json 의 스크립트로 둘을 가른다.

"scripts": {
  "start": "node index.js",
  "deploy:dev": "gcloud builds submit --config cloudbuild_dev.yaml --substitutions=COMMIT_SHA=\"$(git rev-parse HEAD)\"",
  "deploy:prod": "gcloud builds submit --config cloudbuild.yaml --substitutions=COMMIT_SHA=\"$(git rev-parse HEAD)\""
}

gcloud builds submit 의 옵션은 배포 환경 종류 끝에 적어 두었다.

2. nginx 로 Next.js 와 Node 를 한 포트에 묶기

프런트(Next.js, 3000)와 API(Node, 8081)를 컨테이너 하나에서 띄우고 nginx 가 8080 에서 받아 경로로 나눈다.

events {}

http {
    server {
        listen 8080;
        # location 블록의 순서대로 요청을 맞춰 본다

        # /node 로 시작하면 8081 로
        location /node {
            proxy_pass http://localhost:8081;
            proxy_set_header Host $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 $scheme;
        }

        # 나머지는 Next.js 로
        location / {
            proxy_pass http://localhost:3000;
            proxy_set_header Host $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 $scheme;
        }
    }
}

이 프로젝트의 이미지는 Makefile 로 빌드해서 Artifact Registry 에 올리고 kubectl set image 로 교체했다.

3. OS 이미지 하나에 Python, Node, MongoDB 를 다 넣기 (watcher)

Node 가 처리한 결과를 Flask 화면으로 확인하는 백오피스다. 컨테이너 하나에 셋을 넣었다. 패키지 설치가 오래 걸리는 부분은 base 이미지로 떼어 두고 앱은 그 위에 얹는다.

# Dockerfile_base
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y software-properties-common curl gnupg lsb-release

# tzdata 를 KST 로
RUN echo 'tzdata tzdata/Areas select Asia' | debconf-set-selections && \
    echo 'tzdata tzdata/Zones/Asia select Seoul' | debconf-set-selections && \
    apt-get install -y tzdata

# Python 3.12, Node 20, MongoDB 7
RUN add-apt-repository ppa:deadsnakes/ppa && apt-get update && \
    apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
RUN curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | apt-key add - && \
    echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu $(lsb_release -cs)/mongodb-org/7.0 multiverse" \
      | tee /etc/apt/sources.list.d/mongodb-org-7.0.list && \
    apt-get update && apt-get install -y mongodb-org

RUN mkdir -p /data/db
CMD ["mongod"]
# Dockerfile
FROM base_ubuntu_python312_node20_mongodb7_240531

WORKDIR /work

COPY package.json requirements.txt /work/
RUN npm install
RUN pip install -r requirements.txt

COPY watcher.js admin_server.py run.sh /work/
COPY templates /work/templates
RUN chmod +x run.sh

CMD ["/work/run.sh"]
# run.sh — 데몬 둘을 띄우고 마지막 프로세스가 컨테이너를 붙잡는다
mongod --fork --logpath /var/log/mongodb.log
sleep 5
gunicorn --timeout 60 -D -w 4 -b 0.0.0.0:8001 --log-file /var/log/gunicorn.error.log admin_server:app
node watcher.js

배포 스크립트는 날짜 태그로 빌드하고, 기존 컨테이너를 멈춘 뒤, --network host 와 호스트 볼륨으로 새 컨테이너를 띄우고, docker logs -f 를 파일로 받아 둔다. 전체 스크립트와 Flask 쪽 설명은 Flask 관리자 서버를 Docker 한 장에 담기에 있다.

같은 폴더에 pnpm 모노레포용 2단 Dockerfile 도 있다. base 단계에서 pnpm-workspace.yaml, pnpm-lock.yaml 과 각 패키지의 package.json 만 복사해 pnpm install -r 을 하고, 앱 단계에서 소스를 복사해 빌드한다. 의존성 레이어가 소스와 분리되어 코드만 바뀌면 설치를 건너뛴다.

FROM node:18-alpine
RUN npm install -g pnpm
WORKDIR /app

COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
RUN mkdir packages server
COPY packages/package.json packages/
COPY server/package.json server/

RUN pnpm install -r

4. GitHub Actions 에서 빌드하고 SSH 로 서버에 배포하기

release 브랜치에 푸시하면 러너가 이미지를 빌드해 보고, 서버에 SSH 로 들어가 명령을 돌린다. 서버 주소와 키는 전부 Secrets 에 둔다.

name: Production Deployment

on:
  push:
    branches: [ release ]

jobs:
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4   # git pull 과 같은 역할

    - name: Build Docker image locally
      uses: docker/build-push-action@v5
      with:
        context: .
        load: true
        tags: junha-today:latest

    - name: Execute remote SSH commands
      uses: appleboy/ssh-action@v1.0.3
      with:
        host: ${{ secrets.REMOTE_IP }}
        username: ${{ secrets.REMOTE_USER }}
        key: ${{ secrets.REMOTE_PRIVATE_KEY }}
        port: ${{ secrets.REMOTE_SSH_PORT }}
        script: |
          # 서버에서 pull 하고 compose 로 올린다

이 워크플로가 지금 junha.kr 과 이 블로그를 배포하는 CI 의 원형이다.