pm2 를 이용한 무중단 배포

skkkkky·2022년 12월 15일
0

CI/CD

목록 보기
1/1
post-thumbnail

개발자들의 마법 같은 단어 자동화…! 무중단 배포…!! 최첨단…!!!

현재 상황

pm2 와 CodeDeploy 를 통한 자동화, (약…그리 길지 않은…)중단 배포 프로세스를 가지며 서버를 운영하던 중, 무중단 배포에 대한 막연한 욕심이 생겨 조사해보며 글을 써봅니다.

현재 NestJS 로 구성되어 있는 서버의 배포 과정을 간단히 설명하자면

  1. pm2 프로세스를 중단
  2. 새로운 server code 를 다운 받아 교체
  3. typeorm migration 을 실행
  4. pm2 프로세스를 재시작
  5. local 의 8000 포트로 핑을 날려 정상 동작 확인

입니다.

위 과정대로 실행되었을 때 pm2 프로세스가 중단되는 1번 과정부터 다시 재시작되는 4번 과정까지 서버의 다운 타임이 발생합니다. migration 시에 서버코드와 데이터베이스의 일시적인 mismatch 로 인해 생기는 장애를 방지하기 위해 pm2 프로세스를 미리 중단하고 migration 을 모두 실행한 뒤 다시 pm2 프로세스를 재시동하기 때문이죠.

당시에는…

IT 서비스를 운영하는 개발팀에서의 기술의 고도화, 최신 기술의 적용은 매우 중요한 과제입니다. 서비스의 시스템적인 한계지점을 높여주기도 하고, 개발팀의 효율을 높이기도 하며, 때로는 그 자체로 개발팀에 새로운 동기를 제공합니다 (새로운 기술은 언제나 짜릿하죠…!😆). 이렇듯 많은 장점들을 제공할 것으로 보이기에 “일단 하면 좋지!” 라는 생각으로 접근하기 쉽습니다. 이런저런 고민할 시간에 하루라도 빨리 도입해야만 할 것 같은 기분이 들기도 하죠. 하지만 개발의 주체가 “팀”이라면 이야기는 조금 다릅니다.

비즈니스를 수행하는 조직의 개발팀은 매우 많은 비용을 투자 받은 집단입니다. 따라서 팀의 관성을 크게 뒤흔들 결정에 대해서는 정말 많은 판단 기준이 포함되어야 합니다.

“그걸 고민할 시간에 하루라도 빨리 도입해보겠다!”

라고 생각하며 혼자서 마구 달려나가는 개발자들도 여럿 보았습니다. 자신의 공을 키우고 싶어하는 마음도 이해하고, 팀에 기여하고 싶은 마음이 있다는 것도 충분히 공감합니다. 때론 빠른 결단이 도움이 되기도 합니다. 하지만 팀은 단순 결과론적인 상태만으로 정의되지 않습니다. 팀으로써 가장 중요한 것은 “당장 현재 좋은 결과가 보이느냐, 현재 좋은 기술을 쓰고 있느냐”가 아닌 “결국 언젠가 해낼 수 있는 지속성을 지니고 있느냐”라고 생각합니다. 그런 입장에서 새로운 기술의 도입에 고려할 것은 팀 전체의 기회비용과 매몰비용입니다. 그러면서도 개개인의 동기를 존중하며 함께 고민하는 팀이 결국 승리하는 팀이 되는 것이죠. 관련 이야기는 언젠가 한 번 다시 다뤄볼 것 같네요.👨‍💻

다시 돌아와 당시의 생각을 되돌아보자면 맹목적으로 무중단 배포를 추구하다 예기치 못한 장애가 일어나는 것을 경계했던 것 같습니다. 분명 무중단 배포는 매우 끌리는 친구였지만, 기술적으로 매력적일수록 독이 든 성배일 수 있다는 생각에서 말이죠…🤨

최초 설계 시에는 30초에서 1분 남짓되는 다운타임이 발생할테지만 migration 시의 안정성을 보장하기 위해 불가피한 것처럼 보였습니다. 하지만 과연 위처럼 실행되었을 때 정말 문제가 없을까요?

현재의 AWS 로 운영되고 있는 서버의 구조를 단순화해서 보자면 아래와 같습니다.

기본적으로 복수의 가용영역에 서버를 띄우고 있으며, Auto Scaling 으로 인해 서버의 수가 유동적으로 변할 수 있습니다.

