웰제오의 개발 블로그

배포 프로세스 최적화를 통한 GCP functions deploy limit 해결 본문

개발

배포 프로세스 최적화를 통한 GCP functions deploy limit 해결

웰치스제로오렌지 2022. 10. 22. 21:54

Google 에서는 Firebase 라는 모바일 및 웹 어플리케이션을 손쉽게 제작할 수 있게 도와주는 PaaS 를 제공한다.

Firebase 하나로 인프라의 구성 및 유지보수에 전혀 신경쓰지 않고 빠르게 서비스를 빌딩할 수 있고, GCP 에서 제공하는 functions 라는 서버리스 컴퓨팅 서비스를 활용해( AWS Lambda 라고 생각하면 된다 ) 백엔드 API 구성 없이 웹 또는 모바일 어플리케이션에서 DB 에 다이렉트로 접근해 읽기, 쓰기, 삭제와 같은 작업이 가능하다.

위와 같은 편리성으로 인해 많은 중소규모 서비스에서 해당 플랫폼을 애용하고 있지만, 서비스의 크기가 증가함에 따라 배포 관련해서 필연적으로 마주하게 되는 이슈가 하나 존재한다.

Firebase 에서 제공하는 NoSQL 데이터베이스인 firestore 는, 여러 컬렉션에 분산되어 있는 중복되는 데이터에 대한 정합성을 계속 맞춰줘야 하는 단점이 존재하는데, 이는 functions 기반의 이벤트 드리븐으로 구성된 firestore 의 설계와 맞물려, 서비스가 커짐에 따라 기능의 출시 및 변경에 있어 추가되거나 수정되어야 하는 함수의 개수가 기하급수적으로 증가하게 되는 문제점이 발생한다.

functions 의 배포에는 limit 이 존재하는데,

 

https://cloud.google.com/functions/quotas#rate_limits


배포할때는 WRITE 작업이 수행되고, 이는 1세대 함수의 경우 100초당 80개를, 2세대 함수에 대해서는 분당 60개의 제한이 걸려있다.
현재 글을 작성하고 있는 2022. 10. 22 기준으로, firebase 는 1세대 함수에 대해서는 로그 기능을 제공하지 않는 등 ( 서버리스 환경에서 로그 제공이 안되면 그냥 쓰지 말라는거다... ) 2세대로의 마이그레이션을 강제하고 있으므로 사실 상 분당 60개의 배포 리밋이 걸려있다고 보면 되는데, 문제는 실제 배포 환경에서의 제한은 이보다 훨씬 빡빡한 수치이다.
이유는 모르지만 분당 60개를 맞춰서 배포를 진행해도, 배포 도중 제한을 넘겼다고 에러를 던지며 프로세스가 아예 종료되기 일쑤였다.

 

https://firebase.google.com/docs/functions/manage-functions?hl=ko


문제를 해결하기 위해 위의 공식문서를 참고했고, 분당 배포 개수를 10개로 줄이면서 limit exceed 에러가 발생했을 시 이를 catch 해 지수 백오프를 통해 re-try 를 시도하게 구성했다. 문제는 이렇게 배포할 경우 배포되는 함수가 300개일때를 기준으로 모든 함수의 빌드가 완료될 때 까지 2시간이 넘어가게 된다
Google Cloud Support 를 요청해 배포 제한을 올려줄 수 없겠냐고 물어봤지만, 답은 No 였다 ( 돈을 더 낸다고 해도 안된다 하더라.. )

따라서 매 배포 시, 수정, 추가 혹은 삭제된 함수들만 배포될 수 있게 배포 프로세스를 최적화 해 이 문제를 해결하기로 했다.

이를 위해선 다음과 같은 과정을 거쳐야 하는데,

 

  1. 마지막으로 배포된 코드와, 현재 배포하고자 하는 코드의 비교를 통해 수정이 발생한 파일들을 찾고
  2. 해당 파일에서 export 된 모듈들을 import 하는 functions 파일들만 배포


1번 과정부터 해결해보자.


해시값을 통한 코드의 변경여부 확인


