DevOps

Kaniko 빌드 작업이 ECR의 캐시를 사용하지 않는다. 이유를 알아보자.

mokpolar 2024. 3. 3. 16:07
반응형

TL; DR

.dockerignore

들어가며

2024-03-02 작성

 

안녕하세요?

Jenkins에서 구동하는 Kaniko의 빌드 작업이 ECR에 업로드한 리모트 캐시를 사용하지 않는다는 사실을 발견하고
이 문제를 들여다 본 썰을 풀어보겠습니다.

 

언제나 그렇듯이 과정은 복잡하지만 해결 방법은 간단했습니다.
머리가 나쁘면 몸이 고생이라고, 무지함을 해결하지 않고 방치하면 결국에는 긴 고통으로 돌려받게 됩니다.

 

대부분은 이 내용을 아시겠지만 혹시 저처럼 몰랐던 분들이 있지 않을까 하여 이 글을 씁니다.

문제는 어쩌다 발견했나

On-premise A100 GPU들을 최대한 쥐어짜서 사용성을 극대화하기 위해
Kubernetes에 Volcano 를 이용해 Custom GPU Scheduler를 도입하는 과정 중이었습니다.

 

Volcano 환경의 구축 자체는 어렵거나 복잡한게 아니지만
누구나 일상적이고 편리하게 사용할 수 있는 Job의 생성 과정 자체가 중요했습니다.

 

아무래도 모델 학습을 위한 다양한 컨테이너 이미지들도 많이 생겨날 예정이라
컨테이너 빌드 작업도 성능 향상이 필요하다고 생각했습니다.

 

계획은 Jenkins로 Git Repo를 클론하며, Kaniko로 빌드하여 ECR로 푸시하는 것입니다.
이에 여러 가지의 시나리오로 학습용 Git Repo 들을 만들어서 빌드하며,
모델 성격에 따라 공통되는 레이어는 최대한 빌드할 때 캐시를 활용하고 싶었습니다.

 

빌드 캐시에 대해서

컨테이너 빌드를 할 때 레이어가 변경시에 해당 레이어 이후로 캐시 무효화가 되어 사용하지 못한다,
그러니 자주 변경되는 레이어는 최대한 뒤에 배치해야 한다 정도의 피상적인 지식만을 갖고 있었습니다.

 

빌드 캐시에 대한 내용은 아래 도커 공식 문서를 참고하시면 좋을 것 같습니다.

Optimizing builds with cache management

 

이 문서의 설명에 의하면,
아래와 같은 Dockerfile에서 각각의 instruction은 이미지의 레이어로 변환됩니다.

# syntax=docker/dockerfile:1
FROM ubuntu:latest

RUN apt-get update && apt-get install -y build-essentials
COPY main.c Makefile /src/
WORKDIR /src/
RUN make build

그리고 이후 다시 빌드할 때 해당 레이어가 변경되었으면, 그 레이어를 다시 빌드해야 합니다.
그러니까 레이어에 대한 캐시가 무효화된 것이며, 그 뒤의 모든 레이어도 캐시가 무효화됩니다.

 

그래서 이 문서에서는 최적화를 위한 몇 가지 조언을 해주고 있습니다.

발견한 문제에 대해서

아래와 같은 Kaniko 동작을 수행했다고 해보겠습니다.
Kaniko의 버젼은 1.20.1입니다.

  containers:
  - name: kaniko
    image: gcr.io/kaniko-project/executor:v1.20.1-debug
...
stage('Build and Push Image') {
    steps {
        container('kaniko') {
            script {
                sh """
                /kaniko/executor --context `pwd` \
                --destination ${ECR}:${IMAGE_TAG} \
                --cache=true \
                --cache-repo=${ECR}/cache \
                --cleanup \
                --dockerfile Dockerfile \
                --verbosity debug \
                --build-arg=NEXUS_URL=${NEXUS}
                """
            }
        }
    }
}

 

Dockerfile은 예를 들어 아래와 같은 내용을 사용했다고 가정하겠습니다.

FROM pytorch/pytorch:latest

WORKDIR /app

COPY . /app

RUN pip install --upgrade pip && \
    pip install . --no-cache-dir

CMD ["python", "torch-trainer/train.py"]

 

동일한 빌드 작업을 반복하는데도 이상하게 빌드작업이 계속 오래걸리는 모습을 발견했습니다.
변경되는 레이어가 전혀 없는데, 시간이 똑같이 걸리는건 이상해보였습니다.

 

Kaniko 의 cache 관련 additional flags 대해서

