Ch.22 Container와 Orchestration¶
앞에서 Container의 실체를 확인했다. VM이 아니라 호스트 커널 위에서 돌아가는 격리된 프로세스다. 그런데 "격리"를 어떻게 구현하는 건가? 같은 커널을 쓰는데, 컨테이너 안에서 호스트의 프로세스가 안 보이는 이유는?
Linux 커널의 namespace와 cgroup이다. 이 두 가지가 Container의 전부라고 해도 과언이 아니다.
같은 커널인데 어떻게 격리하나: namespace¶
namespace는 프로세스가 "무엇을 볼 수 있는가"를 제한하는 Linux 커널 기능이다. 비유하자면 이렇다. 사무실에 칸막이를 치는 거다. 같은 건물(커널) 안에 있지만, 칸막이 너머는 보이지 않는다.
Linux에서 제공하는 namespace는 여러 종류가 있다. 각각이 다른 종류의 자원을 격리한다:
| namespace | 격리 대상 | 컨테이너에서의 효과 |
|---|---|---|
| PID | 프로세스 ID | 컨테이너 안에서 PID 1부터 시작. 호스트 프로세스가 안 보인다 |
| Network | 네트워크 인터페이스, IP, 포트 | 컨테이너가 자체 IP와 포트 공간을 가진다 |
| Mount | 파일 시스템 마운트 | 컨테이너가 자체 파일 시스템을 가진다 |
| UTS | hostname | 컨테이너마다 다른 hostname을 가진다 |
| IPC | IPC 자원 (공유 메모리, 세마포어) | Ch.3에서 다뤘던 IPC 자원이 컨테이너별로 격리된다 |
| User | UID/GID | 컨테이너 안의 root가 호스트에서는 일반 사용자일 수 있다 |
namespace (네임스페이스)
Linux 커널이 프로세스에게 "자원의 뷰"를 격리해서 제공하는 메커니즘이다. 같은 커널 위에서 돌아가지만, namespace가 다른 프로세스들은 서로의 자원을 볼 수 없다. Container의 격리를 구현하는 핵심 기술이다.
(Ch.4에서 프로세스마다 독립적인 메모리 공간을 가진다고 했는데, namespace는 그 격리를 파일 시스템, 네트워크, 프로세스 ID 등으로 확장한 거다.)
앞에서 봤던 실험 결과를 다시 떠올려보자:
ps -ef를 쳤더니 컨테이너 안 프로세스만 보였다 -> PID namespace가 호스트의 프로세스를 숨긴 거다hostname이 컨테이너 ID였다 -> UTS namespace가 hostname을 격리한 거다ip addr에서 다른 IP가 나왔다 -> Network namespace가 네트워크를 격리한 거다
namespace를 직접 확인하기: /proc/$$/ns¶
추상 개념이 아니라 파일로 실제로 존재한다. /proc/<pid>/ns/ 디렉토리에 그 프로세스가 속한 namespace 핸들이 심볼릭 링크로 노출된다.
# 호스트에서 현재 셸이 속한 namespace
$ ls -l /proc/$$/ns/
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 uts -> 'uts:[4026531838]'
macOS에는 /proc이 없다
/proc은 Linux 전용 가상 파일시스템이라 macOS에는 존재하지 않는다(ls /proc → No such file or directory). 위 "호스트" 명령은 Linux 호스트 기준이다. mac에서는 Docker/podman이 내부적으로 Linux VM을 띄워 그 안에서 컨테이너를 돌리므로, 호스트 namespace를 보려면 그 Linux VM이 "호스트"가 된다. 아래 컨테이너 버전(docker run ... ls -l /proc/self/ns/)은 mac에서도 그대로 동작한다.
이번에는 컨테이너 안에서 같은 명령을 친다.
$ docker run --rm -it ubuntu:22.04 ls -l /proc/self/ns/
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026532567]' # 다름
lrwxrwxrwx 1 root root 0 ipc -> 'ipc:[4026532563]' # 다름
lrwxrwxrwx 1 root root 0 mnt -> 'mnt:[4026532561]' # 다름
lrwxrwxrwx 1 root root 0 net -> 'net:[4026532566]' # 다름
lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026532564]' # 다름
lrwxrwxrwx 1 root root 0 user -> 'user:[4026531837]' # 같음 (User namespace는 공유 설정)
lrwxrwxrwx 1 root root 0 uts -> 'uts:[4026532562]' # 다름
대괄호 안의 숫자가 namespace의 ID다. 호스트와 컨테이너의 ID가 다르면 그 자원이 격리되어 있다는 뜻이다. 위 출력에서는 pid/net/mnt/uts/ipc/cgroup이 전부 다른 ID로 잡혀 있다. User namespace만 같은데, 이건 Docker 기본 설정에서 User namespace를 격리하지 않기 때문이다(--userns-remap을 켜면 따로 떨어진다).
같은 컨테이너 안에서 다른 프로세스로 들어가면 같은 ID가 다시 나온다. 즉 namespace는 "프로세스 그룹이 공유하는 자원 뷰"라고 볼 수 있다.
namespace는 "뭘 볼 수 있는가"를 제한한다. 하지만 "얼마나 쓸 수 있는가"는 제한하지 않는다. 컨테이너가 CPU를 100% 점유하거나 메모리를 전부 잡아먹는 걸 막으려면? cgroup이 필요하다.
자원은 얼마나 쓸 수 있게 할까: cgroup¶
cgroup(Control Group)은 프로세스 그룹의 자원 사용량을 제한하는 Linux 커널 기능이다. namespace가 "뭘 볼 수 있는가"라면, cgroup은 "얼마나 쓸 수 있는가"다.
cgroup (Control Group)
Linux 커널이 프로세스 그룹의 CPU, 메모리, 디스크 I/O, 네트워크 대역폭 등의 자원 사용량을 제한하고 모니터링하는 메커니즘이다. Google이 2006년에 개발을 시작했고, 2008년에 Linux 커널에 통합됐다.
Docker는 cgroup으로 컨테이너별 자원 제한을 구현한다. Kubernetes의 resources.limits도 결국 cgroup 설정이다.
cgroup이 제한하는 주요 자원:
| 자원 | Docker 옵션 | 효과 |
|---|---|---|
| CPU | --cpus=2 |
최대 CPU 2코어 사용 |
| Memory | --memory=512m |
최대 메모리 512MB 사용 |
| Disk I/O | --device-read-bps |
디스크 읽기 속도 제한 |
| PID | --pids-limit=100 |
최대 프로세스 수 제한 |
cgroup v2를 직접 들여다보기¶
cgroup도 namespace처럼 파일 시스템으로 노출된다. 최신 배포판은 cgroup v2를 쓰고, /sys/fs/cgroup 아래에 트리 구조로 마운트된다. 컨테이너 하나에 한도를 걸어두고 그 안에서 한도 파일을 직접 읽으면 cgroup이 실제로 어떻게 보이는지 알 수 있다.
# 256MB / 0.5코어 한도로 컨테이너를 띄우고 안에서 cgroup 파일을 읽는다
$ docker run --rm -it --memory=256m --cpus=0.5 ubuntu:22.04 bash -c '
echo "[cgroup path]"; cat /proc/self/cgroup
echo "[memory.max]"; cat /sys/fs/cgroup/memory.max
echo "[memory.current]"; cat /sys/fs/cgroup/memory.current
echo "[cpu.max]"; cat /sys/fs/cgroup/cpu.max
echo "[pids.current / pids.max]";
cat /sys/fs/cgroup/pids.current; cat /sys/fs/cgroup/pids.max
'
[cgroup path]
0::/
[memory.max]
268435456 # 정확히 256 * 1024 * 1024 = 256MB
[memory.current]
2244608 # 현재 약 2.1MB 사용 중
[cpu.max]
50000 100000 # 100ms 주기마다 50ms (= 0.5 코어)
[pids.current / pids.max]
2
max
memory.max가 한도, memory.current가 지금 쓰는 양이다. docker stats의 MEM USAGE / LIMIT은 결국 이 두 파일을 읽어서 보여주는 것이다. cpu.max의 "50000 100000"은 "100ms 윈도에서 최대 50ms CPU 사용"이라는 뜻이고, --cpus=0.5가 이 값으로 번역된다.
(cgroup v1이 깔린 시스템은 자원별로 별도 디렉토리에 한도 파일이 흩어져 있다. memory.limit_in_bytes, cpu.cfs_quota_us, cpu.cfs_period_us 같은 이름이다. v2에서 하나의 통합 트리로 정리된 것이다.)
OOM Killer와의 연결¶
Ch.4에서 OOM(Out of Memory)을 다뤘다. ProcessPool 워커를 16개 띄웠더니 메모리가 1GB를 넘기더니 OOM으로 프로세스가 죽었다.
컨테이너 환경에서는 cgroup의 메모리 제한이 OOM의 직접적인 원인이 된다:
컨테이너 안의 프로세스가 512MB를 초과하면? Linux 커널의 OOM Killer가 컨테이너를 죽인다. Kubernetes에서는 이게 Pod의 OOMKilled 상태로 나타난다:
Ch.4에서 "ProcessPool 워커 16개 = 1GB"라는 걸 확인했다. 컨테이너 메모리 제한이 512MB인데 워커를 16개 띄우면? 배포하자마자 OOM으로 죽는다. "왜 Pod이 자꾸 Restart 되는가"의 흔한 원인이 바로 이거다.
(Ch.4에서 봤던 유사 사례 "Docker 컨테이너 메모리 제한"이 기억나는가? 그때는 맛보기였는데, 이제 cgroup이라는 원리를 알게 된 거다.)
정리하면:
- namespace = "뭘 볼 수 있는가" (프로세스, 네트워크, 파일 시스템 등의 격리)
- cgroup = "얼마나 쓸 수 있는가" (CPU, 메모리 등의 제한)
- Container = namespace + cgroup으로 격리된 프로세스
이 두 가지를 합치면 Container가 된다. VM처럼 보이지만, 실체는 커널 기능으로 격리된 프로세스다.
그 격리된 프로세스는 무엇으로 띄우나: Image의 Layer 구조¶
격리된 프로세스라는 실체는 알았으니, 이제 그걸 어디서 가져와 띄우는지를 본다. Container를 실행하려면 Docker Image가 필요하다. Image는 Container의 "설계도"이자 "스냅샷"이다. 그런데 Image의 내부 구조가 독특하다.
Docker Image
컨테이너를 실행하기 위한 읽기 전용 파일 시스템 패키지다. OS 기본 파일, 라이브러리, 애플리케이션 코드, 설정 파일 등이 포함된다. Dockerfile의 각 명령어가 하나의 레이어를 만들고, 이 레이어들이 겹겹이 쌓여서 Image가 된다.
Layer (레이어)
Docker Image를 구성하는 읽기 전용 파일 시스템 계층이다. Dockerfile의 각 명령어(FROM, RUN, COPY 등)가 하나의 레이어를 생성한다. 레이어는 캐싱되므로, 이전 레이어가 변경되지 않았으면 다시 빌드하지 않는다.
Union File System(OverlayFS 등)이 여러 레이어를 하나의 파일 시스템처럼 합쳐서 보여준다. 구체적으로는, 읽기 전용 레이어들(lowerdir)을 쌓아 두고, 그 위에 쓰기 가능한 얇은 레이어(upperdir)를 하나 얹어 둘을 합친 뷰(merged)를 만든다. 컨테이너 안에서 파일을 고치면 원본 레이어는 그대로 둔 채 변경분만 upperdir에 복사해서 쓴다(Copy-on-Write).
그래서 두 가지가 따라 나온다. (1) 이미지 레이어는 읽기 전용이라 컨테이너 여러 개가 같은 이미지를 공유해도 안전하다. (2) 쓰기 레이어(upperdir)는 컨테이너와 수명을 같이하므로, 컨테이너를 지우면 그 안에서 바꾼 파일도 사라진다(ephemeral). 데이터를 살려야 하면 Volume으로 컨테이너 바깥에 따로 보관해야 한다 - "왜 Volume이 필요한가"의 답이 여기 있다.
앞에서 작성한 Dockerfile을 다시 보자:
FROM python:3.12-slim # Layer 1: Python 기본 이미지
WORKDIR /app # Layer 2: 디렉토리 설정
COPY pyproject.toml poetry.lock ./ # Layer 3: 의존성 파일
RUN pip install poetry && \ # Layer 4: Poetry + 의존성 설치
poetry config virtualenvs.create false && \
poetry install --no-dev --no-interaction
COPY csbe_study/ ./csbe_study/ # Layer 5: 소스 코드
CMD ["uvicorn", ...] # 메타데이터 (레이어 아님)
각 줄이 하나의 레이어다. 레이어는 이전 레이어 위에 변경 사항만 저장한다. 마치 Git의 커밋처럼.
왜 이렇게 설계했을까? 캐싱 때문이다.
레이어 캐싱이 빌드 속도를 결정한다¶
소스 코드를 한 줄 고치면 Layer 5만 다시 빌드된다. Layer 1~4는 캐시에서 가져온다. 의존성(pyproject.toml)이 바뀌지 않았으니까. 빌드 시간이 수 분에서 수 초로 줄어든다.
docker build의 출력에서 캐시 적중 여부를 한 줄씩 보여준다. 위 Dockerfile로 두 번 빌드한 결과는 대략 이렇다 (Docker Desktop 4.30, BuildKit 활성화, 측정값은 환경에 따라 달라진다).
| 시나리오 | Layer 1 (FROM) | Layer 3 (의존성 파일) | Layer 4 (pip/poetry install) | Layer 5 (소스 코드) | 총 빌드 시간 |
|---|---|---|---|---|---|
| 1차 빌드 (캐시 없음) | 풀다운 | 빌드 | 빌드 | 빌드 | ~95초 |
소스만 1줄 수정 (csbe_study/...) |
CACHED | CACHED | CACHED | 빌드 | ~5초 |
의존성 추가 (pyproject.toml) |
CACHED | 빌드 | 빌드 | 빌드 | ~70초 |
베이스 이미지 갱신 (FROM python:3.12 변경) |
풀다운 | 빌드 | 빌드 | 빌드 | ~95초 |
CACHED로 표시된 줄은 다시 실행하지 않는다. "소스만 수정" 경우와 "의존성 추가" 경우의 차이가 한 자릿수 vs 두 자릿수 초로 벌어진다. 이게 레이어 순서가 빌드 속도를 결정한다는 의미다.
그래서 Dockerfile에서 "자주 바뀌는 것을 아래에, 덜 바뀌는 것을 위에" 배치하는 게 중요하다:
# 나쁜 순서: 소스 코드를 먼저 복사하면
COPY . . # 소스 코드가 바뀔 때마다
RUN pip install poetry && ... # 의존성도 다시 설치해야 한다
# 좋은 순서: 의존성 파일을 먼저, 소스 코드를 나중에
COPY pyproject.toml poetry.lock ./
RUN pip install poetry && ... # 의존성이 안 바뀌면 캐시 사용
COPY csbe_study/ ./csbe_study/ # 소스 코드만 새로 복사
Multi-stage Build: 이미지 크기 줄이기¶
빌드에 필요한 도구(gcc, make, poetry 등)와 실행에 필요한 파일은 다르다. 빌드 도구까지 이미지에 포함하면 이미지가 불필요하게 커진다.
# Stage 1: 빌드 환경
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN pip install poetry && \
poetry config virtualenvs.create false && \
poetry install --no-dev --no-interaction
# Stage 2: 실행 환경 (빌드 도구 없이 깔끔하게)
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY csbe_study/ ./csbe_study/
CMD ["uvicorn", "csbe_study.main:app", "--host", "0.0.0.0", "--port", "8000"]
최종 이미지에는 Poetry도 없고, 빌드 과정의 임시 파일도 없다. 실행에 필요한 것만 들어 있다.
그래서 VM보다 얼마나 가벼운가: Docker vs VM 성능 차이¶
말로만 "Container가 가볍다"고 하면 감이 안 오니까, 수치를 보자. 아래는 같은 OS(Ubuntu 22.04)를 VM과 Container로 띄웠을 때의 대략적인 차이다.
| 항목 | VM (Ubuntu 22.04) | Container (Ubuntu 22.04) | 차이 |
|---|---|---|---|
| 부팅/시작 시간 | ~30초 (OS 부팅) | ~0.5초 (프로세스 시작) | ~60배 |
| 메모리 오버헤드 | ~500MB (Guest OS) | ~5MB (프로세스만) | ~100배 |
| 디스크 이미지 크기 | ~2GB (OS 전체) | ~80MB (slim) | ~25배 |
| 격리 수준 | 하드웨어 가상화 (커널 분리) | 커널 공유 + namespace/cgroup | |
| 한 호스트에 띄울 수 있는 밀도 | 수십 개 | 수백~수천 개 |
출처: Docker 공식 문서(https://docs.docker.com/get-started/docker-overview/), Microsoft Learn "Containers vs. virtual machines" (2024).
(이 수치가 절대적인 건 아니다. VM 이미지를 최적화하면 훨씬 가볍게 만들 수 있고, Container도 무거운 베이스 이미지를 쓰면 500MB를 넘기기도 한다. 하지만 대략적인 규모감은 이 정도다.)
Container가 가벼운 이유는 간단하다. OS를 통째로 올리는 게 아니라, 프로세스 하나를 격리한 것이기 때문이다. 커널은 호스트와 공유하니까 커널 메모리가 추가로 들지 않는다.
이걸 수십 대로 굴리면: Kubernetes¶
Container 하나 두 개는 Docker로 충분하다. docker compose up 하면 끝이니까. 그런데 서비스가 커지면 이야기가 달라진다.
- 서버가 10대이고, 각 서버에 Container가 20개씩 돌아간다
- Container가 죽으면 자동으로 다시 띄워야 한다
- 트래픽이 늘면 Container를 더 띄우고, 줄면 줄여야 한다
- Container 간 네트워크 통신이 필요하다
- 배포할 때 기존 Container를 하나씩 교체해야 한다 (무중단 배포)
이걸 수동으로 관리하면? 운영자가 지친다. 자동으로 관리해주는 도구가 필요하다. 이걸 Container Orchestration이라 하고, 그 도구가 Kubernetes(K8s)다.
Kubernetes (K8s)
Google이 내부에서 사용하던 Borg 시스템의 경험을 바탕으로 만든 오픈소스 컨테이너 오케스트레이션 플랫폼이다. 2014년에 공개됐고, 현재 CNCF(Cloud Native Computing Foundation)가 관리한다.
Container의 배포, 스케일링, 네트워킹, 장애 복구를 자동화한다. "원하는 상태(Desired State)"를 선언하면, K8s가 현재 상태를 원하는 상태와 일치시키려고 끊임없이 노력한다.
(이름이 길어서 K-u-b-e-r-n-e-t-e-s에서 중간 8글자를 빼고 K8s라고 줄여 쓴다.)
K8s를 깊이 다루는 건 이 강의의 범위를 벗어난다. 하지만 핵심 개념 네 가지는 알아야 한다. 면접에서도 나오고, 실무에서 매일 마주치니까. 본격적으로 들어가기 전에 한 표로 먼저 정리한다.
| 리소스 | 역할 | "이걸 모르면" 생기는 일 | 주로 다루는 필드 |
|---|---|---|---|
| Pod | 컨테이너의 실행 단위. 같은 IP/볼륨을 공유하는 컨테이너 묶음 | Pod이 죽었을 때 왜 IP가 바뀌는지 모른다 | containers, resources |
| Deployment | Pod을 몇 개 유지할지, 어떻게 업데이트할지 선언 | 무중단 배포·롤백을 수동으로 한다 | replicas, strategy |
| Service | Pod 집합에 안정적인 DNS 이름과 ClusterIP를 제공 | Pod IP를 하드코딩해서 매번 깨진다 | selector, ports |
| Ingress | 클러스터 외부 HTTP/HTTPS 트래픽을 Service로 라우팅 | LoadBalancer Service를 도메인마다 따로 만든다 | host, path, tls |
이 네 개가 가장 기본이다. Pod이 "프로세스의 묶음", Deployment가 "그 묶음을 N개 복제·교체", Service가 "그 묶음에 이름 붙이기", Ingress가 "외부에서 접근할 라우팅 규칙"이다.
Pod: Container의 실행 단위¶
K8s에서 Container를 직접 관리하지 않는다. Pod이라는 단위로 관리한다.
Pod (파드)
Kubernetes에서 배포할 수 있는 가장 작은 단위다. 하나 이상의 Container를 포함한다. 같은 Pod 안의 Container들은 네트워크(IP, 포트)와 저장소를 공유한다.
대부분의 경우 Pod 하나에 Container 하나를 넣는다. 하지만 메인 Container를 보조하는 사이드카 Container(로그 수집, 프록시 등)를 같은 Pod에 넣기도 한다.
# Pod 정의 (보통 직접 만들지 않고 Deployment가 만든다)
apiVersion: v1
kind: Pod
metadata:
name: csbe-app
spec:
containers:
- name: app
image: csbe-study:latest
ports:
- containerPort: 8000
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi" # cgroup 메모리 제한
cpu: "500m" # cgroup CPU 제한
resources.limits가 보이는가? 이게 앞에서 다뤘던 cgroup 설정이다. K8s가 이 값을 cgroup에 전달해서 Container의 자원을 제한한다.
Deployment: Pod를 몇 개 띄울지 관리¶
Deployment (디플로이먼트)
Pod의 "원하는 상태"를 선언하는 K8s 리소스다. "이 이미지로 Pod을 3개 유지하라"고 선언하면, K8s가 항상 3개를 유지한다. Pod이 죽으면 새로 만들고, 이미지를 업데이트하면 하나씩 교체한다(Rolling Update).
apiVersion: apps/v1
kind: Deployment
metadata:
name: csbe-app
spec:
replicas: 3 # Pod 3개 유지
selector:
matchLabels:
app: csbe
template:
metadata:
labels:
app: csbe
spec:
containers:
- name: app
image: csbe-study:latest
ports:
- containerPort: 8000
replicas: 3이면 항상 Pod이 3개 유지된다. Pod 하나가 죽으면? K8s가 자동으로 새 Pod을 만든다. 이미지를 업데이트하면? 기존 Pod을 하나씩 교체하면서 무중단 배포를 한다.
"하나씩"이라는 게 정확히 몇 개씩인지는 두 값이 정한다. maxUnavailable(교체 중 동시에 내려도 되는 Pod 수)과 maxSurge(정원을 넘겨 임시로 더 띄워도 되는 Pod 수)다. 기본값은 둘 다 25%라 "대략 하나씩"처럼 보인다. 가용성이 중요하면 maxUnavailable: 0(먼저 새 Pod을 띄우고 나서 헌 걸 내림)으로, 배포 속도가 중요하면 두 값을 키운다. 배포가 예상보다 느리거나, 배포 중 잠깐 용량이 떨어지는 현상은 대개 이 두 값을 안 건드려서 생긴다.
(Ch.19에서 "Replica를 200개로 늘려볼까요?"를 다뤘다. K8s에서는 이 replicas 값을 바꾸면 Scale-Out이 된다. 그런데 Ch.19에서 봤듯이, Bottleneck을 안 찾고 replicas만 올리면 돈만 날린다.)
Service: Pod을 어떻게 찾는가¶
Pod은 동적으로 생성되고 삭제된다. Pod이 죽으면 새 Pod이 만들어지는데, IP가 바뀐다. 다른 서비스가 이 Pod에 요청을 보내려면 어떻게 해야 하는가?
IP를 직접 쓸 수 없다. Pod이 재시작될 때마다 바뀌니까. 이름으로 찾아야 한다.
Service (서비스, K8s)
Pod 집합에 대한 안정적인 네트워크 엔드포인트를 제공하는 K8s 리소스다. Pod의 IP가 바뀌어도 Service의 DNS 이름과 ClusterIP는 변하지 않는다. 요청을 받으면 label selector에 매칭되는 Pod 중 하나로 라우팅한다.
앞에서 docker-compose에서 서비스 이름(mysql)으로 접근한 것과 같은 원리다. K8s에서는 이걸 더 체계적으로 관리한다.
apiVersion: v1
kind: Service
metadata:
name: csbe-app-svc
spec:
selector:
app: csbe # label이 app=csbe인 Pod에 라우팅
ports:
- port: 80
targetPort: 8000
다른 서비스에서 csbe-app-svc:80으로 요청을 보내면, K8s가 app=csbe label이 붙은 Pod 중 하나로 라우팅해준다. Pod이 죽고 새로 만들어져도, Service 이름은 변하지 않는다.
IP가 계속 바뀌는데 어떻게 찾나: Service Discovery¶
Service가 이름을 제공한다는 건 봤는데, 그 이름이 실제 위치로 어떻게 연결되는지가 남았다. Service Discovery는 "서비스의 위치(IP:Port)를 동적으로 찾는 메커니즘"이다. 왜 필요한가?
전통적인 서버 환경에서는 서버 IP가 고정이다. 설정 파일에 db.host=10.0.0.5라고 적어놓으면 된다. 그런데 Container 환경에서는:
- Container가 죽으면 새 Container가 만들어진다 (IP가 바뀐다)
- Scale-Out하면 Container가 늘어난다 (여러 IP가 생긴다)
- Scale-In하면 Container가 줄어든다 (IP가 사라진다)
IP를 하드코딩할 수 없다. 이름으로 찾아야 한다.
Service Discovery (서비스 디스커버리)
분산 시스템에서 서비스의 네트워크 위치(IP:Port)를 동적으로 찾는 메커니즘이다. DNS 기반(K8s Service, Consul), 레지스트리 기반(Eureka, etcd), 사이드카 기반(Envoy, Istio) 등 여러 구현 방식이 있다.
Container/K8s 환경에서는 필수다. Pod의 IP가 동적으로 바뀌기 때문에, 이름 기반으로 서비스를 찾을 수 있어야 한다.
K8s에서 Service Discovery는 DNS 기반으로 동작한다:
# 같은 namespace 안에서
csbe-app-svc -> ClusterIP로 해석
csbe-app-svc:80 -> Pod 중 하나로 라우팅
# 다른 namespace에서
csbe-app-svc.default.svc.cluster.local -> 정규화된 DNS 이름
앞에서 docker-compose에서 DB_HOST: mysql로 서비스 이름을 쓴 것과 같은 원리다. docker-compose는 내장 DNS로 이걸 처리하고, K8s는 CoreDNS라는 클러스터 DNS 서버가 처리한다.
동작 예: 다른 Pod을 DNS 이름으로 부르기¶
같은 클러스터에 mysql이라는 Service와 csbe-app이라는 Pod이 있다고 하자. csbe-app Pod 안에서 직접 DNS를 찔러보면 다음과 같다.
# csbe-app Pod 안 셸로 진입
$ kubectl exec -it csbe-app-7b9c8d-abcde -- bash
# 1) 짧은 이름으로 해석된다 (search 도메인 덕분)
$ getent hosts mysql
10.96.42.17 mysql.default.svc.cluster.local
# 2) FQDN으로도 같은 ClusterIP가 나온다
$ getent hosts mysql.default.svc.cluster.local
10.96.42.17 mysql.default.svc.cluster.local
# 3) 그 ClusterIP는 selector로 묶인 실제 Pod IP로 라우팅된다
$ kubectl get endpoints mysql
NAME ENDPOINTS AGE
mysql 10.244.1.5:3306,10.244.2.7:3306 3h
# 4) 애플리케이션 코드는 IP가 아니라 이름만 안다
$ python -c "import socket; print(socket.gethostbyname('mysql'))"
10.96.42.17
여기서 중요한 건 두 가지다. 첫째, Pod이 죽고 새로 떠서 endpoints가 10.244.1.5 대신 10.244.3.9로 바뀌어도 mysql이라는 이름과 ClusterIP 10.96.42.17은 그대로다. 둘째, 한 Service 뒤에 여러 Pod이 붙어 있으면(replicas: 2처럼) 같은 이름으로 받은 트래픽이 두 Pod 중 하나로 라운드 로빈 분배된다. 애플리케이션은 그걸 신경 쓸 필요가 없다. IP가 아니라 이름으로 부르기 때문이다.
전체 그림: 코드에서 서비스까지¶
지금까지 다룬 내용을 정리하면 이렇다:
graph LR
CODE["소스 코드"] -->|"docker build"| IMG["Docker Image<br/>(Layer 구조)"]
IMG -->|"docker run"| CONT["Container<br/>(namespace + cgroup)"]
CONT -->|"K8s가 관리"| POD["Pod"]
POD -->|"Deployment가 복제"| PODS["Pod x N"]
PODS -->|"Service가 라우팅"| SVC["Service<br/>(DNS 이름)"]
SVC -->|"외부 접근"| USER["사용자"]
- 코드를 Dockerfile로 빌드하면 Docker Image가 된다
- Image를 실행하면 Container가 된다 (namespace + cgroup으로 격리된 프로세스)
- K8s에서 Container는 Pod 단위로 관리된다
- Deployment가 Pod을 원하는 수만큼 유지한다
- Service가 DNS 이름을 제공해서, 다른 서비스가 이름으로 찾을 수 있다
이게 현대 백엔드 서비스의 배포 파이프라인이다.
한 가지 강조하고 싶은 게 있다. 이 전체 구조에서 Container의 원리(namespace + cgroup)를 모르면 어떻게 되는가?
- OOM으로 Pod이 죽는데 왜 죽는지 모른다 (cgroup 메모리 제한을 모르니까)
- Container 간 네트워크가 안 되는데 디버깅을 못 한다 (Network namespace를 모르니까)
- Dockerfile을 대충 작성해서 빌드가 10분씩 걸린다 (레이어 캐싱을 모르니까)
- K8s의 resources.limits가 뭔지 모른 채 설정한다 (cgroup과의 관계를 모르니까)
Container의 원리를 모르면 K8s도 제대로 쓸 수 없다.