방대한 양의 파일과 코드들에서, 특정 코드와 파일의 변경 여부를 어떻게 찾을 수 있을까?
파일을 열어서 그 안의 모든 byte 들을 일일이 대조하는것도 하나의 방법이겠지만, 인메모리에서 이전에 배포된 코드와, 배포 될 모든 코드들의 바이트 스트림을 대조하는 것은 비효율적이라 판단했다.
더불어 내가 원하는 작업은 변경사항에 대한 세부적인 내용이 아닌 변경 여부의 확인 이므로 이를 위해 해시함수를 활용하기로 했다.

해시함수는 임의의 크기를 가진 값을 고정 크기의 값에 대응하는 함수로써, 대량의 크기의 파일이라 하더라도 해시값은 크기는 항상 일정하므로, 코드의 변경 여부를 확인하는데 발생하는 탐색비용이 파일 크기에 따라 선형적으로 증가하지 않고 일정하다는 장점이 있다.

모든 해시 알고리듬에는 효율성과 균일성이라는 속성이 존재하는데, 파일의 변경 여부를 확인하는데 있어서 해시충돌이 발생하고, 이로 인해 변경된 파일이 배포가 되지 않을 경우 심각한 문제를 초래하게 된다.
따라서 효율성은 좀 낮더라도 균일성이 높은 암호학적 해시 알고리듬을 사용하기로 했다.
이렇게 해서 최종적으로, MD5 해시 알고리듬을 활용하기로 했다. ( 대중적인 암호학적 해시 알고리듬의 비교: https://codesigningstore.com/hash-algorithm-comparison )

이제 이 해시값들을 어떤 자료구조로 관리할지가 남았다.
초기에는 운영체제의 fs 와 같은 트리 구조로, 프로젝트 디렉토리를 root 로 가지는 각 파일들의 path 에 따라 파일들의 해시값을 json 파일로 관리하려고 했지만, 후술할 모듈간의 의존성 확인을 통해, 배포해야하는 functions 파일을 찾는 과정에서의 효율성을 고려해 프로젝트 디렉토리를 root 로 가지는 상대경로 전체를 key 값으로, 해시값을 value 로 가지는 형식의 json 파일로 관리하기로 결정했다

// 초기 구상
{
	"src": {
    	"functions": {
            "funcA": "...",
            "funcB": "..."
        },
        "repository": {
            "repoA": "...",
            "repoB": "..."
        }
    }
}

// 최종 구조
{
	"src/functions/funcA": "...",
	"src/functions/funcA": "...",
	"src/repository/repoA": "...",
	"src/repository/repoB": "...",
}



위와 같은 json 파일을 생성하기 위해서 전체 프로젝트 디렉토리를 재귀적으로 순회하며, 현재 path 가 디렉토리가 아닌 파일일 경우 path 를 key 로, 파일의 해시값을 value 로 가지는 데이터 쌍을 추가하는 함수를 작성했다. 이는 다음과 같다

import fs from "fs";
import crypto from "crypto";

const getHashOfAllFilesRecursively = (path, ret = {}) => {
    if(fs.lstatSync(path).isDirectory() === false) {	// 디렉토리가 아닌 파일일경우 재귀탐색 종료
        const fileBuffer = fs.readFileSync(path);
        ret[path] = crypto.createHash('md5').update(fileBuffer).digest('hex');	// 데이터 추가
        return ret;
    }
    const childDirectories = fs.readdirSync(path);

	// 하위 경로들을 재귀탐색
    childDirectories.forEach(directory => getHashOfAllFilesRecursively(`${path}/${directory}`, ret));
    return ret;
}

const projectRootDirectory = "...";
const deSerializedJson = getHashOfAllFilesRecursively(projectRootDirectory);

 

deSerializedJson 을 콘솔에 출력해보면 아래와 같이 파일들의 경로와, 해시값들이 제대로 출력됨을 확인할 수 있다.

 

로컬에서 파일 몇개를 만들어 테스트를 진행했다

 

이제 deSerializedJson 을 직렬화한 후 클라우드 스토리지에 업로드 해, 배포과정에서 해당 파일을 참조함으로서 변경, 추가, 삭제된 파일들의 리스트를 뽑아낼 수 있다. 😎 


이제 해당 파일에서 export 된 모듈을 import 하는 function 들을 찾아내보자


모듈간 의존성 그래프의 탐색을 통해 배포할 함수들 추리기


firebase function 에서 함수 전체의 배포가 아닌 개별적인 함수들의 배포 혹은 삭제는 다음과 같은 명령어를 통해 이루어진다

$ firebase deploy --only functions:{nameOfFunction}
$ firebase functions:delete {nameOfFunction}


따라서 어떤 파일이 변경, 추가 되었을 때 이루어 졌을 때, 해당 파일에서 export 된 모듈을 최종적으로 어떤 function 이 import 하는지를 찾아내는것이 이번 챕터의 목표이다.
이를 위해서는 프로젝트의 전체 모듈간의 의존성 그래프를 확인해야 하는데, 이는 조금 까다로운 작업이라 직접 함수를 구현하지 않고 Madge 라는 오픈소스 라이브러리를 활용했다

해당 라이브러리로 의존성 그래프를 생성하면 다음과 같은 개체가 생성되는데

 

 

프로젝트 root 를 절대경로로 하는 파일의 path : [ import 한 모듈 0, import 한 모듈 1 ... ]

과 같은 형식의 그래프를 만들어준다.

이 그래프를 그대로 사용하기에는 조금 무리가 있는게, 최종적으로 원하는것은

모듈을 export 한 파일의 path : [ 모듈을 import 하는 function 0, 모듈을 import 하는 function 1... ]

형식이므로

아래와 같은 함수로 그래프를 뒤집고

const invertGraph = (obj) =>
  Object.keys(obj).reduce((acc, destModule) => {
    obj[destModule].forEach((srcModule) => {
      if (acc.has(srcModule) === false) {
        acc.set(srcModule, new Set());
      }

      acc.get(srcModule).add(destModule);
    });

    return acc;
  }, new Map());



그 다음 inverted 된 그래프를 재귀적으로 탐색하며, 각 모듈이 최종적으로 어떤 function 에 import 되는지를 나타내었다

const setDestRecursive = (currModule, graph) => {
  // default 로 firebase functions 프로젝트는 functions 디렉토리에 위치하므로
  // 현재 탐색중인 디렉토리가 "functions/" 로 시작하게 되면 탐색 종료
  // index 도 마찬가지
  if (currModule.startsWith("functions/") || currModule.startsWith("index")) {
    return currModule;
  }

  const exportedTo = graph.get(currModule);
  const updated = [...exportedTo]
    .map((module) => setDestRecursive(module, graph))
    .flatMap((a) => a);
  graph.set(currModule, new Set(updated));

  return updated;
};


최종적으로 다음과 같은 Graph 가 완성된다

 

 

이렇게 해서, repository/repoB.js 파일의 수정이 이루어졌을 경우, functionDfunctionB 만 배포하면 됨을 바로 찾을 수 있게 되었다.


추가적으로, circular dependency 가 존재하는 상황에서는 위의 함수들에서 무한 호출이 발생한다.

Madge 라이브러리에서 circular 메소드를 통해 circular dependency 가 발생하는 파일들의 리스트를 제공하니, 이 경우를 미리 확인해서 그래프 탐색 이전에 해당 파일들을 그래프에서 remove 해야한다



이렇게 해서 최종적으로 매 배포 시

  1. 클라우드 스토리지에서 이전에 배포되었던 버전의 파일들의 해시값이 적힌 파일 가져오기
  2. 현재 배포하려는 버전의 파일들의 해시값과 이를 비교
  3. 수정, 추가 또는 삭제된 파일들 리스트 추출
  4. 모듈들의 의존성 그래프 탐색을 통해 최종적으로 배포, 삭제 할 function 들 리스트 업
  5. 배포 수행 후 현재 버전의 해시값 클라우드 스토리지에 업로드

의 과정을 거쳐 배포 프로세스의 최적화를 이룰 수 있다



이렇게 해서 Firebase functions 의 배포 프로세스를 최적화 하는 방법에 대해서 알아봤다.

CI/CD 툴에 위의 작업들을 integrate 하는 것도 작성할까 했는데, 필자가 사용했던 툴은 메이저한 툴이 아니다 보니 그냥 생략하기로 했다.

필자는 위의 과정을 통해 배포시간을 획기적으로 줄일 수 있었지만, 이 방법이 최선이라고 생각하지는 않는다. 분명 다양한 방법이 존재할 것 이고, 만약 필자가 사용한 방법보다 더 나은 방법을 알고 있다면 댓글로 알려주길 바란다, 겸허히 배우고 싶다.

오늘도 어디선가 스타트업에서 일당백을 수행하고 있을 백엔드 개발자에게 도움이 되었으면 한다

Comments