Check EKS worker node information

안녕하십니까.
CloudNet@ 팀 'Gasida' 님이 진행하시는 스터디 내용 중,
이전에는 EKS의 제어부 및 시스템 파드에 대해서 알아보았다면
오늘은 워커 노드 정보에 대해서 알아보도록 하겠습니다.
01. 노드 보안그룹 상세 확인
보안 그룹은 아래의 명령으로도 확인이 가능하지만, 직관적으로 보기위해 Console로 살펴보자.
$ aws ec2 describe-security-groups | jq
$ aws ec2 describe-security-groups --filters "Name=tag:Name,Values=myeks-node-group-sg" | jq
$ aws ec2 describe-security-groups --filters "Name=tag:Name,Values=myeks-node-group-sg" --query 'SecurityGroups[*].IpPermissions' --output text
📌 노드 보안 그룹
Node가 가용중인 EC2 인스턴스의 보안그룹을 확인하였을 때 아래와 같이 두 개의 보안그룹이 할당되어있다.

그 중 먼저 'myeks-node-2026 ~' 보안 그룹에 대해서 살펴보도록 하겠습니다.

10개의 인바운드 허용 규칙이 설정되어 있고, EKS클러스터 생성 시 자동 생성되는 노드 그룹인듯 합니다.

Source 부분을 확인해본다면 Control Plane과 노드 사이, 그리고 노드끼리 통신하기 위한 규칙들이
EKS가 자동으로 관리해주는 보안그룹 입니다.
- Source : myeks-cluster SG (Control Plane → Node 방향)
- TCP 443 - Control Plane이 Managed Node Group에 접근할 때
- TCP 10250 - kubewlet API 호출 (로그 조회, exec, health check 등)
- TCP 4443 / 6443 / 8443 / 9443 / 10251 - 모두 Webhook 포트들입니다.
- Admission Webhook 서버나 컨트롤러 (ALB Controller, Karpenter 등)가 노드 위에서 실행될 때, API Server가 해당 포트로 HTTP 요청을 보내야 하기 때문에 허용된 것입니다.
- Source : myeks-node-group SG (Node → Node 방향)
- TCP 1025~65535 - 노드 간 통신 시 ephemral port 범위 전체 허용
- UDP/TCP 53 - CoreDNS쿼리 (클러스터 내 DNS 해석)에 사용됩니다.
다음, 'myeks-node-group-sg' 보안 그룹에 대해서 살펴보겠습니다.

Managed Node Group 생성 시 별도로 만들어지는 보안 그룹으로,
사용자가 직접 접근하기 위한 규칙을 넣는 곳인 것 같습니다 (해당 소스는 저의 공유기 IP 입니다)
그렇다면, Cluster 보안 그룹에는 어떤 규칙이 허용되어 있을까?

Cluster 보안 그룹의 인바운드 규칙은 소스가 노드의 보안 그룹에 대해 443 포트를 허용해주는 규칙이 있네요.
- 노드들이 API Server(kube-apiserver)에 HTTPS로 붙는 것을 허용 하는 규칙입니다.
- 노드 위에서 실행되는 kubelet, kube-proxy 등 각종 add-on들이 API Server에 연결할 때 전부 443을 씁니다.
즉, 정리하자면 아래와 같습니다.
- Node → API Server : Cluster 보안 그룹 Inbound 443 포트
- API Server → Node : Shared Node 보안 그룹 Inbound 10250, webhook 포트들
- Node → Node : Shared Node 보안그룹 Inbound 1025~65535, 53 포트
02. k8s 노드 동작을 위한 필수 설정
📌 노드가 가용중인 EC2 내부에 접속
로컬 환경이 어떤가에 따라 다르겠지만, key pair을 이용하여 접근을 하시면 되겠습니다.
ssh -i ~/workspace/aews4/accesskey/kp-sunghwan.pem ec2-user@3.35.172.15
📌 SELinux 설정
# 현재 모드 출력 (Enforcing / Permissive / Disabled)
getenforce
# 상세 상태 확인
sestatus

