Python Daemon 환경에서 pymongo OS fork safety 문제 해결기

안녕하세요. 실시간 결제 데이터를 관리하고 있는 주니어 개발자입니다.

최근 실시간 데이터를 연동하는 시스템을 개편하는 과정에서 발생했던 Pymongo OS fork safety 문제에 대해 글을 작성 해보려고 합니다.

이 글을 읽는 예상 독자는 파이썬 환경에서 소켓을 데몬화하여 MongoDB 를 제어하는 개발자입니다.

배경

현재 실시간 데이터를 수신 하기 위해 Python 의 socket 라이브러리를 활용하여 서버를 구성하고 있습니다.

접근하는 클라이언트가 다양해짐에 따라 데이터를 처리하는 비즈니스 로직을 추상화 하고, 구현 부분의 확장성을 최대한 확보하고자 리팩터링을 시작하였습니다.

첫번째로 직면했던 문제는 1개의 포트에 1개의 고객사만 접근이 가능한 구조에서 불 필요한 중복 코드가 계속 늘어나고 있음에서 불편함을 느꼈습니다.

예시)

이러한 상황에서 폴더 구조는 아래와 같이 형성됩니다.

src
  - a_main.py
  - schema
    - a_1.json
    - a_2.json
    - a_3.json
  - utils
    ...

이 구조에서 고객사 “A”의 데이터를 처리하기 위해 구성된 폴더 구조는 앞으로 마주하게 될 “B” 고객사도 동일하게 늘어나게 됩니다.

src
  - a_main.py
  - b_main.py
  - schema
    - a_1.json
    - a_2.json
    - a_3.json
    - b_1.json
    - b_2.json
    - b_3.json
  - utils
    ...

그 다음 C, D, E 고객사 또한 마찬가지로 늘어나는 파일이 많고 데이터 처리하는 공통적인 비즈니스 로직이 변경되면 파일을 찾아다니며 수정하는 불편한 상황이었죠.

두번째 문제는 테스트 환경이 굉장히 열악했던 점입니다.

위 처럼 고객사 별로 구성된 파일은 하나의 파이썬 파일 내 모든 비즈니스 로직이 몰려있거나, 유틸 파일에 모두 몰려있습니다.

데이터가 잘 처리가 되었는지 확인하기 위해 클라이언트 파일을 임의로 구성하여 직접 소켓 서버에 접근하여 데이터를 송신합니다.

그리고 결과 로그를 눈으로 직접 확인하여 이슈 사항을 체크 했는데, 사람이 직접 확인 하다보니 놓치는 경우가 생겨서 운영 데이터에 영향을 준 사례도 있었죠.

그래서, 이 불합리한 구조를 개선하기 시작합니다.

데이터 처리 하는 계층을 나누고 레이어드 아키텍처를 도입하여 데이터를 수신 받는 계층, 파싱 하는 계층, 변환 하는 계층으로 나누면서 고객사별로 설정해야 하는 외부 요소들을 컨테이너화 합니다.

유사 스프링이 되어가는 과정이었죠.

시간을 들여 개선이 완료 되었고, 앞으로는 한 개의 파일에서 명령줄 인자를 이용해 여러 소켓 서버를 관리할 수 있게 되었습니다.

그리고, 컨테이너 구성만 변경하면 다른 고객사가 확장 되었을 때 구현 클래스만 바꿔주면 되어 개발 하는데 더 편리해졌습니다.

게다가, 모듈 하나에서 모든게 관리 되던 환경이 분리 되면서 책임이라는 것이 부여 되고 각자의 역할을 순수하게 자동화된 테스트를 할 수 있게 되었죠. 👏👏👏

더 나은 환경에서 개발 하시는 분들은 모르겠지만, 제겐 프레임워크 없이 순수한 파이썬과 스탠다드 라이브러리로 이런 환경을 구성했다는게 뿌듯했습니다.

그럼에도 불구하고 이 글을 작성하는 이유와도 같은 Pymongo OS fork safety 문제를 여기서 직면하게 됩니다.

문제 상황

소켓 서버에서 사용하게 되는 메타 데이터들을 최신화 하기 위해 매일 새벽에 한 번씩 프로세스 리프팅을 진행 합니다.

