kokbee-Hive
article thumbnail

들어가며

"이거 그냥 Docker 이미지 주면 되는 거 아니에요?"

금융권 납품 프로젝트를 처음 전달받았을 때, 솔직히 이렇게 생각했다. 클라우드에서 잘 돌아가는 NMT(Neural Machine Translation) 서비스가 있고, 고객사 서버에 올리면 되니까. Docker라는 좋은 도구도 있고.

결론부터 말하면, 8개월이 걸렸다. 그리고 그 8개월 동안 배운 것은 "클라우드에서 돌아간다"와 "고객 서버에서 돌아간다"는 완전히 다른 문제라는 것이었다.


배경: 왜 설치형이어야 했는가

당시 재직 중이던 회사는 AI 기반 번역 플랫폼을 운영하고 있었다. 핵심은 자체 개발한 NMT 엔진인데, 이걸 SaaS로만 제공하다가 B2B/B2G 시장으로 확장하면서 설치형(On-Premise) 수요가 생겼다.

특히 금융권 고객은 선택지가 없었다. 번역 대상 문서에 고객 개인정보, 내부 보고서, 계약서가 포함되어 있었고, 이 데이터가 외부 네트워크로 나가는 것 자체가 규정 위반이었다. "우리 서버 안에서 돌아가야 합니다" — 이건 협상이 아니라 전제 조건이었다.


시스템 구성: 4개의 컨테이너

클라우드에서 운영하던 NMT 서비스를 온프레미스용으로 재구성했을 때, 최종 형태는 4개의 Docker 컨테이너였다.

┌─ 고객 서버 (GPU) ──────────────────────────────────┐
│                                                      │
│   nmt_gateway          nmt_engine           │
│   (Gateway + Admin)  ──→ (Python 번역 엔진)          │
│   Node.js                 PyTorch + SPM 토크나이저    │
│                                                      │
│   nmt_db          language_identifier   │
│   (데이터베이스)          (언어 감지)                  │
│                                                      │
│   ── NVIDIA Driver + CUDA 12.x + nvidia-toolkit ──   │
│   ── OS: RHEL 8 or 9 ────────────────────────────    │
└──────────────────────────────────────────────────────┘
  • nmt_engine: 핵심. PyTorch 기반 번역 모델이 GPU 위에서 추론을 수행한다. 모델 파일과 SPM 토크나이저를 포함하면 이미지 크기가 수십 GB에 달한다.
  • nmt_gateway: 번역 요청을 받아 엔진으로 라우팅하는 Gateway와, 관리자가 모델 상태를 확인할 수 있는 Admin 페이지. Node.js로 구현되어 있다.
  • nmt_db: 번역 이력, 설정 데이터를 저장하는 DB.
  • lang_identifier: 입력 텍스트의 언어를 자동 감지하는 서비스. 번역 엔진이 어떤 모델을 로드할지 결정하는 데 사용된다.

클라우드에서는 이 컴포넌트들이 EC2/EKS 위에서 각각의 서비스로 돌고 있었다. 온프레미스에는 EC2도 EKS도 없다.


첫 번째 벽: 클라우드 인프라 의존성 제거

클라우드 버전을 그대로 가져다 쓸 수 없는 이유는 명확했다. 서비스가 EC2 인스턴스 위에서 돌고, EKS가 컨테이너 오케스트레이션을 담당하는 구조였기 때문이다.

문제가 된 의존성:
- AWS EC2  → GPU 인스턴스 위에서 서비스 실행
- AWS EKS  → 컨테이너 배포/스케일링/헬스체크 관리

EKS가 해주던 것들 — 컨테이너 실행, 재시작, 상태 확인 — 을 온프레미스에서는 직접 해결해야 했다. Kubernetes 클러스터를 고객사에 구축하는 건 과도한 선택이었다. 운영 부담이 너무 크다.

결국 Docker Compose + 쉘 스크립트로 단순화했다. start.sh, shutdown.sh, check_status.sh 세 개의 스크립트가 EKS가 하던 역할을 최소한으로 대체한다. 컨테이너 헬스체크, 재시작 정책, 로그 수집을 Docker 레벨에서 처리하도록 구성했다.