SELinux는 프로세스가 파일/소켓에 접근할 때 추가 정책으로 차단하는 MAC(강제 접근 제어) 시스템입니다.
K8s에서 Enforcing으로 두면 kubelet, containerd, CNI 플러그인이 /var/lib/kubelet, 소켓 파일 등에 접근할 때정책 충돌로
조용히 실패하는 경우가 생깁니다.그래서 쿠버네티스 공식 문서도 Permissive(감지) 또는 Disabled를 권장합니다.
EKS AMI는 기본적으로 Permissive로 셋팅이 되어있음.
📌 Swap 비활성화
# 현재 swap 사용량 확인
free -h
# 재부팅 후에도 swap 마운트 안 되는지 확인
cat /etc/fstab
kubelet은 swap이 활성화된 상태를 기본적으로 거부합니다.
이유는 메모리 관리 때문인데요. K8s는 Pod에 requests/limits 로 메모리를 보장하는데,
swap이 있으면 OOM이 발생해야 할 Pod가 swap으로 버티면서 어떤 Pod가 실제로 메모리를 얼마나 쓰는지 예측이
불가능 해집니다. 스케줄러의 메모리 보장 모델 자체가 흔들려요.
📌 cgroup v2 확인
stat -fc %T /sys/fs/cgroup/
출력: cgroup2fs → v2 사용 중
tmpfs → v1 사용 중
cgroup(Control Group)은 프로세스별로 CPU / Memory 자원을 제한하는 커널 기능입니다.
kubelet과 containerd가 이걸 통해 각 컨테이너의 자원을 격리합니다.
| cgroup v1 | cgroup v2 | |
| 구조 | 계층이 컨트롤러별로 분리 | 단일 통합 계층 |
| 메모리 관리 | 제한적 | PSI(압박 지표) 등 정교함 |
| k8s 권장 | 여전히 지원 | 1.25부터 stable, 권장 |
※ EKS 최신 AMI (AL2023 기반)는 cgroup v2를 기본으로 사용합니다.
📌 overlay 커널 모듈 로드 확인
lsmod | grep overlay
containerd의 기본 스냅샷 드라이버는 'OverlayFS' 입니다.
컨테이너 이미지의 레이어를 효율적으로 쌓는 방식인데, 커널에 overlay 모듈이 로드되어 있어야 동작합니다.
overlay 모듈이 로드가 되어있을 때 아래 구조로 동작합니다.

- 이미지 레이어는 여러 컨테이너가 공유(읽기 전용)하고, 각 컨테이너가 파일을 수정하면 UpperDir에만 기록됨.
- 모듈 없이는 이 구조 자체가 불가능.
📌 containerd 스냅샷 확인
위 OverlayFS 레이어들이 실제로 디스크에 저장된 위치가 바로 아래 경로입니다.
ctr -n k8s.io snapshots ls
ls -la /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/
tree /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ -L 3

- ctr로 논리적 스냅샷 목록을 확인이 가능하다.


- ls / tree로 실제 파일 경로를 대조해볼 수 있다.
- 각 스냅샷 디렉토리 안에 fs/ (실제 파일), work/ (OverlayFS 작업 디렉토리)가 있다.
📌 커널 파라미터 확인
k8s 노드 트러블슈팅할 때 "설정은 맞는데 왜 안되지?"의 원인의 절반이 커널 파라미터 입니다.
특히 EKS처럼 관리형이어도 커스텀 AMI나 노드 그룹 설정에 따라 값이 달라질 수 있으므로 확인 필수!
tree /etc/sysctl.d/
cat /etc/sysctl.d/00-defaults.conf
cat /etc/sysctl.d/99-sysctl.conf
cat /etc/sysctl.d/99-amazon.conf
cat /etc/sysctl.d/99-kubernetes-cri.conf
1) 00-defaults.conf - "이 노드가 대규모 클러스터를 버틸 수 있나?"

net.ipv4.neigh.default.gc_thresh3 = 16384
확인 이유 : Pod/노드가 많아지면 ARP 테이블이 가득 차서 neighbor table overflow 에러가 납니다.
이 값이 기본(2048)이면 수백 개 Pod 환경에서 네트워크 단절이 발생합니다.
2) 99-sysctl.conf - "Pod 많이 띄워도 괜찮나?"

확인 이유 : 세 값 모두 "노드에 Pod가 몇 개까지 올라올 수 있는가"와 직결되는 파라미터 입니다.
- inotify 부족 : Fluent Bit, Prometheus 같은 DaemonSet이 too many open files로 죽음
- max_map_count 부족 : Elasticsearch 컨테이너가 아예 시작이 안됨
- pid_max 부족 : 노드 전체 PID 고갈, 새 프로세스 생성 불가.
3) 99-amazon.conf - "장애 났을 때 노드가 스스로 살아나나?"

확인 이유 : overcommit_memory = 1 이 없으면 K8s의 메모리 오버커밋 모델(requests < limits)이 제대로 동작하지 않아서
멀쩡한 컨테이너가 malloc 실패로 죽습니다.
kernel.panic 계열은 노드가 불안정한 상태로 좀비처럼 살아있는 것을 방지해서 빠른 자가 복구를 보장해요.
4) 99-kubernetes-cri.conf - "k8s 네트워킹이 동작하는 전제 조건"

