altair의 프로젝트 일기

Diary 프로젝트 일기 (2) - CI/CD 파이프라인 본문

IT/서버

Diary 프로젝트 일기 (2) - CI/CD 파이프라인

altair823 2025. 2. 26. 00:41

CI/CD를 해야하는 이유

 내 서버에 올라가는 서비스를 개발할 때, 나는 제일 먼저 CI/CD 파이프라인부터 구축한다. 자동화된 테스트와 통합, 배포 과정은 개발이 진행될수록 점점 그 구축 비용이 증가하는 경향이 있다. 테스트는 라이브러리와 프레임워크 의존성에 큰 영향을 받고, 통합은 VCS의 브랜치 전략 등에, 배포는 Docker, LXC 등의 서버 인프라와 Dockerfile 등의 빌드 과정에 크게 좌지우지 된다. 만약 CI/CD를 미리 구축하지 않고 개발하기 시작하면 우선 로컬에서만 테스트할 것이고, 손수 SFTP 등으로 배포하게 될 것이다. 개발이 진행됨에 따라 테스트 및 배포 비용이 부담되어 그때 가서 CI/CD 파이프라인을 구축한다면 여러 문제가 발생한다. 다음은 내가 경험한 문제들이다. 

  • 기존 테스트 및 배포 의존성이 구축하려고 하는 CI/CD 환경을 지원하지 않을 수 있다. 
    • 지금까지 사설 IP에서 SSH로만 배포했는데 Github Actions에서 SSH로 배포하려면 공인 IP를 사용할 수 밖에 없다. 이 환경에서 배포하려면 포트포워딩을 사용해 특정 SSH 포트를 외부에 열어야한다. 취약점이 그대로 노출된다. 
  • 배포 시간이 길었던 개발 기간 동안 빠른 배포를 통한 개선이 어렵다.
    • 특히 운영서버가 아닌 개발서버의 경우 빠르게 개발 결과를 테스트해보고 문제를 수정하는 것이 훨씬 유리하다. 배포 과정 자체가 느리면 전체적인 개발 기간 또한 늘어나곤 한다. 
  • 부실한 테스트 커버리지를 방치할 가능성이 높다. 
    • 수동 배포는 빌드 파일만 만들 수 있으면 바로 배포할 수 있으므로 테스트 작성에 대한 강제성이 옅다. 만약 JaCoCo와 같은 도구를 사용해 CI 과정에서 테스트 커버리지를 강제한다면 배포를 위해 어쩔 수 없이 많은 테스트를 작성하게 되고 전체적인 안정성을 올린다. 

 이러한 이유 때문에 프로젝트 맨 처음에 개발부터 배포까지 원활하게 전달하도록 자동화시키는 작업을 하였다. 이번 글에서는 어떤 구조로 이를 달성했는지, 어떻게 변화하였는지 다루어 볼 것이다. 

 

