최근 카카오웹툰은 도커 이미지를 만들어 Amazon ECR[1]에 올리는 방식으로 배포 방법을 바꿔나가고 있습니다. Dockerfile을 만들어서 GitHub에 올려두고, GitHub Actions로 docker build
와 push
를 진행하는 방식이죠.
그런데 몇 번 배포를 하다 보니, 변경된 내용이 거의 없는데도 빌드 시간이 전혀 줄어들지 않는다는 것을 발견했습니다. 분명 로컬에서는 같은 상황일 때 첫 번째 빌드보다 두 번째 빌드할 때 더 빨랐거든요.
그때는 몰랐습니다. 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.json
과 package-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-from
과 cache-to
에 type=gha
라고 입력해줍니다. 이 부분에서 캐싱이 적용됩니다.
🧪 주의 :
type=gha
로 사용하는 방식은 아직 실험 단계입니다. (참고)
buildx 0.6.0 과 Buildkit 0.9.0 부터는 type=gha
캐시를 사용할 수 있습니다. 여기서 gha
는 GitHubActions의 약자죠.
이렇게 작성해주고 어떻게 달라지나 비교해보겠습니다. 배포를 하면
아직 저장된 캐시가 없으므로, 앞에서 테스트했던 결과와 비슷한 시간이 소요되었네요.
이제 재배포해 보겠습니다.
빌드 시간이 1분가량 줄어들었습니다! 👏👏👏
로그를 살펴보니 동일한 작업들이 CACHED
로 되어 있는 걸 볼 수 있습니다. 특히 많은 시간을 차지했던 RUN npm install
부분이 캐시 되면서 도커 빌드 속도가 향상될 수 있었네요.
지금은 간단한 예제이고 빌드 단계도 적어서 크게 체감되진 않지만, 배포 시 동일하게 반복되는 작업이 많은 프로젝트라면 확연한 차이를 확인해볼 수 있겠죠? GitHub Actions를 사용해서 도커 빌드를 하고 계시다면 위 내용이 도움 되었으면 좋겠습니다!
Reference
- https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md
- https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
- https://github.com/moby/buildkit
[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 기술블로그 [바로가기]
'Tech' 카테고리의 다른 글
React 합성 컴포넌트로 재사용성 극대화하기 (0) | 2022.09.26 |
---|---|
HTTP/2 훑어보고 AWS에 적용해보기 (0) | 2022.05.31 |
Test Code Why? What? How? (0) | 2022.05.17 |
카카오웹툰은 GitHub Actions를 어떻게 사용하고 있을까? (0) | 2022.03.23 |
http프록시로 mitmproxy를 사용해보자. (0) | 2022.03.07 |