확인 이유 : 값이 0이면 아무리 kube-proxy 규칙을 잘 만들어도 패킷이 iptables를 우회하거나 라우팅 자체가 안 됩니다.
Service ClusterIP가 동작하지 않고, 노드 간 Pod 통신도 불가능합니다.
03. k8s 노드에서 시간 동기화의 중요성
k8s는 분산 시스템이라 노드 간 시간 차이가 크면 여러 곳에서 조용히 고장납니다.
- TLS 인증서 검증 실패 : 유효 기간 판단이 시간 기반, 노드 시간이 틀리면 kubelet이 API Server 인증서를 거부.
- etcd 일관성 : Raft 합의 알고리즘이 타임스탬프에 의존, 시간 차이가 크면 리더 선출 불안정함.
- log / event 순서 : 여러 노드의 로그를 합쳐볼 때 순서가 뒤섞임
- 토큰 만료 : JWT, ServiceAccount 토큰의 exp 검증이 시간 기반입니다.
📌 /etc/chrony.conf & time server pool 확인
grep "^[^#]" /etc/chrony.conf
tree /run/chrony.d/
# time server pool
cat /usr/share/amazon-chrony-config/link-local-ipv4_unspecified.sources
cat /usr/share/amazon-chrony-config/amazon-pool_aws.sources

메인 소스가 link-local(169.254.169.123)로 되어있음
169.254.x.x는 인터넷을 거치지 않고 하이퍼바이저에서 직접 응답 하는 AWS 전용 NTP라서
레이턴시가 극히 낮고 안정적을 의미하며, k8s 노드 시간 동기화에 가장 이상적인 소스.

amazon-pool.sources(time.aws.com)는 link-local이 죽었을 때만 쓰이는 폴백이며, 정상 로드중.
nslookup time.aws.com 결과를 보면 IP가 10개 반환됐습니다.
pool로 설정했기 때문에 chrony가 이 중 여러 개에 동시 연결해서 다수결로 정확한 시간을 판단합니다.
서버 한 개가 죽어도 나머지로 자동 전환되는 거고요.
결론적으로는 메인 소스(link-local)은 단일 서버지만 직접 응답하며 매우 빠르고 안정적,
time.aws.com은 IP 10개짜리 pool로 이중화가 되어있어서 시간 동기화가 끊길 가능성이 거의 없는 구조.
📌 상태 확인

- system clock이 동기화 되어 있고, chrony 동작중이며, RTC가 UTC 기준으로 잘 되어 있음.
- chroync sources 5개 중, 169.254.169.123 단일 서버를 사용중이고 나머지는 후보입니다.
04. 컨테이너 정보 확인

세 가지의 CLI 도구를 활용하여 컨테이너 정보를 확인할 수 있으며, 상세 내용은 아래와 같습니다.
| CLI | 접속 대상 | 누가 쓰는가? |
| ctr | containerd 직접 (low-level) | containered 개발 / 디버깅용 |
| nerdctl | containerd 직접 (소켓 동일) | Docker 처럼 쓰고 싶을 때 |
| crictl | Kubernetes CRI API | k8s 노드 디버깅 표준 |
ctr vs nerdctl
- 접속 경로는 똑같이 /run/containered/containerd.sock 입니다.
- 차이는 UX
- ctr은 namespace를 매번 '-n k8s.io'로 지정해야 하고 명령어가 불편함.
- nerdctl은 docker cli와 거의 동일해서 'nerdctl ps', 'nerdctl images' 처럼 쓸 수 있다.
crictl
- k8s 노드에서 실질적으로 서야 하는 도구.
- kubelet이 컨테이너를 관리할 때 CRI API를 통해 containerd와 통신하는데, crictl이 그 동일한 경로로 접근함
- 그래서 kubelet 눈에 보이는 것과 동일한 정보를 볼 수 있다.
반대로 이렇게 생각해볼 수 있다. kubectl이 있는데 굳이?
kubectl vs crictl 동작 레이어
kubectl
→ API Server (Control Plane)
→ kubelet
→ containerd
→ 컨테이너
crictl
→ containerd (직접)
→ 컨테이너
즉, 노드가 NotReady 상태일 때 API Server와 통신이 안되거나 kubelet이 죽었으면 kubectl 불가능.
그런데 컨테이너는 살아있을 수 있으므로, 이때 노드에 직접 SSH로 들어가서 'crictl ps'로 컨테이너 상태 확인.
📌 기본 정보 확인