어느 날부터 포트 다운 알람이 새벽에 오기 시작하고, 데이터를 수신 받지 못하는 상황이 1달에 1번 나올까 말까 한 상황이 발생하였습니다.

식은땀을 흘리며 로그를 확인 해본 결과 아래와 같은 에러가 발생하고 프로세스는 다운 되었죠.

Traceback (most recent call last):
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/pool.py", line 789, in command
    write_concern=write_concern,
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/network.py", line 150, in command
--
    return self.db[collection].insert_one(raw_data)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/collection.py", line 635, in insert_one
    comment=comment,
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/collection.py", line 569, in _insert_one
    self.__database.client._retryable_write(acknowledged, _insert_command, session)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/mongo_client.py", line 1476, in _retryable_write
    return self._retry_with_session(retryable, func, s, None)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/mongo_client.py", line 1349, in _retry_with_session
    return self._retry_internal(retryable, func, session, bulk)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/_csot.py", line 105, in csot_wrapper
    return func(self, *args, **kwargs)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/mongo_client.py", line 1390, in _retry_internal
    return func(session, sock_info, retryable)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/collection.py", line 564, in _insert_command
    retryable_write=retryable_write,
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/pool.py", line 795, in command
    self._raise_connection_failure(error)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/pool.py", line 953, in _raise_connection_failure
    _raise_connection_failure(self.address, error)
  File "/home/them/.local/lib/python3.7/site-packages/pymongo/pool.py", line 262, in _raise_connection_failure
    raise AutoReconnect(msg) from error
pymongo.errors.AutoReconnect: tm-seoul-rsms-stg-mongodb:27017: [Errno 9] Bad file descripto

데이터를 수신 받아야 하니 일단 무작정 다시 프로세스를 수행 시키고 봅니다.
그러자 정상적으로 소켓이 바인딩 되고 클라이언트의 접근을 기다리고 있습니다. 🤔

1차 문제 해결 시도

정말 한 번 나올까 말까한 문제를 어떻게 재현 해낼까? 고민하다가 스테이징 환경에서 프로세스를 무한 재실행 하고, 포트 다운 될 때 알람을 받고, 소켓 관련 에러를 모두 파일 로그에 남기도록 설정 하였습니다.

시간이 굉장히 들쑥날쑥했고, 다시 프로세스를 띄웠을 때 정상적으로 수행되는 것으로 보아 1차원적으로 “systemctl 로 자동 restart 되도록 설정해서 해결 해보자.” 라는 생각으로 시스템 등록을 하니 다른 문제가 생겼죠.

여기서, Pymongo fork safety 문제가 발생합니다.

2차 문제 해결 시도

systemctl 명령어로 프로세스를 재수행 하면 발생하는 에러라는 것은 인지가 되었으나 아직 단정 짓기는 일러 파이썬으로 직접 실행 해봤는데요.

파이썬 스크립트로 실행 했을 때는 정상적으로 수신이 가능한 서버가 열렸고, 그 후 유지도 되는 모습인 반면 시스템 컨트롤로 수행 하는 경우 바로 종료되어 프로세스를 무한으로 재실행 하게 됩니다.

systemctl 과 python 스크립트 간 실행 방식에 대한 차이를 알아볼 필요가 있었습니다.

Linux 에서 systemctl 은 systemd를 관리하는 명령어입니다.

Python 스크립트는 Linux shell 을 바로 이어받아 실행하게 됩니다.

이 차이를 알고 나니 문제가 생긴 부분이 어딘지 알 것만 같습니다.
현재 파이썬의 소켓 서버는 python-daemon 라이브러리를 이용하여 자체적으로 데몬화를 하고 있습니다.

이로인해, 이중 데몬 프로세스가 발생 했다고 보여집니다.

문제 코드