클라우드                          온프레미스
─────────                        ─────────
EC2 GPU 인스턴스  ──→  고객사 물리 GPU 서버
EKS 오케스트레이션 ──→  Docker Compose + 쉘 스크립트
EKS 헬스체크       ──→  check_status.sh
EKS 롤링 배포     ──→  shutdown.sh → start.sh (수동)

Kubernetes 없이도 4개의 컨테이너가 안정적으로 돌아가도록 만드는 것. 화려하진 않지만, 고객사 인프라팀이 별도 학습 없이 운영할 수 있어야 했기 때문에 이 정도의 단순함이 오히려 정답이었다.


두 번째 벽: "인터넷이 없습니다"

어댑터 레이어를 만들고 Docker 이미지를 빌드하면 끝이라고 생각했다. 고객사에 이미지를 전달하고, docker load 하고, docker-compose up 하면 되니까.

문제는 그 Docker를 설치할 방법이 없다는 것이었다.

금융사 서버는 폐쇄망이다. 외부 인터넷 접속이 완전히 차단되어 있다. 즉:

  • dnf install docker → 불가
  • pip install → 불가
  • GitHub clone → 불가
  • Docker Hub pull → 불가

OS 위에 올라가는 모든 것을 오프라인으로 설치해야 한다. Docker 자체도, NVIDIA 드라이버도, CUDA도 전부.

RPM 패키징

해결책은 필요한 모든 패키지를 RPM 파일로 미리 다운로드해서 가져가는 것이었다. 인터넷이 되는 환경(AWS EC2)에서 동일한 OS 버전의 서버를 올리고, 거기서 필요한 RPM을 전부 수집했다.

최종적으로 만들어진 설치 패키지의 구조:

rpm_v4/
├── 1_dnf_plugins_core/     # DNF 플러그인 (기본 패키지 관리)
├── 2_docker_ce_and_pkg/    # Docker CE + 의존성
├── 3_devtools/             # Development Tools (gcc, make 등)
├── 4_cuda_toolkit/         # CUDA 12.x 런타임
├── 5_cuda_nvidia_driver/   # NVIDIA GPU 드라이버
│   ├── dkms/               # Dynamic Kernel Module Support
│   └── nvidia_driver/      # 드라이버 바이너리
└── 6_docker_nvidia_toolkit/ # Docker에서 GPU를 쓰기 위한 toolkit

설치 순서가 중요하다. 1번부터 6번까지 순서대로 설치하지 않으면 의존성이 꼬인다. CUDA를 먼저 넣으면 드라이버와 충돌하고, devtools 없이 드라이버를 빌드하면 커널 모듈 컴파일이 실패한다.

# 이 순서를 지켜야 한다
cd 1_dnf_plugins_core && sudo rpm -ivh --nodeps *.rpm
cd 2_docker_ce_and_pkg && sudo rpm -ivh --nodeps --force *.rpm
cd 3_devtools && sudo rpm -ivh --nodeps --force *.rpm
cd 4_cuda_toolkit && sudo rpm -ivh --nodeps --force *.rpm
cd 5_cuda_nvidia_driver/dkms && sudo rpm -ivh --nodeps --force *.rpm
cd 5_cuda_nvidia_driver/nvidia_driver && sudo rpm -ivh --nodeps --force *.rpm
cd 6_docker_nvidia_toolkit && sudo rpm -ivh --nodeps --force *.rpm

IODD 외장하드: 물리적 소프트웨어 배송

RPM 패키지, Docker 이미지, 모델 파일 — 이 모든 것을 고객사에 어떻게 전달할까?

암호화된 외장하드(IODD)로 물리적으로 가져간다.

IODD는 하드웨어 레벨 암호화를 지원하는 외장 스토리지다. 장비 자체에 PIN 패드가 있어서, 비밀번호를 입력하지 않으면 마운트 자체가 안 된다. 그리고 IODD에서 다른 USB 클로저로 하드를 빼서 옮기면 인식이 불가능하다. 물리적으로 복제할 수 없는 구조다.

Admin   → 전체 접근 + 삭제 권한
User1   → 읽기 전용 (삭제 불가)

관리자와 설치 기사의 권한이 분리되어 있어서, 설치 현장에서 실수로 파일이 삭제되는 것도 방지한다.

이런 물리 보안 장비를 써야 하는 이유는 간단하다. 금융사의 데이터 반입 절차가 그만큼 엄격하기 때문이다. USB 메모리나 일반 외장하드는 반입 자체가 불허되는 경우가 많고, 암호화 인증이 된 장비만 허용된다.