- 노드 스펙 정보들도 볼 수 있다 (노드 EC2 인스턴스의 스펙)
- Storage Driver : 앞서 설명한 overlayfs가 실제로 사용중인 걸 확인 (커널 모듈이 정상 로드)
- Cgroup Driver : kubelet과 containerd의 cgroup driver가 반드시 일치해야 한다. (Pod 죽는 문제)
- 현재 containerd의 cgroup driver는 systemd 이며, kubelet 정보는 아래와 같다.

📌 컨테이너 프로세스 정보 확인
현재 이 노드에서 실행중인 시스템 컴포넌트들을 출력 해보았습니다.

- coredns (2개) : 클러스터 내부 DNS 서버
- Pod가 service-name.namespace.svc.cluster.local같은 이름으로 통신할 수 있게 해주는 녀석
- kube-proxy (2개 - 실제 1개 + pause 1개) : 각 노드에서 iptables 규칙을 관리한다.
- Service Cluster IP → Pod IP 변환 규칙을 만드는 역할을 한다.
- k8s NetworkPolicy 리소스를 실제 eBPF/iptables 규칙으로 변환해줌.
- aws-vpc-cni (aws-node) : EKS의 CNI 플러그인
- Pod에 VPC IP를 직접 할당해주는 핵심 컴포넌트
command 부분을 확인해보면 '/pause'로 떠있는 컨테이너를 확인할 수 있다.
- Pod 안의 컨테이너들은 같은 네트워크를 공유하게 되는데, 컨테이너가 죽고 재시작되면 네트워크 네임스페이스도 같이 사라지는 문제가 빈번히 발생한다.
- 그래서 pause가 먼저 떠서 네트워크 네임스페이스를 선점하고, 실제 컨테이너들은 pause의 네트워크를 빌려 쓰는 것입니다.
📌 컨테이너 이미지 정보 확인

현재 노드에 pull된 이미지들을 나열하고 있습니다.
EKS 자체가 시스템 컴포넌트 이미지를 ECR에서 관리하고 있다는 의미이며 (aws account가 내 account가 아님),
kubelet 부팅 시 시스템 컴포넌트가 필요 → AWS ECR에서 자동 pull 이런식으로 가져와서 쓰게 된다.
해당 노드의 IAM Role을 확인해보면 아래의 권한 덕분에 가져와서 쓸 수 있습니다.

그런데 특별하게 "localhost/kubernetes/pause" 의 이미지가 존재합니다.
내가 로컬에 저장한 적이 없는 이미지인데 뭘까?
kubelet은 pause 이미지를 특별하게 취급합니다.
모든 파드 생성 시 가장 먼저, 가장 빈번하게 사용되는 이미지라서 ECR 접근 없이도 항상 쓸 수 있도록 로컬에 별도로 보관함.
kubelet이 ECR에서 pull -> 602401143452.dkr.ecr.us-west-2.amazonaws.com/eks/pause:3.10
kubelet이 localhost/kubernetes/pause로 로컬에 복사 후 'latest'로 재태깅.
05. containerd 정보 확인

그림을 먼저 이해하고 가보고자 합니다.
먼저, 공통점을 살펴보면 둘 다 아래 레이어는 완전히 동일합니다.
containerd → containerd-shim → runc → container
그렇다면 차이점은 containerd 위에 뭐가 앉느냐 차이겠죠?
docker : docker → dockerd → containerd
kubernetes : kubectl → kubelet → containerd
Docker는 dockerd라는 중간 레이어가 하나 더 있고, K8s는 kubelet이 CRI API로 containerd에 직접 붙습니다.
K8s 1.24부터 dockerd 레이어를 제거한 이유가 여기 있어요. 불필요한 중간 레이어를 없앤 겁니다.
📌 containerd 프로세스 확인

Cgroup 아래에 tree 구조를 확인해봅시다.
containerd (PID 2138)
└── containerd-shim (컨테이너 1개당 1개)
└── runc → container
위에서 봤던 아키텍처가 그대로 트리 구조로 나타난 것을 확인할 수 있습니다.
그렇다면 왜 containerd-shim이 왜 중간에 있을까요?
- containerd가 죽어도 이미 실행 중인 컨테이너는 살아있어야 합니다.
- shim이 컨테이너와 1:1로 붙어서 부모 프로세스 역할을 하기 때문에 containerd 재시작해도 컨테이너는 안죽음.
📌 containerd.service 확인