현재의 방식은 다수의 서버를 하나하나 배포하는 방식(one at a time)입니다. 다중 인스턴스가 존재할 때는 migration 시의 일시적 mismatch 를 막기 위해 프로세스를 중단시킨다고 하더라도, 배포가 단일 인스턴스 단위에서 독립적으로 이루어지는 특성상 또다른 인스턴스에서 오는 요청을 방지하지는 못합니다. 다시 말해 다중 서버 구성 시에는 해당 방법으로도 일시적 장애를 100프로 방지할 수는 없습니다. 아래 그림을 참고하면 이해가 빠릅니다.

출처: https://teamplify.com/blog/zero-downtime-DB-migrations/

그렇다면 blue-green 방식으로 배포를 진행한다면 문제가 없을까요? 꼭 그렇지는 않습니다. 데이터베이스 migration 과정까지 blue-green 으로 진행하지 않는다면 이론상 모든 배포가 완료되기 전, 이전 상태의 API 서버에서 migration 이 이미 진행된 데이터베이스로의 요청이 발생할 수도 있습니다.

우선 지금까지의 상황을 정리해보자면, 현재만의 시스템으로는 온전한 다운타임 없는 배포가 보장되지 않습니다😂. 그렇다면 데이터베이스 migration 이 있다면 무중단 배포는 정녕 불가능한 것일까요…?🧐

꼭 모든 경우를 다 아우를 필요는 없잖아..?

불가능은 없는 법. 우선 모든 케이스를 다 대처할 수 있는 보편적 케이스를 고려하기 보다 조금 더 현실에 가까운 케이스를 생각해보는 것으로 시작해봅시다.

현재 서버 배포 시 돌아가는 migration 의 단위는 사소한 필드 추가 및 수정 혹은 없는 경우가 많습니다. 정리해보자면 서버 배포의 경우 대부분 아래 두 가지에 해당됩니다.

  1. 데이터베이스 migration 이 없다.
  2. 일시적인 mismatch 가 장애로 이어지지 않을 작은 migration 이다.

위 두 가지의 경우 migration 으로 인해 서비스가 중단되어야 할 필요가 없습니다. 따라서 위 두 케이스를 구분해낼 수만 있다면 대부분의 경우에는 무중단으로 실행되어도 문제가 없는 것이죠.

쉽게 정리하자면 아래의 규칙을 따르면 됩니다.

  1. 실행해야할 migration 이 없다면 migration 을 실행하지 않는다.
  2. migration 은 가급적 서버와의 mismatch 가 일어나도 장애가 발생하지 않도록 짠다.
  3. 2번이 불가피하게 지켜지지 못 할 경우에 한해 서버 중단 후 배포를 진행한다.

실행해야할 migration 이 없다면 migration 을 실행하지 않는다.

우선 1번을 규칙을 실천하기 위해서는 migration 의 존재여부를 불러와 해당 내용을 바탕으로 migration 을 실행할지 말지 결정하는 로직이 필요합니다. 그렇기에 CLI 로 migration 을 실행하던 이전의 방식에는 불리함이 있다는 생각이 들었습니다.

현재의 방식은 typeorm 의 CLI 를 이용하여 “pending 상태의 migration 파일이 있는지 확인하고, 있다면 실행한다.” 입니다. 존재하는지만을 확인하고, 그 여부에 따라 다른 로직을 돌리는 조건은 실행할 수 없는 상황이죠…🥲

우선 마이그레이션을 실행할 방법이 정말 typeorm CLI 밖에 없을까…? 고민하던 차에 딱 원하는 해결책을 발견했습니다. 공식적으로 지원하는 것 같은데, 공식 문서에는 없는…(투덜 투덜) MigrationExecutor 라는 class 의 존재를 확인한 것이죠. 정확히 원하는 method 들이 객체에 포함되어 있었습니다.

export class MigrationExecutor {
	...
	public async executeMigration(migration: Migration): Promise<Migration> {
		...
	}
	public async getAllMigrations(): Promise<Migration[]> {
		...
	}
	public async getPendingMigrations(): Promise<Migration[]> {
		...
	}
}

getPendingMigrations 로 해당 환경에 아직 실행되지 않은 migration 스크립트를 찾고, 존재한다면 executeMigration 을 통해 실행하면 되는 것이죠.

해당 method 를 활용하면 서버 node 프로세스 안에서 migration 에 대한 문제를 해결할 수 있습니다. 하지만 이에 따른 두 가지 고민 지점이 생겨납니다.