NestJS 백엔드 CI/CD

 NestJS는 Jest라는 자체적인 유닛테스트 프레임워크를 지원한다. 또한 NodeJS 런타임 위에 올라가기 때문에 NodeJS 환경만 구성되어 있다면 테스트와 배포를 원활히 할 수 있다. 

 제일 먼저 간단하게 무료로 할 수 있는 Github Actions를 사용하였다. 테스트를 위한 ci.yml와 배포를 위한 cd.yml 워크플로우로 나누어 사용하였고, 브랜치별로 둘을 적절히 실행시켰다. 예를 들어 새 기능을 위한 feature/* 브랜치의 경우 테스트만, 개발서버를 위한 devlop 브랜치는 테스트와 배포를, 운영서버를 위한 production 브랜치도 테스트와 배포를 실행하였다. 다음은 develop 브랜치의 action 스크립트와 ci.yml, cd.yml 파일이다. 

on:
  push:
    branches:
      - develop
jobs:
  test:
    permissions:
      contents: read
      actions: read
    uses: ./.github/workflows/ci.yml
    secrets:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
  deploy:
    needs: test
    permissions:
      contents: read
      actions: write
      packages: write
    uses: ./.github/workflows/cd.yml
    secrets:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
      SERVER_USER: ${{ secrets.SERVER_USER }}
      SERVER_HOST: ${{ secrets.SERVER_HOST }}
      SERVER_PORT: ${{ secrets.SERVER_PORT }}
      HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
      HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
    with:
      APP_NAME: ${{ vars.APP_NAME }}
      # APP_NAME: diarity-be
      APP_PATH: ${{ vars.APP_PATH }}
      # APP_PATH: /home/ubuntu/diarity-be
      NODE_ENV: develop
더보기

ci.yml

name: CI for NestJS

on:
  workflow_call:
    secrets:
        SLACK_WEBHOOK_URL:
            required: true


jobs:
  test-and-build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
            
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22.11.0
                
      - name: Install dependencies
        run: npm install
              
      - name: Lint
        run: npm run lint

      - name: Run tests
        run: npm run test
            
      - name: Build
        run: npm run build

      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author,action,eventName,ref,workflow,job,took,pullRequest
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

cd.yml

name: CD for NestJS Deployment

on:
  workflow_call:
    secrets:
      SSH_PRIVATE_KEY:
        required: true
      SERVER_USER:
        required: true
      SERVER_HOST:
        required: true
      SERVER_PORT:
        required: true
      HARBOR_USERNAME:
        required: true
      HARBOR_PASSWORD:
        required: true
      SLACK_WEBHOOK_URL:
          required: true
    inputs:
      APP_NAME:
        required: true
        type: string
      APP_PATH:
        required: true
        type: string
      NODE_ENV:
        required: true
        type: string

jobs:
  deploy:
    permissions:
      contents: read
      actions: write
      packages: write
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login to Harbor Registry
        uses: docker/login-action@v3
        with:
          registry: altairharbor.duckdns.org
          username: ${{ secrets.HARBOR_USERNAME }}
          password: ${{ secrets.HARBOR_PASSWORD }}
      
      - name: Build and push
        env:
          APP_NAME: ${{ inputs.APP_NAME }}
          NODE_ENV: ${{ inputs.NODE_ENV }}
        run: |
          docker buildx build \
            --push \
            -t altairharbor.duckdns.org/diarity-be/${APP_NAME}:${NODE_ENV} \
            .

      - name: Deploy
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            export APP_NAME=${{ inputs.APP_NAME }}
            export NODE_ENV=${{ inputs.NODE_ENV }}
            export APP_PATH=${{ inputs.APP_PATH }}
            docker login altairharbor.duckdns.org -u ${{ secrets.HARBOR_USERNAME }} -p ${{ secrets.HARBOR_PASSWORD }}
            docker pull altairharbor.duckdns.org/diarity-be/$APP_NAME:$NODE_ENV
            docker stop $APP_NAME || true
            docker rm $APP_NAME || true
            docker run -d \
              --name $APP_NAME \
              -p 3000:3000 \
              -v $APP_PATH/.secrets.$NODE_ENV:/usr/src/app/.secrets.$NODE_ENV \
              -v $APP_PATH/logs:/usr/src/app/logs \
              -e NODE_ENV=$NODE_ENV \
              altairharbor.duckdns.org/diarity-be/$APP_NAME:$NODE_ENV
            docker image prune -f
          
      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author,action,eventName,ref,workflow,job,took,pullRequest
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

위 스크립트를 다이어그램으로 바꾸면 다음과 같이 만들 수 있을 것이다. 

 특히 Docker 이미지를 저장하고 관리하기 위해 Harbor를 따로 VM에 올려 사용하였다. Docker Hub는 돈을 지불해야 프라이빗 이미지를 올릴 수 있기 때문에 어쩔 수 없이 도커 이미지 저장소를 따로 만들어야만 했다. 배포 대상 서버는 이 Harbor에서 Pull해서 이미지를 사용할 수 있었다. 

 

배포 과정의 문제

 cd.yml 파일을 자세히 살펴보면 뭔가 이상한 점을 볼 수 있다. 도커 이미지로 Push한 뒤, Proxmox에 있는 원격 서버에 SSH로 접속해 최신 이미지를 Pull하고 새 컨테이너로 시작하는 것을 볼 수 있다. 문제는 저 Proxmox 서버는 내 홈서버고, 따라서 우리 집을 가리키는 하나의 공인 IP만 있다는 것이다. 어쩔 수 없이 해당 LXC의 SSH 포트를 포워딩하여 외부에 노출할 수 밖에 없었다. 

 물론 비밀키 파일을 사용해 접속하도록 했지만, 만약 개발서버나 배포서버 중 하나라도 실수로 패스워드 인증을 막지 않았다면, 그리고 패스워드가 어딘가에 노출되었다면 언제든지 내부로 들어올 수 있는 환경이었다. 서버의 SSH 포트를 열어둔 것 자체가 위험하다고 느꼈다. 

 이러한 일이 발생한 이유는 Github Action에서 내 홈서버로 배포하기 위한 쉬운 방법이 없다는 것이다. 가장 빠르고 쉬운 방법은 SSH 배포였기 때문에 일단 한동안 사용하였다. 

 

백엔드 스택 변경

 언제나 그렇듯이 이번 프로젝트를 할 때도 여러 변경사항이 있었다. 그 중 가장 큰 변경은 바로 백엔드 기술 스택의 변경이다. NestJS로 백엔드를 개발하다보니 여러 불편함이 있었다. 아무래도 애초에 프론트엔드 개발을 위해 개발된 언어라 여러 불편함이 있었고, 아무래도 C계열과 백엔드 언어에 익숙하다보니 개발 속도가 만족스럽지 않았다. 

 그러나 무엇보다 문제가 되었던 것은 데이터베이스의 불편함이었다. 반 쯤은 학습적인 목적으로 MongoDB를 사용하고 있었는데, 아무래도 게시판 형 서비스에서 NoSQL에 저장할만큼 형태가 다른 데이터들이 거의 없었다. 오히려 모든 데이터가 정형 데이터였고 잦은 join 연산을 필요로 했다. 

 예를 들어 좋아요 기능을 살펴보자. 사용자는 특정 게시물을 "좋아요"할 수 있고, 본인이 좋아요를 눌렀다는 것, 그리고 몇 사람이 눌렀는지 확인할 수 있어야 한다. 또한 자신이 좋아요를 누른 게시물들을 모아 볼 수 있어야 하고 게시물 주인은 어떤 사람이 좋아요를 눌렀는지 확인할 수 있어야 한다. 

 만약 정말 많은 사용자가 서비스를 이용한다면 물론 새로운 데이터베이스 로직이 필요할 것이지만, 현재로서는 기본적인 테이블 형태로 정규화하여 저장하는 것이 가장 빠르고 안정적이라고 생각했다. 선배들의 말은 틀린게 없었다. 처음에는 RDB로, 나중에 NoSQL로.

 그래서 더 늦어지기 전에 결국 백엔드 기술 스택 전체를 바꾸기로 했다. 백엔드 서버는 Spring Boot로, DB는 MySQL로 교체하기로 했다. 그 결과 새로운 CI/CD 파이프라인이 필요해졌다. 이 참에 앞서 말한 공인 IP를 통한 SSH 포트 노출 문제를 해결하기로 하였다. 

 

새로운 CI/CD 파이프라인

가장 안전한 배포 방법은 모든 CI/CD 과정을 내 홈서버 안에서 처리하는 것이다. 마치 AWS 안에서만 관리되는 서비스 플로우는 외부에서 접근하지 못하니 안전한 것과 같다고 볼 수 있겠다. EC2로 배포하는 CodeDeploy 작업을 어떻게 외부에서 침입할 수 있겠는가? 

 다행히 젠킨스는 이렇게 내 서버에 직접 설치해 사용하기 딱 알맞은 서비스다. 젠킨스를 사용해 내 서버 안에서 사설 IP를 사용해 서로 통신하고 따로 포트를 포워딩하지 않는다면 아무리 SSH를 사용한다고 해도 외부로 노출되지 않고 안전하게 배포할 수 있을 것이다. 또한 젠킨스가 올라가는 VM에 내가 직접 접근 가능하니 더욱 깊은 관여를 할 수 있을 것이다. 

 위 다이어그램은 구성 결과를 표현한 다이어그램이다. Github Repository를 제외한 모든 것이 내 홈서버에서 돌아간다(사실 Gitea를 사용해 Repository도 홈서버에 올릴까 했지만 잔디 관리도 그렇고 없어지지 않을 기록으로써 저장되길 바래서 Github에 올리고 Gitea에 백업하는 식으로 구성했다). 자세한 워크플로우는 다음과 같다. 

  1. Github Repository의 특정 브랜치에 Push된다. 이는 Webhook을 발생시킨다. 
  2. Webhook의 목적지는 Jenkins Master Node이며 Nginx 리버스 프록시 뒤에 있다. 
    1. 이 리버스 프록시는 SSL 인증서를 자동으로 갱신 및 관리하며, virtual host를 사용해 정확히 원하는 Jenkins Master Node를 향해 요청을 보낼 수 있다. 내 다른 서비스도 많기 때문에 가상 호스트를 사용해야 공인 IP 하나로 여러 도메인을 사용할 수 있다. 
  3. Jenkins Master는 웹훅을 받고 어떤 브랜치의 이벤트가 발생했는지 확인한다. 
  4. feature, development, production에 따른 각각의 task를 생성한다. 
    1. feature 브랜치는 테스트 작업만 생성한다. 
    2. development 브랜치는 테스트와 그 이후 개발서버로의 배포 작업을 생성한다. 
    3. production 브랜치는 테스트와 그 이후 운영서버로의 배포 작업을 생성한다. 
  5. 각 task는 직접 구성한 Jenkins Worker Pool에서 실행한다. 만약 여러 작업이 큐에 들어오면 풀에서 워커끼리 잘 나눠서 실행할 것이다. 
  6. 배포 시에는 빌드한 도커 이미지를 역시 내부에 있는 Harbor에 Push한다. 
  7. 배포 대상 서버 역시 내부에 있으니 안전하게 SSH를 사용해 배포 스크립트를 실행한다.

 특징적인 것은 Jenkins Worker Pool인데, Jenkins는 Jenkins가 설치된 다른 VM을 Worker노드로 설정할 수 있다. 이는 Master 노드의 성능 요구를 줄일 뿐만 아니라 보다 필요에 맞는 의존성을 제공할 수 있다. 예를 들어 어떤 빌드에는 JDK와 Docker가, 어떤 빌드에는 NodeJS가 필요하다고 하자. task에 worker 필터를 잘 걸어놨다면 JDK와 Docker가 설치된 노드에 앞선 작업이, NodeJS가 설치된 노드에는 뒤의 작업이 할당될 것이다. 이러면 Master노드는 다른 의존성을 전혀 설치할 필요가 없다. 추후에 다른 의존성이 필요하거나 기존 의존성이 불필요해진다면 Master는 단지 task만 관리하고 단지 Worker Pool에서 해당 노드만 추가/제거하면 된다. 

 이러한 간편함과 Master가 격리된다는 보안적인 측면 때문에 Jenkins 문서에서도 Master와 Worker들을 분리할 것을 권장한다. 

 위 사진은 컨트롤러, 즉 Master 노드의 모습이다. 개발서버와 운영서버에 따라 테스트와 배포 task를 나누어 놓았다. 

 현재는 두 노드가 등록되어 있는 모습이다. 작업이 없을 때는 오프라인으로, 작업이 생기면 온라인으로 올라오게 설정해서 지금은 오프라인인 상황이다. 직접 홈서버에서 실행하는 입장에서 이러한 기능은 정말 유용한 것 같다. 안쓰는 컴퓨팅 자원은 다른 서비스에서 쓰게 하거나 전기를 아낄 수 있기 때문이다. 

 

결론

 이렇게 Jenkins와 Harbor, 특히 Proxmox가 돌아가는 홈서버를 활용해 안전하고 빠른 CI/CD 파이프라인 구성을 리뷰해보았다. 얼마 전에 기존에 쓰던 i5 7300, 16GB 홈서버가 간헐적으로 아예 꺼지는 현상이 발생했다. 그래서 새 12400과 메인보드를 구매해 원래 쓰던 램, 파워, 데스크탑 케이스까지 새 홈서버를 조립했다. 워낙 성능이 훌륭한 새 서버다보니 이렇게 여러 Jenkins 노드들도 올리고 Harbor도 올리고 여러 VM도 올릴 수 있게 되었다. CI/CD 개선을 할 수 있었던 것도 새 서버 덕이 있지 않나 싶다. 

 사실 소프트웨어 마에스트로에서 서비스를 개발할 때 Jenkins를 사용해보고 싶었다. 멘토님께서도 말을 꺼내시기도 했기 때문이다. 그렇지만 워낙 타이트한 시간 탓에 Github Action에서 만족해야 했다. 사실 여기에 AWS CodeDeploy만 있어도 AWS 배포에는 무리가 전혀 없었기 때문에 딱히 Jenkins가 필요하지 않았다. 

 그렇지만 이번엔 성능 좋은 서버도 있겠다, 백엔드 스택도 바꿔서 새 CI/CD 구성도 필요했겠다, 한 번 Jenkins로 구성해봤다. 예전에 어릴 때 라즈베리파이로 Jenkins를 구성해본 기억이 있기는 했지만 이제 웬만큼 배우고 나서 직접 사용해보니 크게 어렵지도 않고 편리한 것 같다. 앞으로 다른 프로젝트를 해도 직접 배포까지 필요하다면 Github Action보다 Jenkins를 사용할 것 같다. 더 다양한 기능과 편리한 인터페이스가 마음에 든다. 

 그리고 글을 쓰며 조사하다가 알아낸 것인데, Github Action도 self-hosted runner를 구성할 수 있다고 한다. 이렇게 하면 Github Action을 사용하면서도 안전하게 내부에서만 SSH를 사용해 배포할 수 있을 것이다. 물론 그럴수록 프로젝트가 Github에 종속된다는 것은 불편하게 느껴지지만 말이다. 

 앞으로는 Jenkins에서 프리스타일 아이템이 아닌 선언적 언어로 구성하는 Pipeline을 사용해보고 싶다. 개발과 동시에 이러한 DevOps 기술도 배우고 사용할 수 있어서 기분 좋았다. 

Comments