cache와 관련한 Kaniko의 additional flags를 살펴봤습니다.
해당 문서를 보면 아래와 같은 내용이 있습니다.

  • --cache : 이 플래그를 --cache=true로 설정하여 카니코 캐시을 사용하도록 설정합니다.
  • --cache-repo : 캐시된 레이어를 저장하는 데 사용할 원격 리포지토리를 지정하려면 이 플래그를 설정합니다. 이 플래그를 제공하지 않으면 --destination 플래그에서 캐시 저장소를 유추합니다.
  • --cache-run-layers : 이 플래그를 캐시 실행 레이어(기본값=true)로 설정합니다.
  • --cache-dir : 기본 이미지의 로컬 디렉터리 캐시를 지정하려면 이 플래그를 설정합니다. 기본값은 /cache입니다.

local cache가 아니라 remote를 사용하려는 목적이니까, cache=true 를 설정하고 cache-repo만 지정해주면 됩니다.
cache-repo를 지정하지 않아도 destination에서 추론해냅니다.


별로 문제는 없어 보였습니다.

 

문제시 확인한 로그에 대해서

로그를 확인해보면 cache를 찾지 못한다는 내용이 보입니다.

Failed to retrieve layer: GET ${ECR}/cache/manifests/xxxxxxxxx: MANIFEST_UNKNOWN: Requested image not found 
No cached layer found for cmd RUN pip install --upgrade pip && pip install . --no-cache-dir

 

왜 매번 cache를 찾지 못하는지 확인이 필요해서 두 번 연속으로 빌드하고 로그를 비교해봤습니다.

 

* 첫 번째

DEBU[0004] Optimize: composite key for command COPY . /app {[sha256:42204bca460bb77cbd524577618e1723ad474e5d77cc51f94037fffbc2c88c6f |... COPY . /app a542ed6802c1dab030927d9798e271c9f682d67039c8268ed916d42f8c3143bc]}
DEBU[0004] Optimize: cache key for command COPY . /app 399f81252bd5d5d6af3a9bdc3728c2f1db895e8273127c49ccbd1ceb69840a33

 

* 두 번째

DEBU[0005] Optimize: composite key for command COPY . /app {[sha256:42204bca460bb77cbd524577618e1723ad474e5d77cc51f94037fffbc2c88c6f |... COPY . /app 1090aea1088c79606d535ba012a85004673d8f4519974029ee12c3cb60c7707c]}
DEBU[0005] Optimize: cache key for command COPY . /app b8cb4fc82da4c052e60b9da897a628a4c20cb641646b728e848689064ecee1db

 

위 두 지점을 살펴보면 레이어는 같은데 생성된 cache key가 아래와 같이 다릅니다. 

아래 두개입니다. 
399f81252bd5d5d6af3a9bdc3728c2f1db895e8273127c49ccbd1ceb69840a33
b8cb4fc82da4c052e60b9da897a628a4c20cb641646b728e848689064ecee1db

 

Kaniko는 cache key를 어떻게 만들까요

그래서 어떻게 저 composite key가 만들어지고, cache 가 만들어지는지 잠깐 살펴보기로 했습니다. 

Kaniko의 해당 부분 코드입니다. 

디렉토리를 순회하며 해시를 계산하는 것 같습니다. 
clone된 내용 중에 변경되는 내용이 있어서 cache가 변하는 것 같습니다.

 

build.go