첫째는 migration 의 실행 단계입니다. 어떤 단계에서 migration 을 실행해야 문제 없는 migration 을 보장하는 동시에 불필요한 실행을 방지할 수 있을지 고민이 들었습니다.

둘째는 pm2 의 다중 프로세스 환경에서의 단일성 보장입니다. 복수의 프로세스가 클러스터 형태로 실행되고 있는 특성상 단일 프로세스에서 돌아가는 migration 을 중첩 없이 실행해야 한다는 과제가 있었습니다.

MigrationExecutor 의 존재로 쾌재를 불렀지만, 복잡도가 높은 환경에서는 그에 맞는 선택들이 존재해야 추구하는 방향대로 시스템을 완성할 수 있었죠.

언제 실행되는 것이 좋을까?

migration 의 경우 우선 프로세스 단위에서 배포 시에 최초 1회만 돌아가도록 하는 단계에 migration 을 실행하는 것이 가장 효율적인 형태입니다. 최초 1회 자동으로 실행되어야 하며, 서버가 다시 시작되지 않는 한 추가로 실행될 필요가 없다 라는 규칙이 필요한 상태였죠. 현재의 백앤드 서버는 NestJS 로 구성되어 있습니다. 따라서 NestJS 의 lifecycle event 를 따라 실행되는 것이 가장 적합해보였죠.

출처: https://docs.nestjs.com/fundamentals/lifecycle-events

굳이 모듈 단위의 컨텍스트는 필요 없었기에 onApplicationBootstrap 단계에서 실행되는 것이 적절해보였습니다. 모든 모듈이 초기화되고, 외부로의 연결을 수용하기 직전의 상태 말이죠.

첫번째 문제를 해결했으니 이제 두번째 문제가 남아있습니다. pm2 를 클러스터 모드로 활용하게 되면 하나의 서버에서도 복수의 프로세스가 실행될 수 있습니다. 복수의 서버가 함께 재시작되면 migration 의 중첩이 일어날 수 있게 되는 것이죠. 처음에는 pm2 의 reload 를 사용하면 해결될 것이라 생각했습니다. 서버의 다운타임이 발생하지 않도록 프로세스를 하나하나 종료하고 재시작한 뒤 재시작이 완료되었다는 시그널을 받으면 다음 프로세스로 종료, 재시작 과정을 이어가게 되죠.

좋아보입니다. 그러나 한 가지 문제가 있습니다. pm2 입장에서 하나의 프로세스가 실행 완료되었다는 기준을 무엇에 두느냐 였습니다. 별도의 설정 없이 구성하게 된다면 pm2 는 단순히 프로세스의 시그널만을 기다리게 됩니다. Node 환경이 모두 초기화 완료되어 외부 접속을 수용하는 상태까지 기다리는 것이 아니라, 프로세스가 정상적으로 실행되었다는 시그널을 받기만 하면 해당 프로세스 실행을 마쳤다고 판단하여 다음 프로세스 재시작 단계로 넘어가는 것이죠.

쉽게 설명하자면 이전 프로세스에서 migration 이 실행 완료되기 전에 다음 프로세스 재시작 단계로 넘어갈 수 있게 되고, migration 이 중복 실행될 가능성이 있습니다. 가장 쉬운 해결책은 pm2 가 프로세스가 실행 완료되었다고 판단하는 조건을 NestJS 의 초기화 과정 전체가 완료된 단계로 만들어버리면 됩니다. pm2 는 ecosystem.config 라는 파일로 설정값을 조정할 수 있는데, wait_ready 라는 속성값을 true 로 부여하고, node 프로세스가 초기화 완료되어 외부로의 연결을 수용할 때 ready 이벤트를 보내게 하면 됩니다.

// ecosystem.config.js
module.exports = {
  apps: [
    {
      ...
      wait_ready: true,
      exec_mode: 'cluster',
      instances: 0,
      env: {
        ...
      }
    },
  ],
};
// main.ts
import { NestFactory } from '@nestjs/core';

import { AppModule } from './app.module';

async function main() {
  const app = await NestFactory.create(AppModule);

  ...
  
  await app.listen(8000, () => {
		process.send('ready');
  });
}
main();

ready 이벤트를 기다림에 따라 최초 1개의 프로세스에서 migration 이 실행 완료되어야지만 다음 프로세스의 재시작 단계로 넘어가도록 만들 수 있습니다. 이로써 두 번째 문제도 어느 정도는 해결되었습니다.😎

