# Introduction to containers

컨테이너란 코드와 해당 종속성들을 감싸는 보이지 않는 상자(invisible box)이다. 컨테이너는 자신만의 파일 시스템 및 하드웨어 파티션에 제한된 접근 권한만 가지고 있다. 컨테이너 생성은 단 몇 개의 시스템 호출만으로도 가능하고 프로세스 수준으로 빠르게 시작된다.

각 서버(호스트 머신)에는 컨테이너 기능을 지원하는 OS 커널과 컨테이너 런타임만(컨테이너를 실행할 수 있는 런타임 도구 - Docker 등) 있으면 충분하다. 컨테이너는 운영체제나 필요한 소프트웨어 환경까지 하나의 패키지처럼 묶어두는 방식이기 때문에 이를 가상화 및 격리된 상태로 제공하는 것이다.

컨테이너를 통해 PaaS(Platform as a Service)의 확장성과 IaaS(Infrastructure as a Service)의 유연성을 동시에 제공한다. 이를 통해 코드 이식성(portability)가 매우 높아지며 운영체제 및 하드웨어를 블랙박스처럼 취급할 수 있게 된다.

  • PaaS는 앱을 개발하고 배포하기 위한 플랫폼을 제공하는 서비스이다. PaaS를 통해 아래 과정들을 개발자가 크게 신경쓸 필요가 없어진다.
    • 서버 프로비저닝: 서버를 사용 가능한 상태로 준비하는 일련의 과정 (인스턴스 생성 / 운영체제 설치 및 설정 / 필요 소프트웨어 설치 / 네트워크 설정 / 보안 설정 / 로깅 도구 설정 등)
    • 로드 밸런싱
    • 오토 스케일링
    • 모니터링

컨테이너도 오토 스케일링이 가능하다. Kubernetes같은 오케스트레이션 도구를 사용하면 수천개 컨테이너를 자동 생성 및 제거가 가능하여 부하 관리가 쉬워진다. 또한 서비스 배포 속도가 빠르고 필요시 인스턴스 즉시 추가도 가능하다. 컨테이너 생성 시 컨테이너 이미지를 미리 만들고 해당 이미지를 실행만 하면 서버가 바로 준비되는 것으로 서버 프로비저닝도 대체된다.

  • IaaS는 가상머신, 네트워크, 저장소 등 인프라를 직접 구성하고 관리할 수 있게 해주는 서비스이다. 여기서 사용자가 원하는 환경 (OS, 라이브러리, 보안 설정 등)을 자유롭게 설정 가능하다.
    • 컨테이너는 애플리케이션에 필요한 종속성을(OS 설정 및 필요 소프트웨어 패키지) 모두 묶어 배포한다.
    • 컨테이너 안에서 어떤 언어나 프레임워크도 자유롭게 선택 및 설정 가능
    • 컨테이너는 VM보다 훨씬 가볍지만 동일한 수준의 커스터마이징 가능

컨테이너는 자신이 원하는 환경을 직접 만들고 세밀히 조정하는 자유도를 가지는데, 이는 IaaS의 장점에 해당한다.

개발 환경 -> 스테이징 -> 운영 환경 / 로컬 노트북 -> 클라우드로 이동할때도 코드 변경 및 빌드를 새로 하지 않아도 된다.

컨테이너 복제가 갖는 의미

인스턴스 하나에서 여러 컨테이너를 실행하는 것은 여러 목적을 갖는다. 컨테이너 기술은 단일 인스턴스 내에서도 여러 컨테이너를 효율적으로 실행할 수 있다. 이는 같은 물리 자원 내에서 더 많은 워크로드를 돌릴 수 있는 것을 말한다.

컨테이너 기반 스케일 아웃은, 하나의 VM에 여러 애플리케이션 인스턴스를 복제·실행하여, 리소스 사용률을 극대화하고, 가볍고 유연하게 수평 확장할 수 있는 구조이다. 이 구조를 통해 VM 기반 확장보다 더 빠르고 유연하게 애플리케이션 복제가 가능하다.

