콘텐츠로 건너뛰기
Home » Blog » gitlab-runner를 통한 배포자동화 (2)

gitlab-runner를 통한 배포자동화 (2)

  • cicd

이전 단계에서 다뤘던 gitlab-runner 설치 및 사전 환경 구축을 끝냈다면,

이번 글에서는 gitlab에서 파이프라인을 구성하는 단계를 다루고자 한다.
이전 글을 읽지 않았다면 정독 후 이번 단계를 진행하는 것을 권장한다.

지금부터 다루는 스크립트는 모노 레포지토리 구성을 바탕으로 한다.

현재 진행중인 프로젝트가 하나의 레포지토리만 사용 가능하기에..

불가피하게 한 레포지토리 안에 프론트엔드, 백엔드 코드가 모두 배치되어 있다.

즉, 최상위 디렉토리에 backend 폴더, frontend 폴더가 있고 각각 내부에 분리하여 배치를 시킨 형태이기에

최상위 경로가 일반적인 프로젝트의 경로와는 상이한점을 감안해주길 바랍니다..

추가로, 스크립트 중간중간에 디렉토리를 이동하는 라인이 있는데 참고부탁합니다.


파이프라인 스테이지 설명

(파이프라인은 백엔드 배포 스크립트를 기반으로 설명한다)

배포 파이프라인의 스테이지 구성은 다음과 같다.

1. 환경변수 파일 생성
2. 빌드
3. 도커 빌드

4. 도커 pull

각각의 단계를 대략적으로 살펴보자면,


1번 스테이지는 secret 변수들을 넣는 작업이다.

예를들어 api secret key, db 계정 정보, 서버 접근 정보 등 외부 노출을 피해야하는 다양한 변수들이 존재한다.

이러한 정보들은 github에는 github-secret이 있다면 gitlab에서는 setting-cicd 탭의 variables 탭에서 관리한다.
여기에 태운 환경변수들을 env파일로 작성을 하는 단계가 1번 환경변수 파일 생성 스테이지 이다.

2번 스테이지는 빌드를 하는 스테이지이다.

코드가 제대로 작동을 하는지 확인 함으로써 빌드에 문제가 없는지 판단하는 단계라고 할 수 있다.


3번 스테이지는 도커 빌드 단계로, 2번 스테이지에서 빌드 완료를 거쳤다면 이를 이미지화 하는 단계이다.

이미지로 만들었다면, 도커 허브에 로그인하여 업로드를 하는 작업까지 진행한다.

4번 스테이지는 도커 pull을 받는 스테이지이다.

서버 내에서 도커 허브에 접속하여, 3번 스테이지에서 업로드한 이미지를 pull 받고,

이를 컨테이너화하여 실행하는 작업을 진행한다.


docker compose 작성

파일명은 docker-compose.yml 이며 최상위 디렉토리에 배치한다.

포트는 8080:8080으로 바인딩

version: '3'
services:
  app:
    image: ${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest
    ports:
      - "8080:8080"
    env_file:
      - ./.env

Dockerfile 작성

파일명은 Dockerfile이며 backend 디렉토리 내부에 배치한다.

FROM openjdk:17-alpine
WORKDIR /usr/src/app
ARG JAR_FILE=./build/libs/jansorry-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} /usr/src/app/app.jar
EXPOSE 8080
ENV TZ Asia/Seoul
ENTRYPOINT ["java", "-jar", "./app.jar"]

스크립트 설명

파일명은 gitlab-ci.yml 이며 최상위 디렉토리에 배치한다.
스크립트 내용은 다음과 같으며 대략적인 주석은 달아 놓았다.

stages:
  - create-env
  - backend-build
  - docker-build
  - docker-pull

variables:
  DOCKER_COMPOSE_FILE: docker-compose.yml

create-env:
  stage: create-env
  script:
    - echo "DB_URL=${DB_URL}" >> .env
    - echo "DB_NAME=${DB_NAME}" >> .env
    - echo "DB_USERNAME=${DB_USERNAME}" >> .env
    - echo "DB_PASSWORD=${DB_PASSWORD}" >> .env
    - echo "SECRET_KEY = ${SECRET_KEY}" >> .env
    - echo "REFRESH_SECRET_KEY = ${REFRESH_SECRET_KEY}" >> .env
    - echo "REDIS_HOST = ${REDIS_HOST}" >> .env
    - echo "REDIS_PORT = ${REDIS_PORT}" >> .env
    - echo "REDIS_PASSWORD = ${REDIS_PASSWORD}" >> .env
    - echo "CLIENT_ID = ${CLIENT_ID}" >> .env
    - echo "REDIRECT_URI = ${REDIRECT_URI}" >> .env
  artifacts:
    paths:
      - .env
  only:
    - be-deploy