1) 시작 순서
ExecStartPre=-/sbin/modprobe overlay "overlay 커널 모듈 먼저 로드"
ExecStart=/usr/bin/containerd "그 다음 containerd 실행"
2) cgroup 관리
Delegate=yes
cgroup 관리를 systemd가 containerd에게 위임한다는 선언입니다.
이게 있어야 containerd가 하위 컨테이너들의 cgroup을 직접 만들고 관리가 가능합니다.
3) 안정성 설정
Restart=always
RestartSec=5
containerd가 죽으면 5초 후 자동 재시작.
아까 말씀드렸던 shim 덕분에 재시작해도 컨테이넌느 안 죽는 것과 연결됩니다.
4) OOM killer
OOMScoreAdjust=-999
OOM killer가 메모리 부족 시 프로세스를 골라 죽이는데, -999는 "containerd는 절대 죽이지 마라'는 설정임.
containerd가 죽으면 노드의 모든 컨테이너 관리가 불가능해지기 때문이죠.
5) 리소스 제한 해제
LimitNPROC=infinity
LimitCORE=infinity
LimitNOFILE=infinity
TasksMax=infinity
containerd는 노드의 모든 컨테이너를 관리해야 하므로 프로세스 수, 파일 디스크립터 수 제한을 전부 해제.
📌 관련 설정 파일 확인 - 데몬 설정
/etc/containerd/config.toml 분석

