요약

SUMMARY

호환 불가 변경이 있는 클라이언트 라이브러리(weaviate-client) 두 버전을 하나의 컨테이너에서 환경변수로 골라 써야 했다. multi-stage 빌드로 구버전을 별도 디렉토리에 설치하고, 런타임에 sys.path.insert로 import 우선순위를 분기해 해결했다. docker-compose로 버전별 서버를 붙여 CRUD와 버전 전용 API 차이까지 실제로 돌려 검증했다.

1. 환경

  • Python 3.11 (uv 기반 이미지)
  • weaviate-client 4.15.0 / 4.19.2 두 버전 공존
  • Weaviate 서버 1.26.1 / 1.34.0 (docker-compose로 각각 기동)

2. 이슈

같은 클라이언트 라이브러리의 두 버전을 하나의 컨테이너 이미지에서 환경변수로 골라 로드해야 하는 상황이 있었다. 구버전 서버와 신버전 서버를 모두 상대해야 하는데, 클라이언트 라이브러리는 그 사이에 호환 불가 변경(breaking change)이 있었다.

구체적으로 weaviate-client는 4.16을 기점으로 named vector 생성 API가 갈린다. 4.15는 vectorizer_config=Configure.NamedVectors.none()을 넘기는데, 4.19는 vector_config=Configure.Vectors.self_provided()를 넘긴다. 파라미터명도 클래스도 다르다. 한쪽 코드를 반대 버전에서 실행하면 그냥 깨진다.

보통은 이런 경우에 컨테이너를 둘로 나누거나 가상환경을 따로 만든다. 그것이 정석이다. 다만 이번에는 “이미지는 하나로 두고 실행 시점에 버전을 고른다”는 방식이 가능한지가 궁금했다. pip install은 한 환경에 같은 패키지의 두 버전을 나란히 두지 못한다. 나중에 설치한 것이 앞선 것을 덮어쓴다. 그러면 하나의 site-packages 안에서 두 버전을 어떻게 공존시킬 것인지, 그리고 런타임에 어느 쪽을 로드할지 어떻게 결정할 것인지가 문제였다.

3. 해결

핵심 아이디어는 두 개다. 하나는 설치 위치를 물리적으로 분리하는 것이고, 다른 하나는 런타임에 sys.path 우선순위를 바꿔 import를 분기하는 것이다.

1. import 탐색 순서

Python은 import weaviate를 만나면 sys.path에 담긴 디렉토리를 앞에서부터 탐색해서 처음 만난 패키지를 로드한다. 즉 같은 이름의 패키지가 두 경로에 있어도, sys.path 앞쪽에 있는 쪽이 우선한다. 이 성질에 기대면 된다. 기본 경로에 한 버전을 설치하고, 다른 버전은 별도 디렉토리에 설치한 뒤, 필요할 때만 그 디렉토리를 sys.path 맨 앞에 끼워 넣으면 로드되는 버전이 바뀐다.

2. multi-stage 빌드로 버전을 격리 설치

먼저 두 버전을 물리적으로 다른 경로에 배치했다. builder 스테이지에서 구버전(4.15.0)을 --target으로 격리 디렉토리에 설치하고, runtime 스테이지에서 신버전(4.19.2)을 기본 경로에 설치한 뒤 격리 디렉토리만 복사해 왔다.

# builder: 4.15.0 과 그 종속성만 별도 디렉토리(/app/libs_4_15)로 설치
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
WORKDIR /app
RUN uv pip install "weaviate-client==4.15.0" --target /app/libs_4_15 --system
 
# runtime: 신버전(4.19.2)은 기본 경로로 설치하고, 격리 디렉토리만 가져온다
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runtime
WORKDIR /app
RUN uv pip install "weaviate-client==4.19.2" --system
COPY --from=builder /app/libs_4_15 /app/libs_4_15
COPY main.py .
COPY my_app/ ./my_app/
CMD ["python", "main.py"]

--target은 패키지와 그 종속성을 지정한 디렉토리에 통째로 설치한다. 이렇게 하면 4.15.0 트리는 /app/libs_4_15 안에, 4.19.2 트리는 기본 site-packages에 각각 온전하게 들어간다. 종속성까지 함께 격리되므로 버전이 얽힐 여지가 없다. 구버전을 굳이 builder에서 만들어 복사한 것은 최종 이미지에 빌드 흔적을 남기지 않으려는 관례 때문인데, 여기서는 사실 runtime에서 바로 --target으로 설치해도 결과는 같다.

3. 환경변수로 sys.path 분기

런타임에서는 MODE 환경변수를 읽어 로드할 버전을 정한다. 구버전을 쓸 때만 격리 디렉토리를 sys.path 맨 앞에 끼워 넣고, 그 직후에 import를 트리거하는 게 요점이다.

# main.py — MODE 에 따라 import 경로를 분기한다.
if mode == "4.15":
    sys.path.insert(0, "/app/libs_4_15")   # 격리 경로를 최우선으로
    from my_app.logic_old import run_task   # → /app/libs_4_15 의 4.15.0 로드
    run_task(url, grpc_port)
 
elif mode == "4.19":
    from my_app.logic_new import run_task    # → 기본 경로의 4.19.2 로드
    run_task(url, grpc_port)

logic_old/logic_new는 모듈 최상단에서 import weaviate를 실행한다. 그래서 이 모듈을 언제 import하느냐가 곧 어느 버전을 로드하느냐가 된다. sys.path.insert(0, ...)를 먼저 실행하고 나서 logic_old를 import하면, 그 안의 import weaviate가 격리 경로의 4.15.0을 먼저 만난다. sys.path.insert는 반드시 import 앞에 와야 한다. 순서가 뒤집히면 이미 기본 경로 버전이 로드된 뒤이므로 효과가 없다.

버전별 코드는 별도 모듈로 분리해 두었다. 한 파일에서 억지로 if version 분기를 처리하는 대신, 버전마다 파일을 나눠 각자 해당 버전의 API 스타일로 작성하게 한 편이 읽기에 낫다. named vector 생성만 봐도 차이가 이렇게 크다.

# logic_old.py (4.15) — 구 API
client.collections.create(
    name=NAMED_VEC_COLLECTION,
    vectorizer_config=[                          # 4.15 파라미터명
        Configure.NamedVectors.none(name="custom_vec"),
    ],
    properties=[Property(name="label", data_type=DataType.TEXT)],
)
 
# logic_new.py (4.19) — 신 API (4.16+)
client.collections.create(
    name=NAMED_VEC_COLLECTION,
    vector_config=[                              # 4.16+ 파라미터명
        Configure.Vectors.self_provided(name="custom_vec"),
    ],
    properties=[Property(name="label", data_type=DataType.TEXT)],
)

4. docker-compose로 버전별 서버 붙이기

검증하려면 클라이언트만 분리해서는 안 되고, 각 버전이 연결할 서버도 있어야 했다. 같은 이미지를 두 번 띄우되 환경변수만 다르게 주어서, 각각 자기 버전에 맞는 Weaviate 서버를 상대하도록 했다. 서버가 healthy가 될 때까지 기다린 후 앱이 뜨도록 depends_oncondition: service_healthy를 설정했다.

services:
  weaviate-v1:      # Weaviate 서버 1.26.1
    image: semitechnologies/weaviate:1.26.1
    # ... healthcheck 로 ready 확인
 
  weaviate-v2:      # Weaviate 서버 1.34.0
    image: semitechnologies/weaviate:1.34.0
    # ... healthcheck 로 ready 확인
 
  python-app-v1:    # 같은 이미지, MODE 만 다르게
    build: .
    depends_on:
      weaviate-v1:
        condition: service_healthy
    environment:
      MODE: "4.15"
      WEAVIATE_URL: "http://weaviate-v1:8080"
 
  python-app-v2:
    build: .
    depends_on:
      weaviate-v2:
        condition: service_healthy
    environment:
      MODE: "4.19"
      WEAVIATE_URL: "http://weaviate-v2:8080"

4. 확인

docker compose up --build로 두 앱을 동시에 띄웠다. 먼저 각 앱이 실제로 의도한 버전을 로드했는지부터 확인했다. weaviate.__version__을 로그로 출력하게 해 두었는데, python-app-v14.15.0, python-app-v24.19.2로 나왔다. 같은 이미지인데 환경변수만으로 로드된 버전이 갈렸다는 뜻이다.

그다음은 각자 자기 서버에 붙어 CRUD가 동작하는지였다. 컬렉션 생성 → batch 삽입 → 조회까지 두 버전 모두 정상이었다. 여기까지가 “경로 분기가 의도대로 작동한다”는 확인이다.

마지막으로 버전 간 API 차이를 실제로 확인했다. 각 버전에서 특정 API가 존재하는지·동작하는지를 직접 실행해 대조했다.

항목4.15.04.19.2
Named vector 파라미터명vectorizer_config=vector_config=
Named vector 클래스Configure.NamedVectors.none()Configure.Vectors.self_provided()
Configure.Vectors 클래스 존재✓ (4.16+)
Filter.contains_none() 메서드 존재✓ (4.17+)
backups.list() 메서드 존재✓ (4.16.8+)

4.15에서는 Configure.Vectors가 아예 없고 named vector도 구 API로만 만들어지는데, 4.19에서는 신 API가 동작하고 4.16 이후 추가된 메서드들도 포함되어 있었다. 두 버전이 한 이미지 안에서 서로를 오염시키지 않고 각자 온전하게 동작한다는 것을, 존재하는 API 목록의 차이로 확인한 셈이다.

정석대로라면 버전마다 이미지를 나누는 것이 맞고, 대부분의 경우 그 방식이 낫다. 다만 이렇게 --target으로 설치 위치를 나누고 sys.path로 로드 순서를 정하면, 하나의 이미지에서 두 버전을 골라 쓰는 것도 가능하다는 것을 직접 확인했다. Python이 import를 어떻게 찾는지만 알면 우회로가 하나 더 생긴다.

참고