class MainReceiver:
    def __init__(self):
        self.container: Optional[Container] = None
        self.args: Optional[Namespace] = message_receiver_arguments.parse_args()
        self.log: Optional[Logger] = None
        self.container_log: Optional[ContainerLog] = None

    def __enter__(self):
        self.container = ContainerFactory.create_container(self.args)
        self.container.wire(modules=[__name__])
        self.container.init_resources()  # 문제 지점
        self.container_log = ContainerLog(self.container)

        self.log: Logger = logging.getLogger(__name__)

        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.container:
            self.container.shutdown_resources()

    def run(
            self,
    ) -> Callable:
        socket_daemon = self.container.core.socket_daemon()
        runner: Dict[str, Callable] = {
            "start": socket_daemon.start_daemon,
            "stop": socket_daemon.stop_daemon,
            "restart": socket_daemon.restart_daemon,
        }

        if self.args.oper == "start":
            self.container_log.log_container_dependencies()

        server_status: Optional[Callable] = runner.get(self.args.oper, self._exception)
        self.log.info(f"Connect to {server_status.__name__}")

        return server_status()

    def _exception(self):
        self.log.error(f"realtime_receiver_args.oper not found {self.args.oper}")
    def start_daemon(self) -> None:
        pid_lock_file = pidfile.TimeoutPIDLockFile(os.path.join(self.config.pid_file_path,
                                                                self.receiver_schema["pid_file_name"]))

        if pid_lock_file.is_locked():
            raise OSError(f"Running already process: {pid_lock_file.read_pid()}")

        with daemon.DaemonContext(
                working_directory=self.config.pid_file_path,
                umask=0o002,
                pidfile=pid_lock_file,
                files_preserve=self.stream_list,
                stderr=open(error_log_path, "a")
        ):
            self.receiver_service.run_server()  # 문제 지점

위 코드에서 DaemonContext 로 자체적으로 데몬화를 진행하고 있고, systemctl 도 데몬으로 프로세스를 관리했죠.

또한, DaemonContext 이전에 컨테이너 객체가 전부 메모리에 올라오게 되면서 자식 프로세스로 fork 된 이전 객체를 fork 후 사용하려고 하다보니 근본적인 Bad file descriptor 에러 발생 이유를 알게 됩니다.

이 문제를 해결하기 위해서는 컨테이너 객체의 지연 로딩으로 DaemonContext 이후 객체를 생성하도록 수정하면 됩니다.

1차 문제 해결하려고 했던 당시 가끔 재실행 되었던 Bad file descriptor 에러가 해결되면 systemctl 로 실행할 필요가 사라져 이중 데몬 문제도 사라집니다.

        with daemon.DaemonContext(
                working_directory=self.config.pid_file_path,
                umask=0o002,
                pidfile=pid_lock_file,
                files_preserve=self.stream_list,
                stderr=open(error_log_path, "a")
        ):
            daemon_pid = os.getpid()
            self.log.info(f"Daemon process successfully created with PID: {daemon_pid}")

            try:
                self.log.info("Attempting to initialize container resources (e.g., DB connection)...")
                self.container.init_resources()
                self.log.info("Container resources initialized successfully.")

                receiver_service = self.container.realtime_message_receiver_service()
                receiver_service.run_server()

            except Exception as e:
                self.log.error(e, exc_info=True)

최종적으로는 위와 같이 DaemonContext 이후에 컨테이너를 초기화 하고, 필요한 객체를 초기화 하도록 수정하였습니다.

이렇게 해결하기 어려웠던 문제가 완전히 해결되는 순간을 경험하게 됩니다.

마무리하며

위 에러를 해결하기 위해 GPT 같은 모델을 이용하여 많이 물어봤음에도 돌아오는 답변은 “python 의 c 모듈 thread 문제야” 라는 내용이 대부분이었는데요.

청천벽력과도 같은 답을 들은 저로서는 압도당하게 되었으나, 결코 문제의 근본적인 원인이 무엇일지 파악해보려면 작은 것을 여러번 시도 해보면서 문제를 “재현” 하는 것을 목표로 삼아야 했습니다.

어떤 방식으로 해결할지 고민은 문제를 재현부터 한 뒤였습니다.
“항상 어떻게 해결하지” 를 먼저 고민 했던 방식이 이번 사례에서 굉장히 안좋게 작용했었던 것 같습니다.

마지막으로 AI 와 같은 시대를 지내는 개발자로서 그들이 주는 답변을 어떻게 판단할지는 아직 인간의 역량을 필요로 하는 것 같습니다.

더욱더 기본기가 중요시 될 것이라고 생각을 하게 되었습니다.

감사합니다.

참고 자료

[Linux] systemctl 명령어