[React] Turborepo 와 캐싱 - 2️⃣ Custom remote cache 적용하기
캐싱용 원격 저장소 - Vercel
만약에 Vercel에서 호스팅을 하고 있다면, (Next JS 기본 설정으로 배포 등등) Turborepo는 기본적으로 vercel 에서 캐싱용 원격 저장소를 지원합니다. 1편에서 이에 관한 내용을 다뤘는데요, 아래 링크를 통해 참조할 수 있습니다.
[React] Turborepo 용 custom remote cache 서버 구축하기 - 1️⃣ 터보레포의 캐싱 구조
Vercel에서도 자체적으로 데이터를 보관하는데 비용이 들기 때문에, 사용할 수 있는 원격 캐싱 양은 정해져 있습니다. 근데 Vercel 의 기본 제공량은 꽤 후하네요. 월에 10GB 까지 다운로드, 100GB 까지 업로드가 가능하다고 합니다. (23.07.25 기준)
캐싱용 원격 저장소 - 직접 구축하기
Vercel의 기본제공되는 원격 캐싱을 이용하는 것이 제일 편하겠지만요, 그러기 힘든 상황들이 있죠.
사내에서 중요 데이터를 캐시해야 해서 보안 문제에 걸린다던지, Pro나 Enterprise 요금제를 이용하기가 꺼린다던지 하는 케이스에서는 캐싱용 원격 저장소를 직접 구축할 수도 있습니다. 아래 저장소의 소스코드를 이용하면 되는데요,
https://github.com/ducktors/turborepo-remote-cache
fastify를 기반으로 구성되어 매우 가벼우면서도, 미리 구현된 엔드포인트를 통해 따로 작업없이 서버를 띄우는것만으로도 캐싱용 원격 서버를 구축할 수 있습니다. 기반이 되는 데이터 저장소로는 해당 서버의 로컬 파일시스템을 이용할 수도 있고, 기타 외부 클라우드 저장소를 이용할 수도 있습니다. 아래와 같은 저장소를 지원한다고 하네요.
- Local filesystem
- AWS S3
- Google Cloud Storage
- Azure Blob Storage
자세한 내용은 아래 문서에서 확인 가능합니다!
https://ducktors.github.io/turborepo-remote-cache/supported-storage-providers
구축 과정
이런 커스텀 캐시 서버를 구축하는 과정을 간단하게 요약하면 아래와 같습니다.
- 해당 레포 소스코드를 클론한다.
- 저장소 설정 및 기타 환경변수 설정값을 입력한다. (https://ducktors.github.io/turborepo-remote-cache/environment-variables)
- pnpm 을 이용해 서버를 띄운다.
- 해당 서버의 '/' 엔드포인트를 turbo 명령어의 --api 태그에 기입한다. (예시)
turbo run build --api="https://my-server.example.com" --token="xxxxxxxxxxxxxxxxx"
혹은, 저장소에서 제공하는 도커이미지를 이용해서 컨테이너로 띄울 수도 있습니다.
FROM --platform=${TARGETPLATFORM} node:18.17.0-alpine3.17@sha256:d04e594a9834bed3290a9ec5fadcca98d92ea4e042c413da4aa146a35dba54f1 as build
# set app basepath
ENV HOME=/home/app
# add app dependencies
COPY package.json $HOME/node/
COPY pnpm-lock.yaml $HOME/node/
# change workgin dir and install deps in quiet mode
WORKDIR $HOME/node
# enable pnpm and install deps
RUN corepack enable
RUN pnpm --ignore-scripts --frozen-lockfile install
# copy all app files
COPY . $HOME/node/
# compile typescript and build all production stuff
RUN pnpm build
# remove dev dependencies and files that are not needed in production
RUN rm -rf node_modules
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
RUN rm -rf $PROJECT_WORKDIR/.pnpm-store
# start new image for lower size
FROM --platform=${TARGETPLATFORM} node:18.17.0-alpine3.17@sha256:d04e594a9834bed3290a9ec5fadcca98d92ea4e042c413da4aa146a35dba54f1
# dumb-init registers signal handlers for every signal that can be caught
RUN apk update && apk add --no-cache dumb-init
# create use with no permissions
RUN addgroup -g 101 -S app && adduser -u 100 -S -G app -s /bin/false app
# set app basepath
ENV HOME=/home/app
# copy production complied node app to the new image
COPY --chown=app:app --from=build $HOME/node/ $HOME/node/
# run app with low permissions level user
USER app
WORKDIR $HOME/node
EXPOSE 3000
ENV NODE_ENV=production
ENTRYPOINT ["dumb-init"]
CMD ["node", "--enable-source-maps", "build/index.js"]
🚨 캐시 아티팩트가 쌓이는 문제
위 서버에 빌드 아티팩트가 저장되고, 저장된 아티팩트가 캐싱에 이용되겠죠. 허나 캐싱 결과물이 리모트 서버 로컬 파일시스템에 계속해서 쌓이게 되면 문제가 생길 수 있습니다. 관련해서 서버 자체적으로 일정 스토리지 이상이 되면 캐싱 결과물을 지워주는 기능이 있는지 알아봤는데, 누군가가 관련 내용을 문의한 글이 있었습니다.
- https://github.com/ducktors/turborepo-remote-cache/issues/64
위 글을 읽어보니 이 서버가 여러 스토리지를 지원하다 보니 해당 기능은 없다는 것을 알게 되었고 🥲, 어떻게 이 문제를 해결했는지 트러블 슈팅 과정을 공유해보고자 합니다!
1. 캐싱 아티팩트를 담고 있는 디렉토리가 일정 용량 이상일 때 가장 오래된 파일들을 정리하는 스크립트 작성
아래와 같이 bash 스크립트를 작성했습니다.
# 디렉토리 크기 계산 (MB 단위)
SIZE=$(du -sm "$DIR" | cut -f1)
# 디렉토리 크기가 MAX_SIZE보다 크면 가장 오래된 파일부터 삭제
while [ "$SIZE" -gt "$MAX_SIZE" ]; do
# 가장 오래된 파일 찾기
OLD_FILE=$(find "$DIR" -type f -printf "%T+ %p\n" | sort | head -n 1 | cut -d' ' -f 2-)
if [ -z "$OLD_FILE" ]; then
echo "No more files to delete."
break
fi
# 가장 오래된 파일 삭제
echo "Deleting $OLD_FILE"
rm -f "$OLD_FILE"
# 새로운 디렉토리 크기 계산
SIZE=$(du -sm "$DIR" | cut -f1)
done
빌드 결과물이 담기는 디렉토리가 특정 용량이 넘었면, 해당 용량이 될 때까지 오래된 순으로 빌드 결과물을 삭제하는 bash 스크립트입니다.
2. 리모트 캐시 서버를 띄울 때 위 스크립트를 실행하는 cron 프로세스를 띄움
Docker 컨테이너에 service 명령을 사용하려면 init 시스템이 컨테이너 안에서 실행되어야 합니다. 그러나 Docker는 기본적으로 단일 프로세스를 실행하기 위해 설계되었으므로, 보통 Docker 컨테이너 내에서 init 시스템을 실행하는 것은 권장되지 않습니다.
하지만 cron 프로세스를 띄우는 방법은 아래와 같은 문제가 있었습니다.
- service 명령을 실행하기 어렵다.
- Docker 컨테이너에서는 권장되지 않는 방법이다.
3. 캐싱 서버에 아티팩트 정리용 API 라우트 를 추가
Cron 프로세스를 띄우는 대신에, Fastify 서버에 라우터를 열어 캐싱 결과물이 생성되는 디렉토리의 파일을 정리할 수 있도록 했습니다.
import type { Server } from 'http'
import type { RouteOptions, RawRequestDefaultExpression, RawReplyDefaultExpression } from 'fastify'
import { type Querystring, type Params, querystring } from './schema'
import * as fs from 'fs'
import * as path from 'path'
import { promisify } from 'util'
const readdir = promisify(fs.readdir)
const stat = promisify(fs.stat)
const unlink = promisify(fs.unlink)
function getFileList(dirPath: string): string[] {
const entries = fs.readdirSync(dirPath)
const fileList = entries.map(entry => path.join(dirPath, entry))
return fileList
}
async function getDirectorySize(directory: string): Promise<number> {
const fileNames = await readdir(directory)
let size = 0
for (const fileName of fileNames) {
const filePath = path.join(directory, fileName)
const fileStat = await stat(filePath)
size += fileStat.size
}
return size
}
async function deleteOldestFiles(directory: string, maxSize: number): Promise<void> {
const fileNames = await readdir(directory)
const files = await Promise.all(
fileNames.map(async fileName => {
const filePath = path.join(directory, fileName)
const fileStat = await stat(filePath)
return { fileName: filePath, mtime: fileStat.mtime }
}),
)
files.sort((a, b) => a.mtime.getTime() - b.mtime.getTime())
for (const file of files) {
await unlink(file.fileName)
console.log(`Deleted file ${file.fileName}`)
if ((await getDirectorySize(directory)) < maxSize) {
break
}
}
}
async function maintainDirectorySize(directory: string, maxSizeInMB: number): Promise<void> {
const maxSize = maxSizeInMB * 1024 * 1024 // Convert MB to bytes
const directorySize = await getDirectorySize(directory)
if (directorySize > maxSize) {
await deleteOldestFiles(directory, maxSize)
}
}
export const removeArtifact: RouteOptions<
Server,
RawRequestDefaultExpression,
RawReplyDefaultExpression,
{
Querystring: Querystring
Params: Params
}
> = {
method: 'DELETE',
exposeHeadRoute: true,
url: '/artifacts',
schema: { querystring },
async handler(req, reply) {
try {
const teamId = req.query.teamId ?? req.query.slug
const mb = req.query.mb ?? 1024
const directoryPath = `/tmp/turborepocache/${teamId}`
await maintainDirectorySize(directoryPath, mb)
reply.code(200).send(getFileList(directoryPath))
} catch (err) {
reply.code(400).send({ err })
}
},
}
`/artifacts` 라우팅으로 API 요청이 들어오게 되면, 오래된 캐시 아티팩트들이 삭제됩니다. 모두 타입스크립트 코드로 전환했기 때문에 1번 과정에서 만든 스크립트를 사용할 필요도 없어졌습니다 ㅎㅎ
그리고 Github Action을 이용해서, 주기적으로 위 API 를 호출해주면 됩니다. 서버 리소스 용량에 따라 호출 주기와 파라미터를 조절하면서 관리하면 되겠죠?
실행 예시
Github Actions를 이용한 Artifacts 지우기 실행 예시
커스텀 캐시 서버를 이용한 캐싱 성공 예시
명령어
turbo run build --filter={패키지명} --api={커스텀 서버 주소} --token={토큰값} --team={팀이름}
감사합니다 😙