데이터베이스를 쓸 수 없어서 파일시스템을 골랐다
사용자가 올린 파이썬을 런타임에 불러오려면 그 코드는 디스크의 진짜 파일이어야 한다. 그 하나의 제약에서 다섯 달치 버그가 나왔다.
이 엔진이 하는 일은 한 문장이다. 사용자가 작성한 파이썬 파일을 받아서, 서버를 재시작하지 않고, 지금 뜨는 API로 만든다.
그래서 상태를 어디에 둘지에 선택지가 없었다. 코드를 데이터베이스에 넣을 수 없다. importlib은 컬럼을 읽지 않는다. 디스크 위의 진짜 파일이 있어야 모듈이 된다.
문서에는 이렇게 한 줄로 남아 있다.
핵심 로직에 관계형 데이터베이스를 쓰지 않고 파일시스템에 영속화한다 — 동적 모듈 로딩을 가능하게 하기 위해.
이 문장은 아키텍처 결정처럼 보이지만, 실제로는 청구서였다. 이후 다섯 달 동안 올라온 버그의 대부분이 여기서 파생됐다.
모듈은 네 군데에 동시에 존재한다
문제를 이해하려면 파일 하나가 API가 되기까지 몇 군데를 거치는지 봐야 한다.
노드에 커서를 올리면 연결된 것만 남는다
일반적인 웹 서버라면 이 중 어느 것도 런타임에 변하지 않는다. 여기서는 넷 다 변한다. 그리고 넷은 서로의 상태를 모른다.
실행 중엔 되고, 재기동하면 깨진다
사용자가 같은 디렉터리에 파일 두 개를 두고 한쪽에서 다른 쪽을 불러왔다.
# routes/{에이전트}/sandboxes/bbb.py
from aaa import example_var서버가 떠 있는 동안은 잘 됐다. 재기동하면 이렇게 됐다.
ModuleNotFoundError: No module named 'aaa'원인은 sys.path였다. 감시 대상인 상위 디렉터리만 검색 경로에 넣고, 모듈이 실제로 놓인 디렉터리는 넣지 않았다. 실행 중에는 앞선 등록 과정의 부수효과로 우연히 경로가 맞아떨어졌고, 재기동하면 그 우연이 사라졌다.
고친 건 세 줄이다. 모듈 자신의 디렉터리도 검색 경로에 넣는다.
등록 순서가 싱글톤을 둘로 만든다
더 고약한 건 이거였다. 라우터 모듈 A와 B가 공통으로 서비스 모듈 C를 불러오고, C는 싱글톤이었다.
등록 코드는 모듈을 무조건 다시 읽고 있었다.
if normalized_module_name in sys.modules:
del sys.modules[normalized_module_name] # 있으면 지우고 새로 만든다파일을 수정했을 때 반영되게 하려는 의도였다. 그런데 sys.modules에서 이름을 지우면 그 다음 import 는 새 객체를 만든다. 이미 그 객체를 참조하고 있던 쪽은 옛 객체를 계속 쥔다.
01A 가 C 를 불러온다. C 가 처음으로 적재되고 싱글톤 인스턴스 하나가 만들어진다. A 는 그것을 참조한다.
싱글톤은 "하나뿐"이라는 약속인데, 그 약속이 등록 순서에 의해 깨졌다. A → B → C 순서였다면 아무 일도 없었다.
고친 방향은 반대로 뒤집는 것이었다. 이미 적재된 모듈은 재사용하고, 다시 읽는 것은 파일이 실제로 수정됐을 때만 한다.
컨테이너가 자기 모듈을 잃어버린다
세 번째는 파이썬 바깥에서 왔다.
ModuleNotFoundError: No module named 'utils.commons.debugger'이 패키지는 사용자 코드가 아니라 이미지에 들어 있는 공용 유틸리티다. 로컬에서는 당연히 있었다. 쿠버네티스에 올리자 사라졌다.
공유 볼륨을 이미지의 같은 경로에 마운트하고 있었다. 마운트는 그 경로의 원래 내용을 가린다. 이미지에 분명히 들어 있는 파일이, 볼륨이 붙는 순간 없는 것이 된다.
해결은 배포 쪽이었다. 초기화 컨테이너가 이미지의 공용 패키지를 볼륨으로 복사한 뒤, 편집 서비스 쪽에는 읽기 전용으로 붙였다. 코드는 한 줄도 안 고쳤다.
세 버그의 오류 메시지는 거의 같다. ModuleNotFoundError. 그런데 원인은 각각 검색 경로, 모듈 캐시, 볼륨 마운트였다. 같은 예외가 서로 다른 층에서 난다.
땜빵 세 개, 그리고 방향 전환
중복 등록 문제는 한동안 국소 수정으로 버텼다. 중복 검사 로직을 간소화하고, 중복이어도 라우터가 있으면 통과시키고, 그 수정이 낳은 회귀를 같은 날 핫픽스로 막았다. 며칠 사이의 일이다.
넉 달 뒤에 방향을 바꿨다. 파일을 하나씩 "읽어서 실행하고 라우터를 꺼내는" 단일 루프를 버리고, 두 단계로 쪼갰다.
1단계. 모든 파일을 컴파일하고, 빈 모듈 객체를 만들어 sys.modules에 등록한다. 실행하지 않는다.
2단계. 등록된 모듈들을 실행하고 라우터를 꺼낸다.
이렇게 하면 두 가지가 생긴다. 첫째, 2단계가 시작될 때 모든 모듈 이름이 이미 sys.modules에 있으므로 import 는 처리 순서와 무관하게 이름을 찾는다. 둘째, 컴파일이 전부 끝난 뒤에 실행이 시작되므로 세 번째 파일의 문법 오류가 앞선 두 파일을 절반쯤 배포된 상태로 남기지 않는다.
2단계가 실제로 보장하는 것
여기서 멈추지 않고 재봤다. 순서 문제가 정말로 사라졌는지 확인하려고 같은 구조를 스무 줄로 재현했다. 컴파일해서 빈 모듈로 등록만 하고, 실행 순서만 바꿔가며 돌렸다.
그러니까 2단계 로딩이 해결한 것은 이름 해석이지 실행 순서가 아니다. 모듈 최상단에서 from X import name 을 쓰면 여전히 순서를 탄다. 사용자가 import X 를 쓰고 속성 접근을 함수 안으로 미루면 순서가 사라진다.
이건 사용자가 어떻게 쓰느냐에 달린 문제이고, 우리가 강제할 수 있는 게 아니다.
남은 것
sys.modules 는 프로세스 전역 가변 상태다. 우리는 그걸 배포 상태 저장소처럼 쓰고 있다. 키를 지우면 남이 쥔 참조가 낡고, 안 지우면 수정이 반영되지 않는다. 이 둘 사이에 안전한 지점은 없고, 우리가 고른 건 "파일이 실제로 바뀌었을 때만 지운다"는 타협이다. 그 판단이 틀리면 두 증상 중 하나가 돌아온다.
같은 예외가 네 층에서 난다. ModuleNotFoundError 하나를 보고 검색 경로인지, 모듈 캐시인지, 볼륨 마운트인지, 등록 순서인지 알 방법이 로그에 없다. 층마다 다른 진단 메시지를 붙이는 일은 아직 안 했다.
되돌아보면 진짜 결정은 "데이터베이스를 안 쓴다"가 아니었다. 사용자 코드를 프로세스 안에서 import 하기로 한 것이다. 별도 프로세스나 컨테이너에서 실행했다면 이 글의 버그는 전부 없었을 것이고, 대신 호출 지연과 자원 격리 비용을 냈을 것이다. 그 교환을 다시 계산해 본 적은 없다. 지금은 이 방식이 감당 가능한 범위 안에 있다고 보고 있을 뿐이다.