세 번째 벽: GPU 드라이버 호환성

RPM을 순서대로 넣으면 끝이라고 생각했다. 하지만 GPU 드라이버는 다른 차원의 문제였다.

에러 1: dkms 버전 불일치

nothing provides dkms >= 3.1.8 needed by 
  kmod-nvidia-latest-dkms-3:575.57.08-1.el9.x86_64

NVIDIA 드라이버가 dkms 3.1.8 이상을 요구하는데, RHEL 9 기본 레포에 있는 dkms 버전이 그보다 낮았다. 인터넷이 있었다면 dnf install로 최신 버전을 받으면 끝이지만, 폐쇄망에서는 그럴 수 없다.

해결: Fedora EPEL에서 정확한 버전(dkms-3.2.1-1.el9.noarch.rpm)을 사전에 다운로드해서 RPM 패키지에 포함시켰다. 이후 모든 납품 패키지에 이 파일이 기본으로 들어간다.

에러 2: 커널 버전과 드라이버 버전 불일치

$ nvidia-smi
NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver.

이 에러를 처음 봤을 때는 당황했다. RPM은 전부 정상 설치됐고, rpm -qa | grep nvidia로 확인하면 패키지도 다 있다. 그런데 nvidia-smi가 동작하지 않는다.

원인을 추적해보니 커널 버전 문제였다.

커널 버전:   5.14.0-570.25.1.el9_6    (570)
드라이버 버전: 575.57.08                (575)

NVIDIA 드라이버는 현재 실행 중인 커널에 맞춰 커널 모듈을 빌드해야 한다. RPM으로 설치하면 패키지 자체는 들어가지만, 커널 모듈 빌드가 자동으로 트리거되지 않는 경우가 있다. 특히 --nodeps --force 옵션으로 설치했을 때.

해결: dkms로 수동 빌드.

sudo dkms build nvidia/575.57.08
sudo dkms install nvidia/575.57.08
sudo modprobe nvidia
nvidia-smi  # 이제 동작한다

이 경험 이후, 설치 스크립트에 dkms 빌드 → 모듈 로드 → nvidia-smi 검증을 자동으로 수행하는 단계를 추가했다. 사람이 기억에 의존해서 수동으로 실행하는 건 반드시 누락이 생긴다.

에러 3: 미니멀 OS에서 빌드 도구 부재

고객사마다 OS 설치 상태가 다르다. 어떤 곳은 풀 설치, 어떤 곳은 미니멀 설치. 미니멀 설치된 RHEL에서는 gcc, make 같은 기본 빌드 도구가 없어서 dkms 빌드 자체가 실패한다.

해결: 3_devtoolsDevelopment Tools 그룹의 RPM을 전부 포함시켰다. 설치 스크립트 첫 단계에서 무조건 넣는다.


Docker 이미지 전달: 분할 압축

NMT 엔진 Docker 이미지는 PyTorch 모델 파일을 포함하면 수십 GB에 달한다. IODD 외장하드의 용량 제한도 있고, 단일 파일이 너무 크면 파일시스템 제한에 걸리기도 한다.

# 이미지를 tar로 추출한 뒤 분할
docker save nmt_engine | split -b 4G - nmt_engine_a

# 고객사에서 복구
cat nmt_engine_a* > nmt_engine.tar
docker load -i nmt_engine.tar

모델 파일, SPM 토크나이저, 설정 파일, 환경 변수 파일, 그리고 시작/종료/상태확인 쉘 스크립트까지 — 전체 납품 패키지의 구성은 이렇다:

납품 패키지/
├── docker images/
│   ├── nmt_engine_a*    # 분할된 엔진 이미지
│   ├── nmt_gateway.tar       # Gateway/Admin
│   ├── nmt_db.tar       # Database
│   └── language_identifier_a*  # 분할된 언어감지 이미지
├── model/                      # PyTorch 모델 weight
├── spm/                        # SentencePiece 토크나이저
├── json/                       # 모델 설정 파일
├── env/                        # 환경 변수 파일
├── rpm/                        # 오프라인 설치용 RPM
├── start.sh                    # 전체 서비스 시작
├── shutdown.sh                 # 전체 서비스 종료
└── check_status.sh             # 상태 확인