...
for i, command := range s.cmds {
		if command == nil {
			continue
		}
		files, err := command.FilesUsedFromContext(&cfg, s.args)
		if err != nil {
			return errors.Wrap(err, "failed to get files used from context")
		}

		compositeKey, err = s.populateCompositeKey(command, files, compositeKey, s.args, cfg.Env)
		if err != nil {
			return err
		}
...

func (s *stageBuilder) populateCompositeKey(command commands.DockerCommand, files []string, compositeKey CompositeCache, args *dockerfile.BuildArgs, env []string) (CompositeCache, error) {

	replacementEnvs := args.ReplacementEnvs(env)

	sort.Strings(replacementEnvs)

	if command.IsArgsEnvsRequiredInCache() {
		if len(replacementEnvs) > 0 {
			compositeKey.AddKey(fmt.Sprintf("|%d", len(replacementEnvs)))
			compositeKey.AddKey(replacementEnvs...)
		}
	}

	compositeKey.AddKey(command.String())

	for _, f := range files {
		if err := compositeKey.AddPath(f, s.fileContext); err != nil {
			return compositeKey, err
		}
	}
	return compositeKey, nil
}

 

composite_cache.go

func (s *CompositeCache) AddPath(p string, context util.FileContext) error {
    sha := sha256.New()
    fi, err := os.Lstat(p)
    if err != nil {
        return errors.Wrap(err, "could not add path")
    }

    if fi.Mode().IsDir() {
        empty, k, err := hashDir(p, context)
        if err != nil {
            return err
        }

        if !empty || !context.ExcludesFile(p) {
            s.keys = append(s.keys, k)
        }
        return nil
    }

    if context.ExcludesFile(p) {
        return nil
    }
    fh, err := util.CacheHasher()(p)
    if err != nil {
        return err
    }
    if _, err := sha.Write([]byte(fh)); err != nil {
        return err
    }

    s.keys = append(s.keys, fmt.Sprintf("%x", sha.Sum(nil)))
    return nil
}

func hashDir(p string, context util.FileContext) (bool, string, error) {
    sha := sha256.New()
    empty := true
    if err := filepath.Walk(p, func(path string, fi os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        exclude := context.ExcludesFile(path)
        if exclude {
            return nil
        }

        fileHash, err := util.CacheHasher()(path)
        if err != nil {
            return err
        }
        if _, err := sha.Write([]byte(fileHash)); err != nil {
            return err
        }
        empty = false
        return nil
    }); err != nil {
        return false, "", err
    }

    return empty, fmt.Sprintf("%x", sha.Sum(nil)), nil
}

 

뭐가 변했을까요?

그래서 두 빌드 간 달라지는 내용을 찾기위해 스텝을 추가했습니다.

        stage('Diff Info') {
            steps {
                script {
                    sh "find . -type f -exec sh -c 'echo {} && sha256sum {}' \\;"
                }
            }
        }

 

이렇게 확인해보니 빌드마다 변경되는 내용이 있었습니다.

 

* 첫 번째 

./.git/logs/refs/remotes/origin/main
9440323114114a04b420f78ac72b59818f15fa681f42dc377b17c40b076fde17  ./.git/logs/refs/remotes/origin/main
./.git/logs/HEAD
90b7d8a844a33b28578c0de288d1e1a429bbc01b262573f40578297b32727214  ./.git/logs/HEAD
./.git/index
796230617cce8835c688a5f6ef8905e0be4b61b2ddccd3e2dc29871bcb4a7956  ./.git/index

 

* 두 번째

./.git/logs/refs/remotes/origin/main
f75fd679bc322aeb8737cd48578b2daf0dcb588cf05a87e4288bae6d1bcc13d5  ./.git/logs/refs/remotes/origin/main
./.git/logs/HEAD
d13605d8b09f79409995f031dc7bcd5310d29ece65c40f20915cf20bb6caa87d  ./.git/logs/HEAD
./.git/index
dd2e98d7a20618d9e9bb25c3f415bc169a70720b4a50ff9c7b96fdb9d289faf9  ./.git/index

 

다른 내용들은 그대로인데 git clone을 하면서 딸려오는 이 파일들만 내용이 변했습니다.
그래서 build시 다른 캐시를 생성했고, 그래서 차이가 나는 것이라고 생각했습니다.

 

.dockerignore가 빠져 있었습니다. 

왜 다른 빌드들과 달리 cache를 사용하지 않는지 이유를 알게 되었습니다.
여러 테스트용 레포들을 만들다보니 .dockerignore 파일이 포함되지 않아 .git 의 내용이 그대로 들어갔고,
위에서 살펴본 바와 같이 내용이 달라지기 때문에 cache도 달라진다는 개인적인 결론을 내렸습니다.

 

이제 제대로 cache를 사용하면 아래와 같은 로그를 볼 수 있습니다. 

 

.dockerignore의 사용목적

 

그동안 .dockerignore 파일을 여러 레포들에서 생각없이 포함시켜 왔었지만 피상적인 지식 이외에
왜 포함시켜야 하는지 직접적인 이유를 알게 되었습니다.

 

그외에 .dockerignore 파일을 포함시키는 이유를 찾아보면, 아래와 같은 이유들이 있습니다.

  • 빌드 속도 향상 : 불필요한 파일 포함시키지 않기
  • 안전한 이미지 생성 : 중요한 파일이 이미지에 포함되지 않게
  • 이미지 최적화 : 이미지 크기를 최소화 하자.

내용은 길었지만 .dockerignore만 잘 생성하면 해결되는 문제였습니다.

지금까지 긴 삽질기를 읽어주셔서 감사합니다.

 

참고

반응형