샘플 workflow 파일을 참고하여 캐시를 적용하면 Github Actions 사용 시 빠른 배포가 가능하다!
최근 프론트엔드 라이브러리가 함께 설치된 레거시 노드 애플리케이션 위에서 작업을 하게 되면서 거슬리는 점이 하나 생겼다.
바로 배포 한 번에 30분 넘는 시간이 걸린다는 점이었다.
배포는 Github Actions(이하 GA)를 사용하여 도커 이미지 빌드/원격 저장소 푸시/ECS 업데이트 순으로 이뤄지는데, 빌드 로그를 확인해보니 npm install
에 매번 20 ~ 22분을 사용하고 있었다.
이는 node_modules
사이즈가 큰 것도 있었지만, GA가 self-hosted runner를 사용하지 않는한 매번 새로운 인스턴스에서 빌드를 실행하기 때문이다. (참고)
다행히 로그를 확인해보니 디펜던시 설치 시간만 줄인다면 10분 내외로 배포할 수 있는 것으로 보였다.
동일한 도커 이미지를 여러 차례 빌드하면 처음보다 빌드 시간이 줄어드는데, 이는 도커가 레이어 별로 캐싱을 수행하기 때문이다. (참고)
캐시 테스트를 위해 아래 도커 파일에 대해 빌드를 수행해보자.
FROM node:20-slim
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
COPY . /app
EXPOSE 3000
CMD [ "node", "src/index.js" ]
아래 명령어로 여러 차례 빌드를 수행해보면, 스크린샷에 보이듯이 레이어가 캐시되어 CACHED
로 표기된다.
docker build -t <tag> .
이제 도커 파일의 명령어 순서를 조금 바꾸고, 빌드를 실행해보자.
FROM node:20-slim
RUN corepack enable
WORKDIR /app
COPY . /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
EXPOSE 3000
CMD [ "node", "src/index.js" ]
아래에서 두번째에 위치했던 COPY . /app
명령어를 WORKDIR
바로 아래로 위치시켰다.
그랬더니 파일 변경이 없음에도 아래와 같이 pnpm install
단계가 다시 수행되었다.
이는 도커 빌드 시 명령어의 순서에 따라서 캐시 무효화 (cache invalidation)가 일어나기 때문에 발생하는 현상이다.
여기서 도커 파일 수정 없이 현재 디렉토리에 위치한 아무 파일이나 수정해보자.
다시 빌드를 해보면 이번에도 캐시 무효화가 일어난 것을 알 수 있다.
이는 소스 코드를 복사하는 레이어(COPY . /app
)가 새롭게 빌드되었고, 그 이후의 모든 단계는 자동적으로 캐시 무효화되었기 때문이다.
즉 도커 빌드의 캐시를 최대한 활용하려면 최대한 캐시 가능한 레이어를 위쪽에 배치시켜야 하는 것이다.
그러므로 자주 변하지 않는 디펜던시 설치를 위에 위치시키고 소스 코드 복사와 같은 단계는 아래에 위치시키는 것이 좋다.
더 좋은 방법은 docker CLI가 제공하는 docker init
커맨드를 활용하는 것이다. (참고)
docker init
커맨드를 활용하면 사용하는 언어, 디펜던시 관리자에 맞춰서 적절한 Dockerfile, .dockerignore 파일 등을 생성해주므로 직접 도커 파일을 작성하는 수고를 덜 수 있다.
도커 파일을 새로 작성해서 로컬 캐시가 동작하는 것까지 확인했다.
하지만 GA에서 빌드를 해보면 여전히 디펜던시를 매번 새로 설치한다.
이는 처음에 언급했듯 빌드가 매번 새로운 인스턴스에서 일어나 도커 빌더가 캐시를 불러올 수 없기 때문이다.
로컬 환경에서 캐시를 불러오는 것처럼 하려면 GA 워크플로 파일을 변경할 필요가 있다.
여기서는 ECR 원격 저장소에 이미지를 빌드하고 푸시하는 것을 기준으로 설명하겠다.
name: Build with Github Actions cache
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
uses: docker/build-push-action@v5
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
REPOSITORY: ecr-cache-test
IMAGE_TAG: latest
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
중요한 부분은 docker/setup-buildx-action@v3
를 사용하여 buildkit 호환 빌더를 활성화시키는 부분과 cache-from
, cache-to
를 지정하는 부분이다.
cache-from/to
옵션에 type=gha
를 지정하고 빌드를 진행하면 아래와 같이 캐시가 생성되는 것을 확인할 수 있다.
위에서 buildkit-blob-<digest>
형식의 이름을 가진 파일은 대략 각 도커 레이어에 대응된다고 볼 수 있다.
한 번 어느 정도 빨라지는지 확인해보자.
캐시를 적용하지 않을 경우 33~36초 가량이 걸린다.
캐시를 적용하면 31초 가량 걸린다. (37초의 경우 캐시 히트가 되지 않은 상황)
2~5초 정도를 아꼈으니 그리 큰 차이는 없는 것으로 보인다.
이는 디펜던시 설치를 하지 않아 아낀 시간이 캐시를 불러오고 작성하는 시간에 상쇄되었기 때문이다.
GA 캐시의 경우 레포지토리 별로 최대 10GB까지만 저장할 수 있고, 7일간 접근하지 않을 경우 삭제된다. (참고)
백엔드 애플리케이션의 경우 프론트엔드보다 적은 디펜던시를 설치하므로 나쁘지 않은 제한이지만, 내 경우 빌드된 이미지가 5GB에 육박했으므로 다른 솔루션이 필요했다.
작년 10월 경에 올라온 AWS 블로그 글을 보면 ECR 레지스트리에 대해 원격 캐시를 사용할 수 있다고 적혀있다.
이는 쉽게 말해 원격 레지스트리에 저장된 도커 이미지를 캐시 삼아 빌드를 수행할 수 있다는 것이다.
이를 적용하기 위한 yaml 파일 문법은 GA 캐시와 크게 다르지 않다.
몇 가지 옵션을 추가로 명시해주기만 하면 된다.
name: Build with AWS ECR cache
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
uses: docker/build-push-action@v5
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
REPOSITORY: ecr-cache-test
IMAGE_TAG: latest
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=registry,mode=max,ref=${{ env.REGISTRY }}/${{ env.REPOSITORY }}:cache,image-manifest=true,oci-mediatypes=true
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.REPOSITORY }}:cache,image-manifest=true,oci-mediatypes=true
중요한 부분은 type=registry
로 명시하고, ref
에는 캐시할 이미지 그리고 image-manifest
및 oci-mediatypes
에는 true
를 명시해주는 것이다.
위와 같이 GA 워크플로를 작성하고 빌드를 수행해보면 cache
태그를 단 이미지가 ECR에 푸시된 것을 알 수 있다.
그리고 cache
이미지의 아티팩트 타입을 확인해보면 의도한대로 media type이 설정된 것을 알 수 있다.
레지스트리 캐시의 경우 빌드 시간은 29 ~ 32초 가량 걸렸다. (5x 초의 경우 캐시 미스)
이는 GA 캐시를 활용할 때와 큰 차이가 없는 수준으로 캐시 관리에 큰 문제가 없다면 어떤 방법을 사용하더라도 소요 시간은 비슷할 것 같다.
여기서는 GA에서 어떻게 도커 빌드 캐시를 적용할 수 있는지 위주로 소개했지만 더 좋은 방법은 도커 이미지의 크기 자체를 줄이는 것이다. (운영 환경에 필요한 디펜던시만 설치, 멀티 스테이지 빌드, slim 이미지 사용 등)
그러므로 도커 이미지 크기를 줄인 뒤에도 여전히 빌드가 오래 걸린다면 캐시를 적용해볼 수 있다.
캐시를 적용한 뒤에 캐시를 불러오고, 작성하는 오버헤드로 인해 빌드 시간이 큰 차이가 없거나 더 느려질 수 있으므로 꼼꼼하게 비교하고 적용해보자.