Docker 베이스 이미지와 리눅스 커널 이해하기
핵심 요약
도커에서 ubuntu 이미지를 실행해도 실제로는 "우분투를 부팅"하는 것이 아니라, 호스트의 리눅스 커널 위에 우분투 사용자 공간(userland) 파일들을 올려서 쓰는 것이다.
이 구조 때문에 컨테이너는 가볍고 빠르지만, 모든 컨테이너가 커널을 공유하므로 보안·호환성·운영 측면에서의 이해가 필수적이다.
아래 내용을 알면 "컨테이너 = 가벼운 VM"이라는 오해에서 벗어나, 베이스 이미지 선택과 운영 전략을 더 똑똑하게 설계할 수 있다.
"도커에서 우분투를 실행한다"는 말의 진짜 의미
터미널에서 다음과 같이 실행하면 마치 우분투를 한 대 부팅한 느낌이 듭니다.
docker run -it ubuntu:22.04 bash셸 프롬프트도 우분투처럼 보이고, apt update도 잘 동작해서 전체 OS가 돌아가는 것처럼 느껴집니다.
하지만 컨테이너 안에서 uname -r로 커널 버전을 확인해 보면, 우분투 버전과 상관없이 호스트 머신의 커널 버전이 그대로 나타납니다. 반면 cat /etc/os-release로 확인한 배포판 정보는 우분투로 표시됩니다.
이 말은 곧, 컨테이너 안의 "우분투"는 커널이 아니라 /bin, /usr, /etc 같은 파일 시스템(사용자 공간)만을 의미한다는 뜻입니다.
즉, 컨테이너 = "호스트 커널 + 특정 리눅스 배포판의 사용자 공간" 조합일 뿐이고, 컨테이너가 자신의 커널을 따로 부팅하는 일은 없습니다.
컨테이너와 VM의 구조적 차이
VM은 "하드웨어를 가상화"해서 그 위에 완전한 운영체제(커널+유저랜드)를 올립니다.
반면 컨테이너는 이미 올라간 하나의 커널을 여러 프로세스가 공유하되, 네임스페이스와 cgroup 같은 기능으로 "각자 독립된 OS처럼 보이게" 만드는 방식입니다.
VM은 각자 커널을 하나씩 들고 있어 부팅에도 시간이 걸리고, 메모리도 커널만 수백 MB씩 차지합니다. 반대로 컨테이너는 커널을 공유하므로 시작 속도는 거의 프로세스 실행 수준이고, 메모리·디스크 오버헤드도 매우 작습니다.
정리하면, VM은 "컴퓨터를 여러 대" 만드는 것이고, 컨테이너는 "같은 컴퓨터 위에서 프로세스를 격리해서 여러 개" 띄우는 방식에 가깝습니다.
베이스 이미지 안에 실제로 들어있는 것들
ubuntu:22.04 같은 베이스 이미지를 풀 받으면, 커널이 아니라 특정 디렉터리 구조를 가진 하나의 파일 시스템 덩어리를 받는 것입니다.
여기에는 대표적으로 다음과 같은 것들이 포함됩니다.
우선 /bin, /usr/bin 같은 위치에 있는 기본 명령어들이 있습니다. bash, ls, cat, apt 등 우리가 "우분투"에서 기대하는 셸과 유틸리티들이 여기에 들어 있습니다.
또한 /lib, /usr/lib 쪽에는 libc(glibc)와 스레드, 수학 연산 등 대부분의 프로그램이 의존하는 공유 라이브러리들이 들어 있습니다. C로 짠 대부분의 프로그램은 이 라이브러리들 위에서 움직입니다.
/etc 디렉터리에는 사용자·그룹 정보, 패키지 저장소, DNS 설정 등 시스템 성격을 정의하는 각종 설정 파일들이 포함됩니다. 이 덕분에 컨테이너는 "우분투 스타일"의 동작과 디폴트 설정을 보이게 됩니다.
마지막으로 /var/lib/dpkg, /var/lib/apt/lists 같이 패키지 관리 시스템이 설치된 패키지와 저장소 정보를 기록해 둔 데이터베이스가 있습니다.
요약하면, 베이스 이미지 = "배포판의 사용자 공간 파일 시스템 스냅샷"이지, OS 전체가 아닙니다. 커널, 부트로더, 디바이스 드라이버는 들어 있지 않습니다.
모든 컨테이너가 공유하는 호스트 커널과 syscall
리눅스 커널은 프로세스 스케줄링, 메모리 관리, 파일 시스템 접근, 네트워크, 디바이스 드라이버 등을 담당합니다.
컨테이너 안에서 실행되는 프로그램이 open(), read(), fork() 같은 시스템 콜을 호출하면, 이 호출은 컨테이너를 감싸고 있는 네임스페이스/보안 레이어를 지나 결국 호스트 커널로 바로 전달됩니다.
커널은 "이 호출이 우분투 컨테이너에서 왔다"거나 "알파인 컨테이너에서 왔다"는 사실을 크게 신경 쓰지 않고, 그저 하나의 프로세스 요청으로 취급하여 처리합니다.
그 중간에 있는 libc(glibc, musl 등)는 베이스 이미지에서 가져오지만, 그 아래의 실제 syscall 처리자는 언제나 호스트 커널입니다.
결국 "컨테이너 OS"의 정체는 `libc + 유틸리티 + 설정"의 묶음이고, 진짜 OS의 핵심(커널)은 한 개만 있어 모든 컨테이너가 그 위에서 돌아가는 구조입니다.
리눅스 syscall 인터페이스와 호환성
리눅스 커널은 사용자 공간과 통신하기 위해 syscall(시스템 콜) 번호와 규칙을 사용합니다.
리눅스는 이 syscall 인터페이스를 매우 안정적으로 유지해 왔기 때문에, 예전 배포판에서 컴파일한 바이너리라도, 충분히 새로운 커널 위에서 그대로 잘 동작하는 경우가 많습니다.
예를 들어, Ubuntu 18.04용 바이너리를 Ubuntu 24.04 커널 위에서도 실행할 수 있는 이유는, 커널이 "이전 버전의 syscall 호출 패턴"을 대부분 여전히 지원해 주기 때문입니다.
이 덕분에 최신 커널을 사용하는 호스트에서도 오래된 우분투, 데비안, 알파인 이미지를 컨테이너로 띄워서 곧바로 돌릴 수 있고, 이것이 컨테이너 생태계의 강력한 호환성 기반이 됩니다.
다만, 커널에 없는 신규 기능을 요구하거나, 특정 커널 모듈에 의존하는 프로그램이라면 호환성이 깨질 수 있습니다. 예를 들어 io_uring 같은 최신 기능은 일정 버전 이상의 커널이 필요합니다.
컨테이너가 압도적으로 효율적인 이유
먼저 커널을 중복해서 로딩하지 않는다는 점이 큽니다. VM은 VM 개수만큼 커널을 메모리에 올려야 하지만, 컨테이너는 이미 올라간 커널을 모두가 공유하므로 이 부분 메모리 비용이 거의 1회만 발생합니다.
또한 시작 속도 측면에서, VM은 BIOS, 부트로더, 커널 초기화, 서비스 시작 등 "부팅 시퀀스"를 전부 거쳐야 합니다. 반면 컨테이너는 사실상 새로운 프로세스를 fork/exec 하는 수준이라 수백 밀리초 내에 "준비 완료"가 가능한 경우가 많습니다.
디스크도 효율적입니다. 동일한 베이스 이미지에서 여러 개의 컨테이너를 올리면, 그 베이스 레이어는 read-only로 한 번만 디스크에 존재하고, 각 컨테이너는 아주 얇은 쓰기 가능한 레이어만 따로 가집니다.
메모리에서도 커널의 페이지 캐시와 공유 라이브러리 덕분에 중복이 많이 제거됩니다. 여러 컨테이너가 같은 라이브러리 파일을 사용하면, 그 코드 영역은 메모리에서 한 번만 로딩되어 여러 프로세스가 공유할 수 있습니다.
이런 이유들 때문에, 하나의 물리/가상 서버에서 수십~수백 개의 컨테이너를 운용하는 것이 현실적인 선택이 됩니다.
이론상 얼마나 많은 컨테이너를 올릴 수 있을까?
16GB RAM이 있는 서버(또는 VM)를 예로 들어 보면, 운영체제 자체와 도커 데몬, 컨테이너 런타임이 일정량의 메모리를 먼저 사용합니다.
이를 제외하고 남은 메모리를 컨테이너에 나누어 쓰게 되는데, 컨테이너당 사용하는 메모리 양에 따라 이론적인 최대 개수가 결정됩니다.
예를 들어, 거의 아무것도 하지 않고 sleep만 하는 최소 컨테이너라면 프로세스 오버헤드 정도라 1MB 안팎으로 잡을 수 있습니다. 이 경우 16GB 중 가용 메모리 전체를 나눠 보면 만 단위의 컨테이너 개수가 계산상 가능해집니다.
반대로, 파이썬 런타임과 여러 라이브러리를 올린 "일반적인 웹/백엔드 앱"이라면 컨테이너 하나당 수십~100MB 정도는 우습게 사용합니다. 이 경우 16GB에서 현실적인 컨테이너 수는 100개 안팎 수준이 됩니다.
Java 기반 마이크로서비스처럼 런타임 자체 메모리 풋프린트가 큰 경우에는 컨테이너당 수백 MB를 쓰게 되어, 16GB 환경에서는 수십 개 수준이 한계가 됩니다.
여기에 CPU 스케줄링, 파일 디스크립터 제한, PID 제한, 디스크 I/O 경쟁, 네트워크 포트 수 등 다른 자원 제약까지 고려하면, "실제 운영에서 안정적으로 사용할 수 있는 컨테이너 수"는 항상 계산상 이론치보다 훨씬 적어집니다.
베이스 이미지 선택 전략: scratch, alpine, debian, ubuntu, distroless
베이스 이미지는 단순히 "좋아하는 리눅스"가 아니라, 크기, 호환성, 보안, 디버깅 편의성 등을 모두 고려해서 고르는 것이 좋습니다.
scratch 이미지는 말 그대로 비어 있는 상태로, OS 파일이 하나도 없습니다. 정적으로 컴파일된 단일 바이너리를 그대로 복사해 넣고 실행할 때 사용합니다. 이미지 크기를 극한으로 줄이고 싶고, 의존성이 없는 Go/Rust 바이너리 등에 잘 어울립니다.
alpine은 아주 작은 이미지 크기와 기본적인 패키지 매니저(apk)를 제공하는 미니멀 배포판입니다. 다만 glibc 대신 musl libc를 사용하기 때문에, glibc에 의존하는 바이너리를 그대로 가져오면 실행이 안 되는 경우가 있습니다.
debian-slim이나 ubuntu는 apt를 사용해 패키지를 쉽게 설치할 수 있고, glibc를 사용하기 때문에 기존 리눅스 환경에서 돌아가던 바이너리들이 별 문제 없이 돌아가는 경우가 많습니다. 크기는 조금 더 크지만, 개발·디버깅·호환성을 중시할 때 유용합니다.
distroless 이미지는 패키지 매니저와 셸을 제거해서 공격 표면을 줄인 "런타임 전용" 이미지입니다. 보안과 최소화에 좋지만, 컨테이너 안에 들어가서 디버깅하기가 상당히 불편합니다.
실무에서는 개발 단계에서는 ubuntu/debian 기반 이미지를 쓰고, 최종 배포용으로는 multi-stage 빌드로 distroless나 alpine 기반의 더 작은 이미지를 만드는 패턴이 많이 사용됩니다.
네임스페이스와 "가짜 PID 1": 컨테이너 격리의 본질
컨테이너 안에서 ps aux를 실행해 보면, 어떤 프로세스가 PID 1로 보입니다.
이 PID 1은 마치 "컨테이너의 init 프로세스"처럼 느껴지지만, 호스트에서 같은 프로세스를 보면 PID가 완전히 다릅니다.
리눅스 커널은 PID, 네트워크, 마운트, 사용자 등 여러 자원을 "네임스페이스"로 분리해서, 각 컨테이너가 자기만의 독립된 세상을 가진 것처럼 보이게 합니다. 하지만 실제로는 같은 커널 위의 하나의 프로세스로, 호스트 PID 공간에서는 평범한 높은 숫자의 PID일 뿐입니다.
이런 네임스페이스 덕분에 컨테이너마다 프로세스 목록, 네트워크 인터페이스, 마운트된 파일 시스템 등이 각자 독립된 것처럼 보이지만, 물리적으로는 대부분 같은 커널과 하드웨어 자원을 공유하고 있습니다.
이 구조 때문에 격리 수준은 VM(하드웨어 수준)의 격리보다는 약하지만, 프로세스 수준에서 보기에는 꽤 강력한 분리처럼 느껴집니다.
커널 공유 구조가 주는 운영·보안상의 함의
컨테이너가 모두 같은 커널을 공유한다는 사실은, 운영과 보안 측면에서 매우 중요한 의미를 가집니다.
첫째, 커널 취약점은 모든 컨테이너에 영향을 줍니다. 특정 컨테이너만 패치해서는 해결되지 않고, 호스트 커널을 업데이트해야 합니다. 따라서 컨테이너를 많이 쓸수록 호스트 OS 패치 전략이 더 중요해집니다.
둘째, 컨테이너가 요구하는 기능은 결국 호스트 커널이 지원해 줘야 사용할 수 있습니다. io_uring, 최신 eBPF 기능, 특정 네트워크/스토리지 드라이버 등은 커널 버전과 모듈 설정에 강하게 의존합니다. 컨테이너 이미지만 바꿔서는 해결되지 않습니다.
셋째, 베이스 이미지에서 사용하는 C 라이브러리(glibc vs musl)는 이식성과 호환성에 직접적인 영향을 줍니다. glibc를 기대하는 바이너리를 Alpine(musl)에서 그대로 실행하면 라이브러리를 찾지 못하고 실패합니다.
마지막으로, 커널 관점에서 "우분투 컨테이너", "데비안 컨테이너"라는 구분은 사실상 의미가 없습니다. 모두 "같은 커널에게 syscall을 던지는 단순 프로세스 집합"일 뿐이며, 이 점을 이해하면 OS를 바꾸지 않고도 다양한 배포판 사용자 공간을 혼용해서 쓸 수 있게 됩니다.
자주 나오는 오해 정리
컨테이너를 "가벼운 VM"이라고 설명하는 경우가 많지만, 실제로는 "커널을 공유하는 프로세스 격리 기법"에 가깝습니다. VM처럼 하드웨어를 가상화하지 않고, 하나의 커널을 쓰는 한편 네임스페이스와 cgroup으로 격리를 흉내 내는 방식입니다.
또 하나의 오해는 "컨테이너마다 커널이 하나씩 있다"는 생각입니다. 실제로 컨테이너 이미지는 커널을 포함하지 않으며, 커널은 언제나 호스트에서 제공하는 한 개만 사용합니다.
"우분투 컨테이너를 실행하면 우분투를 부팅한 것이다"라는 생각도 잘못입니다. 실제로는 호스트 커널 + 우분투 사용자 공간 조합일 뿐이며, 호스트가 데비안이라면 결국 데비안 커널 위에서 우분투 유저랜드를 사용하는 셈입니다.
또한 "컨테이너가 많아질수록 무조건 메모리를 많이 쓴다"는 것도 반은 맞고 반은 틀립니다. 커널 페이지 캐시, 공유 라이브러리, layered filesystem 덕분에 예상보다 훨씬 효율적으로 중복이 제거됩니다. 물론 애플리케이션 자체가 많이 메모리를 쓰면 결국 한계는 있습니다.
인사이트
컨테이너를 제대로 활용하려면 "베이스 이미지 = 배포판의 사용자 공간"이고, "실제 OS = 호스트 커널"이라는 관점을 기준점으로 삼는 것이 좋습니다.
이 관점을 가지면, 베이스 이미지 선택, 커널 버전 관리, 보안 패치, 리소스 설계, 디버깅 전략을 훨씬 논리적으로 설계할 수 있습니다.
실무적으로는 다음을 기억해 두면 유용합니다.
개발 단계에서는 친숙하고 호환성 좋은 ubuntu/debian 기반 이미지를 쓰고, 배포 단계에서는 multi-stage 빌드로 distroless나 alpine 기반의 소형·보안 강화 이미지를 만드는 전략을 고려해 볼 만합니다.
또한 컨테이너 개수의 상한은 메모리만이 아니라 CPU, FD, PID, 디스크 I/O, 네트워크 등 다양한 자원에 의해 제약되므로, "몇 개까지 돌릴 수 있나?"를 항상 전체 시스템 관점에서 바라보는 습관을 들이는 것이 좋습니다.
마지막으로, 커널 취약점은 모든 컨테이너에 동시에 영향을 준다는 사실을 잊지 말고, 커널 패치·호스트 보안·권한 최소화(권한 없는 컨테이너, seccomp, capabilities 제한)를 컨테이너 설계의 기본 원칙으로 삼는 것이 장기적으로 가장 큰 이득을 줍니다.
출처 및 참고 : Demystifying Docker Base Images: Why Ubuntu in a Container Isn't Really Ubuntu