쿠버네티스가 컨테이너 런타임을 몰라도 되게 만든 인터페이스, CRI와 CNI
CRI가 컨테이너 런타임을, CNI가 네트워킹을 쿠버네티스에서 어떻게 떼어내는지 containerd·CRI-O·Calico·Cilium 사례로 정리한다
2026-08-12 · 최초 발행 2026-01-19
CRI와 CNI는 이름부터 인터페이스다. 컨테이너 런타임과 네트워킹처럼 구현체가 여럿이라 표준이 필요한 두 영역을, 쿠버네티스라는 오케스트레이터에서 떼어내는 것이 이 둘의 역할이다. CNCF(Cloud Native Computing Foundation)는 이런 표준들을 포함해 클라우드 네이티브 생태계 전반의 프로젝트를 관리하는 상위 조직이다.
CNCF는 표준을 정의하고, 구현은 여러 프로젝트가 나눠 맡는다
CNCF Graduated 프로젝트만 봐도 역할이 뚜렷이 나뉜다. Kubernetes는 오케스트레이션을, containerd는 런타임을, Envoy는 서비스 메시 데이터 플레인을, CoreDNS는 클라우드 네이티브 DNS를, etcd는 분산 키-값 저장을, Helm은 패키지 관리를, Prometheus는 메트릭 모니터링을, Fluentd는 로깅을 맡는다. 이 프로젝트들이 각자 표준 인터페이스를 사이에 두고 조립되기 때문에, 한 계층의 구현체를 바꿔도 다른 계층은 영향받지 않는다. CRI와 CNI는 그 조립을 가능하게 하는 접합부다.
CRI: 런타임을 갈아 끼울 수 있게 만드는 gRPC 계약
CRI(Container Runtime Interface)는 kubelet이 컨테이너를 다루는 방식을 gRPC API로 정의한 표준이다. kubelet은 이 API만 알면 되고, 실제로 컨테이너를 만들고 실행하는 일은 API 뒤의 런타임이 담당한다.
CRI가 정의하는 gRPC 인터페이스는 RuntimeService와 ImageService 두 축이다.
// CRI RuntimeService 프로토콜 정의 (주요 메서드)
service RuntimeService {
// Pod Sandbox 관리
rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse);
rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse);
rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse);
rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse);
rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse);
// Container 관리
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
rpc StartContainer(StartContainerRequest) returns (StartContainerResponse);
rpc StopContainer(StopContainerRequest) returns (StopContainerResponse);
rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse);
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse);
// 실행 관리
rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse);
rpc Exec(ExecRequest) returns (ExecResponse);
}
service ImageService {
// 이미지 관리
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse);
rpc PullImage(PullImageRequest) returns (PullImageResponse);
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
}
kubelet은 이 두 서비스만 호출하면 되기 때문에, 런타임을 containerd에서 CRI-O로 바꾸더라도 kubelet 쪽 코드는 그대로다.
containerd는 Docker의 코어 런타임이었던 부분을 독립시킨 프로젝트로, CRI 플러그인을 활성화하면 kubelet과 바로 통신한다.
# containerd 설치 및 설정
apt-get update
apt-get install -y containerd
# 설정 파일 생성
mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
# CRI 플러그인 활성화
cat <<EOF | tee -a /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri"]
[plugins."io.containerd.grpc.v1.cri".containerd]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
EOF
# containerd 재시작
systemctl restart containerd
# crictl을 이용한 컨테이너 관리
crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps
crictl --runtime-endpoint unix:///run/containerd/containerd.sock images
CRI-O는 처음부터 쿠버네티스 전용으로 설계된 런타임이라 더 가볍다.
# CRI-O 설치
OS=xUbuntu_22.04
VERSION=1.28
echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/ /" > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/$VERSION/$OS/ /" > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable:cri-o:$VERSION.list
apt-get update
apt-get install -y cri-o cri-o-runc
# CRI-O 설정
cat <<EOF | tee /etc/crio/crio.conf
[crio.runtime]
default_runtime = "runc"
[crio.runtime.runtimes.runc]
runtime_path = "/usr/bin/runc"
runtime_type = "oci"
[crio.network]
network_dir = "/etc/cni/net.d/"
plugin_dirs = ["/opt/cni/bin/"]
EOF
# CRI-O 시작
systemctl enable crio
systemctl start crio
# Pod 및 컨테이너 확인
crictl --runtime-endpoint unix:///var/run/crio/crio.sock pods
crictl --runtime-endpoint unix:///var/run/crio/crio.sock ps
둘 다 최종 실행은 OCI 표준 런타임인 runc에 위임하고, runc가 Linux 네임스페이스와 cgroup을 조작해 실제 격리를 만든다. 즉 CRI 계층에서 어떤 런타임을 고르든, 바닥에서는 결국 같은 커널 기능 위에서 돌아간다.
CNI: 네트워크 설정도 똑같이 플러그인화한다
CNI(Container Network Interface)는 컨테이너에 네트워크 인터페이스를 붙이고 떼는 절차를 표준화한 스펙이다. CRI가 런타임을 추상화했다면, CNI는 그 위에서 네트워킹을 추상화한다.
CNI 플러그인은 실행 파일이고, CNI_COMMAND(ADD/DEL), CNI_CONTAINERID, CNI_NETNS, CNI_IFNAME 같은 환경 변수로 무엇을 어디에 붙일지 전달받는다.
# CNI 플러그인은 환경 변수로 설정 전달받음
export CNI_COMMAND=ADD
export CNI_CONTAINERID=container123
export CNI_NETNS=/var/run/netns/container123
export CNI_IFNAME=eth0
export CNI_PATH=/opt/cni/bin
# CNI 설정을 stdin으로 전달
cat /etc/cni/net.d/10-mynet.conf | /opt/cni/bin/bridge
# 플러그인은 stdout으로 결과 반환 (JSON 형식)
# {
# "cniVersion": "1.0.0",
# "interfaces": [...],
# "ips": [
# {
# "version": "4",
# "address": "10.244.1.5/24",
# "gateway": "10.244.1.1"
# }
# ]
# }
네트워크 설정 자체는 이런 JSON 스펙으로 정의된다.
{
"cniVersion": "1.0.0",
"name": "mynet",
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"subnet": "10.244.0.0/16",
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
}
언어나 구현 방식에 무관하게 이 계약만 지키면 CNI 플러그인이 된다. IPAM(IP Address Management) 서브플러그인이 따로 있어 IP 할당 방식(정적 host-local, 동적 DHCP)도 네트워크 플러그인과 분리돼 있다.
Calico와 Cilium은 같은 CNI 인터페이스를 구현하면서도 접근 방식이 정반대다. Calico는 BGP 라우팅으로 노드 간 경로를 알리는 방식이라 전통적인 네트워크 장비의 사고방식에 가깝다.
# Calico 설치 (Kubernetes)
apiVersion: v1
kind: ConfigMap
metadata:
name: calico-config
namespace: kube-system
data:
cni_network_config: |-
{
"name": "k8s-pod-network",
"cniVersion": "0.3.1",
"plugins": [
{
"type": "calico",
"log_level": "info",
"datastore_type": "kubernetes",
"nodename": "__KUBERNETES_NODE_NAME__",
"ipam": {
"type": "calico-ipam"
},
"policy": {
"type": "k8s"
},
"kubernetes": {
"kubeconfig": "__KUBECONFIG_FILEPATH__"
}
},
{
"type": "portmap",
"capabilities": {"portMappings": true}
}
]
}
---
# Calico DaemonSet
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: calico-node
namespace: kube-system
spec:
selector:
matchLabels:
k8s-app: calico-node
template:
metadata:
labels:
k8s-app: calico-node
spec:
hostNetwork: true
containers:
- name: calico-node
image: calico/node:v3.26.0
env:
- name: CALICO_NETWORKING_BACKEND
value: "bird"
- name: CLUSTER_TYPE
value: "k8s,bgp"
volumeMounts:
- name: cni-bin-dir
mountPath: /host/opt/cni/bin
- name: cni-net-dir
mountPath: /host/etc/cni/net.d
volumes:
- name: cni-bin-dir
hostPath:
path: /opt/cni/bin
- name: cni-net-dir
hostPath:
path: /etc/cni/net.d
Cilium은 eBPF로 커널 안에서 직접 패킷을 처리해 iptables 규칙 체인을 우회한다.
# Cilium 설치
apiVersion: v1
kind: ConfigMap
metadata:
name: cilium-config
namespace: kube-system
data:
enable-ipv4: "true"
enable-ipv6: "false"
enable-bpf-masquerade: "true"
enable-host-reachable-services: "true"
bpf-lb-algorithm: "maglev"
kube-proxy-replacement: "strict"
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: cilium
namespace: kube-system
spec:
selector:
matchLabels:
k8s-app: cilium
template:
metadata:
labels:
k8s-app: cilium
spec:
hostNetwork: true
containers:
- name: cilium-agent
image: cilium/cilium:v1.14.0
args:
- --config-dir=/tmp/cilium/config-map
env:
- name: K8S_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: bpf-maps
mountPath: /sys/fs/bpf
mountPropagation: Bidirectional
- name: cni-path
mountPath: /host/opt/cni/bin
volumes:
- name: bpf-maps
hostPath:
path: /sys/fs/bpf
type: DirectoryOrCreate
- name: cni-path
hostPath:
path: /opt/cni/bin
type: DirectoryOrCreate
kube-proxy-replacement 같은 옵션이 있는 것도 이 때문이다 — Cilium은 kube-proxy가 하던 일까지 eBPF로 대체할 수 있다.
인터페이스는 결국 Linux 네임스페이스와 cgroup으로 내려간다
runc가 컨테이너를 만들 때 하는 일은 근본적으로 두 가지다. unshare 시스템 콜로 마운트(CLONE_NEWNS)·UTS·IPC·PID·네트워크(CLONE_NEWNET) 네임스페이스를 새로 만들어 프로세스를 격리하고, cgroup 경로에 cpu.shares나 memory.limit_in_bytes 같은 파일을 써서 자원 상한을 건다. 최소 구현으로 보면 이런 식이다.
# Python을 이용한 컨테이너 런타임 기본 구현
import os
import subprocess
from ctypes import CDLL, c_int
# Linux 시스템 콜 로드
libc = CDLL('libc.so.6')
# Namespace 플래그
CLONE_NEWNS = 0x00020000 # 마운트 네임스페이스
CLONE_NEWUTS = 0x04000000 # UTS 네임스페이스
CLONE_NEWIPC = 0x08000000 # IPC 네임스페이스
CLONE_NEWPID = 0x20000000 # PID 네임스페이스
CLONE_NEWNET = 0x40000000 # 네트워크 네임스페이스
def create_container(rootfs_path, command):
"""기본 컨테이너 생성"""
# 1. Namespace 생성
flags = CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNET
libc.unshare(c_int(flags))
# 2. 루트 파일시스템 변경
os.chroot(rootfs_path)
os.chdir('/')
# 3. 프로세스 실행
subprocess.run(command, shell=True)
# Cgroup 리소스 제한
def setup_cgroups(container_id, cpu_shares=512, memory_limit_mb=512):
"""Cgroup 설정"""
# CPU 제한
cpu_cgroup_path = f"/sys/fs/cgroup/cpu/containers/{container_id}"
os.makedirs(cpu_cgroup_path, exist_ok=True)
with open(f"{cpu_cgroup_path}/cpu.shares", 'w') as f:
f.write(str(cpu_shares))
# 메모리 제한
memory_cgroup_path = f"/sys/fs/cgroup/memory/containers/{container_id}"
os.makedirs(memory_cgroup_path, exist_ok=True)
with open(f"{memory_cgroup_path}/memory.limit_in_bytes", 'w') as f:
f.write(str(memory_limit_mb * 1024 * 1024))
# PID를 cgroup에 추가
pid = os.getpid()
with open(f"{cpu_cgroup_path}/cgroup.procs", 'w') as f:
f.write(str(pid))
with open(f"{memory_cgroup_path}/cgroup.procs", 'w') as f:
f.write(str(pid))
CNI가 네트워크 인터페이스를 붙일 때도 마찬가지로 네트워크 네임스페이스를 만들고 veth 페어의 한쪽 끝을 그 네임스페이스 안으로 옮긴 뒤, 반대쪽 끝을 브리지에 연결하는 식으로 동작한다.
# 컨테이너 네트워킹 설정
# 1. 네트워크 네임스페이스 생성
ip netns add container1
# 2. veth 페어 생성
ip link add veth0 type veth peer name veth1
# 3. veth1을 컨테이너 네임스페이스로 이동
ip link set veth1 netns container1
# 4. 호스트 측 veth0을 브리지에 연결
ip link set veth0 master cni0
ip link set veth0 up
# 5. 컨테이너 측 veth1 설정
ip netns exec container1 ip addr add 10.244.1.5/24 dev veth1
ip netns exec container1 ip link set veth1 up
ip netns exec container1 ip route add default via 10.244.1.1
# 6. NAT 설정 (외부 통신)
iptables -t nat -A POSTROUTING -s 10.244.0.0/16 -j MASQUERADE
CRI와 CNI가 상위 계층에서 표준 인터페이스를 제공하지만, 격리 자체는 여전히 커널 기능이 하고 있다는 뜻이다.
Pod 하나가 뜨는 순서: CRI가 먼저, CNI가 그다음
순서가 이렇게 고정된 이유는 명확하다. 네트워크를 붙이려면 먼저 네임스페이스가 있어야 하고, 컨테이너 프로세스를 시작하려면 그 프로세스가 붙을 네트워크가 이미 준비돼 있어야 한다. RunPodSandbox가 먼저 네임스페이스와 cgroup의 뼈대를 만들고, CNI가 그 안에 네트워크를 채운 다음에야 CreateContainer와 StartContainer가 호출된다. kubelet이 CNI를 호출하는 코드는 대략 이런 구조다.
// Kubernetes kubelet CNI 호출 (Go 코드 예시)
package main
import (
"context"
"encoding/json"
"fmt"
"os/exec"
cni "github.com/containernetworking/cni/pkg/invoke"
"github.com/containernetworking/cni/pkg/types"
)
type CNIManager struct {
cniConfigPath string
cniBinPath string
}
func (m *CNIManager) SetupPodNetwork(podID, namespace, containerID, netns string) error {
// CNI 설정 로드
netConfig, err := m.loadCNIConfig()
if err != nil {
return err
}
// CNI 플러그인 실행
rt := &cni.RuntimeConf{
ContainerID: containerID,
NetNS: netns,
IfName: "eth0",
Args: [][2]string{
{"IgnoreUnknown", "1"},
{"K8S_POD_NAMESPACE", namespace},
{"K8S_POD_NAME", podID},
},
}
result, err := cni.AddNetwork(context.Background(), netConfig, rt)
if err != nil {
return err
}
// IP 주소 확인
ipResult, _ := json.Marshal(result)
fmt.Printf("Pod network setup complete: %s\n", ipResult)
return nil
}
func (m *CNIManager) TeardownPodNetwork(containerID, netns string) error {
netConfig, err := m.loadCNIConfig()
if err != nil {
return err
}
rt := &cni.RuntimeConf{
ContainerID: containerID,
NetNS: netns,
IfName: "eth0",
}
return cni.DelNetwork(context.Background(), netConfig, rt)
}
보안과 모니터링도 같은 계층에서 걸린다
Seccomp 프로파일은 컨테이너가 호출할 수 있는 시스템 콜을 화이트리스트로 제한하는 방식으로 동작하며, defaultAction을 SCMP_ACT_ERRNO로 두고 허용할 콜만 SCMP_ACT_ALLOW로 지정하는 식이다.
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"read", "write", "open", "close", "stat", "fstat",
"lseek", "mmap", "mprotect", "munmap", "brk",
"rt_sigaction", "rt_sigprocmask", "ioctl", "access",
"socket", "connect", "accept", "sendto", "recvfrom"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
런타임 모니터링 쪽에서는 crictl stats로 CRI 수준의 컨테이너 리소스 사용량을, Cilium이라면 cilium monitor로 eBPF 레벨의 패킷 드롭·트레이스를 확인할 수 있고, containerd는 /metrics 엔드포인트로 Prometheus 스크레이핑을 지원한다.
# containerd 메트릭 확인
crictl stats
# CNI 네트워크 통계
ip netns exec container1 ifconfig eth0
# eBPF를 이용한 네트워크 모니터링 (Cilium)
cilium monitor --type drop
cilium monitor --type trace --to-endpoint <pod-ip>
# Prometheus 메트릭 수집
# containerd는 /metrics 엔드포인트 제공
curl http://localhost:1338/v1/metrics
CRI와 CNI가 표준 인터페이스인 덕분에, 모니터링 도구도 런타임이나 네트워크 플러그인을 바꿀 때마다 새로 만들 필요가 없다.