Tech

GitHub Actions에서 도커 캐시를 적용해 이미지 빌드하기

ENTER TECH 2022. 5. 31. 09:00

 

최근 카카오웹툰은 도커 이미지를 만들어 Amazon ECR[1]에 올리는 방식으로 배포 방법을 바꿔나가고 있습니다. Dockerfile을 만들어서 GitHub에 올려두고, GitHub Actions로 docker buildpush를 진행하는 방식이죠.
그런데 몇 번 배포를 하다 보니, 변경된 내용이 거의 없는데도 빌드 시간이 전혀 줄어들지 않는다는 것을 발견했습니다. 분명 로컬에서는 같은 상황일 때 첫 번째 빌드보다 두 번째 빌드할 때 더 빨랐거든요.

그때는 몰랐습니다. GitHub Actions를 이용해 빌드할 때는 도커 캐시가 동작하지 않는다는 사실을요.

 

도커 레이어와 캐시

들어가기에 앞서 도커 빌드 속도에 영향을 미치는 레이어(layer)와 캐시(cache)에 대해 알아보겠습니다.

도커 이미지는 빌드시 Dockerfile 의 명령어들을 차례로 실행하면서 레이어를 생성합니다. 이때 특정 명령어들(RUN, ADD, COPY)로 생성된 레이어는 파일 용량을 커지게 만들고, 이미지를 생성하는 시간도 길어지게 하죠.

# Dockerfile - old 🙁
FROM ubuntu

RUN apt-get update
RUN apt-get install vim
# Dockerfile - new 👍
FROM ubuntu

RUN apt-get update && apt-get install vim

두 코드를 비교해보면 RUN 명령어를 2개 → 1개로 줄인 것을 볼 수 있습니다. 이렇게 && 을 이용해 명령어를 연결 해주는 것 만으로도 이미지의 크기를 줄일 수 있습니다.

 

크기를 줄여봤으니 이제 도커 캐시에 대해 알아볼게요.

 

Dockerfile 을 작성하고 docker build 를 실행하게 되면 빌드 속도를 높이기 위해 캐시를 사용합니다. 첫 번째 빌드에서는 각 단계별 캐시를 생성하고, 이후 동일한 명령어가 실행되면 만들어둔 레이어를 재사용합니다. 만약 변경되는 단계가 있다면 레이어는 다시 만들어집니다.

 

예제) Express 앱 + 도커

Express를 이용한 간단한 웹앱 + Dockerfile 예제입니다. Node js 와 도커에 익숙하시다면 다음 단락으로 넘어가셔도 됩니다.