컨테이너의 경량성은(Lightweight) 중요한 특징이다. 무거운 전체 OS를 복제하는 것이 아니라 커널을 공유하면서 독립된 사용자 공간만 할당받는 구조이다. 이러한 구조 기반으로 컨테이너를 분리했을 때 이점은 아래와 같다.

  1. 멀티 프로세싱을 통한 CPU 활용 최적화 (리소스 사용률 극대화)
    • 싱글 프로세스 기반 서버라면 멀티코어 CPU 활용을 100%로 하지 못함 (파이썬 Flask, Node.js 등 기본 HTTP 서버가 이에 해당)
    • 이때 컨테이너를 2개, 3개 띄우면 각각이 별개 프로세스로 CPU 코어를 나눠 쓰게 되어 병렬 처리 효율이 증대 (더 큰 throughput 처리)
  2. 애플리케이션 격리를 통한 안정성 향상
    • 하나의 컨테이너에만 문제가 생겨도 다른 컨테이너는 영향없음
    • 특정 요청 유형이 서버를 다운시켜도 컨테이너 단위로 격리 -> 리스크 분산
  3. 무중단 배포 가능
    • 1개의 컨테이너만 돌고 있으면 배포 시 해당 서버가 중단
    • 반면 컨테이너가 2개 이상이면 하나 업데이트 이후 점진적 트래픽 전환 가능
  4. 리소스 제한 및 모니터링 단위 유연성
    • 각 컨테이너에 대해 CPU / 메모리 리소스 제한 설정 가능
    • 컨테이너 단위 모니터링 지표 수집 가능 & 세밀한 원인 파악
  5. 스케일링 테스트 / 적응 준비
    • 장기적인 관점에서 인스턴스를 여러 개로 스케일아웃 할 준비가 됨
    • 초당 트래픽이 늘었을때 동일한 구조로 인스턴스만 새로 할당하면 됨

싱글 스레드 기반으로 서버 환경이 구축되어 있을때, 초당 100개의 트래픽을 감당하기 위해서는 FIFO 구조를 갖기 때문에 나중 요청에 대한 지연이 갈수록 커진다. 이러한 한계점을 극복하기 위해 파이썬 gunicorn 서버 필요성이 대두되었다.

gunicorn은 파이썬 WSGI(Web Server Gateway Interface) 서버이며 워커 프로세스를 관리하는 역할을 한다.

WSGI란?

WSGI는 (Web Server Gateway Interface)의 약자로 파이썬 웹 애플리케이션과 웹 서버 사이의 표준 인터페이스를 의미한다. 파이썬 웹 앱(Flask, Django, Sanic 등)을 Apache, Nginx, Gunicorn같은 서버가 실행할 수 있도록 연결해주는 표준 규격이다.

파이썬은 자체적인 웹 서버 기능이 없다. 웹 서버가 요청을 처리하고 파이썬 애플리케이션에 전달해주는 중간 연결 고리가 필요하다.

WSGI가 이 역할을 공통된 방식으로 규정하여 아래의 장점들을 얻게 된다.

  • 파이썬 웹 프레임워크 <-> 웹 서버 간 호환성 확보
  • 다양한 서버에서 같은 코드 재사용 가능

WSGI는 아래 컴포넌트로 구성된다.

  1. WSGI 서버: gunicorn, Apache용 mod_wsgi 등 클라이언트 요청을 받아 WSGI 앱에 전달
  2. WSGI 애플리케이션: Flask, Django 같은 파이썬 웹 프레임워크 또는 직접 구현한 애플리케이션
[클라이언트] → [웹 서버(Gunicorn)] → [WSGI 애플리케이션(Django/Flask)]

웹 서버가 클라이언트 요청을 받아 WSGI 인터페이스 형식으로 변환한 뒤 WSGI 애플리케이션에 넘겨주고, 응답 결과를 다시 클라이언트에게 전달한다. WSGI는 동기처리 기반 인터페이스이고, 비동기 처리를 위한 ASGI라는 인터페이스도 존재한다. 이를 위한 대표적인 프레임워크로 Fast API가 존재한다.

[Gunicorn Master Process]
        |
  ┌─────┴─────┐
  │           │
[Worker 1]  [Worker 2] ... (n 개)

마스터 프로세스가 전체 서버의 생명주기 및 워커를 관리하며, 워커 프로세스들이 실제 클라이언트 요청을 처리한다. 워커에 대한 타입은 지정할 수 있고 여러 타입이 존재한다.

  1. sync: 기본 워커, 하나의 요청을 순차적으로 처리
  2. gthread: 스레드 기반 병렬 처리
  3. gevent, uvicorn.workers.UvicornWorker: 비동기용 워커 (FastAPI, Sanic과 주로 함께 사용)
    • ex) gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker

용어를 정리하면 다음과 같다.

  1. Gunicorn: 파이썬 애플리케이션 배포를 위한 WSGI 서버
  2. Worker: 실제 요청을 처리하는 프로세스 단위
  3. Master Process: 전체 서버를 통제하고 워커 생성 / 재시작 / 종료 주관
  4. Worker Class: 요청 처리 방식을 정의 (sync, async)

