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 처럼 판을 적어 다시 빌드해도 같게 한다 |
층의 캐시를 흉내 내기
층 하나의 캐시 열쇠는 "아래 층의 열쇠 + 이 줄의 명령 + 복사하는 파일의 내용"입니다. 그래서 아래가 바뀌면 위도 모두 새 열쇠가 됩니다.
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장).