(출처: https://nodejs.org/en/docs/guides/nodejs-docker-webapp/)

간단한 예제를 만들어 보겠습니다.

적당한 폴더에서

npm init -y

package.json 파일을 아래와 같이 수정하고, npm install 해줍니다.

{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.16.1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
npm install

이제 package-lock.json 파일이 만들어졌습니다. 이어서 server.js 파일을 만들고 express를 이용해 간단한 웹앱을 구현해줍니다.

"use strict";

const express = require("express");

// Constants
const PORT = 8080;
const HOST = "0.0.0.0";

// App
const app = express();
app.get("/", (req, res) => {
  res.send("Hello World");
});

app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);

 

Dockerfile 생성

이제 Dockerfile을 만들겠습니다.

touch Dockerfile
FROM node:16

WORKDIR /usr/src/app

# package.json, package-lock.json 파일을 복사해서 넣어줍니다.
COPY package*.json ./

# 필요한 패키지들을 설치해주고
RUN npm install

# 작업했던 파일들을 복사해서 넣어 줍니다.
COPY . .

# 포트는 8080 으로 열어 줍니다.
EXPOSE 8080

CMD [ "node", "server.js" ]

추가로, .dockerignore 파일을 만들어서 불필요한 폴더나 파일들은 빌드 시 포함되지 않게 해줍니다.

node_modules
npm-debug.log

이제, 빌드를 진행해 보겠습니다!

docker build -t docker-test:1 .

[+] Building 53.2s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                               0.0s
 => => transferring dockerfile: 169B                                                               0.0s
 => [internal] load .dockerignore                                                                  0.0s
 => => transferring context: 67B                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:16                                         4.5s
 => [internal] load build context                                                                  0.0s
 => => transferring context: 15.28kB                                                               0.0s
 => [1/5] FROM docker.io/library/node:16@sha256:68e34cfcd8276ad531b12b3454af5c24cd028752dfccacce  45.9s
 => => resolve docker.io/library/node:16@sha256:68e34cfcd8276ad531b12b3454af5c24cd028752dfccacce4  0.0s
 => => sha256:68e34cfcd8276ad531b12b3454af5c24cd028752dfccacce4e19efef6f7cdbe0 1.21kB / 1.21kB     0.0s
 => => sha256:054bccad10b08705d61ff87ad3ccf825b297a68d13e6f7e6b567e7d74b6f934a 7.65kB / 7.65kB     0.0s
 => => sha256:d88439e7b50a5f3923f67f432b6863c1e11adf4e45bf9740515d2cc01fd8e155 7.83MB / 7.83MB     2.9s
 => => sha256:44e59f8582c39b17ab895261edbcfeadb082ea2a881701ffbce68f5536c5c342 2.21kB / 2.21kB     0.0s
 => => sha256:7d66b83ec869a899bc8364af9c9eb0f1a5ba6907f699ef52f3182e19e2598924 50.44MB / 50.44MB  20.6s

(...중략)

 => => sha256:dd2ee15b7435ef176b1afd305fb5aae94c3f8aecd81550b979fdf7d8a95e3de1 450B / 450B        27.0s
 => => extracting sha256:5f327ea23de9da0941be6aa9b1b0106716beba2b2221ccc2cf867db344fdc38b          7.9s
 => => extracting sha256:c398d8bd2d496fc1ba9d4658870242ce78aefe3041899476238df6cd427638f3          0.1s
 => => extracting sha256:98d88b5f83dde6aed913ee56cfd04363b90da51a9740c5eb8e6f12b99ec1d639          1.5s
 => => extracting sha256:8df9d92108465cd690600c5c2b878274db5d262b17aa88b111ebdf828bd47097          0.1s
 => => extracting sha256:dd2ee15b7435ef176b1afd305fb5aae94c3f8aecd81550b979fdf7d8a95e3de1          0.0s
 => [2/5] WORKDIR /usr/src/app                                                                     0.2s
 => [3/5] COPY package*.json ./                                                                    0.0s
 => [4/5] RUN npm install                                                                          2.3s
 => [5/5] COPY . .                                                                                 0.0s
 => exporting to image                                                                             0.1s
 => => exporting layers                                                                            0.1s
 => => writing image sha256:40550cb3188291841bc36b53be7ccec6925c928f23b5ed742d85c34f52cd5e96       0.0s
 => => naming to docker.io/library/docker-test:1

53초가 걸렸습니다. 아무런 수정 없이, 한 번 더 빌드해보겠습니다.

docker build -t docker-test:2 .

[+] Building 1.2s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                                                       0.0s
 => => transferring dockerfile: 36B                                                                                        0.0s
 => [internal] load .dockerignore                                                                                          0.0s
 => => transferring context: 34B                                                                                           0.0s
 => [internal] load metadata for docker.io/library/node:16                                                                 1.1s
 => [internal] load build context                                                                                          0.0s
 => => transferring context: 161B                                                                                          0.0s
 => [1/5] FROM docker.io/library/node:16@sha256:68e34cfcd8276ad531b12b3454af5c24cd028752dfccacce4e19efef6f7cdbe0           0.0s
 => CACHED [2/5] WORKDIR /usr/src/app                                                                                      0.0s
 => CACHED [3/5] COPY package*.json ./                                                                                     0.0s
 => CACHED [4/5] RUN npm install                                                                                           0.0s
 => CACHED [5/5] COPY . .                                                                                                  0.0s
 => exporting to image                                                                                                     0.0s
 => => exporting layers                                                                                                    0.0s
 => => writing image sha256:40550cb3188291841bc36b53be7ccec6925c928f23b5ed742d85c34f52cd5e96                               0.0s
 => => naming to docker.io/library/docker-test:2

1.2초가 걸렸네요! 첫 번째 빌드에 비해 시간이 확연히 줄었습니다. 단계를 잘 살펴보면 CACHED 라고 되어있고, 특별한 작업을 진행하지 않고 넘어가는 것을 볼 수 있습니다.

변경 사항이 있으면 어떻게 되는지 보겠습니다.

npm install react react-dom lodash typescript

(당장 필요는 없지만) 몇 가지 패키지를 더 설치해봅니다. 이러면 package.jsonpackage-lock.json 파일이 변경되었을 것이고, Dockerfile의 COPY package*.json ./ 부터는 변화를 감지해 캐시가 동작하지 않겠죠?

docker build -t docker-test:3 .

[+] Building 7.6s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                       0.0s
 => => transferring dockerfile: 36B                                        0.0s
 => [internal] load .dockerignore                                          0.0s
 => => transferring context: 34B                                           0.0s
 => [internal] load metadata for docker.io/library/node:16                 2.3s
 => [1/5] FROM docker.io/library/node:16@sha256:68e34cfcd8276ad531b12b345  0.0s
 => [internal] load build context                                          0.0s
 => => transferring context: 17.27kB                                       0.0s
 => CACHED [2/5] WORKDIR /usr/src/app                                      0.0s
 => [3/5] COPY package*.json ./                                            0.0s
 => [4/5] RUN npm install                                                  4.6s
 => [5/5] COPY . .                                                         0.0s
 => exporting to image                                                     0.6s
 => => exporting layers                                                    0.6s
 => => writing image sha256:b9c9711d6b9c943f70bc19ae513edaf4595c22f996d27  0.0s
 => => naming to docker.io/library/docker-test:3                           0.0s

7.6초 걸렸네요. 로그를 보면 총 5단계 중 3단계([3/5] COPY package*.json ./) 부터는 캐시가 적용되지 않았습니다. 그래서 npm install 명령이 실행되면서 두 번째 빌드 보다 시간이 조금 더 걸린거죠.

지금까지 도커 캐시가 어떻게 동작하는지 알아봤습니다.

 

GitHub Actions 를 이용한 빌드

카카오웹툰은 코드를 GitHub 에 올리고, push 할 때마다 GitHub Actions로 도커 빌드 및 배포를 실행해주고 있습니다. GitHub Actions 가 생소하시다면, Ray가 작성해주신 훌륭한 글이 있으니 참고하시면 되겠습니다.

GitHub > Actions > Workflows > New workflow를 클릭하면 여러 가지 샘플들을 만날 수 있습니다.

 

여기서 도커 빌드를 위한 Docker Image를 선택하고, 작업 환경에 맞게 코드를 수정해주면 됩니다.

 

name: Docker Image CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - name: Build the Docker image
        run: docker build -t my-image-name:$(date +%s) .

특별한 수정 없이 사용 해보겠습니다. main 브랜치로 push 할 때마다 docker build를 실행합니다.

 

 

동일한 코드를 여러 번 push 해봤습니다. 시간이 별로 차이 나지 않네요. GitHub Actions 로그를 살펴보니

 

 

모든 작업을 다시 해주고 있었습니다. 특히, package.json 파일에 변경이 없었음에도 불구하고 매번 npm install 을 실행해주는 부분이 시간을 많이 차지했죠.

캐시가 동작하지 않는 겁니다.

 

왜 도커 캐싱이 이뤄지지 않을까?

GitHub Actions의 러너는 매번 새로운 가상 환경에서 실행됩니다.[2] 그래서 모든 작업은 새롭게 다시 시작되죠. GitHub 에서 일부 환경을 위한 캐싱을 제공하지만, Docker 레이어에 대한 내용은 없습니다.

 

 

docker/build-push-action을 이용해보자.

도커에서 공식적으로 제공하는 docker/build-push-action을 이용해 도커 이미지의 build, push를 할 수 있습니다.
이때 buildx 라는 CLI 플러그인을 사용하는데요, 빌더 툴킷인 BuildKit의 다양한 기능[3]을 모두 사용할 수 있게 해줍니다. 또한 GitHub Actions에 직접 캐시를 저장시키는 GitHub Cache API를 제공하기 때문에 이를 활용해보려고 합니다.

 

아래 예시를 보시죠.

name: ci

on:
  push:
    branches:
      - 'main'

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      -
        name: Checkout
        uses: actions/checkout@v2
      -
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1 # buildx 설정
      -
        name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      -
        name: Build and push
        uses: docker/build-push-action@v2 # build-push-action 사용
        with:
          context: .
          push: true
          tags: user/app:latest
          cache-from: type=gha # 여기서 gha 는 Guthub Actions 용 캐시를 의미합니다.
          cache-to: type=gha,mode=max

(출처: https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md)

 

우선 buildx 설정을 해주고, docker/build-push-action@v2를 이용해 build 와 push를 해주고 있습니다. 그리고 cache-fromcache-totype=gha라고 입력해줍니다. 이 부분에서 캐싱이 적용됩니다.

 

🧪 주의 : type=gha 로 사용하는 방식은 아직 실험 단계입니다. (참고)

 

buildx 0.6.0 과 Buildkit 0.9.0 부터는 type=gha 캐시를 사용할 수 있습니다. 여기서 gha 는 GitHubActions의 약자죠.

이렇게 작성해주고 어떻게 달라지나 비교해보겠습니다. 배포를 하면

 

 

아직 저장된 캐시가 없으므로, 앞에서 테스트했던 결과와 비슷한 시간이 소요되었네요.

이제 재배포해 보겠습니다.

 

 

빌드 시간이 1분가량 줄어들었습니다! 👏👏👏

 

 

로그를 살펴보니 동일한 작업들이 CACHED로 되어 있는 걸 볼 수 있습니다. 특히 많은 시간을 차지했던 RUN npm install 부분이 캐시 되면서 도커 빌드 속도가 향상될 수 있었네요.

 

지금은 간단한 예제이고 빌드 단계도 적어서 크게 체감되진 않지만, 배포 시 동일하게 반복되는 작업이 많은 프로젝트라면 확연한 차이를 확인해볼 수 있겠죠? GitHub Actions를 사용해서 도커 빌드를 하고 계시다면 위 내용이 도움 되었으면 좋겠습니다!

 

Reference

 

 

[1]: Amazon Elastic Container Registry. Docker Hub 처럼 빌드한 도커 이미지를 올려둘 수 있는 저장소.

[2]: Jobs on GitHub-hosted runners start in a clean virtual environment and must download dependencies each time. (link)

[3]:
- Automatic garbage collection
- Extendable frontend formats
- Concurrent dependency resolution
- Efficient instruction caching
- Build cache import/export
- Nested build job invocations
- Distributable workers
- Multiple output formats
- Pluggable architecture
- Execution without root privileges

 

 

 

더 많은 FE 지식을 나누고 싶다면?! 카카오엔터테인먼트 FE 기술블로그 [바로가기]