도커 이미지에 내가 쓰던 venv를 넣어도 될까?
안티패턴이라고? 그럼 거기엔 이유가 있다
가상환경 디렉터리를 도커 이미지에 복사하면 안 된다는 것을 배웠고 그건 안티패턴이라고 당연하게 여겼다. 그러나 당연하다고 느끼는 것조차 이유가 있기 마련이라 ‘왜?’라는 궁금증이 생겼다.
이번 글에서는 간단한 웹 어플리케이션을 예시로, 도커에 개발할 때 쓰던 가상환경을 넣었을 때 생기는 문제와 가상환경 없이 도커 이미지에 패키지를 쉽게 포함하는 방법을 작성하고자 한다. 이 글을 통해 작업하던 가상환경을 도커에 넣는 게 맞을지 고민하고 있는 사람들에게 도움이 되었으면 한다.
해당 글 작성에는 파이썬의 가상환경 중 가장 기본이 되는 venv와 flask 웹 어플리케이션을 기반으로 한 실습을 포함한다.
글을 이해하기 위한 prerequisite는 ‘도커에 대한 기본 개념’이다.
venv를 사용하여 간단하게 개발해보기
가상환경이란, 컴퓨터 전체의 환경에 영향을 받지 않기 위해 사용하는 프로젝트별(또는 폴더별)로 관리하는 환경을 말한다.
파이썬 유저라면 venv와 같은 가상환경을 먼저 만들고 이를 실행하며 프로젝트를 시작한다.
bashpython -m venv .venv # 가상환경 만들기 - 로컬에 설치된 파이썬 버전을 따른다 source .venv/bin/activate # 가상환경 실행하기
(.venv) lizzie@lizzie:~/projects/test$ 와 같이 터미널 커맨드 라인 맨 앞에 (.venv) 라는 가상환경 이름이 뜬다.
requirements.txt 라는 txt를 생성하고 필요한 패키지를 설치한다. 이번 실습에서는 flask와 pandas를 이용해서 간단한 어플리케이션을 만들 예정이다.
plain# requirements.txt 파일 내에 사용할 패키지와 버전을 고정해서 작성한다. flask==3.1.0 pandas==2.2.3
그리고 다음의 명령어로 패키지를 설치한다.
bashpip install -r requirements.txt
다음, app.py 파일에 아주 간단한 Flask API 서버를 구현하였다.
python# app.py from flask import Flask, jsonify import pandas as pd import numpy as np app = Flask(__name__) def load_sales(): rng = np.random.default_rng(42) return pd.DataFrame({ "region": rng.choice(["seoul", "busan", "daegu"], size=300), "amount": rng.integers(1_000, 50_000, size=300), }) @app.get("/stats") def stats(): df = load_sales() summary = ( df.groupby("region")["amount"] .agg(["count", "sum", "mean"]) .round(0) .astype(int) .reset_index() ) return jsonify(summary.to_dict(orient="records")) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)
이렇게 하면 우리는 아주 간단한 플라스크 앱을 실행하여 사용해볼 수 있다.
Dockerfile을 작성해보자(venv와 함께)
Dockerfile이란, 도커 이미지를 만드는 파일이며 어떤 환경에서 어떤 어플리케이션을 어떻게 실행할 것인지를 작성한다. 즉, 작성한 코드를 실행하기 위한 최소한의 서버를 구축하기 위한 청사진과 같은 것이다.
컨테이너로 어플리케이션 배포를 하는 것은 거의 국룰인 시대다. 컨테이너로 배포하기 위해서는 도커 이미지를 먼저 만들어야 한다.
다음은 도커 이미지를 빌드하기 위해 작성하는 Dockerfile 파일이다. 이번 이미지에는 venv를 포함해본다. venv를 넣어야 패키지를 그대로 쓸 수 있다고 생각할 수도 있다.
dockerFROM python:3.12-slim WORKDIR /app # 다음 명령어는 직접 .venv 폴더를 추가한다. COPY .venv /app/.venv # 그리고 실제로 venv를 사용하기 위한 환경변수도 주입한다. ENV PATH="/app/.venv/bin:$PATH" ENV VIRTUAL_ENV="/app/.venv" COPY app.py . CMD ["flask", "run"]
이렇게 했을 때 이 이미지로 만든 컨테이너는 과연 실행이 될까?
이미지 빌드를 하고 실행해보기
먼저, 도커 이미지를 빌드한다.
bashdocker build -t test_venv .
그리고 docker run 명령어로 컨테이너를 실행해보았다. 그랬더니 다음의 에러가 났다.
분명 .venv를 복사해 넣었는데 flask를 실행할 파일을 찾지 못한다. 왜 이런 일이 생기는 것일까?
로컬에서 쓰던 venv가 컨테이너에서 동작 안 하는 이유
로컬 컴퓨터에서 확인한 것
이유를 확인하기 전에, 먼저 로컬 컴퓨터 경로를 알아야 한다. 이걸 알아야 이유에 대해 전반적으로 이해가 가능하기 때문이다.
필자는 로컬 컴퓨터에서 pwd 명령어를 이용해 홈 디렉터리의 전체 경로를 확인했다.
flask 실행 파일은 정말 없을까
일단 .venv 폴더에 정말로 flask 실행 파일이 없는지 확인한다. 하지만 파일은 컨테이너 안에 멀쩡히 존재한다.
이렇듯 파일이 존재하는데도 실행에 실패했다. 그 이유는 스크립트의 본문에서 힌트를 얻을 수 있었다.
bashcat .venv/bin/flask
스크립트 내용을 보니, 첫 줄에 shebang과 함께 어떤 파이썬 인터프리터로 실행해야 하는지, 그 경로가 작성되어 있었다. 이 인터프리터의 경로를 보면 가상환경을 만들 당시의 절대 경로, 즉 로컬 컴퓨터의 인터프리터 경로가 그대로 박혀 있다.
shebang(셔뱅)이란?
스크립트 파일의 첫 줄에 #!로 시작해 실행 파일 경로를 적어두는 것. 이 파일을 실행하면 커널이 첫 줄을 읽어, 해당 실행 파일로 스크립트를 넘겨 실행한다. 본 글에서는 python 코드를 실행하기 위해, 사용할 python 인터프리터 경로를 작성해둔다.
이 경로는 로컬 컴퓨터에만 존재하고 컨테이너에는 없다. 그래서 커널이 스크립트를 넘길 파이썬을 찾지 못하고 실행에 실패한다. 없는 것은 flask 파일이 아니라 그 파일이 가리키는 파이썬 인터프리터인데, 에러 메시지에는 flask 경로가 찍히기 때문에 에러만 보고 원인을 짐작하기도 어렵다.
실행 방법을 바꾸면 어떨까
간단한 flask 웹 어플리케이션은 실행 방법을 바꿀 수 있다. 같은 Dockerfile에서 다음과 같이 CMD만 바꿔본 후, 빌드 및 컨테이너 실행을 해보았다.
dockerFROM python:3.12-slim WORKDIR /app COPY .venv /app/.venv ENV PATH="/app/.venv/bin:$PATH" ENV VIRTUAL_ENV="/app/.venv" COPY app.py . # 아까의 Dockerfile에서 실행만 변경. 기존에는 flask run이 실행 명령어였다 CMD ["python", "app.py"]
이번에는 flask 모듈을 못 찾는다고 한다. 하지만 venv를 그대로 옮겼고 flask 실행 스크립트는 여전히 존재한다. 그럼 왜 이 모듈을 못 찾는 것일까?
어떤 파이썬이 실행되고 있을까
이번 예시에서도 컨테이너 안에서 확인할 것이 있다. 컨테이너 런타임 때 어떤 파이썬이 실행되고 있는지 확인해보았다. 먼저 파이썬 인터프리터를 찾기 위한 PATH 환경변수를 확인하였다.
bashdocker run --rm --entrypoint /bin/bash test_venv -c 'echo $PATH; which python'
PATH 맨 앞에 /app/.venv/bin이 잘 들어가 있는데도 실행되는 것은 다른 파이썬 인터프리터이다. 복사해 넣은 venv는 쓰이지 않고 있었던 것이다.
직접적인 원인은 로컬 컴퓨터의 인터프리터를 가리키는 symlink의 절대 경로가 그대로 복사되어 있기 때문이다.
symlink는 다른 파일을 가리키는 링크이다. .venv/bin/python은 파이썬 실행 파일의 복사본이 아니라 호스트 인터프리터를 가리키는 symlink다. 애초에 가상환경은 다른 인터프리터를 갖지 않고 시스템에 설치된 인터프리터를 연결해서 쓰는 구조인 셈이다.
Dockerfile 마지막 명령어인 CMD ["python", "app.py"]를 실행하면 PATH에 등록된 /app/.venv/bin 경로 내의 파이썬부터 찾아 실행하는데, 여기서 문제가 된다. 사실 링크가 가리키는 /usr/bin/python은 컨테이너에 존재하지 않는다. 즉 복사된 symlink는 아무것도 가리키지 못하는 상태다. 컨테이너의 파이썬은 /usr/local/bin에 있다.
끊어진 symlink는 실행할 수 없으므로 후보에서 제외된다. 대신 실제 파이썬 인터프리터가 있는 /usr/local/bin의 경로를 찾아 그 인터프리터를 실행한다. 그리고 이 인터프리터는 /app/.venv 안의 패키지를 보지 않으므로 ModuleNotFoundError가 난다.
이 예시를 포함한 이유는, 도커 이미지를 만들 때 python 명령어로 컨테이너를 실행하는 경우가 존재하기 때문이다. CMD ["python", "{스크립트_명}.py"]의 명령어 형태라면, 그 어떤 패키지를 사용하는 스크립트를 실행해도 패키지를 못 찾는 동일한 문제가 생긴다.
실행 스크립트는 첫 줄에 박힌 경로 때문에 실행 단계에서 실패하고, symlink는 끊어진 채 조용히 건너뛰어진다. 컨테이너 실행의 실패 방식은 다르지만 이 또한 가상환경 안의 파일들이 만들어질 당시의 로컬 경로가 하드코딩되어 있기 때문에 생기는 일이다.
(참고) 다른 가상환경 툴이라면 괜찮을 수도 있지 않을까?
이번 글에서 실험해본 것은 venv에 국한된 것이니 ‘다른 가상환경 툴이라면 상관없지 않을까?’라는 생각이 들 수 있다.
venv 외에 많이 쓰는 가상환경들은,
- virtualenv
- conda (아나콘다 가상환경)
- poetry
- uv
- pyenv
등이 있다.
이 도구들은 구조가 다르다. 다만 생성 시점의 로컬 컴퓨터 환경에 종속된다는 성질은 같다.
결론적으론, 어떤 가상환경을 쓰더라도, 로컬에서 만든 가상환경은 로컬 컴퓨터의 경로와 OS 등에 강하게 의존성이 있다. 즉, 전부 생성 시점의 환경에서만 돌아간다는 의미다. 특히 설치된 패키지 중 C로 컴파일된 것들은 설치 당시의 OS와 CPU 아키텍처 전용이기 때문에 가상환경의 종류와 별개로 문제가 된다. 도커 이미지는 로컬 컴퓨터의 환경과 동일하다는 것을 보장할 수 없으므로 가상환경을 도커 이미지에 포함시키지 않는 것이 좋다.
가상환경 없이 도커 이미지 만들기
그럼 어떻게 가상환경 없이 도커 이미지를 만들어야 할까?
아주 간단하다. pip install 명령어를 직접 작성하면 된다. 다음은 .venv를 복사해서 넣는 대신 pip install 명령어를 작성한 Dockerfile이다.
dockerFROM python:3.12-slim WORKDIR /app # requirements.txt에 작성된 패키지부터 설치해준다. COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . CMD ["python", "app.py"]
requirements.txt를 가장 먼저 복사 및 pip install하는 이유
도커 이미지를 빌드할 땐 레이어 캐싱을 하기 때문에 명령어의 순서가 중요하다. 상태가 변하지 않는 명령어는 재빌드 시 캐시를 이용하여 빌드타임 자체를 줄이기 때문이다.
requirements.txt의 패키지 목록이 변하지 않는 한 레이어 캐시를 사용하기 때문에 재빌드 타임을 줄일 수 있는 장점이 있다.
.dockerignore 파일을 생성해 .venv 폴더가 이미지 빌드할 때 포함하지 않도록 방지한다.
plain.venv/ # ... 그 외 docker에 포함하지 않을 파일과 폴더들
마지막으로 이를 이용해 이미지 빌드, 컨테이너 실행을 하면 문제없이 서버가 작동되는 것을 확인할 수 있다.
이렇게 도커 이미지에 담아야 할 것은 로컬에서 만들어진 가상환경 자체가 아니라, 프로그램을 돌리기 위한 패키지들이 적힌 requirements.txt였다는 것을 알 수 있었다.