backend-build:
  stage: backend-build
  image: gradle:jdk17
  script:
    - cd backend
    - chmod +x gradlew
    - ./gradlew clean build
  cache:
    paths:
      - .gradle/wrapper
      - .gradle/caches
  artifacts:
    paths:
      - backend/build/libs/jansorry-0.0.1-SNAPSHOT.jar
  only:
    - be-deploy

docker-build:
  stage: docker-build
  dependencies:
    - create-env
    - backend-build
  script:
    <em># image removal</em>
    - docker rmi ${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest || true
    <em># login</em>
    - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD}
    <em># backend push</em>
    - cd backend
    - docker build -t ${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest -f Dockerfile .
    - docker push ${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest
  only:
    - be-deploy

docker-pull:
  stage: docker-pull
  script:
    <em># login again</em>
    - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD}
    <em># container removal</em>
    - docker stop $(docker ps -q --filter ancestor=${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest) || true
    - docker rm $(docker ps -aq --filter ancestor=${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest) || true
    - docker-compose -f $DOCKER_COMPOSE_FILE down || true
    - docker rmi $(docker images -q ${DOCKER_USERNAME}/${DOCKER_REPO}:backend-latest) || true
    <em># deploy</em>
    - docker-compose -f $DOCKER_COMPOSE_FILE pull
    - docker-compose -f $DOCKER_COMPOSE_FILE up -d
    - docker image prune -f
  only:
    - be-deploy

먼저 살펴보아야 할 것은 artifact와 dependencies 이다.

artifacts

artifacts는 특정 작업(job) 실행 후 생성된 파일이나 디렉토리를 지정하여, 후속 작업에서 사용할 수 있도록 GitLab에 의해 저장되는 객체이다. 여기에는 빌드, 테스트 결과, 로그, 컴파일된 바이너리 같은 것들이 포함될 수 있다. 이러한 artifacts를 정의함으로써, 특정 작업이 완료된 후 결과물을 후속 작업에 전달할 수 있다.

dependencies

dependencies는 특정 작업이 실행되기 전에 다른 작업의 artifacts에 의존한다는 것을 명시하는 것이다. 이 설정을 통해 gitlab은 의존하는 작업의 artifacts를 현재 작업으로 가져올 수 있다. 이는 작업 간의 명시적인 의존 관계를 생성하고, 필요한 artifacts를 자동으로 전달받기 위해 사용된다.

추가로, 상단에 정의된 variables는 스크립트 내부에서 사용되는 변수로,

cicd 탭에서 설정하는 variables와는 다르다. (분리해서 이해하도록 하자)

only는 브랜치의 이름을 넣는 곳인데, 특정 브랜치 명을 명시함으로써,

해당 스테이지가 명시된 브랜치에 트리거가 왔을 경우에 동작하는 것을 의미한다.

필자의 경우에는 be-dev 브랜치에서 파생된 be-deploy 브랜치(백엔드 배포 브랜치)로 모두 명시했다.

1번 환경변수파일 생성 스테이지에서는 빌드 단계에 필요한 다양한 secret 변수들을 넣었는데
이는 variables 에 등록한 키 네임과 동일해야한다.

빌드에 필요한 모든 변수들을 어느하나 빼먹지 말고 모두 작성하는게 중요하다.

2번 빌드 스테이지에서는 chmod 로 execute 권한을 부여하고 gradlew 클린 빌드 시킨다.
(디렉토리 경로 이동은 모노 레포지토리라 그렇다)

3번 도커 빌드스테이지에서는 기존에 존재하는 이미지를 삭제하고, (실패할 경우 종료되므로 || true 를 명시)
도커 허브에 로그인, 도커 빌드 후 이미지 푸시 과정을 거친다.

4번 도커 pull 단계에서는 3번 스테이지와 동일하게 도커 허브에 로그인 한 뒤,
기존에 돌고 있던 컨테이너와 이미지를 삭제 및 compose 또한 down 한 뒤,
허브에서 3번 스테이지에서 업로드한 이미지를 pull 받아 업데이트한다.


마치며

이후 해당 push나 merge 이벤트가 발생할 때마다 서버의 백엔드 컨테이너가 업데이트 된다.
기존에는 프론트엔드와 백엔드 스크립트를 모두 섞어서 5단계로 진행했었다.
즉, 빌드 단계가 두단계였고, 총 5개의 스테이지 구성이었다.
그러나 이러한 방식은, 빌드를 두번이나 하기에 배포 시간이 4분 이상 소요되는 문제가 있었다.

결국 보다 원활한 작업을 위해 파이프라인을 분리하여 독립적으로 동작하게하였다.
프론트엔드는 문외한이라 팀원의 도움을 많이 받아가며 스크립트를 작성했었는데.. 
관련 내용은 하단에 추가로 첨부하였다.(프론트엔드는 next.js를 사용하였음)

배포를 하는 단계에서는 디버깅하기가 굉장히 까다롭다.
스크립트 중간중간마다 해당 경로가 어디인지 판단하기위해서는

ls나 pwd 같은 명령어를 끼워넣어서 배포 시 로그를 찍어보는게 큰 도움이 된다.

추가로, env 변수파일이 잘 들어갔는지 cat 명령어 등을 활용해보는 것도 큰 도움이 될 것이다.


Dockerfile (front)

# 기반 이미지
FROM node:18-alpine

# 작업 디렉토리 설정
WORKDIR /usr/src/app

# 빌드된 파일들을 이미지로 복사
COPY ./.next/standalone ./
#COPY ./public ./public
#COPY ./.next/static ./static

# 시스템 사용자 및 그룹 설정
RUN addgroup --system --gid 1001 nodejs \
    && adduser --system --uid 1001 nextjs

# 애플리케이션 실행을 위한 사용자 권한 설정
USER nextjs

# 애플리케이션에 할당할 포트
EXPOSE 3000

# 한국 시간으로 설정
ENV TZ Asia/Seoul

# 애플리케이션 실행 명령어
CMD ["node", "server.js"]

docker-compose.yml (front)

version: '3'
services:
  frontend:
    image: ${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest
    ports:
      - "3000:3000"
    env_file:
      - ./.env

gitlab-ci.yml (front)

stages:
  - create-env
  - frontend-build
  - docker-build
  - docker-pull

variables:
  DOCKER_COMPOSE_FILE: docker-compose.yml

create-env:
  stage: create-env
  script:
    - echo "NEXT_SHARP_PATH = ${NEXT_SHARP_PATH}" >> .env
    - echo "NEXT_PUBLIC_SERVER_URL = ${NEXT_PUBLIC_SERVER_URL}" >> .env
    - echo "NEXT_PUBLIC_KAKAO_REST_KEY = ${NEXT_PUBLIC_KAKAO_REST_KEY}" >> .env
    - echo "NEXT_PUBLIC_KAKAO_JS_KEY = ${NEXT_PUBLIC_KAKAO_JS_KEY}" >> .env
    - echo "NEXT_PUBLIC_KAKAO_LOGIN_REDIRECT_URI = ${NEXT_PUBLIC_KAKAO_LOGIN_REDIRECT_URI}" >> .env
    - echo "NEXT_PUBLIC_KAKAO_INTEGRITY_KEY = ${NEXT_PUBLIC_KAKAO_INTEGRITY_KEY}" >> .env
    - echo "NODE_ENV = ${NODE_ENV}" >> .env
  artifacts:
    paths:
      - .env
  only:
    - fe-deploy


frontend-build:
  stage: frontend-build
  image: node:18-alpine
  script:
    - cd frontend
    - npm install -g pnpm
    - pnpm install --production=false
    - pnpm build
  artifacts:
    paths:
      - frontend/.next/standalone
#      - frontend/public
#      - frontend/.next/static
  only:
    - fe-deploy

docker-build:
  stage: docker-build
  dependencies:
    - create-env
    - frontend-build
  script:
    # image removal
    - docker rmi ${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest || true
    # login
    - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD}
    # frontend push
    - cd frontend
    - docker build -t ${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest -f Dockerfile .
    - docker push ${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest
  only:
    - fe-deploy

docker-pull:
  stage: docker-pull
  script:
    # login again
    - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD}
    # container removal
    - docker stop $(docker ps -q --filter ancestor=${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest) || true
    - docker rm $(docker ps -aq --filter ancestor=${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest) || true
    - docker-compose -f $DOCKER_COMPOSE_FILE down || true
    - docker rmi $(docker images -q ${DOCKER_USERNAME}/${DOCKER_REPO}:frontend-latest) || true
    # deploy
    - docker-compose -f $DOCKER_COMPOSE_FILE pull
    - docker-compose -f $DOCKER_COMPOSE_FILE up -d
    - docker image prune -f
  only:
    - fe-deploy