sanic이란?

현재 몸담고 있는 회사에서는 sanic을 파이썬 웹 서버 프레임워크로 사용중인데, django처럼 HTTP 요청 / 응답 처리의 저수준 코드를 자동으로 처리해주고 라우팅 기능 등을 쉽게 해준다.

Dockerfile 작성 시 gunicorn에 대한 프로세스 구조를 명시적으로 지정할 수 있는데, 그 구조에서 설정해줘야 하는 부분이 바로 워커의 갯수이다. 위의 예시 커맨드에서 볼 수 있듯 --workers 4 옵션은 워커 갯수를 4개로 지정하는건데, 실제 서버에서는 gunicorn에서 기본 포함하는 마스터 프로세스까지 합하여 총 다섯개의 프로세스가 생성된다.

각 워커들은 별도의 PID로 관리된다. 마스터는 요청을 직접 처리하지 않고 워커들을 스폰 / 모니터링 / 재시작하는 역할을 한다. 워커 수를 늘리면 작업에 대한 병렬처리가 가능해져 CPU 코어를 더 효율적으로 사용할 수 있다. Gunicorn은 멀티프로세스 모델이기 때문에 각 워커가 별도 프로세스로 요청을 처리한다.

무작정 워커 수가 많다고 해서 좋은 것은 아니다. 워커 수가 CPU 코어 수보다 많아지면 OS 스케줄러가 교대로 실행시킨다. Gunicorn 공식 문서에서는 경험적으로 workers = (2 x CPU 코어 수) + 1을 제안한다. CPU 코어 수당 2~4 워커가 적당하다는 문서도 있다.

WSGI / ASGI 서버 환경이 필요한 이유

파이썬은 GIL(Global Interpreter Lock)이라는 특성으로 인해 멀티 스레드 환경으로 병렬 코드 수행이 제대로 이루어지지 않는다. GIL은 프로세스 당 한 번에 하나의 기본 스레드만 실행될 수 있도록 스레드 실행을 동기화하기 위해 인터프리터에서 사용되는 메커니즘이다.

GIL을 사용하는 인터프리터는 멀티코어 프로세스에서 실행되는 경우에도 항상 하나의 스레드가 한 시에 실행되도록 허용한다. 파이썬 인터프리터 성능 개선을 위해 CPython이 표준이 되었는데, CPython은 GIL을 사용하고 있다.

멀티 스레딩 환경에서 코드 수행시 전역 인터프리터에 Lock을 걸고 해제하는 과정이 반복되면서 오히려 단일 스레드 기반 환경에서 코드를 수행할 때보다 성능이 낮아지게 된다. (컨텍스트 스위칭, 스레드 간 데이터 동기화)

위의 성능 저하는 CPU bounding 작업 (CPU 연산, 이미지 처리, 파싱 등) 에서만 해당되고, I/O bounding 작업 (파일 읽고쓰기 / 네트워크 접속 등)의 경우에는 GIL이 영향을 미치지 않아 싱글 스레드 기반 처리보다 성능이 개선된다.

이러한 이유로 파이썬 환경 기반으로 실행되는 프로세스는 하나의 환경에 여러 프로세스가 독립적으로 실행되는 구조가 적합하다. Gunicorn과 같은 멀티프로세스 모델을 기반으로 동시성을 확보하는 것이다. (worker 수만큼 독립된 프로세스로 동작)

Gunicorn같은 애플리케이션 서버에는 단순히 애플리케이션 실행 성능을 넘어, 멀티스레딩 이상으로 필요성이 존재하기도 한다.

  1. 커넥션 큐 관리: 다수의 요청 대기열 처리
    • 커널이 운영체제 수준에서 소켓 큐에 요청을 일시 보관
  2. Graceful shutdown: 안정적인 종료 및 재시작
    • 프로세스 종료 시 OS로부터 SISTERM과 같은 현재 진행 중 요청 처리를 마친 뒤 종료
    • 이를 통해 요청 중단 없이 서버를 재시작하거나 배포가 가능
    • (서버를 종료해도 처리중인 결제 요청 건이 완료된 이후에 서버가 종료)
  3. worker health check / 재시작: 비정상 워커 자동 재시작
    • 마스터 워커가 다른 워커들을 감시 / 비정상 종료 시 새로운 워커를 생성하여 대체
    • exit code / signal / watchdog timeout 등으로 판단
  4. Preload / Fork 최적화: 메모리 공유 효율 향상
  5. timeout / keep-alive: 느린 클라이언트 차단

# Kubernetes and Google Kubernetes Engine