migration 은 가급적 서버와의 mismatch 가 일어나도 장애가 발생하지 않도록 짠다.

migration 으로 인한 데이터베이스와 API 서버의 버전 mismatch 가 일어나도 장애가 발생하지 않으려면 어떻게 해야할까요?

우선 migration 의 단위가 너무 크지 않아야 합니다. migration 의 단위가 크면 무조건 장애가 발생하는 것은 아니지만 장애 발생 여부에 대한 불확실성이 너무 커지게 됩니다. 따라서 불필요하게 장애 여부를 파악하는데 시간을 많이 쏟게 될 수도 있습니다.

먼저 장애를 일으킬 수 있는 흔한 migration 예시를 살펴보겠습니다.

새로운 기능이 만들어질 때 그것을 위해 새로운 column 이 추가되는 경우가 많이 발생합니다. 만약 새로운 기능이 해당 column 을 참조하게 된다면 데이터베이스의 버전과 API 서버의 버전이 다른 순간은 충분히 장애를 일으킬 수 있죠.

대표적인 케이스는 non-nullable 한 column 을 추가할 때입니다. 기존에 존재하는 데이터들에 한해서는 migration 코드 포함시켜 일괄 생성해줄 수 있습니다. 그러나 데이터베이스 버전만 업그레이드가 되고 서버 버전이 아직 맞춰지지 않은 경우에 만약 요청이 온다면 해당 non-nullable 한 column 에 대한 이해 없이 로직이 돌아갈 수 있고, 그에 따라 오류를 발생시킬 수 있습니다.

위 경우는 migration 을 단계별로 잘 쪼개고, 비즈니스 로직 설계를 잘하게 되면 장애 없이 migration 을 수행할 수 있습니다. 우선 크게 두 개의 나뉘어진 migration 으로 실행하는 것을 전제로 하여 아래 두 과정을 순서대로 진행하면 됩니다.

  1. nullable 한 필드를 생성한 뒤 해당 필드 참조 및 값 생성 비즈니스 로직을 배포

    필드가 nullable 하기 때문에 아직 서버가 업데이트 되지 않은 상태에서 요청이 발생하더라도 장애를 일으키지 않습니다.

  2. 비어있는 필드에 값을 생성해준 뒤 nullable 한 필드를 non-nullable 로 변경

    위 migration 실행 시에는 이미 1번 과정을 통해 서버의 요청에서 해당 필드에 대한 로직 적용이 완료된 상태이기 때문에, 비어있는 필드들에 대해 처리해준 뒤 non-nullable 로 변경하여도 장애를 일으키지 않습니다.

위 예시처럼 migration 의 실행 순서를 잘 조절만 한다면 장애를 일으킬 가능성을 낮추거나, 아예 제거할 수도 있습니다.

그럼 이제 해결…?!

우선은 이렇게 정리가 된 것 같습니다만, 완전한 해결은 아닐 수 있습니다. 또다른 많은 변수들이 시스템을 잡아먹기 위해 도사리고 있겠죠…🥶 다만 해당 해결 방안의 지향점이 "장애를 일으키지 않는다", 또는 "다운 타임을 발생시키지 않는다"가 아닌 그 가능성과 정도를 줄이는 것에 있다는 것이 중요할 것 같습니다.

또한 정도를 줄이는 것에는 단순히 시스템적인 측면 뿐만 아니라, 실제 이용 데이터를 기반으로 가장 장애 발생 확률이 적은 시간대를 활용해 migration 을 실행하는 정책 등도 있을 수 있습니다. 여러 가지 변수를 고려할 수 있다면 비용 효율적인 선택을 할 수 있을 확률도 그에 따라 높아집니다.

개발을 하며 항상 느끼는 것이지만 언제나 궁극의 정답은 없는 것 같습니다. 다만 확실한 방향성은 있습니다. 그리고 그 방향성에는 항상 해당 시스템의 특성이 포함되고, 시스템의 특성을 결정하는 가장 중요한 요소는 해당 시스템의 사용자입니다. 따라서 때때로 기준이 명확하지 않은 선택과 판단을 해야할 때, 항상 귀 기울이게 되는 것은 누가, 언제, 어떤 상황에서, 왜 사용하는가? 인 것 같습니다.

profile
스타트업 개발자입니다

0개의 댓글