도커는 OS 위에서 어떻게 격리된 것처럼 보이는가?

도커의 정체는 '리눅스 커널의 기능을 이용해 격리된 척하는 프로세스' 이다.

1. 도커의 거짓말 : 격리 (Namespaces)

가상머신(VM) 은 실제로 집(OS) 을 한 채 더 짓는 것이지만, 도커는 방 하나에 프로세스를 가둬두고, "여기가 집 전체야" 라고 거짓말 하는 기술이다.

이 거짓말을 수행하는 리눅스 커널 기술이 바로 Namespace(네임 스페이스) 이다.

1-1. "너 말고 다른 프로세스는 없어" (PID Namespace)

  • 상황 : 리눅스 서버(커널)에서 수백 개의 프로세스가 돌고 있다.

  • 도커의 동작 : 새로운 컨테이너를 띄우면, 커널에게 "이 프로세스에게는 별도의 번호표(PID) 공간을 줘" 라고 요청한다.

  • 결과:

    • 커널은 호스트(서버), 컨테이너 각각에게 PID 를 할당한다.

      • 예를 들어, 스프링 + MySQL + Redis 서비스에서

프로세스
컨테이너 내부 (Namespace PID)
커널이 보는 호스트 PID (Real PID)

Spring 컨테이너의 JVM

1

예 : 1000

DB 컨테이너의 mysqld

1

예 : 2000

캐시 컨테이너의 redis

1

예 : 3000

호스트

컨테이너가 아니기 때문에 할당 X

예 : 100

  • 의미 : 프로세스 목록을 격리해서, 컨테이너 안에서는 옆방(다른 컨테이너)의 프로세스를 보거나 죽일 수 없게 만든다.

1-2. "네가 쓰는 파일 시스템이 전부야" (Mount Namespace)

  • 상황 : 서버에는 /etc, /home, /usr 등 수 많은 파일이 있다.

  • 도커의 동작 : chroot 라는 기술을 사용한다. 컨테이너가 올라가는 순간 파일 시스템이 격리가 되기 때문에, 외부(호스트, 커널 등..) 환경에 영향을 주지 않는다.

  • 결과 : 컨테이너 내부 프로세스가 /(루트) 에 접근하면, 외부 환경이 아니라 , 도커 이미지가 풀어져 있는 특정 컨테이너 폴더를 보여준다.

  • 의미 : 컨테이너 안에서 rm -rf / 를 해도, 호스트 서버의 파일은 안전하다. 그 프로세스 입장에서는 세상이 지워진 것 같겠지만, 밖에서 보면 폴더 하나가 비워진 것에 불과하기 때문이다.

