# 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를 복제하는 것이 아니라 커널을 공유하면서 독립된 사용자 공간만 할당받는 구조이다. 이러한 구조 기반으로 컨테이너를 분리했을 때 이점은 아래와 같다.
- 멀티 프로세싱을 통한 CPU 활용 최적화 (리소스 사용률 극대화)
- 싱글 프로세스 기반 서버라면 멀티코어 CPU 활용을 100%로 하지 못함 (파이썬 Flask, Node.js 등 기본 HTTP 서버가 이에 해당)
- 이때 컨테이너를 2개, 3개 띄우면 각각이 별개 프로세스로 CPU 코어를 나눠 쓰게 되어 병렬 처리 효율이 증대 (더 큰 throughput 처리)
- 애플리케이션 격리를 통한 안정성 향상
- 하나의 컨테이너에만 문제가 생겨도 다른 컨테이너는 영향없음
- 특정 요청 유형이 서버를 다운시켜도 컨테이너 단위로 격리 -> 리스크 분산
- 무중단 배포 가능
- 1개의 컨테이너만 돌고 있으면 배포 시 해당 서버가 중단
- 반면 컨테이너가 2개 이상이면 하나 업데이트 이후 점진적 트래픽 전환 가능
- 리소스 제한 및 모니터링 단위 유연성
- 각 컨테이너에 대해 CPU / 메모리 리소스 제한 설정 가능
- 컨테이너 단위 모니터링 지표 수집 가능 & 세밀한 원인 파악
- 스케일링 테스트 / 적응 준비
- 장기적인 관점에서 인스턴스를 여러 개로 스케일아웃 할 준비가 됨
- 초당 트래픽이 늘었을때 동일한 구조로 인스턴스만 새로 할당하면 됨
싱글 스레드 기반으로 서버 환경이 구축되어 있을때, 초당 100개의 트래픽을 감당하기 위해서는 FIFO 구조를 갖기 때문에 나중 요청에 대한 지연이 갈수록 커진다. 이러한 한계점을 극복하기 위해 파이썬 gunicorn
서버 필요성이 대두되었다.
gunicorn은 파이썬 WSGI(Web Server Gateway Interface) 서버이며 워커 프로세스를 관리하는 역할을 한다.
WSGI란?
WSGI는 (Web Server Gateway Interface)의 약자로 파이썬 웹 애플리케이션과 웹 서버 사이의 표준 인터페이스를 의미한다. 파이썬 웹 앱(Flask, Django, Sanic 등)을 Apache, Nginx, Gunicorn같은 서버가 실행할 수 있도록 연결해주는 표준 규격이다.
파이썬은 자체적인 웹 서버 기능이 없다. 웹 서버가 요청을 처리하고 파이썬 애플리케이션에 전달해주는 중간 연결 고리가 필요하다.
WSGI가 이 역할을 공통된 방식으로 규정하여 아래의 장점들을 얻게 된다.
- 파이썬 웹 프레임워크 <-> 웹 서버 간 호환성 확보
- 다양한 서버에서 같은 코드 재사용 가능
WSGI는 아래 컴포넌트로 구성된다.
- WSGI 서버: gunicorn, Apache용 mod_wsgi 등 클라이언트 요청을 받아 WSGI 앱에 전달
- WSGI 애플리케이션: Flask, Django 같은 파이썬 웹 프레임워크 또는 직접 구현한 애플리케이션
[클라이언트] → [웹 서버(Gunicorn)] → [WSGI 애플리케이션(Django/Flask)]
웹 서버가 클라이언트 요청을 받아 WSGI 인터페이스 형식으로 변환한 뒤 WSGI 애플리케이션에 넘겨주고, 응답 결과를 다시 클라이언트에게 전달한다. WSGI는 동기처리 기반 인터페이스이고, 비동기 처리를 위한 ASGI
라는 인터페이스도 존재한다. 이를 위한 대표적인 프레임워크로 Fast API
가 존재한다.
[Gunicorn Master Process]
|
┌─────┴─────┐
│ │
[Worker 1] [Worker 2] ... (n 개)
마스터 프로세스가 전체 서버의 생명주기 및 워커를 관리하며, 워커 프로세스들이 실제 클라이언트 요청을 처리한다. 워커에 대한 타입은 지정할 수 있고 여러 타입이 존재한다.
sync
: 기본 워커, 하나의 요청을 순차적으로 처리gthread
: 스레드 기반 병렬 처리gevent
,uvicorn.workers.UvicornWorker
: 비동기용 워커 (FastAPI, Sanic과 주로 함께 사용)- ex)
gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker
- ex)
용어를 정리하면 다음과 같다.
- Gunicorn: 파이썬 애플리케이션 배포를 위한 WSGI 서버
- Worker: 실제 요청을 처리하는 프로세스 단위
- Master Process: 전체 서버를 통제하고 워커 생성 / 재시작 / 종료 주관
- 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같은 애플리케이션 서버에는 단순히 애플리케이션 실행 성능을 넘어, 멀티스레딩 이상으로 필요성이 존재하기도 한다.
- 커넥션 큐 관리: 다수의 요청 대기열 처리
- 커널이 운영체제 수준에서 소켓 큐에 요청을 일시 보관
- Graceful shutdown: 안정적인 종료 및 재시작
- 프로세스 종료 시 OS로부터 SISTERM과 같은 현재 진행 중 요청 처리를 마친 뒤 종료
- 이를 통해 요청 중단 없이 서버를 재시작하거나 배포가 가능
- (서버를 종료해도 처리중인 결제 요청 건이 완료된 이후에 서버가 종료)
- worker health check / 재시작: 비정상 워커 자동 재시작
- 마스터 워커가 다른 워커들을 감시 / 비정상 종료 시 새로운 워커를 생성하여 대체
- exit code / signal / watchdog timeout 등으로 판단
- Preload / Fork 최적화: 메모리 공유 효율 향상
- timeout / keep-alive: 느린 클라이언트 차단
# Kubernetes and Google Kubernetes Engine
쿠버네티스는 컨테이너화된 워크로드와 서비스를 자동으로 배포하고, 확장하고 운영할 수 있게 해주는 오픈소스 플랫폼이다. 여러 호스트에 걸친 수많은 컨테이너들을 효과적으로 오케스트레이션 하기 위한 시스템이다.
쿠버네티스 특징은 다음과 같다.
- 오케스트레이션
- 여러 컨테이너를 여러 노드에 배포 / 관리
- 컨테이너간 통신, 연결, 배포 순서 등 정의 가능
- 자동화된 스케일링
- 트래픽 증가 시 자동으로 컨테이너 수 증가
- 사용량 감소 시 자동으로 자원 축소
- 롤아웃 / 롤백
- 새로운 버전 배포 시 점진적 서비스 교체
- 문제 발생 시 자동으로 롤백
- API 중심 제어
- 모든 작업은 쿠버네티스 API 기반으로 정의 및 명령
쿠버네티스를 구성하는 요소들은 다음과 같다.
- Control Plane: 전체 클러스터를 관리하는 중앙 제어 시스템 (API 서버, 스케줄러 등)
- Node: 실제 컨테이너가 실행되는 컴퓨팅 자원
- Cluster: 여러 노드의 집합으로, 하나의 논리적 단위로 관리
쿠버네티스에는 Pod(팟)이라는 개념이 존재한다. 팟은 컨테이너를 배포할 수 있는 가장 작은 단위이다. 쿠버네티스에서는 컨테이너를 직접 실행하는 것이 아닌 항상 팟을 통해 실행한다.
팟은 컨테이너에 대한 Wrapper 역할을 한다. 또한 팟 하나에는 일반적으로 컨테이너 하나만 포함되지만 복수의 컨테이너도 하나의 팟 안에 넣을 수 있다. (로그 수집 + 애플리케이션처럼 연결성이 강한 두 컨테이너를 하나의 팟에 포함)
팟의 역할과 특징은 다음과 같다.
- 네트워크 IP: 팟마다 고유 IP주소 부여 (팟 내의 컨테이너들은 IP주소 공유)
- 포트 설정: 팟 단위로 포트 열기 닫기 등의 설정 가능
- 스토리지 공유: 팟 내부 컨테이너들끼리는 볼륨 공유 가능
- 실행 옵션: 환경변수, 리소스 제한 등 설정 가능
쿠버네티스의 디플로이먼트는 동일한 팟의 복제본(replicas) 집합을 나타낸다. 팟이 실행되고 있는 노드가 실패하더라도 팟을 계속 실행할 수 있도록 유지해준다. 디플로이먼트는 애플리케이션 하나의 구성 요소 또는 전체 애플리케이션을 대표할 수 있다.
# 현재 프로젝트에서 실행중인 Pod 리스트를 확인하는 명령어
$ kubectl get pods
Kubernetes는 Pod를 대상으로 하는 Service 객체에 고정 클러스터 IP를 부여하며, 사용자가 type: LoadBalancer로 Service를 생성하면, 컨트롤 플레인의 구성 요소 중 하나인 Service 컨트롤러가 이를 감지하여 외부에서 접근 가능한 로드 밸런서(public IP 포함)를 자동으로 생성 및 연결합니다. 이를 통해 클러스터 외부에서도 해당 서비스에 접근할 수 있게 됩니다.
쿠버네티스에는 서비스라는 개념이 필요한데, 그 이유는 아래와 같다.
- 팟은 임시적 존재로, 재시작 및 새 버전 배포시 IP 주소가 변경된다.
- 서비스는 팟들의 집합을 하나의 IP 혹은 DNS 이름으로 묶어 접근 지점을 만들어준다.
- 이렇게 하면 클라이언트나 다른 서비스가 팟 변화에 영향을 받지 않고 안정적인 통신이 가능하다.
일반적으로 하나의 Pod에는 하나의 주요 컨테이너가 포함되지만, Kubernetes 입장에서는 Pod 내부에 몇 개의 컨테이너가 있는지는 중요하지 않다. Kubernetes는 컨테이너를 직접 관리하지 않고, Pod이라는 추상화를 통해 일관된 방식으로 네트워킹, 스케줄링, 상태 관리 등을 수행하며, 이는 컨테이너 런타임이 바뀌더라도 동일한 방식으로 Pod을 관리할 수 있게 해준다.
디플로이먼트 스케일을 위해서는 kubectl scale
명령어를 입력하면 된다. CPU 사용률이 특정 임계치를 초과하는 경우 팟 수를 자동으로 증가시킬 수 있도록 설정 가능하다.
직접 scale 같은 명령어 입력을 통해 쿠버네티스를 다룰 수도 있지만, 실제 쿠버네티스의 장점은 선언형 방식에서 드러난다. 명령어를 각각 실행하는게 아닌 원하는 상태가(desired state) 어떤 모습이어야 하는지를 기술한 구성 파일을(configuration file) 쿠버네티스에 제공하면 쿠버네티스가 해당 상태에 도달하기 위한 절차를 실제로 판단하고 실행해준다. 이러한 선언형 관리를 위해 디플로이먼트 설정 파일을 사용한다. (아래는 Deployment config file 예시)
apiVersion: v1
kind: Deployment
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.15.7
ports:
- containerPort: 80
kubectl get deployments
또는 kubectl describe deployments
명령어를 사용하면 지정된 수의 복제본이 제대로 실행되고 있는지 확인할 수 있다. 레플리카 수를 설정파일에서 변경하고 싶으면, 위의 spec - replicas를 수정한 뒤 kubectl apply -f deployment.yaml
로 변경된 내용을 적용하면 된다.
새 코드 적용 후 컨테이너를 업데이트 할때 한번에 업데이트 하는 것이 아니라 쿠버네티스 명령어들을 사용하면 업데이트 전략에 맞춰 새 버전의 Pod들이 생성되고 각 팟들의 정상 동작을 체크한 뒤 기존 pod들을 종료하는 방식으로 점진적 업데이트가 가능하다.
위의 설명들은 쿠버네티스 시스템에 대한 설명이었다. 구글에서 제공하는 GKE(Google Kubernetes Engine)이라는 서비스가 있는데, 이는 구글에서 제공하는 쿠버네티스 관리형 서비스이다. 사용자가 직접 쿠버네티스 클러스터를 설치하고 운영할 필요 없이 구글이 직접 제어 및 운영을 호스팅하고 관리해준다.
GKE 환경은 구글의 Compute Engine 인스턴스로 구성되고 이 인스턴스들이 모여 하나의 클러스터를 형성한다.
GKE는 쿠버네티스와 다르다. 관리형 서비스인 만큼 컨트롤플레인 구성 요소를 직접 관리해준다. 쿠버네티스 API 요청을 보낼 수 있는 IP 주소는 노출되지만 뒷단의 인프라 구성 및 관리는 GKE가 전담한다.
노드 구성 및 관리는 사용자가 선택한 GKE 모드에 따라 달라진다.
- Autopilot 모드
- 노드 구성, 오토스케일링, 자동 업그레이드, 기본 보안 구성, 네트워킹 설정 등을 GKE가 완전 관리
- 프로덕션 환경에 최적화 / 운영 효율성 / 강력한 보안체계 형성
- Standard 모드
- 사용자가 직접 인프라 관리, 각 노드 구성까지 수동으로 설정
- 클러스터 구성 및 관리, 최적화가 사용자의 책임
GKE를 사용하면 구글 클라우드 콘솔 및 Cloud SDK에서 제공하는 gcloud
명령어를 통해 쿠버네티스 클러스터를 관리할 수 있다. GKE를 통해 클러스터를 운영하면 아래와 같은 장점들이 있다.
- Compute Engine 인스턴스를 위한 구글 클라우드 로드밸런싱 기능
- 클러스터 노드 인스턴스 수 오토스케일링
- 클러스터 노드 소프트웨어 자동 업그레이드
- 노드 상태 유지를 위한 자동 복구기능
- 클러스터 가시성을 위한 Google Cloud Observability 기반 로깅 및 모니터링