1) 기본 경로 설정
root = "/var/lib/containerd" 이미지, 스냅샷 등 영구 데이터
state = "/run/containerd" 소켓, shim 등 런타임 상태 (재부팅 시 초기화)
[grpc]
address = "/run/containerd/containerd.sock"
kubelet, ctr, nerdctl, crictl 모두 이 소켓으로 containerd에 접근합니다.
2) 이미지 관련
discard_unpacked_layers = true
이미지 레이어 압축 해제 후 원본 blob 삭제 → 디스크 절약
sandbox = "localhost/kubernetes/pause"
Pod 생성 시 pause 이미지를 ECR 대신 로컬에서 가져옴 : 앞서 nerdctl images 참고.
config_path = "/etc/containerd/certs.d:/etc/docker/certs.d"
프라이빗 레지스트리 인증서 경로.
ECR 같은 프라이빗 레지스트리 접근 시 여기서 인증서를 찾습니다.
3) 런타임 관련
default_runtime_name = "runc"
BinaryName = "/usr/sbin/runc"
runtime_type = "io.containerd.runc.v2"
base_runtime_spec = "/etc/containerd/base-runtime-spec.json"
컨테이너 실행 엔진으로 runc를 사용하고, 기본 보안 스펙은 base-runtime-spec.json에서 가져옵니다.
SystemdCgroup = true
cgroup 관리를 systemd에 위임 — kubelet config와 반드시 일치해야 하는 값.
4) CNI 관련
[plugins.'io.containerd.cri.v1.runtime'.cni]
bin_dir = "/opt/cni/bin"
conf_dir = "/etc/cni/net.d"
Pod 네트워크 설정 위치입니다.
containerd가 파드를 만들 때 이 경로에서 CNI Plugin(aws-vpc-cni)을 찾아서 실행하며,
Pod IP 할당이 여기서 시작됩니다.
📌 관련 설정 파일 확인 - 런타임 스펙
[root@ip-192-168-2-43 ~]# cat /etc/containerd/base-runtime-spec.json | jq
{
"linux": {
"maskedPaths": [
"/proc/acpi",
"/proc/asound",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/sched_debug",
"/proc/scsi",
"/proc/timer_list",
"/proc/timer_stats",
"/sys/firmware"
],
"namespaces": [
{
"type": "ipc"
},
{
"type": "mount"
},
{
"type": "network"
},
{
"type": "pid"
},
{
"type": "uts"
}
],
"readonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
],
"resources": {
"devices": [
{
"access": "rwm",
"allow": false
}
]
}
},
"mounts": [
{
"destination": "/dev",
"options": [
"nosuid",
"strictatime",
"mode=755",
"size=65536k"
],
"source": "tmpfs",
"type": "tmpfs"
},
{
"destination": "/dev/mqueue",
"options": [
"nosuid",
"noexec",
"nodev"
],
"source": "mqueue",
"type": "mqueue"
},
{
"destination": "/dev/pts",
"options": [
"nosuid",
"noexec",
"newinstance",
"ptmxmode=0666",
"mode=0620",
"gid=5"
],
"source": "devpts",
"type": "devpts"
},
{
"destination": "/proc",
"options": [
"nosuid",
"noexec",
"nodev"
],
"source": "proc",
"type": "proc"
},
{
"destination": "/sys",
"options": [
"nosuid",
"noexec",
"nodev",
"ro"
],
"source": "sysfs",
"type": "sysfs"
}
],
"ociVersion": "1.1.0",
"process": {
"capabilities": {
"bounding": [
"CAP_AUDIT_WRITE",
"CAP_CHOWN",
"CAP_DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_MKNOD",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SETFCAP",
"CAP_SETGID",
"CAP_SETPCAP",
"CAP_SETUID",
"CAP_SYS_CHROOT"
],
"effective": [
"CAP_AUDIT_WRITE",
"CAP_CHOWN",
"CAP_DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_MKNOD",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SETFCAP",
"CAP_SETGID",
"CAP_SETPCAP",
"CAP_SETUID",
"CAP_SYS_CHROOT"
],
"permitted": [
"CAP_AUDIT_WRITE",
"CAP_CHOWN",
"CAP_DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_MKNOD",
"CAP_NET_BIND_SERVICE",
"CAP_NET_RAW",
"CAP_SETFCAP",
"CAP_SETGID",
"CAP_SETPCAP",
"CAP_SETUID",
"CAP_SYS_CHROOT"
]
},
"cwd": "/",
"noNewPrivileges": true,
"rlimits": [
{
"type": "RLIMIT_NOFILE",
"soft": 65536,
"hard": 1048576
}
],
"user": {
"gid": 0,
"uid": 0
}
},
"root": {
"path": "rootfs"
}
}
1) 네임스페이스 격리
컨테이너가 호스트와 격리되는 Linux namespace 5개입니다.
- pid : 프로세스 ID (컨테이너 안에서 PID 1부터 시작)
- network : 네트워크 인터페이스, IP
- mount : 파일시스템 마운트
- ipc : 프로세스 간 통신
- uts : hostname
즉, VM처럼 하드웨어를 가상화 하는게 아니라 커널 기능으로 격리하는 것입니다.
2) 파일 시스템 보호
maskedPaths 설정
여기서 maskedPaths란, 아예 안 보이게 차단하는 파라미터.
- /proc/kcore : 호스트 메모리 전체 접근 가능한 파일
- /proc/sched_debug : 호스트 프로세스 스케줄링 정보
- /sys/firmware : 펌웨어 접근
반면에 readonlyPaths 설정도 되어 있습니다.
readonlyPaths란, 읽기만 가능하도록 설정하는 파라미터
- /proc/sys : sysctl 파라미터 (컨테이너가 커널 설정 변경 불가)
- /proc/bus : 하드웨어 버스 정보.
컨테이너가 호스트 커널 정보를 보거나 변경하는 것을 막는 설정들입니다.
3) capabilities - 컨테이너에 허용된 권한
Linux capabilities는 root권한을 세분화한 것입니다. (주요 일부 기능만 나열)
| capability | 의미 |
| CAP_NET_BIND_SERVICE | 1024 이하 포트 바인딩 (80, 443 등) |
| CAP_NET_RAW | raw 소켓 사용 (ping 등) |
| CAP_KILL | 다른 프로세스에 시그널 전송 |
| CAP_CHOWN | 파일 소유자 변경 |
| CAP_SYS_CHROOT | chroot 사용 |
반대로 없는 것이 중요합니다. CAP_SYS_ADMIN, CAP_NET_ADMIN 같은 위험한 권한은 없습니다.
컨테이너가 호스트 네트워크 설정을 바꾸거나 커널 모듈을 로드하는 등 작업을 못합니다.
"컨테이너는 기본적으로 아무것도 못하게 하고, 필요한 것만 열어준다"는 최소 권한 원칙이
이 파일 (베이스 런타임 스펙)에 적용된 것입니다.
06. kubelet 정보 확인
📌 프로세스 확인 명령어
ps afxuwww
cat /etc/systemd/system/kubelet.service
systemctl status kubelet --no-pager
1) ps afxuwww - 프로세스 트리 핵심

프로세스 내용을 도식화 해보면 아래와 같습니다.

