도커 컨테이너에서 멀티 프로세싱을 하면?
프로그래밍/데이터 엔지니어링

도커 컨테이너에서 멀티 프로세싱을 하면?

데이터 엔지니어링을 하다 보면 실제로 컴퓨팅 리소스를 최대한으로 사용할 수 있도록 멀티스레딩 혹은 멀티프로세싱을 사용한다. 더불어 요새는 국 룰이 돼버린 컨테이너를 활용해서 프로세스를 격리하고 배포, 테스팅을 훨씬 쉽게 진행할 수 있게 되었다.

그러다 문득 궁금한 점이 하나 생겼다. 만약 멀티 프로세싱을 구현했을 때 컨테이너에서는 어떻게 동작할까. 사실 도커를 사용한 지는 오래 됐지만, 그냥 단순하게 하나의 격리된 프로세스라고만 생각했었다. 그래서 본 질문에 대해 대답이 제대로 떠오르지 않았다.

 

  • 결과부터 이야기하면 컨테이너 내에서 Multi Process 동작은 한 컨테이너에서 호스트 머신의 CPU 자원을 최대한 활용하여 동작한다. 컨테이너의 네임스페이스 내부에서 멀티 프로세스들이 동작하며, 주어진 리소스 quota(cgroup)을 최대한 활용하며 동작하게 된다.
  • 도커 컨테이너는 결국엔 호스트 머신(혹은 가상 머신) 위에 동작하는 프로세스들을 네임스페이스, cgroup 등을 기준으로 격리한 것이라고 한다.
    참고 이미지 : https://dololak.tistory.com/351
    • 네임스페이스는 프로세스가 실행될 때 시스템의 리소스를 분리할 수 있도록 도와주는 기능이다. cgroup(호스트 자원), pid(프로세스), mount(볼륨) 등의 분리를 지원한다. 
    • cgroup은 자원에 대한 제어를 가능하게 해주는 기능이다. 즉 한 cgroup에서 memory, cpu, I/O 등을 제어할 수 있다는 이야기이다.
    • 컨테이너는 이를 통해 하나의 프로세스가 다른 프로세스에 영향을 끼치지 않도록 네임스페이스를 이용해 격리하고, 자원도 cgroup를 통해 할당할 수 있게 된다. 
    • 실제로 컨테이너에 접근해서 ps -all 을 돌려도 해당 컨테이너와 관련된 프로세스만 확인할 수 있다. 

  • 멀티 프로세스를 사용하는 애플리케이션을 컨테이너로 말게 되면, 보통 entrypoint에서 실행되는 프로세스가 pid가 1인 process가 되며, 거기서 포크되는 프로세스들은 ppid를 1로 가지며 실행된다. 그리고 해당 프로세스들은 같은 namespace, cgroup을 공유하게 된다. 
    • 보통 docker run을 할 때 실행되는 command가 pid가 1인 process가 된다.
    • 실제 컨테이너 런타임에 sh로 접근한 후 top 를 해보면 아래와 같이 프로세스가 나온다. 이 경우 다른 프로세스의 PPID는 전부 1이며 부모 process로부터 fork 되어 만들어지게 된다.
      $ docker run -it --rm busybox /bin/sh
      
      $ sleep 10s & sleep 30s & top
      
      PID  PPID USER     STAT   VSZ %VSZ CPU %CPU COMMAND
          1     0 root     S     1332  0.0   1  0.0 /bin/sh
         21     1 root     R     1324  0.0   1  0.0 top
         19     1 root     S     1312  0.0   0  0.0 sleep 10s
         20     1 root     S     1312  0.0   1  0.0 sleep 30s
       - 호스트 머신의 관점에서 이는 다 같은 프로세스이며, 그저 격리된 네임스페이스 안에 존재하는 프로세스일 뿐이다.

  • 파이썬 애플리케이션으로 멀티 프로세스를 구현한다면?
    • 파이썬에서 multiprocessing.cpu_count()를 사용하면 사용가능한 CPU 코어 개수를 반환한다.
      $ (container 내부) python -c 'import multiprocessing; print(multiprocessing.cpu_count())' 
      12 # 호스트 머신의 CPU 갯수를 가리킨다.​
      • 본 실험을 진행한 호스트 머신은 도커에서 cpu를 100% 활용하도록 설정하였다.
      • 도커 클라이언트 엔진 설정을 통해서도 available cpu를 정해줄 수 있으며 도커 컨테이너를 실행할 때 --cpuset-cpus flag를 주면 해당 컨테이너에 CPU 코어 수를 할당해줄 수 있다.
    • 멀티 프로세싱을 하는 코드를 구현한다
      from concurrent.futures import ProcessPoolExecutor
      import multiprocessing as mp
      import logging
      import sys
      import psutil
      
      def get_logger():
          logger = logging.getLogger()
          if not logger.hasHandlers():
              handler = logging.StreamHandler(sys.stdout)
              formatter = logging.Formatter("[%(process)d/%(processName)s] %(message)s")
              handler.setFormatter(formatter)
              handler.setLevel(logging.DEBUG)
              logger.addHandler(handler)
              logger.setLevel(logging.DEBUG)
          return logger
      
      def func(n):
          get_logger().debug(f"number: {n}, cpu_num: {psutil.Process().cpu_num()}")
      
      if __name__ == "__main__":
          with ProcessPoolExecutor(max_workers=mp.cpu_count()) as executor:
              for n in range(1,100):
                  executor.submit(func, n)​
    • docker build 후 run을 해보면 아래와 같은 로그가 찍힌다. 보통 하나의 프로세스는 하나의 cpu 코어에 할당되어 수행되는데, 결과를 보면 process 별로 cgroup에 할당된 cpu 자원을 활용하고 있는 걸 확인할 수 있다.
      [16/ForkProcess-9] number: 89, cpu_num: 4
      [14/ForkProcess-7] number: 88, cpu_num: 11
      [11/ForkProcess-4] number: 87, cpu_num: 0
      [18/ForkProcess-11] number: 90, cpu_num: 1
      [19/ForkProcess-12] number: 91, cpu_num: 6
      [10/ForkProcess-3] number: 92, cpu_num: 8
      [13/ForkProcess-6] number: 93, cpu_num: 4
      [12/ForkProcess-5] number: 94, cpu_num: 10
      [17/ForkProcess-10] number: 95, cpu_num: 7
      [9/ForkProcess-2] number: 97, cpu_num: 3
      [15/ForkProcess-8] number: 96, cpu_num: 3
      [8/ForkProcess-1] number: 98, cpu_num: 6
      [16/ForkProcess-9] number: 99, cpu_num: 4​
  • docker 공식 문서에서는 한 컨테이너에서 여러 프로세스를 구현하기보단, 싱글 프로세스 - 멀티 컨테이너 전략을 사용하라고 한다.
    • 하나의 컨테이너에서 멀티 프로세싱을 하게 되면, 컨테이너 특성상 디버깅과 테스트하기가 힘들다고 한다. 그도 그럴 것이 컨테이너에 문제가 생기면 돌고 있던 자식 프로세스들도 함께 영향을 받을 것 같음. 또한 E2E 테스트를 하게 됐을 때 사이드 이펙트도 무시할 수 없을 것 같다.
    • 그래도 만약에 하게 된다면 process들을 managing해줄 수 있는 supervisord 같은 프로그램을 entrypoint에서 실행해 주는 게 좋다는 글도 있음
    • 하지만 케바케라는 생각도 들음. 만약 1 process 1 container라면 격리된 컨테이너의 특성상 이들의 진행을 중재, 관리해주는 컨테이너(POD)가 별도로 존재해야 해서 복잡도가 올라갈 것 같다는 생각이 들었다. 또한 단순히 병렬적인 프로세스 운영이 아닌, 커플링이 강하게 된 두 프로세스를 띄워야 할 때는 불가피하지 않을까? 하는 생각도 든다.

 

[참고]

http://cloudrain21.com/examination-of-docker-process-binary

https://tech.ssut.me/what-even-is-a-container/

https://www.44bits.io/ko/keyword/linux-namespace

https://stackoverflow.com/questions/38519223/can-we-run-multi-process-program-in-docker