LLM 엔지니어링19장 · MLOps 와 LLMOps, 워크플로 자동화8 / 23← 이전목차다음 →MetaContents

19장. MLOps 와 LLMOps, 워크플로 자동화

Docker 심화: 이미지 층과 캐시

이삿짐 상자를 아래부터 쌓았다고 해 봅니다. 맨 위 상자를 바꾸려면 위 상자 하나만 내리면 됩니다. 맨 아래 상자를 바꾸려면 위의 상자를 모두 내렸다가 다시 쌓아야 합니다. Docker 이미지도 층을 아래부터 쌓고, 한 층이 바뀌면 그 위의 층을 모두 다시 만듭니다.

기반 이미지, 패키지 설치, 코드 복사, 실행 명령의 네 층을 아래부터 쌓는 흐름도

그림의 1번에서 4번이 아래에서 위로 쌓이는 층입니다. 1번 기반 이미지와 2번 패키지는 잘 바뀌지 않고, 3번 코드는 자주 바뀝니다. 그래서 잘 바뀌지 않는 것을 아래에, 자주 바뀌는 것을 위에 둡니다. 코드만 고쳤다면 1번과 2번은 빌드 캐시에서 그대로 가져옵니다.

층 순서 견주기

14장의 Dockerfile 을 이 원칙으로 다시 봅니다. 아래 두 가지는 이 PC 에서 실행하지 않은 안내 코드입니다.

출력
# 나쁜 순서: 코드를 먼저 복사해서, 코드를 고칠 때마다 패키지를 다시 설치한다
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]

# 좋은 순서: 패키지 목록만 먼저 복사해 설치하고, 코드는 마지막에 복사한다
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY api.py .
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]

좋은 순서로 쓴 뒤 api.py 만 고쳐 다시 빌드하면 이렇게 나올 것으로 봅니다. 예상 출력(실행하지 않고 유추한 예시)입니다. 걸린 시간은 PC 와 패키지에 따라 다릅니다.

출력
docker build -t inquiry-api:2 .
 => CACHED [2/5] WORKDIR /app
 => CACHED [3/5] COPY requirements.txt .
 => CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt
 => [5/5] COPY api.py .                      약 0.1초
 => exporting to image                       약 1초
방법하는 일
잘 안 바뀌는 것을 아래에기반 이미지 → 패키지 → 코드 차례로 쓴다
.dockerignore.venv, data, 모델 파일처럼 이미지에 넣지 않을 것을 적는다
다단계 빌드빌드에만 쓰는 도구는 앞 단계에 두고, 실행에 필요한 파일만 마지막 이미지로 옮긴다
버전 고정python:3.12-slim, fastapi==0.162.0 처럼 판을 적어 다시 빌드해도 같게 한다

층의 캐시를 흉내 내기

층 하나의 캐시 열쇠는 "아래 층의 열쇠 + 이 줄의 명령 + 복사하는 파일의 내용"입니다. 그래서 아래가 바뀌면 위도 모두 새 열쇠가 됩니다.

Python
import hashlib


def build(steps, cache):
    """층마다 열쇠를 만들고, 캐시에 있으면 다시 쓰고 없으면 새로 만든다"""
    key, out = "", []
    for cmd, content in steps:
        key = hashlib.sha256((key + cmd + content).encode()).hexdigest()[:8]
        out.append(("캐시" if key in cache else "새로") + " " + cmd)
        cache.add(key)
    return out


cache = set()
first = [("FROM python:3.12-slim", ""), ("COPY requirements.txt", "fastapi==0.162.0"),
         ("RUN pip install", ""), ("COPY api.py", "1판")]
build(first, cache)
# api.py 만 고쳤을 때: 마지막 층만 새로 만든다
print(build(first[:3] + [("COPY api.py", "2판")], cache))
# 패키지 목록을 고쳤을 때: 그 층부터 위가 모두 새로
print(build([first[0], ("COPY requirements.txt", "fastapi==0.162.0\njoblib"), first[2], first[3]], cache))

코드에 나오는 함수

코드쉬운 뜻
cache = set()만들어 둔 층의 열쇠를 모으는 집합(set)이다. 같은 값을 한 번만 담고, 들어 있는지 빨리 찾는다
for cmd, content in steps층마다 (명령 한 줄, 복사하는 파일의 내용)을 꺼낸다. cmd 는 command(명령)의 줄임말이다
key = hashlib.sha256((key + cmd + content).encode()).hexdigest()[:8]아래 층의 열쇠에 이 줄의 명령과 내용을 이어 붙여 새 열쇠를 만든다
key in cache같은 열쇠의 층을 전에 만든 적이 있으면 참이다
cache.add(key)이번 층의 열쇠를 캐시에 넣는다
first[:3] + [("COPY api.py", "2판")]앞 세 층은 그대로 두고 마지막 층만 바꾼 목록을 만든다

이 코드가 뜻하는 것

레고 설명서의 단계 사진을 떠올려 봅니다. 단계마다 "앞 단계의 완성품 + 이번 부품"을 찍어 둡니다. 앞 단계와 이번 부품이 같으면 찍어 둔 사진을 그대로 씁니다. 중간 부품이 바뀌면 그 단계부터 뒤의 사진은 모두 다시 찍습니다. 여기서 단계 사진이 층, 사진의 이름표가 열쇠, 사진첩이 cache 입니다.

줄하는 일쉽게 말하면
build(first, cache)처음이라 네 층을 모두 새로 만든다. 결과는 출력하지 않는다사진을 처음부터 다 찍는다
첫 print(build(…))api.py 만 2판으로 바꿔 빌드한다. 앞 세 층은 캐시, 마지막 층만 새로 만든다마지막 단계 사진만 다시 찍는다
둘째 print(build(…))패키지 목록의 내용을 바꿔 빌드한다. 그 층부터 위가 모두 새로다둘째 단계 부품이 바뀌어 그 뒤 사진을 모두 다시 찍는다

둘째 출력에서 COPY api.py 는 1판 그대로인데도 새로 입니다. 아래 층의 열쇠가 바뀌면 그 위 층의 열쇠도 따라 바뀌기 때문입니다. 비유와 다른 점은, 사람은 사진을 눈으로 보고 같은지 알지만 이 코드는 열쇠 글자 8개만 견준다는 것입니다.

주의할 점

  • pip install 의 캐시까지 이미지에 남기면 이미지가 커집니다. --no-cache-dir 를 줍니다.
  • 비밀 키는 Dockerfile 이나 이미지에 넣지 않습니다. 실행할 때 환경 변수로 넣습니다(13장).
← 이전 절19장 · LLMOps 프레임워크다음 절 →19장 · Docker 심화: 모델과 데이터는 밖에 둔다