shim마다 pause가 먼저 뜨고, 그 아래 실제 컨테이너가 붙는 구조가 그대로 보이는 것 확인할 수 있습니다.
추가적으로 65535 UID로 실행되는 pause, 65532로 실행되는 coredns를 보았을 때
root(0)가 아닌 별도 UID로 실행되는 것도 보안 설계입니다.
2) kubelet.service 핵심
시작 순서
After=containerd.service
ExecStartPre=/sbin/iptables -P FORWARD ACCEPT
ExecStart=/usr/bin/kubelet $NODEADM_KUBELET_ARGS
containerd가 먼저 떠야 kubelet이 시작됩니다.
그리고 kubelet 시작 전에 iptables FORWARD 정책을 ACCEPT로 설정 부분들은 앞서 말씀드린
ip_forward, bridge-nf-call-iptables와 연결되는 부분입니다 (커널 파라미터 확인 부분)
cgroup 위치
CGroup: /runtime.slice/kubelet.service
containerd도 /runtime.slice/containerd.service였습니다.
둘 다 runtime.slice 아래 있어서 systemd가 함께 관리합니다.
📌 /etc/kubernetes 트리 구조
/etc/kubernetes/
├── kubelet/
│ ├── config.json ← kubelet 메인 설정
│ └── config.json.d/
│ └── 40-nodeadm.conf ← EKS가 추가한 설정 (덮어씀)
├── manifests/ ← Static Pod 정의 위치 (현재 비어있음)
└── pki/
└── ca.crt ← 클러스터 CA 인증서
📌 k8s ca 인증서 확인
cat /etc/kubernetes/pki/ca.crt | openssl x509 -text -noout

1) 발급자 / 대상
- Issuer : CN = kubernetes
- Subject : CN = kubernetes
자기 자신이 발급한 self-signed CA 입니다. EKS Control Plane이 클러스터 생성 시 만든 것.
2) 유효 기간
- Not Before: Mar 18 13:58:00 2026 GMT
- Not After: Mar 15 14:03:00 2036 GMT
오늘 만든 인증서고 10년짜리입니다. 만료되면 클러스터 전체 통신이 중단되는 이슈가 있음.

3) x509v3 Basic Constraints: critical
- CA: TRUE 가 있어야 다른 인증서(kubelet, kube-proxy 등)에 서명할 수 있는 진짜 CA
📌 kubelet 설정 파일 확인
[root@ip-192-168-2-43 ~]# cat /etc/kubernetes/kubelet/config.json | jq
{
"address": "0.0.0.0",
"authentication": {
"x509": {
"clientCAFile": "/etc/kubernetes/pki/ca.crt"
},
"webhook": {
"enabled": true,
"cacheTTL": "2m0s"
},
"anonymous": {
"enabled": false
}
},
"authorization": {
"mode": "Webhook",
"webhook": {
"cacheAuthorizedTTL": "5m0s",
"cacheUnauthorizedTTL": "30s"
}
},
"cgroupDriver": "systemd",
"cgroupRoot": "/",
"clusterDNS": [
"10.100.0.10"
],
"clusterDomain": "cluster.local",
"containerRuntimeEndpoint": "unix:///run/containerd/containerd.sock",
"evictionHard": {
"memory.available": "100Mi",
"nodefs.available": "10%",
"nodefs.inodesFree": "5%"
},
"featureGates": {
"DynamicResourceAllocation": true,
"MutableCSINodeAllocatableCount": true,
"RotateKubeletServerCertificate": true
},
"hairpinMode": "hairpin-veth",
"kubeReserved": {
"cpu": "70m",
"ephemeral-storage": "1Gi",
"memory": "299Mi"
},
"kubeReservedCgroup": "/runtime",
"logging": {
"verbosity": 2
},
"maxPods": 4,
"protectKernelDefaults": true,
"providerID": "aws:///ap-northeast-2b/i-0d8b02e873d3d354a",
"readOnlyPort": 0,
"serializeImagePulls": false,
"serverTLSBootstrap": true,
"shutdownGracePeriod": "2m30s",
"shutdownGracePeriodCriticalPods": "30s",
"systemReservedCgroup": "/system",
"tlsCipherSuites": [
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_RSA_WITH_AES_256_GCM_SHA384"
],
"kind": "KubeletConfiguration",
"apiVersion": "kubelet.config.k8s.io/v1beta1"
}
1) 인증 / 보안
"clientCAFile" : "/etc/kubernetes/pki/ca.crt"
방금 본 ca.crt가 여기서 실제로 사용됩니다. kubelet API에 접근하는 클라이언트를 이 CA로 검증.
"anonymous": { "enabled": false }
"authorization": { "mode": "Webhook" }
익명 접근 차단, 권한 검증은 API Server에 위임. kubelet API에 아무나 접근 못합니다.
"readOnlyPort" : 0
읽기 전용 포트(10255) 비활성화.
예전엔 인증 없이 노드 정보를 볼 수 있었던 보안 취약점이었어요.
2) containerd 연결
"containerRuntimeEndpoint": "unix:///run/containerd/containerd.sock"
"cgroupDriver": "systemd"
아까 확인한 containerd 소켓 경로와 일치, cgroupDriver도 systemd로 일치 — 여기서 최종 확인됩니다.
3) 리소스 예약
"kubeReserved": { "cpu": "70m", "memory": "299Mi" }
kubelet, containerd 등 시스템 컴포넌트용으로 CPU 70m, 메모리 299Mi를 미리 빼둡니다.
Pod가 이 영역을 침범 못합니다.
"evictionHard": {
"memory.available": "100Mi",
"nodefs.available": "10%"
}
메모리가 100Mi 이하로 떨어지면 Pod를 강제 퇴출시킵니다.
노드 전체가 OOM으로 죽는 걸 방지하는 안전장치입니다.
"maxPods" : 4
이 노드에 최대 4개 Pod만 허용. 1GB 메모리 노드라 작게 설정된 것. (테스트 용 노드로 필자가 micro라서)
📌 EKS가 추가한 kubelet 설정 파일 확인
EKS가 노드 초기화(nodeadm)시 자동으로 생성해서 덮어쓰는 파일입니다.