Ansible로 자동화: 반나절에서 2시간으로

처음 두 고객사는 수동으로 설치했다. SSH 접속해서 RPM 하나씩 넣고, 에러 나면 대응하고, Docker 이미지 로드하고, 컨테이너 띄우고. 반나절이 걸렸다.

세 번째 고객사부터는 이 과정을 Ansible playbook으로 자동화했다.

자동화의 핵심은 멱등성(idempotency)이었다. 설치 도중에 네트워크가 끊기거나(폐쇄망이라 SSH도 내부망), 서버가 재부팅되거나 해도, playbook을 다시 돌리면 이미 완료된 단계는 건너뛰고 실패한 지점부터 재개되어야 했다. rpm -qa로 이미 설치된 패키지를 체크하고, nvidia-smi 출력으로 드라이버 상태를 확인하고, docker ps로 컨테이너 상태를 검증하는 식이다.

결과적으로 설치 시간이 반나절 → 2시간으로 줄었고, 더 중요한 건 설치 품질이 일정해졌다는 것이다. 사람이 수동으로 하면 "이번에는 3번 단계를 빼먹었다" 같은 실수가 반드시 생기는데, 자동화하면 그런 일이 없다.


RHEL 8 vs 9: 호환성 매트릭스

4개 고객사의 환경이 전부 달랐다.

고객사 OS GPU CUDA 특이사항
A사 RHEL 9 A100 12.x 풀 설치
B사 RHEL 8 H100 11.x 미니멀 설치
C사 RHEL 9 T4 12.x 커널 커스텀
D사 RHEL 9 T4 12.9 폐쇄망 + 보안 강화

OS 버전이 다르면 RPM 패키지가 다르다. GPU 모델이 다르면 드라이버 버전이 다르다. CUDA 버전이 다르면 PyTorch 호환성이 달라진다. 이 조합을 정리한 것이 호환성 매트릭스다.

각 조합별로 "이 RPM 세트 + 이 드라이버 + 이 CUDA"가 동작하는지 검증된 결과를 기록해두고, 새 고객사가 오면 매트릭스에서 가장 가까운 조합을 찾아 적용한다. 처음부터 시행착오를 반복할 필요가 없어진다.


돌아보며

이 프로젝트에서 가장 어려웠던 건 코드가 아니었다. 어댑터 패턴을 적용하는 건 설계 패턴 교과서에 나오는 내용이고, Docker 이미지를 빌드하는 것도 일상적인 작업이다.

진짜 어려웠던 건 "통제할 수 없는 환경"에서 소프트웨어를 동작시키는 것이었다.

클라우드에서는 문제가 생기면 인스턴스를 새로 띄우면 된다. terraform apply 한 번이면 동일한 환경이 재현된다. 하지만 고객사 서버는 내가 통제할 수 없다. OS 버전이 예상과 다르고, 커널이 커스텀되어 있고, 보안 정책 때문에 특정 포트가 막혀 있고, 그리고 무엇보다 인터넷이 없다.

이 제약 조건 아래서 "어떻게든 돌아가게" 만드는 과정은, 사실 내가 이전 직장에서 GPU 렌더팜 200대를 운영하던 때와 닮아 있었다. 그때도 물리 서버였고, 각 노드의 상태가 제각각이었고, 한 대가 죽으면 SSH로 들어가서 직접 고쳐야 했다.

클라우드 시대에 온프레미스 납품이라니, 시대를 역행하는 것 같다고 생각할 수 있다. 하지만 금융, 공공, 국방 — 데이터가 밖으로 나갈 수 없는 도메인은 여전히 존재하고, 오히려 AI 서비스의 온프레미스 수요는 늘어나고 있다. LLM이든 번역 엔진이든, "우리 데이터를 외부에 보내지 않고 AI를 쓰고 싶다"는 요구는 앞으로도 계속될 것이다.

그 수요를 충족시키려면 결국 누군가는 Docker 이미지를 외장하드에 담아서 가져가고, RPM 의존성 순서를 맞추고, 커널 버전과 GPU 드라이버를 일치시키는 일을 해야 한다.

그 "누군가"가 되어본 경험은, 클라우드만 경험한 엔지니어와는 다른 시각을 만들어줬다고 생각한다.

profile

kokbee-Hive

@콕비

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!