1-3. "너 혼자만의 네트워크 카드를 줄께" (Network Namespace")

  • 상황 : 서버의 80 번 포트는 이미 Nginx 가 사용하고 있다.

  • 도커의 동작 : 프로세스에게 가상의 네트워크 인터페이스(eth0) 를 따로 만들어준다.

  • 결과 : 컨테이너 안의 앱은 자기가 80번 포트를 독점한다고 생각한다. 하지만 실제로는, 도커가 중간에 다리(Bridge) 를 놓아 외부와 연결해줄 뿐이다.


2. 자원 통제 (Cgroups)

격리(Namespace) 만 시켜두면 문제가 생긴다. 가둬놓은 프로세스 하나가 미친 듯이 연산을 해서 서버의 CPU 100% 를 혼자 다 써버리면 서버가 죽는다. 이를 막는 기술이 Cgroup(Control Groups) 이다.

  • 작동 원리 : 리눅스 커널에서 "이 프로세스 그룹(컨테이너)은 CPU 20% 이상 못 쓰게 막아줘" 라고 딱지를 붙인다.

  • 깊이 있는 포인트(OOM Killer) : 메모리 제한이 중요하다.. 만약 컨테이너에서 "메모리 500MB만 써" 라고 설정했는데, 앱이 501MB를 요구하면 어떻게 될까?

    • 리눅스 커널의 OOM(Out Of Memory) Killer 가 등장해서, "그 프로세스를 즉시 Kill 해버린다."

    • 실무 포인트 : 서버 로그에 "컨테이너가 갑자기 죽었다.(Exited with code 137)" 라고 뜨면 99% 확률로 Cgroups 제한이 걸려 OOM Killer 에게 당한 것이다.

만약 실시간 서비스에서는 컨테이너가 Kill 당하게 되면 서비스를 사용하지 못할 수 있기 때문에, 다음과 같은 방법을 사용한다.

  • 다중 컨테이너 환경 구성

  • 오토스케일링

  • 서킷 브레이커

  • 트래픽 제어

  • 등등 ..


3. 저장소 혁명 : "변한 것만 저장한다" (UnionFS & Copy-on-Write)

이 부분이 면접관들이 가장 좋아하는 "도커가 가벼운 기술적 이유" 이다.

보통 VM 을 복사하면 20GB 짜리 OS 파일 전체를 복사해야 해서 느리다.. 하지만 도커는 UnionFS(Union File System) 라는 기술을 사용한다.

3-1. 레이어(Layer) 구조

  • 도커 이미지는 통짜 파일이 아니다. Ubuntu + Java + Source Code 이렇게 층층이 쌓여 있다.

  • 이 층(Layer) 들은 읽기 전용이다. 절대로 안바뀐다.

  • 컨테이너 100개를 띄워도, 이 읽기 전용 데이터는 딱 1개만 디스크에 올려두고 100개의 컨테이너가 레퍼런스를 공유하여 사용한다.

3-2. Copy-on-Write(CoW) 전략

  • 상황 : 컨테이너 안에서 /app/config.conf 라는 파일을 수정하려고 한다.

  • 문제 : 이미지는 읽기 전용이라서 수정이 불가하다.

  • 해결책(CoW):

    • 도커는 그 파일을 수정하려고 하는 순간, 이미지에서 그 파일만 몰래 복사(Copy) 해서 컨테이너의 쓰기 전용 레이어로 가져온다.

    • 그리고 그 복사본에 수정(Write) 한다.

    • 컨테이너 입장에서는 파일이 그 자리에서 수정된 것처럼 보인다.

  • 결론 : 파일을 건드리지 않으면 복사도 안한다. 그래서 컨테이너를 띄우는 속도가 0초에 가까운 것이다.

주의점

CoW 전략을 구사할 때 디스크와 메모리에 있어 다른 전략을 사용한다.

  • 디스크 (HDD/SSD)

    • 각각의 컨테이너가 읽기 전용 계층의 수정을 시도할 때만 해당 파일이 쓰기 전용 계층으로 복사되어 디스크를 사용한다.

    • 결론적으로, 디스크 입장에서는 10개의 컨테이너를 띄워도 1개의 이미지 파일 만큼만 기본 디스크 공간을 사용한다.

  • 메모리 (RAM)

    • /app/myapp.jar 와 같은 실행 코드는 메모리에 한 번만 로드 되고 10개의 컨테이너 프로세스가 읽기 전용으로 함께 참조한다.

    • 하지만, Spring Boot 의 JVM Heap, Stack, 로그 버퍼, 변수 등 프로세스가 실행 중 생성/수정하는 데이터는 각 컨테이너마다 독립적으로 RAM 에 할당되어 관리된다.

이론적으로는 읽기 전용 파일이 변경되지 않고, 프로세스가 데이터를 변경하지 않는다면, 10개의 컨테이너가 동일한 메모리를 사용하여 동작할 수 있다.

하지만 현실에서는 각각의 프로세스가 동적으로 데이터를 변경하고 생성/삭제하기 때문에, 각 컨테이너마다 동적으로 메모리 공간을 사용해야 한다.

4. 네트워크 심화 : 도커는 어떻게 통신하는가? (veth & Bridge)

"도커 포트 포워딩(-p 8080:80) 이 어떻게 되는 거지?" 를 깊이 보면 리눅스 네트워크 가상화 기술이 나온다.

  1. veth(Virtual Ethernet) : 랜선 양쪽 끝이라고 생각하고, 컨테이너가 생성되면 veth 쌍이 생성된다. 한쪽 끝은 컨테이너에 꽂히고(eth0), 다른 쪽 끝은 호스트에 꽂힌다(vethXXXX).

  2. docker0 (Bridge) : 호스트에 꽃힌 랜선들은 docker0 라는 가상의 스위치(공유기) 에 연결된다. 이 스위치를 통해서 컨테이너들끼리 통신이 가능해진다.

  3. NAT (IP Masquerade) : 컨테이너가 외부 인터넷(ex, www.google.com) 등으로 나갈때, docker0 을 거쳐 호스트의 실제 랜카드로 나간다. 이때 패킷의 출발지 IP 를 호스트 서버의 IP 로 바꿔치기(NAT) 해서 나간다.

5. 운영체제 관점의 중요 포인트 : PID 1 문제

이건 실무 경험과 OS 이해도가 동시에 필요한 깊이 있는 주제이다.

  • 리눅스의 규칙 : PID 1 번 프로세스(init process) 는 아주 특별하다.

    • 운영체제가 부팅될 때 가장 먼저 실행되며, 시스템 전체를 관리하는 핵심적인 역할

    • 자식 프로세스들이 죽으면 뒷처리를 해주고(좀비 프로세스 청소),

    • SIGTERM 같은 종료 신호를 받으면 자식들에게 전파해야 한다.

  • 도커의 문제 : 도커 컨테이너 안에 띄운 내 애플리케이션(Spring, Node.js) 이 PID 1번이 된다. 그런데 일반적인 애플리케이션은 PID 1번 프로세스가 하는 역할(신호 처리, 좀비 청소) 을 할 줄 모른다.

5-1. 발생하는 현상 (좀비 프로세스)

  1. Spring Boot 앱 실행: /app/myapp.jar가 컨테이너 내부에서 PID 1을 할당받아 실행된다.

  2. 자식 프로세스 종료: Spring Boot 앱이 실행한 자식 프로세스가 작업을 마치고 종료된다.

  3. 좀비 프로세스 발생: 자식 프로세스는 종료되었지만, PID 1인 Spring Boot 앱은 좀비 프로세스 청소 임무를 수행할 줄 모른다. 따라서 종료된 자식 프로세스의 종료 상태 정보가 메모리에 계속 남아있게 되며, 이 상태의 프로세스를 좀비 프로세스라고 부른다.

  4. 결과 : 컨테이너를 오래 운영하면 좀비 프로세스가 계속 쌓여 프로세스 테이블의 자원을 낭비하고 시스템 성능을 저하 시킬 수 있다.

5-2. 발생하는 현상 (종료 신호 무시)

  1. 호스트 컴퓨터에서 docker stop 명령을 친다.

  2. 일반적인 애플리케이션은 PID 1 번 프로세스의 역할을 할줄 모르기 때문에, SIGTERM 신호를 처리할 수 없다.

  3. 일반적으로 도커는 10초 동안 컨테이너가 스스로 종료하기를 기다린다.

  4. 컨테이너가 바로 안 죽고 10초 뒤에 강제로 죽음 (SIGTERM 신호를 무시해서)

해결책 : 그래서 실무에서는 tini 같은 가벼운 init 프로세스를 PID 1 로 세우고, 내 앱을 그 자식으로 실행하거나, 앱 코드 내에서 시그널 핸들링 처리를 명시적으로 구현해야 한다.

5-3. 해결책 (Tini 또는 Init 프로세스 사용)

이 문제를 해결하기 위해 실무에서는 PID 1 의 역할을 대행하는 가볍고 안전한 유틸리티를 사용한다.

  • 해결책 : Tini, dunp-init 같은 작은 프로그램을 컨테이너의 PID 1 로 먼저 실행한다.

  • 구조 : Tini 가 PID 1 이 되고, Tini 가 Spring Boot 앱을 자식 프로세스(PID 2) 로 실행한다.

  • 결과 :

    • 좀비 청소 : Tini가 PID 1로서 좀비 프로세스를 깔끔하게 청소합니다.

    • 신호 전파 : docker stop 시 Tini가 SIGTERM 신호를 받아 자식 프로세스(Spring Boot 앱)에게 즉시 전달하여, 앱이 정상적으로 종료될 시간을 벌어줍니다.

꼭 알아둬야 하는 점!

좀비 프로세스가 차지하는 영역은 메모리가 아니다! PID 테이블을 점유하는 것이다. (실제 점유 메모리는 KB 정도로 매우 적다..)

여기서 문제점은 리눅스 시스템은 생성 가능한 최대 PID 수를 설정해 놓는데, 일반적으로는 32,768 개이고, 컨테이너의 경우에는 더 낮게 제한될 수 있다.

좀비 프로세스로 인해 PID 가 고갈되게 된다면 외부 프로세스에 요청을 날리지 못하게 되어 아예 서비스가 멈춰버리는 아주 극단적인 상황이 발생하게 된다.

때문에, 사실상 컨테이너 환경에서 Tini 또는 유사한 Init 프로세스를 사용하는 것이 강력하게 권장되며, 사실상 필수적이라고 할 수 있다.

Last updated