- clusterDNS는 클러스터마다 다르고, maxPods는 인스턴스 타입마다 다릅니다.
- 이 값들을 config.json 안에 직접 넣어서 관리하면 AMI를 수정해야 하는데, 별도 파일로 분리하면 nodeadm이 노드 초기화시 이 파일만 생성 / 교체하면 됩니다.
- config.json은 건드리지 않고 필요한 값만 오버라이드 하는 구조임.
📌 /var/lib/kubelet 분석
tree /var/lib/kubelet -L 2
kubelet의 런타임 데이터 디렉터리 입니다.
설정은 /etc/kubernetes/ 에 있고, 실제 동작하면서 생기는 데이터는 여기에 쌓입니다.

1) kubeconfig
- kubelet이 API Server에 접속하기 위한 인증 정보입니다.
- 클러스터 주소, 토큰, 인증서 경로가 담겨있습니다.
2) pki/
kubelet-server-2026-03-18-14-07-41.pem → 첫 번째 발급
kubelet-server-2026-03-18-14-07-57.pem → 두 번째 발급 (현재)
kubelet-server-current.pem → ...57.pem → 심볼릭 링크
config.json에서 본 serverTLSBootstrap: true 덕분에 kubelet이 API Server에서 자동 발급받은 인증서들입니다.
갱신될 때마다 새 파일이 생기고 current 링크만 교체됩니다.
(인증서가 두 개인 건 노드 초기화 시 한 번, 정상 등록 후 한 번 발급된 것)
3) pods/
- 현재 노드에 스케줄된 Pod 3개의 데이터 디렉터리입니다
- 각 디렉터리 안에 볼륨 마운트, ServiceAccount 토큰 등이 있습니다.
- nerdctl ps에서 봤던 컨테이너 수와 일치합니다.
4) 상태 파일들
actuated_pods_state → 실행 중인 Pod 상태
allocated_pods_state → 할당된 Pod 상태
cpu_manager_state → CPU 할당 상태
memory_manager_state → 메모리 할당 상태
kubelet이 재시작되어도 이전 상태를 복구할 수 있도록 디스크에 상태를 저장해두는 파일들입니다.
📌 kubeconfig 분석
kubelet이 API Server에 접속하는 방법이 담긴 파일입니다.

어디에 접속하는지?
server: https://5351090779003B57DF1FF9B8ACBAFAE8.gr7.ap-northeast-2.eks.amazonaws.com
certificate-authority: /etc/kubernetes/pki/ca.crt
EKS API Server 엔드포인트입니다. 접속 시 방금 본 ca.crt로 서버를 검증합니다.
누구로 접속하는지?
user: kubelet
어떻게 인증할건지?
exec:
command: aws
args: ["eks", "get-token", "--cluster-name", "myeks"]
```
토큰을 파일에 저장해두는 게 아니라 매번 `aws eks get-token` 명령어를 실행해서 토큰을 동적으로 발급받습니다.
IAM 기반 인증이라 토큰이 만료되면 자동으로 새로 받아요
EC2 인스턴스에 붙은 IAM Role이 없으면 토큰 발급 자체가 안되서 kubelet이 API Server에 접근을 못합니다.
노드 IAM Role이 중요한 이유가 여기에 있죠!
긴 글 읽어주셔서 감사합니다.