Docker 안에 R + Bioconductor 넣기 — 4GB 이미지와의 싸움
Python 백엔드에서 R의 limma, clusterProfiler, fgsea를 호출하기 위해 Docker에 R + Bioconductor를 넣은 과정. rpy2 S4 객체 변환 실패, ContextVar 스레드 에러, 4.13GB 이미지 최적화 삽질기.
왜 Docker에 R을 넣어야 했나
BioAI Market의 Differential Expression 분석에서 limma는 빠질 수 없는 도구다. Empirical Bayes moderated t-test를 제공하는 R 패키지인데, Python에는 동등한 구현체가 없다. Pathway 분석에 필요한 clusterProfiler와 fgsea도 마찬가지.
백엔드는 Python(FastAPI)으로 작성했으니, 선택지는 두 가지였다:
- R 스크립트를 subprocess로 호출
- rpy2로 Python 안에서 직접 R 호출
subprocess는 데이터 직렬화/역직렬화 오버헤드가 크고 에러 핸들링이 어려워서, rpy2를 선택했다. 그리고 이 선택이 일주일간의 삽질로 이어졌다.
Dockerfile — 4.13GB의 탄생
최종 Dockerfile의 핵심 부분이다:
FROM debian:13-slim
# System dependencies (R 그래픽 라이브러리 포함)
RUN apt-get update && apt-get install -y \
r-base=4.5.0* \
python3.11 python3.11-dev python3-pip \
libcairo2-dev libfreetype6-dev libfontconfig1-dev \
libcurl4-openssl-dev libssl-dev libxml2-dev \
&& rm -rf /var/lib/apt/lists/*
# R packages
RUN R -e "install.packages('BiocManager', repos='https://cran.r-project.org')" \
&& R -e "BiocManager::install(version='3.22')" \
&& R -e "BiocManager::install(c('limma', 'clusterProfiler', 'fgsea', 'enrichplot'))" \
&& R -e "BiocManager::install(c('org.Hs.eg.db', 'org.Mm.eg.db', 'org.Rn.eg.db'))"
# Python packages
RUN pip3 install rpy2 fastapi uvicorn numpy pandas scipy
빌드 후 이미지 크기를 확인했을 때 충격을 받았다:
$ docker images
REPOSITORY TAG SIZE
bioai-backend latest 4.13GB
4.13GB. 대부분은 Bioconductor annotation DB 때문이었다:
org.Hs.eg.db(Human) — ~800MBorg.Mm.eg.db(Mouse) — ~600MBorg.Rn.eg.db(Rat) — ~400MB
이 세 패키지만 1.8GB. 하지만 Human/Mouse/Rat 3종을 지원해야 하니 빼는 것도 아니었다.
ReactomePA — 설치 실패로 제외
원래 Pathway 분석에 ReactomePA도 쓰려고 했다. 하지만 설치에서 실패했다:
ERROR: dependency 'reactome.db' is not available for package 'ReactomePA'
* removing '/usr/local/lib/R/site-library/ReactomePA'
reactome.db가 Bioconductor 3.22에서 빌드 실패하는 이슈였고, 해결하려면 소스를 직접 컴파일해야 했다. 이미지 크기가 5GB를 넘을 게 뻔해서 과감히 제외했다. GO + KEGG만으로도 충분했다.
rpy2 S4 객체 변환 실패 — 첫 번째 벽
rpy2로 limma를 호출하는 건 간단할 거라 생각했다. 전혀 아니었다.
import rpy2.robjects as ro
from rpy2.robjects import pandas2ri
pandas2ri.activate()
# limma 실행
ro.r('''
library(limma)
fit <- lmFit(expr_matrix, design)
fit <- eBayes(fit)
''')
# 결과 추출 시도
fit = ro.globalenv['fit']
results = fit.rx2('coefficients') # S4 객체 접근
에러:
rpy2.rinterface_lib.sexp.NACharacterType: NA
NotImplementedError: Conversion of S4 object to Python is not supported
limma의 MArrayLM 객체는 S4 클래스인데, rpy2의 자동 변환이 S4를 제대로 처리하지 못했다. coefficients, p.value 등을 하나씩 꺼내려고 해도 중첩된 S4 구조에서 변환이 깨졌다.
해결: ro.r()로 R 코드를 직접 실행하고 결과를 data.frame으로 변환
# ❌ Python에서 S4 객체 직접 접근 — 실패
fit = ro.globalenv['fit']
coefficients = fit.rx2('coefficients') # NotImplementedError
# ✅ R 안에서 처리하고 data.frame으로 꺼내기 — 성공
ro.r('''
tt <- topTable(fit, coef=2, number=Inf, sort.by="none")
result_df <- data.frame(
protein = rownames(tt),
logFC = tt$logFC,
adj.P.Val = tt$adj.P.Val,
t = tt$t,
B = tt$B
)
''')
# data.frame은 변환이 잘 됨
result_df = ro.globalenv['result_df']
pandas_df = pandas2ri.rpy2py(result_df)
교훈: rpy2에서 복잡한 R 객체를 Python으로 가져오지 말고, R 안에서 단순한 data.frame으로 만든 뒤 가져와라.
rpy2 ContextVar 스레드 에러 — 두 번째 벽
단일 요청에서는 잘 돌아갔다. 하지만 FastAPI가 여러 요청을 동시에 처리하면 이런 에러가 터졌다:
rpy2.rinterface_lib.embedded.RRuntimeError:
Error in context_get(key): ContextVar used in wrong thread
rpy2는 내부적으로 ContextVar를 사용하는데, 이게 Python 스레드 간에 공유되지 않아서 생기는 문제였다. FastAPI의 async 핸들러가 스레드풀에서 실행될 때 컨텍스트가 꼬였다.
해결: localconverter(default_converter) 컨텍스트 매니저 사용
from rpy2.robjects.conversion import localconverter, default_converter
from rpy2.robjects import pandas2ri
import threading
_r_lock = threading.Lock()
def run_limma(expr_df, groups):
with _r_lock: # R은 thread-safe하지 않으므로 락 필요
with localconverter(default_converter + pandas2ri.converter):
# 이 블록 안에서만 변환이 활성화됨
ro.globalenv['expr'] = pandas2ri.py2rpy(expr_df)
ro.r('''
library(limma)
design <- model.matrix(~0 + factor(groups))
fit <- lmFit(as.matrix(expr), design)
fit <- eBayes(fit)
result_df <- topTable(fit, coef=2, number=Inf, sort.by="none")
''')
result = pandas2ri.rpy2py(ro.globalenv['result_df'])
return result
localconverter가 스레드-로컬 컨텍스트를 만들어서 ContextVar 충돌을 방지한다. 추가로 threading.Lock()으로 R 호출 자체를 직렬화했다. R 자체가 thread-safe하지 않기 때문이다.
enrichKEGG와 ENTREZ ID — 세 번째 벽
Pathway 분석에서 enrichKEGG를 호출했더니:
Warning message:
No gene can be mapped....
--> Expected input gene ID: ENTREZ gene ID
프로테오믹스 데이터에는 gene symbol(예: TP53, BRCA1)이 들어있는데, KEGG는 ENTREZ ID(숫자)를 요구했다. 변환이 필요했다.
# Gene Symbol → ENTREZ ID 변환
library(clusterProfiler)
library(org.Hs.eg.db)
gene_list <- c("TP53", "BRCA1", "EGFR", "MYC")
converted <- bitr(gene_list,
fromType = "SYMBOL",
toType = "ENTREZID",
OrgDb = org.Hs.eg.db)
# 변환 결과
# SYMBOL ENTREZID
# 1 TP53 7157
# 2 BRCA1 672
# 3 EGFR 1956
# 4 MYC 4609
# 이제 enrichKEGG 호출 가능
kegg_result <- enrichKEGG(gene = converted$ENTREZID,
organism = 'hsa',
pvalueCutoff = 0.05)
organism별로 다른 OrgDb를 사용해야 한다:
- Human →
org.Hs.eg.db - Mouse →
org.Mm.eg.db(organism = 'mmu') - Rat →
org.Rn.eg.db(organism = 'rno')
이래서 3개 annotation DB가 다 필요했던 것이다.
시스템 의존성 — 숨은 복병들
R 패키지가 설치는 되는데 런타임에 에러가 나는 경우가 있었다. 대부분 시스템 라이브러리 누락이었다:
Error in Cairo(...): unable to load shared object 'cairo.so'
R의 그래픽 기능(plot, enrichplot 등)이 Cairo를 필요로 했다. 최종적으로 필요한 시스템 패키지들:
RUN apt-get install -y \
libcairo2-dev \ # Cairo 그래픽
libfreetype6-dev \ # 폰트 렌더링
libfontconfig1-dev \ # 폰트 설정
libcurl4-openssl-dev \ # httr (HTTP)
libssl-dev \ # openssl
libxml2-dev # XML 파싱
이거 하나하나 에러 메시지 보면서 추가한 것이다. Dockerfile 한 줄을 고치고 rebuild하면 또 20분... 이 과정이 가장 고통스러웠다.
docker commit — 현실적인 타협
Dockerfile을 수정할 때마다 Bioconductor 패키지를 처음부터 다시 설치하는 건 미친 짓이었다. R 패키지 설치만 30분이 걸린다.
결국 docker commit으로 현재 상태를 이미지로 저장하는 방식을 택했다:
# 컨테이너에서 수동으로 패키지 설치/설정 완료 후
docker commit bioai-container bioai-backend:v1.2
# 나중에 이 이미지로 시작
docker run -d --name bioai --gpus all bioai-backend:v1.2
Dockerfile의 재현성(reproducibility)을 포기한 것이다. 하지만 매번 30분씩 빌드하는 것보다 현실적이었다. Dockerfile은 "레퍼런스"로 유지하되, 실제 배포 이미지는 commit으로 관리했다.
최종 이미지 구성
bioai-backend:latest — 4.13GB
├── Debian 13 slim (~80MB)
├── Python 3.11 (~120MB)
├── R 4.5.0 (~400MB)
├── Bioconductor 3.22 (~800MB)
├── Annotation DBs (~1.8GB)
│ ├── org.Hs.eg.db
│ ├── org.Mm.eg.db
│ └── org.Rn.eg.db
├── R packages (~600MB)
│ ├── limma, clusterProfiler, fgsea
│ └── enrichplot, AnnotationDbi
├── Python packages (~200MB)
│ └── rpy2, fastapi, numpy, pandas, scipy
└── System libs (~130MB)
4GB가 무겁긴 하지만, 이 안에 Python + R + Bioconductor 전체 분석 파이프라인이 들어있다고 생각하면 나쁘지 않다.
참고 링크: