0. 들어가며

 Kubernetes 클러스터는 크게 Master 노드(Control Plane 역할)와 Worker 노드(워크로드 구동 역할)로 나뉩니다. 프로덕션 환경에서는 고가용성(HA) 및 로드 밸런싱을 위해 Master 노드를 여러대로 하여 클러스터 형식으로 구현하기도 합니다. 그런데 여기서 문제가 생깁니다.

 

1) 어떻게 단일 API End-Point를 제공할 것인가?

 생각해 보면 Control Plane을 구성하는 노드가 1대만 존재한다면 크게 문제될 것이 없는 부분이지만, 여러 대가 존재 한다면 그때는 어떤 Master 노드가 제공하는 API 서버의 주소를 사용해야 할지 정하는 것이 좀 애매해집니다. 1대의 Master 노드가 대표로 API 서버 주소를 노출한다면 해당 노드에만 많은 부하가 걸려서 Control Plane을 클러스터로 구성한 이점을 누리지 못합니다.

 

2) API End-Point를 제공하던 노드가 응답하지 않을때 Failover는 어떤 식으로 작동하게 할 것인가?

 첫 번째의 문제와 이어지는 부분으로, 특정 Master 노드를 API End-Point로 제공한다고 하더라도 해당 노드가 어떤 문제가 생겨서 응답하지 않을 경우 Failover 되어 다른 노드가 API End-Point를 제공할 수 있도록 설정하는 것이 가능한지도 의문입니다. 설령 가능하다 하더라도, Kubernetes 클러스터 사용자가 API End-Point 변경을 어떻게 감지하고 대처할 수 있느냐도 문제가 되는 부분이라 할 수 있겠습니다.

 

3) 로드 밸런싱은 어떻게 구현할 것인가?

 어쨌든 Control Plane을 클러스터 형식으로 구성하게 되었을 때, 각 노드들의 부하 분산을 위해서라도 API End-Point를 하나의 Master 노드가 제공하는 것은 불합리합니다. 각각의 Control Plane 노드들이 부하를 적절하게 분산하여 처리할 수 있도록 자동으로 분배해 줄 기능이 필요합니다.


1. Kubernetes API 클러스터 HA 구현을 위해 필요한 것들

 Kubernetes를 클라우드 환경에서 운영중이라면 해당 클라우드 제공 업체의 로드 밸런싱 기능을 사용하면 문제가 간단히 해결되겠지만 저와 같이 On-Premise 환경에서 운영한다면 약간 수고스러운 작업들이 필요합니다. 이것저것 찾아본 결과 HAProxy와 Keepalived를 사용해 기능을 구현해 보기로 했습니다.

[그림 1] Kubernetes API 클러스터 HA 아키텍처

 위 그림은 구현하고자 하는 구성의 아키텍처입니다. Kubernetes의 Control Plane은 3대의 Master 노드로 구성하였고, 그 앞에 로드 밸런서 및 단일 API End-Point를 제공하는 역할을 해줄 로드 밸런서(LB) 2대가 있습니다. LB에는 앞서 말씀드린 대로 HAProxy와 Keepalived를 통해 구축했습니다.

 

 자세한 구축 방법을 확인하기 전, HAProxy와 Keepalived가 어떤 기능을 하는지 알아보고 가는 것이 좋을 것 같습니다.

 

1) HAProxy

 HAProxy는 안정적인 TCP/HTTP 로드 밸런서 및 프록시 솔루션입니다. 유로인 Enterprise 버전과 무료인 Community Edition 버전이 있습니다. 해당 포스팅에는 무료 버전을 사용하도록 하겠습니다. 두 버전 간에는 여러 차이점이 있지만 그중 무료 버전은  Active/Active 모드가 아닌 Active/Standby 모드로 작동한다는 점이 가장 큰 부분이 아닐까 생각합니다.

 

 HAProxy는 여러 기능이 있지만, 이번 포스팅에서는 가상 IP로 들어오는 API 요청을 Kubernetes Control Plane의 각 노드로 로드 밸런싱하는 기능을 사용해 보도록 하겠습니다.

 

2) Keepalived

 Keepalived는 고가용성의 로드 밸런싱 서비스르 제공하는 오픈소스 소프트웨어입니다. VRRP(Virtual Router Redundancy Protocol)와 IPVS(IP Virtual Service)를 통해 작동합니다. HAProxy가 Kubernetes Control Plane 노드들에 대한 부하 분산을 제공한다면, Keepalived는 가상 IP를 통해 API 단일 End-Point를 제공하는 역할을 한다고 보면 됩니다. 물론, Active 상태의 LB가 서비스 불능 상태에 빠지면 Standby 상태에 있던 LB가 작동하여 HAProxy 뒤에 있는 Kubernetes API 서버가 작동하는데 이상이 없도록 하는 고가용성 역시도 제공합니다.

 

 Kubernetes Control Plane과 HAProxy, Keepalived의 상호 작용에 대해서 요약해 보자면 다음과 같습니다.

 

  •  우선 Kubernetes의 Control Plane을 3대의 노드로 구성함으로써, Kubernetes API 서버의 고가용성(HA)을 확보합니다.
  •  HAProxy는 이러한 Kubernetes Control Plane의 앞에 위치하여 API 서버로 들어오는 API 요청을 수신합니다. 수신된 API 요청을 적절한 방식으로(해당 포스팅에서는 Roundrobin 방식을 사용할 예정입니다.) Kubernetes의 Control Plane 노드로 분배하는 로드 밸런서 역할을 담당합니다.
  •  Keepalived는 가상 IP를 통해 Kubernetes API의 단일 End-Point를 제공합니다. 이를 통해 사용자는 Kubernetes Control Plane의 장애 여부에 관계없이 Kubernetes 클러스터와 통신할 수 있게 됩니다. 또한 HAProxy 서비스가 다운되거나, 혹은 LB 자체가 서비스 불능 상태에 빠지더라도, 대기하고 있던 다른 LB를 통해 서비스가 지속될 수 있도록 하여 HAProxy 서버의 고가용성(HA)을 확보합니다.

 

2. HAProxy와 Keepalived를 활용하여 로드 밸런서(LB) 구축

이름 IP Address 역할
LB 1 172.16.11.157 HAProxy + Keepalived로 LB 역할
LB 2 172.16.11.158 HAProxy + Keepalived로 LB 역할
Kubernetes Master 01 172.16.11.151 Kubernetes Control Plane
Kubernetes Master 02 172.16.11.152 Kubernetes Control Plane
Kubernetes Master 03 172.16.11.153 Kubernetes Control Plane
가상 IP 172.16.11.150 Kubernetes API 서버 단일 End-Point

 위 표는 구축하고자 하는 환경에 대한 정보입니다. 가상 IP를 제외한 나머지는 모두 VM 형식으로 생성하였습니다.(가상 IP는 Keepalived 설정 값으로 들어가게 됩니다.) VM의 크기는 실습 환경이라 크게 중요하지는 않지만, LB의 경우 4 vCPU와 4GB의 메모리, Kubernetes Master 노드의 경우 8 vCPU와 8GB의 메모리를 사용하였습니다. 소프트웨어 소스 정보는 아래와 같습니다.

 

  • OS : Ubuntu 20.04.6 LTS
  • HAProxy : 2.0.31-0ubuntu0.2 (2023/08/16)
  • Keepalived : v2.0.19 (10/19,2019)
  • kubeadm, kubelet, kubectl : v1.26.1

 이제 본격적으로 LB 구축 방법에 대해 알아보도록 하겠습니다.

 

Step1. HAproxy 설치

 기본적으로 Ubuntu 설치를 완료하고 IP 주소를 입력해 준 후 작업을 진행해 주시면 됩니다. LB 1LB 2 모두에 공통적으로 작업해 줍니다.

sudo apt udpate

sudo apt install haproxy -y

 명령어는 매우 간단합니다. apt udpate를 한번 진행해 주시고 HAProxy를 설치해주면 끝입니다. 설치가 완료되면 HAProxy의 설정 파일을 편집해 줍니다.

sudo vim /etc/haproxy/haproxy.cfg

#haproxy.conf 파일 하단에 아래 내용을 추가해 줍니다.

frontend kube-apiserver
  bind *:6443                #외부로 노출할 IP:포트번호 형식으로 해당 설정은 모든 Ip:6443 포트로 설정
  mode tcp
  option tcplog
  default_backend kube-apiserver

backend kube-apiserver
    mode tcp
    option tcplog
    option tcp-check
    balance roundrobin
    default-server inter 10s downinter 5s rise 2 fall 2 slowstart 60s maxconn 250 maxqueue 256 weight 100
    server kube-apiserver-1 172.16.11.151:6443 check #IP 주소를 Kubernetes Master 노드의 IP 주소로 변경
    server kube-apiserver-2 172.16.11.152:6443 check #IP 주소를 Kubernetes Master 노드의 IP 주소로 변경
    server kube-apiserver-3 172.16.11.153:6443 check #IP 주소를 Kubernetes Master 노드의 IP 주소로 변경
    
    #Kubernetes API는 기본적으로 6443 포트를 사용하여 통신하기 때문에 내부 6443 포트를 외부 6443 포트에 매핑한 설정

 위 설정은 HAProxy가 외부에서 들어오는 모든 IP의 6443 포트에 대한 요청을 backend로 설정한 Kubernetes API 서버인 172.16.11.151~3:6443으로 라운드로빈 형식으로 로드밸런싱 하도록 합니다. 다음으로 HAProxy를 재시작하여 설정 내용이 적용될 수 있도록 한 후, 서버가 시작될 때 HAProxy도 함께 작동하도록 systemd에 등록해 주면 됩니다. 그리고 HAProxy가 잘 작동하고 있는지 확인해 보도록 하겠습니다.

systemctl restart haproxy   #설정한 haproxy.cfg의 내용이 적용 될 수 있도록 HAProxy 재시작

systemctl enable haproxy    #systemd에 등록

systemctl status haproxy    #HAProxy 실행 상태 확인
● haproxy.service - HAProxy Load Balancer
     Loaded: loaded (/lib/systemd/system/haproxy.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2023-09-12 15:57:58 KST; 12s ago
       Docs: man:haproxy(1)
             file:/usr/share/doc/haproxy/configuration.txt.gz
    Process: 31037 ExecStartPre=/usr/sbin/haproxy -Ws -f $CONFIG -c -q $EXTRAOPTS (code=exited, status=0/SUCCESS)
   Main PID: 31039 (haproxy)
      Tasks: 5 (limit: 4598)
     Memory: 34.6M
     CGroup: /system.slice/haproxy.service
             ├─31039 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock
             └─31041 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock

 9월 12 15:57:58 lbadmin-virtual-machine haproxy[31039]: Proxy kube-apiserver started.
 9월 12 15:57:58 lbadmin-virtual-machine haproxy[31039]: Proxy kube-apiserver started.
 9월 12 15:57:58 lbadmin-virtual-machine haproxy[31039]: Proxy kube-apiserver started.
 9월 12 15:57:58 lbadmin-virtual-machine haproxy[31039]: Proxy kube-apiserver started.
 9월 12 15:57:58 lbadmin-virtual-machine haproxy[31039]: [NOTICE] 254/155758 (31039) : New worker #1 (31041) forked
 9월 12 15:57:58 lbadmin-virtual-machine systemd[1]: Started HAProxy Load Balancer.
 9월 12 15:57:59 lbadmin-virtual-machine haproxy[31041]: [WARNING] 254/155759 (31041) : Server kube-apiserver/kube-apiserver-1 is DOWN, reason: Layer4 co>
 9월 12 15:58:05 lbadmin-virtual-machine haproxy[31041]: [WARNING] 254/155805 (31041) : Server kube-apiserver/kube-apiserver-2 is DOWN, reason: Layer4 co>
 9월 12 15:58:08 lbadmin-virtual-machine haproxy[31041]: [WARNING] 254/155808 (31041) : Server kube-apiserver/kube-apiserver-3 is DOWN, reason: Layer4 co>
 9월 12 15:58:08 lbadmin-virtual-machine haproxy[31041]: [ALERT] 254/155808 (31041) : backend 'kube-apiserver' has no server available!

 명령어 systemctl status haproxy을 통해 HAProxy가 잘 작동하고 있는 것을 볼 수 있습니다. 아래 상태 정보 중 경고가 몇 줄 보이는데 걱정하실 필요는 없습니다. 이는 HAProxy의 backend로 설정한 kube-apiserver가 응답하지 않기 때문에 그런 것으로 다음 포스팅을 통해 Kubernetes Control Plane에 대한 설정에 대해서도 설명드리도록 하겠습니다.

[사진 1] backend의 응답이 없어서 표시된 경고이므로 걱정할 필요는 없습니다.

  앞서 언급드렸지만 LB 1과 LB 2 모두에 위 과정을 진행해 주면 됩니다. 다음으로 Keepalived를 설치하고 구성하는 방법을 알아보겠습니다.

 

Step2. Keepalived 설치

 Keepalived 설치 역시 HAProxy 만큼이나 간단합니다. Keepalived 패키지 설치만 해주면 됩니다. 역시 모두 LB 1과 LB 2 모두에 공통적으로 작업을 진행해 줍니다.

sudo apt install keepalived -y

 그런데 여기서 주의할 점이 하나 있습니다. HAProxy와는 다르게 Keepalived의 경우 다음 작업들을 root 유저로 진행해 주셔야 합니다. 일반 유저로 작업을 진행했을 때, 가상 IP를 네트워크 인터페이스에 부여하는 것은 가능했지만, Active LB가 다운되었을 때 Standby LB가 가상 IP를 받아서 서비스를 이어나가지 못하는 현상이 발생했습니다. 아마도 Linux 권한 문제로 예상됩니다. 네트워크 인터페이스에 IP를 부여하는 것과 같은 작업은 시스템에서 root 수준의 권한이 필요하기 때문입니다.

sudo -s                                 #root 유저로 전환

vim /etc/keepalived/keepalived.conf     #keepalived.conf 파일 생성

 텍스트 편집기로 /etc/keepalived 위치에 keepalived.conf 파일을 생성합니다. 빈 파일이 열리는데, 아래 내용을 입력해 줍니다. 설정 파일의 내용이 LB 1과 LB 2 사이에 약간의 차이가 있어서 작업을 따로 진행해 주셔야 합니다.

#LB 1에 아래 내용 입력

global_defs {
  notification_email {
  }
  router_id LVS_DEVEL
  vrrp_skip_check_adv_addr
  vrrp_garp_interval 0
  vrrp_gna_interval 0
}

vrrp_script chk_haproxy {
  script "killall -0 haproxy"
  interval 2
  weight 2
}

vrrp_instance haproxy-vip {
  state BACKUP
  priority 100
  interface ens160                       #LB 1의 네트워크 인터페이스 장비명 입력
  virtual_router_id 60
  advert_int 1
  authentication {
    auth_type PASS
    auth_pass 1111
  }
  unicast_src_ip 172.16.11.158      #LB 1의 IP 주소 입력
  unicast_peer {
    172.16.11.157                         #LB 2의 IP 주소 입력
  }

  virtual_ipaddress {
    172.16.11.150/24                  #생성할 가상 IP의 주소 입력
  }

  track_script {
    chk_haproxy
  }
}

---------------------------------------------------------------------------------

#LB 2에 아래 내용 입력

global_defs {
  notification_email {
  }
  router_id LVS_DEVEL
  vrrp_skip_check_adv_addr
  vrrp_garp_interval 0
  vrrp_gna_interval 0
}

vrrp_script chk_haproxy {
  script "killall -0 haproxy"
  interval 2
  weight 2
}

vrrp_instance haproxy-vip {
  state BACKUP
  priority 100
  interface ens160                       #LB 2의 네트워크 인터페이스 장비명 입력
  virtual_router_id 60
  advert_int 1
  authentication {
    auth_type PASS
    auth_pass 1111
  }
  unicast_src_ip 172.16.11.158      #LB 2의 IP 주소 입력
  unicast_peer {
    172.16.11.157                         #LB 1의 IP 주소 입력
  }

  virtual_ipaddress {
    172.16.11.150/24                  #생성할 가상 IP의 주소 입력
  }

  track_script {
    chk_haproxy
  }
}

 먼저 interface의 경우에는 가상 IP를 뛰우고자 하는 IP 주소 대역과 같은 IP를 가진 네트워크 인터페이스의 장비명을 입력해 주면 됩니다. 간단하게 ip a s 명령어를 통해 조회가 가능하며 본 포스팅의 경우에는 172.16.11.0/24 대역의  IP를 가진 네트워크 인터페이스의 장비명은 ens160으로 해당 장비명을 사용하였습니다.

 다음으로 unicast_src_ip 부분입니다. 이는 현재 작업하고 있는 LB의 IP 주소를 입력해 주면 됩니다. LB 1의 경우 LB 1의 IP 주소를, LB 2의 경우 LB 2의 IP 주소를 입력해 주고, unicast_peer를 상대방의 LB의 IP 주소를 입력해 주면 됩니다. 위의 코드 예시에서 보시는 바와 같이 unicast_src_ip와 unicast_peer값이 서로 반대가 되면 됩니다.

 마지막으로 가상 IP 주소로 Kubernetes API 서버의 단일 End-Point로 사용될 IP 주소를 virtual_ipaddress에 입력해 주면 됩니다.


 추가로 말씀을 드리자면, 맨 처음에 Keepalived를 설치하고 구성할 때 root 유저로 진행하지 않아서 가상 IP에 대한 HA가 잘 작동하지 않아 이것저것 설정을 많이 건드려 봤습니다. state 값을 Master와 BACKUP으로 나눠 보기도 하고, priority 값을 변경하기도 해 보았습니다. 하지만 모두 소용이 없었고 마지막으로 설정 값은 위와 같이 하고 root 유저 권한으로 설정하니 문제없이 작동하는 것을 확인할 수 있었습니다. 꼭 root 유저로 해당 내용을 진행하시길 당부드립니다.


 다음으로 root 유저 권한으로 keepalived를 재시작하여 설정값이 적용되도록 하고, systemd에 등록해 보도록 하겠습니다. 마지막으로는 keepalived의 작동 상태를 확인해 보도록 하겠습니다.

#반드시 root 권한에서 진행

systemctl restart keepalived

systemctl enable keepalived

systemctl status keepalived

=============================================================================================
#Active LB의 경우 출력 값
● keepalived.service - Keepalive Daemon (LVS and VRRP)
     Loaded: loaded (/lib/systemd/system/keepalived.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2023-09-12 16:44:40 KST; 32s ago
   Main PID: 20180 (keepalived)
      Tasks: 2 (limit: 4598)
     Memory: 1.5M
     CGroup: /system.slice/keepalived.service
             ├─20180 /usr/sbin/keepalived --dont-fork
             └─20181 /usr/sbin/keepalived --dont-fork

 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: Opening file '/etc/keepalived/keepalived.conf'.
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: WARNING - default user 'keepalived_script' for script execution does not exist - please >
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: WARNING - script `killall` resolved by path search to `/usr/bin/killall`. Please specify>
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: SECURITY VIOLATION - scripts are being executed but script_security not enabled.
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: Registering gratuitous ARP shared channel
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: (haproxy-vip) Entering BACKUP STATE (init)
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: VRRP_Script(chk_haproxy) succeeded
 9월 12 16:44:40 lbadmin-virtual-machine Keepalived_vrrp[20181]: (haproxy-vip) Changing effective priority from 100 to 102
 9월 12 16:44:53 lbadmin-virtual-machine Keepalived_vrrp[20181]: (haproxy-vip) Backup received priority 0 advertisement
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[20181]: (haproxy-vip) Entering MASTER STATE


=============================================================================================
#Standby LB의 경우 출력 값
● keepalived.service - Keepalive Daemon (LVS and VRRP)
     Loaded: loaded (/lib/systemd/system/keepalived.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2023-09-12 16:44:54 KST; 5s ago
   Main PID: 32514 (keepalived)
      Tasks: 2 (limit: 4598)
     Memory: 1.5M
     CGroup: /system.slice/keepalived.service
             ├─32514 /usr/sbin/keepalived --dont-fork
             └─32515 /usr/sbin/keepalived --dont-fork

 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: Registering Kernel netlink reflector
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: Registering Kernel netlink command channel
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: Opening file '/etc/keepalived/keepalived.conf'.
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: WARNING - default user 'keepalived_script' for script execution does not exist - please >
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: WARNING - script `killall` resolved by path search to `/usr/bin/killall`. Please specify>
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: SECURITY VIOLATION - scripts are being executed but script_security not enabled.
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: Registering gratuitous ARP shared channel
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: (haproxy-vip) Entering BACKUP STATE (init)
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: VRRP_Script(chk_haproxy) succeeded
 9월 12 16:44:54 lbadmin-virtual-machine Keepalived_vrrp[32515]: (haproxy-vip) Changing effective priority from 100 to 102

[사진 2] Active 상태로 설정된 LB의 경우 MASTER STATE에 진입했다는 설명을 볼 수 있습니다.
[사진 3] Standby 상태의 LB의 경우 BACKUP STATE에 진입했다는 설명을 볼 수 있습니다.

 위의 사진에서 볼 수 있듯 각 LB에서 Keepalived의 상태를 확인해 보면 하나는 Master 상태로, 하나는 Backup 상태로 진입하는 것을 확인할 수 있습니다. Master 상태의 LB에서 hostname -I 명령어를 입력해 보면 가상 IP 주소(본 포스팅에서는 172.16.11.150)와 네트워크 인터페이스에 부여된 IP 주소(172.16.11.157 또는 172.16.11.158)가 출력되는 것을 확인할 수 있고, Backup 상태의 LB에서는 같은 명령어를 입력하면 네트워크 인터페이스에 부여된 IP 주소만을 확인할 수 있습니다.

 

 이제 마지막으로 가상 IP가 잘 이동하는지를 통해 LB의 HA 기능이 잘 작동하는지 확인해 보도록 하겠습니다.

 

Step 3. LB의 HA 기능 확인

 먼저 두대의 LB에 모두 hostname -I를 입력하여 현재 IP 주소 정보를 확인합니다. 가상 IP인 172.16.11.150이 출력되는 LB가 Active 상태로 해당 LB에서 명령어 sudo systemctl stop haproxy를 입력하여 HAProxy를 종료합니다. 

#Acitve 상태의 LB에서 아래 명령어를 입력
sudo systemctl stop haproxy     #haproxy 끔

systemctl status keepalived     #keepalived 상태를 확인하여 BACKUP STATE로 진입하는지 확인

● keepalived.service - Keepalive Daemon (LVS and VRRP)
     Loaded: loaded (/lib/systemd/system/keepalived.service; enabled; vendor preset: enabled)
     Active: active (running) since Wed 2023-09-13 16:34:45 KST; 3min 35s ago
   Main PID: 98789 (keepalived)
      Tasks: 2 (limit: 4598)
     Memory: 1.5M
     CGroup: /system.slice/keepalived.service
             ├─98789 /usr/sbin/keepalived --dont-fork
             └─98790 /usr/sbin/keepalived --dont-fork

 9월 13 16:34:46 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) received lower priority (102) advert from 172.16.11.158 - discarding
 9월 13 16:34:47 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) received lower priority (102) advert from 172.16.11.158 - discarding
 9월 13 16:34:48 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) received lower priority (102) advert from 172.16.11.158 - discarding
 9월 13 16:34:49 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) received lower priority (102) advert from 172.16.11.158 - discarding
 9월 13 16:34:49 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) Entering MASTER STATE
 9월 13 16:38:11 lbadmin-virtual-machine Keepalived_vrrp[98790]: Script `chk_haproxy` now returning 1
 9월 13 16:38:11 lbadmin-virtual-machine Keepalived_vrrp[98790]: VRRP_Script(chk_haproxy) failed (exited with status 1)
 9월 13 16:38:11 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) Changing effective priority from 103 to 101
 9월 13 16:38:14 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) Master received advert from 172.16.11.158 with higher priority 102, ours 101
 9월 13 16:38:14 lbadmin-virtual-machine Keepalived_vrrp[98790]: (haproxy-vip) Entering BACKUP STATE

[사진 4] haproxy 서비스가 종료되면 Master상태의 LB가 Backup 상태로 진입하는 것을 볼 수 있습니다.

 위 내용에서 보시는 바와 같이, Active 상태의 LB에서 HAProxy 서비스를 종료하면 Keepalived는 이를 감지하고 상대편 LB로 가상 IP 주소를 이전하게 됩니다. 좀 더 상세하게 설명하면, 앞서 keepalived.conf 파일의 vrrp_script chk haproxy에서 설정한 값에 따라 HAProxy가 종료됨을 인지하고 priority값을 103에서 101로 낮춘 후 마스터에서 Backup 상태로 진입하며 가상 IP 주소인 172.16.11.150을 Backup 상태의 LB로 이전하는 것입니다.

 

 Backup 상태에 있던 LB에서 명령어 systemctl status keepalived를 실행해 보면 아래와 같은 결과를 얻을 수 있습니다.

systemctl status keepalived     #Backup 상태의 LB에서 Keepalived의 상태 확인

● keepalived.service - Keepalive Daemon (LVS and VRRP)
     Loaded: loaded (/lib/systemd/system/keepalived.service; enabled; vendor preset: enabled)
     Active: active (running) since Wed 2023-09-13 16:33:08 KST; 21s ago
   Main PID: 82410 (keepalived)
      Tasks: 2 (limit: 4598)
     Memory: 1.5M
     CGroup: /system.slice/keepalived.service
             ├─82410 /usr/sbin/keepalived --dont-fork
             └─82411 /usr/sbin/keepalived --dont-fork

 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: Opening file '/etc/keepalived/keepalived.conf'.
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: WARNING - default user 'keepalived_script' for script execution does not exist - please creat>
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: WARNING - script `killall` resolved by path search to `/usr/bin/killall`. Please specify full>
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: SECURITY VIOLATION - scripts are being executed but script_security not enabled.
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: Registering gratuitous ARP shared channel
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: (haproxy-vip) Entering BACKUP STATE (init)
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: VRRP_Script(chk_haproxy) succeeded
 9월 13 16:33:08 lbadmin-virtual-machine Keepalived_vrrp[82411]: (haproxy-vip) Changing effective priority from 100 to 102
 9월 13 16:33:25 lbadmin-virtual-machine Keepalived_vrrp[82411]: (haproxy-vip) Backup received priority 0 advertisement
 9월 13 16:33:26 lbadmin-virtual-machine Keepalived_vrrp[82411]: (haproxy-vip) Entering MASTER STATE
 
 hostname -I
 172.16.11.158 172.16.11.150     #hostname -I로 IP 주소를 조회해 보면 가상 IP 주소 172.16.11.150이 표시 됨

[사진 5] Backup 상태의 LB가 Master상태에 진입하면서 가상 IP 주소를 받아오는 것을 확인할 수 있습니다.

 보시는 바와 같이 Backup 상태의 LB가 Master 상태로 진입하면서 가상 IP 주소인 172.16.11.150을 계속 서비스하는 것을 을 확인할 수 있습니다.


 이렇게 HAProxy와 Keepalived를 사용하여 HA 기능을 갖춘 로드 밸런서를 구성해 봤습니다. 다음 포스팅에는 이렇게 구성된 로드 밸런서와 서비스되고 있는 가상 IP를 활용하여 Kubernetes API 단일 End-Point를 구성하는 방법에 대해 알아보도록 하겠습니다.

 얼마 전 CKAD를 취득하게 되었습니다. 작년에 취득했던 CKA에 이어서 벌써 Kubernetes 자격증도 2개가 되었군요. CKAD 자격시험 준비 과정과 시험 내용에 대해서 간단히 포스팅해보고자 합니다.

 

 우선 CKADCertified Kubernetes Application Developer의 줄임말로 이름에서도 알 수 있듯 개발자 입장에서 Kubernetes를 다루는 능력을 평가하는 시험입니다. 시험 내에서 실제로 애플리케이션을 개발하는 것은 아니지만, 그래도 CKA에 비해서 조금 더 개발자적인 부분을 다룹니다.

 

 시험 준비는 역시나 CKA 취득때와 마찬가지로 우리 뭄형(영상으로 자주 보다 보니 내적 친밀감이...)Udemy 강의[링크]를 기본으로 하였습니다. 내용은 CKA와 많이 비슷합니다. Kubernetes에 기본적으로 Pod나 Deployment 등을 실행하는 것을 기본으로 하고 있어서 크게 어렵지는 않았습니다. 만약 CKA를 무리 없이 취득하신다면 CKAD도 무리 없이 취득할 수 있을 것입니다. 그럼에도 불구하고 CKAD에만 다루는 몇 가지 요소가 있습니다. 이를 중점적으로 준비한다면 크게 어렵지 않을 것입니다. 추가 요소 중 생각나는건 아래와 같습니다.

  1. Job/CronJob 설정
  2. Helm 차트 다루기
  3. Ingress 트러블 슈팅

 또한, CKA에서 다루는 etcd 백업이라던가, 클러스터 버전 업그레이드 등과 같은 운영적인 요소는 빠져서 저는 오히려 조금 더 수월하다고 느꼈습니다.


 시험 등록 방법이나 일정을 정하는 방법 등은 CKA와 완벽히 동일합니다. 따라서 해당 내용은 이전 포스팅을 참고해 주셔도 좋을 것 같습니다.

 

CKA 취득 후기

안녕하세요? 오래간만에 포스팅 올립니다. 그동안 이것저것 좀 한다고 포스팅이 뜸했네요. 그동안 여전히 스터디를 지속하고 있었습니다. 특히 Kubernetes에 좀 집중하여 스터디를 진행했습니다.

tech-recipe.tistory.com

 다만 이번에도 시험용 노트북에 문제가 있었습니다. 지난번 CKA는 윈도우 노트북에서 문제가 발생하더니 이번에는 Macbook에서 문제가 발생했습니다. 허가되지 않은 스크린 캡처 프로그램이 있는데 이것이 작동하고 있어서 시험에 사용되는 PSI 브라우저 실행이 안 되는 것이었습니다. 하지만 아무리 찾아봐도 해당 프로세스를 종료할 수 없었고, 그래서 또 급하게 회사에 있는 Window 노트북을 사용했는데 문제없이 작동하여 이걸 가지고 시험을 치렀습니다.

 

 시험 전에 PSI 브라우저를 테스트해 볼 수 있으면 참 좋을 텐데, 이 부분이 너무 아쉽습니다. 시험 가격도 높은 편인데 사전에 미리 테스트를 해볼 수 없고, 시험 30분 전에야 이를 확인할 수 있는데 이번처럼 대체할 수 있는 노트북이 있어서 다행이지 그게 아니었으면...

 

 어쨌든 이번 시험은 CKA때보다는 쉽다 느꼈던 게 가장 큰 것 같습니다. 아무래도 현업에서 Kubernetes를 많이 다루다 보니 그런 것도 있을 것이고, 내용 중복이 많아서 그런 것도 있지 않나 싶습니다. 최종적으로 CKS를 취득할지 말지는 조금 고민해 봐야 할 것 같습니다. 이제 저도 주니어 레벨에서 조금은 벗어나고 있는터라 자격증보다는 실무 능력이 중요하지 않나 하는 생각이 많이 드는 요즘입니다.

'Study > Certification' 카테고리의 다른 글

CKA 취득 후기  (1) 2024.02.11

 지난 포스팅들을 통해서 리눅스에서의 프로세스 라이프사이클과 좀비 프로세스에 대해서 알아보았습니다. 특히 컨테이너 환경에서 발생할 수 있는 좀비 프로세스는 전체 시스템에 영향을 줄 수 있어 주의가 필요합니다.

 

 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [1편]

리눅스에서 지금 당장 아래 명령어를 입력해보자!$ ps aux$ pstree 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재

tech-recipe.tistory.com

 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [2편]

이번 포스팅에서는 본격적으로 리눅스 좀비 프로세스에 대해서 이야기해보고자 합니다. 앞으로 논의할 내용을 이해하기 위해서는 리눅스 프로세스에 대한 기본적인 개념을 알아야 합니다. 이

tech-recipe.tistory.com

 이번 포스팅에서는 컨테이너에서 좀비 프로세스 생성을 방지하거나, 혹은 생성 되었더라도 이를 회수할 수 있는 방법에 대해서 알아보도록 하겠습니다.


코드 레벨에서의 수정

 앞선 포스팅에서 살펴 보았던 go 코드 조각을 다시 가져와 보겠습니다.

// backend/internal/process/manager.go
func CreateZombieProcess() error {
	cmd := exec.Command("sh", "-c", `		
       trap 'wait' EXIT			# trap 주석 제거
       sleep 1000 &					
       sleep_pid=$!					
       
       sleep 2
       kill -TERM $sleep_pid		
       exit 0						
    `)
	return cmd.Run()
}

 이미 코드에 있었던 "trap 'wait' EXIT"을 추가해 주는 것입니다. 사실 이미 코드에 있었죠. 이 코드는 sh이 EXIT 신호, 즉 종료 신호를 받으면 wait() 시스템 콜을 통해 자식 프로세스의 수거를 마치도록 하는 방식입니다. 간단한 코드 수정으로 sh의 자식 프로세스에 대한 회수가 제대로 이루어지도록 한다는 점에서 상당히 장점이 있습니다.

 다만 위 방식은 sh에 한정된 방식이라는 점에서 그 한계가 있습니다. 또한 sh이 비정상적으로 종료되는 경우에는 trap이 실행되지 않을 수도 있고, 자식 프로세스의 종료를 모두 기다려 wait() 시스템 콜을 처리 해야 하므로 전체 프로세스의 종료가 지연될 가능성도 있습니다.

 

컨테이너 레벨에서의 해결책

 앞선 포스팅에서 '컨테이너 내에서 고아 프로세스가 발생하면 네임스페이스 내의 PID 1이 이를 입양한다는 것'에 착안한 방식입니다. 바로 tini와 같은 컨테이너용 경량화 init을 도입하는 방식입니다. 이를 통해 컨테이너 내의 PID 1을 tini로 지정하여 고아 프로세스가 발생하고 이 프로세스가 좀비가 되더라도 입양과 회수를 할 수 있도록 하는 것입니다. 아래 Dockerfile을 보시죠

# backend/Dockerfile_tini
FROM golang:1.23.5-alpine

WORKDIR /app

RUN apk add --no-cache procps tini

COPY . .
RUN go build -o main ./cmd/main.go

EXPOSE 8080

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["./main"]

 우선 apk add를 통해 tini를 컨테이너에 설치하고, 엔트리 포인트를 /sbin/tini로 하여 PID 1이 되도록 합니다. 실제로 tini의 github 레포의 설명을 보아도 좀비 프로세스 처리에 관한 설명이 있습니다. [git repository] krallin/tiny

Tini is the simplest init you could think of.

All Tini does is spawn a single child (Tini is meant to be run in a container), and wait for it to exit all the while reaping zombies and performing signal forwarding.

 그렇다면 위 Dockerfile로 빌드된 컨테이너의 프로세스 상태를 알아 보겠습니다. ps -ef 명령어를 통해서 한번 알아보죠.

$ docker exec -it zombie-process-demo-with-tini ps -ef

docker exec -it zombie-process-demo-with-tini ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:37 ?        00:00:00 /sbin/tini -- ./main
root           6       1  0 11:37 ?        00:00:00 ./main
root          36       0 50 11:40 pts/0    00:00:00 ps -ef

 출력되는 정보를 보면 PID 1번이 tini로 실행되고 있는 것을 알 수 있습니다. 그리고 그 자식 프로세스로 PID 6의 go 애플리케이션 main이 실행되고 있는 것을 알 수 있습니다. 실제로 이전 포스팅에서 사용했던 api를 호출해 보면 아래와 같이 tini - main - sh - sleep 순으로 부모 - 자식 관계를 형성하고 있는것을 알 수 있습니다.

$ docker exec -it zombie-process-demo-with-tini ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:37 ?        00:00:00 /sbin/tini -- ./main
root           6       1  0 11:37 ?        00:00:00 ./main
root          65       6  0 11:44 ?        00:00:00 sh -c 
root          66      65  0 11:44 ?        00:00:00 sleep 1000
root          67      65  0 11:44 ?        00:00:00 sleep 2
root          68       0 99 11:44 pts/0    00:00:00 ps -ef

 다만 2초 정도가 지난 후 다시 프로세스를 확인해 보면 앞선 포스팅에서 생성되었던 좀비 프로세스인 sleep이 존재하지 않는다는 것을 알 수 있습니다. 바로 tini가 좀비 프로세스를 회수했기 때문입니다.

$ docker exec -it zombie-process-demo-with-tini ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:37 ?        00:00:00 /sbin/tini -- ./main
root           6       1  0 11:37 ?        00:00:00 ./main
root          75       0 50 11:46 pts/0    00:00:00 ps -ef

 이 외에도 dump-init과 같은 경량 init을 사용할 수 도 있습니다. 그러나 tini나 dump-init과 같은 것에도 단점은 있습니다. 프로세스 계층 구조에 새로운 레이어가 추가되는 점에서 프로세스 추적이 조금 더 복잡해질 수도 있고, 로그 확인과 같은 트러블 슈팅 상황이 조금 더 복잡해 질수 있습니다.

 

Docker의 '--init' 옵션 사용

 다른 방식으로는 Docker로 컨테이너 실행 시 --init 옵션을 사용하는 것입니다. 아래 출력 정보를 보면 docker-init이 PID 1로 등록되어 있는 것을 알 수 있습니다. 역시 좀비 프로세스를 생성하는 api를 호출하여도 프로세스가 더 이상 좀비로 남지 않게 됩니다.

$ docker run --init --name init-option-zombie-process-demo -p 8080:8080 zombie-process-demo
$ docker exec -it init-option-zombie-process-demo ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:54 ?        00:00:00 /sbin/docker-init -- ./main
root           7       1  0 11:54 ?        00:00:00 ./main
root          18       0 50 11:55 pts/0    00:00:00 ps -ef

 그러나 이는 docker에서 제공하는 옵션으로 kubernetes에서 사용하기에는 다소 어려운 점이 있습니다.


AI에게 질문

 클로드 3.5를 통해 이런 좀비 프로세스 관리에 대해서 질문해 보았습니다. 크게 두 가지 방법을 알려주었는데, 실제로 작동하는지 테스트해보지는 않았습니다. 따라서 적용에 유의를 요합니다. 우선 첫 번째 해결책은 프로세스 그룹을 통한 관리였습니다.

// Go 언어에서의 더 견고한 프로세스 관리 예시
func ImprovedProcessManagement() error {
    cmd := exec.Command("sleep", "1000")
    
    // 프로세스 그룹 설정
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Setpgid: true,
    }
    
    if err := cmd.Start(); err != nil {
        return fmt.Errorf("failed to start process: %w", err)
    }
    
    // 종료 시그널 처리
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
    
    go func() {
        <-sigChan
        // 프로세스 그룹 전체에 시그널 전송
        syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
    }()
    
    // 프로세스 상태 관리
    if err := cmd.Wait(); err != nil {
        return fmt.Errorf("process failed: %w", err)
    }
    
    return nil
}

 두 번째 방법은 PID 1, 즉 main에서 직접 자식 프로세스를 관리를 구현하는 방법입니다. 코드는 아래와 같습니다.

// 컨테이너의 PID 1 프로세스에서의 자식 프로세스 관리
func main() {
    // SIGCHLD 시그널 처리
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGCHLD)
    
    go func() {
        for range sigChan {
            for {
                // 모든 종료된 자식 프로세스 처리
                if pid, err := syscall.Wait4(-1, nil, syscall.WNOHANG, nil); err != nil || pid <= 0 {
                    break
                }
            }
        }
    }()
    
    // 메인 애플리케이션 로직
    // ...
}

 다시 한번 말씀드리지만 위 코드들은 테스트를 하지 않아 실제로 잘 작동하는지 여부는 확인이 필요합니다.

 

 어쨌든, 이렇게 리눅스 프로세스 관리와 좀비 프로세스에 대해서 그 원인을 파악해 보고, 해결책까지 논의해 보았습니다. 저도 이번 시리즈 포스팅을 작성하면서 더 깊이 있게 리눅스에 대해서 이해할 수 있는 좋은 기회였던 것 같습니다. 아무쪼록 현업에 계시는 개발자 분들, 혹은 리눅스와 컨테이너를 학습하고 계신 분들께 도움이 되기를 바라면서 긴 포스팅 마치도록 하겠습니다. 감사합니다.

 이번 포스팅에서는 본격적으로 리눅스 좀비 프로세스에 대해서 이야기해보고자 합니다. 앞으로 논의할 내용을 이해하기 위해서는 리눅스 프로세스에 대한 기본적인 개념을 알아야 합니다. 이에 대해서는 앞선 포스팅에 정리해 두었으니 참고하시면 좋을 것 같습니다.

 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [1편]

리눅스에서 지금 당장 아래 명령어를 입력해보자!$ ps aux$ pstree 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재

tech-recipe.tistory.com


고아 프로세스의 생성

 이름에서 알 수 있듯, 고아(Orphan) 프로세스란 부모 프로세스가 자식 프로세스보다 먼저 종료되어 부모를 잃은 프로세스를 의미합니다. 하지만 이 고아 상태는 매우 일시적입니다. 기본적으로 리눅스의 커널이 고아가 된 프로세스를 감지하고 PID 1인 systemd(init) 프로세스의 자식 프로세스로 재설정 하기 때문이죠. 이 작업은 우선순위가 높은 커널 작업이기 때문에 대부분의 경우 프로세스가 다음 명령어를 실행하기 전에 이미 완료됩니다.

 또한, 특별히 고아 프로세스가 부모 프로세스를 호출해야 하는 경우와 같은 상황이 아니라면, 그 자체의 작업 수행에는 크게 영향을 받지 않습니다. 그리고 앞서 말한 데로 곧바로 systemd(init)에 입양되므로 다시금 부모 - 자식 관계를 형성하며 안정적인 상태가 되죠.

 

좀비 프로세스?

 그렇다면 좀비 프로세스는 무엇일까요? 이를 이해하기 위해서는 사실 리눅스 커널에 대해서 깊은 이해가 필요합니다. 그러나 이를 모두 설명하기에는 너무 긴 내용이므로 간략하게 이야기해 보겠습니다. 커널에는 task_struct라는 모든 프로세스의 상세한 정보를 담고 있는 구조체가 있습니다. 그리고 프로세스 테이블이라는 task_struct 구조체에 대한 색인과도 같은 요소도 존재합니다.(정확한 설명은 아니자만, 대략적으로...)

 어쨌든, 프로세스는 실제로 종료되기 전 모든 리소스를 정리하고 그다음에 EXIT_ZOMBIE 상태가 됩니다. 그리고 프로세스 테이블PID, 종료 상태(exit status), 리소스 사용 통계 정도만 남겨두게 됩니다. 그리고 부모 프로세스가 wait() 시스템 콜을 통해 회수하기를 기다리게 되는데 프로세스 종료와 회수 직전의 상태가 바로 좀비 프로세스 상태인 것입니다.

시스템 호출을 통해 종료 과정은 완료되었지만, 여전히 프로세스 테이블에 남아있는 상태


 

systemd(init)의 고아 프로세스와 좀비 프로세스 핸들링

 드디어 서론이 끝난 기분이군요. 본격적으로 좀비 프로세스, 그리고 특히 컨테이너 환경에서의 좀비 프로세스에 대해서 논의할 수 있게 된 것 같습니다. 고아 프로세스와 좀비 프로세스는 서로 같은 개념은 아니자만, 둘 다 모두 부모프로세스의 상태나 동작에 영향을 받습니다. 게다가 이를 컨테이너 내에서 의도적으로 잘 조작하면, 좀비 프로세스가 계속 프로세스 테이블에 남아 있도록 할 수 있습니다.

[git repository] 좀비 프로세스 데모

// backend/internal/process/manager.go
func CreateZombieProcess() error {
	cmd := exec.Command("sh", "-c", `		# sh 프로세스가 생성됨(부모)
       #trap 'wait' EXIT
       sleep 1000 &					# 백그라운드로 sleep 1000 실행(자식)
       sleep_pid=$!					# 방금 전 생성된 프로세스의 PID를 환경변수로 지정
       
       sleep 2
       kill -TERM $sleep_pid				# sleep 1000(자식)에 종료 시그널 전송
       exit 0						# sh(부모) 종료
    `)
	return cmd.Run()
}

 이 go 코드 조각에서 어떤 방식으로 작동하는지 살펴보겠습니다. 로컬에서 이 go 애플리케이션을 실행하면 프로세스가 생성됩니다. 그리고 API를 통해 CreateZombieProcess를 호출하면 애플리케이션 프로세스이 자식 프로세스로 'sh'이 생성됩니다. 그리고 이 sh은 다시 'sleep 1000'이라는 자식 프로세스를 백그라운드로 실행합니다. 아래 그림을 참고하시면 될 것 같습니다.

 이제 우리는 PID 201인 'sh'의 입장에서 좀 살펴 보겠습니다. sh은 go app(PID 101)의 자식 프로세스이면서, sleep 1000 &(PID 301)의 부모 프로세스이기도 합니다. sh은 sleep 1000을 백그라운드로 실행하고 2초를 기다린 후 백그라운드 프로세스에 종료 시그널을 보냅니다. 그리고 바로 'exit 0'으로 스스로를 종료시킵니다.

 

 이 과정에서 일어나는 일들을 앞서 이야기한 프로세스 생명주기에 관한 내용을 통해 유추해 봅시다. 아마 아래와 같은 단계를 거칠 것입니다.

  1. 종료 신호를 받은 sleep 1000(PID 301)은 자신의 리소스를 정리하고 EXIT_ZOMBIE 상태가 됨
  2. 프로세스 테이블에 sleep 1000의 PID와 종료 상태, 리소스 사용량 통계만 남음
  3. 이때 sh(PID 201)은 자신의 자식 프로세스에 대한 회수를 수행하지 않은 채로 exit 0를 통해 종료
  4. PID 301은 부모가 없어지면서 좀비 이면서 고아 상태가 됨
  5. go app(PID 101)은 자신의 자식프로세스에 대해서만 회수 책임이 있으므로 sh의 종료만을 처리
  6. systemd가 PID 301이 고아 상태임을 파악하고 입양하여 고아 상태를 해소, 회수 책임이 발생
  7. 프로세스 상태 확인하고 wait() 시스템 콜을 통해 좀비 상태의 PID 301을 회수

이런 식으로 좀비이면서, 고아인 프로세스에 대한 안정적인 라이프사이클 관리를 하게 됩니다. 따라서 이 애플리케이션을 Linux 로컬 머신에서 작동한다면 좀비 프로세스가 생성되지 않는다는 것을 확인할 수 있습니다. 하지만 상황이 Container 내부에서 일어난다면 어떻게 될까요?

 

컨테이너 환경의 특수성과 좀비 프로세스의 출현

 그러나 위와 같은 상황이 컨테이너 환경에 발생하면 전혀 다른 결과가 나타납니다. 다시 한번 아래 그림을 봅시다.

 여기서 우리가 주목해야 할 부분은 바로 컨테이너를 위한 네임스페이스와 이로 인한 프로세스 격리입니다. 이것이 매우 중요한 이유는 고아 프로세스의 입양을 누가 하느냐가 결정되기 때문입니다. 고아 프로세스가 발생하게 되면 커널은 해당 프로세스가 속한 PID 네임스페이스를 먼저 확인합니다. 그다음 프로세스가 속한 PID 네임스페이스의 1번 프로세스가 이 고아 프로세스를 입양하게 합니다.

 

 위 그림에서 보면 컨테이너 내부에서 PID 31 프로세스(sleep 1000 &)가 고아가 되면 컨테이너 내부의 1번 프로세스인 go app이 이를 입양하게 된다는 것이죠. 그런데 여기서 문제가 발생합니다. systemd(init)의 경우에는 이런 고아 프로세스를 입양한 후, 이 고아 프로세스가 작업을 종료하고 EXIT_ZOMBIE 상태가 되면 이를 감지하고 wait() 시스템 콜을 호출하여 자식 프로세스에 대한 회수 책임을 다 하게 됩니다. 그러나 불행하게도 우리의 go app에는 그러한 기능이 없습니다. 따라서 커널의 작업에 따라 PID 31인 sleep 1000 &을 PID 1인 go app이 입양하기는 하지만 wait() 시스템 콜을 호출하는 기능이 없기 때문에 입양한 프로세스가 EXIT_ZOMBIE인 상태로 남게 되는 것입니다.


 실제 실습을 통해 과정을 하나씩 살펴보겠습니다. 우선 api 호출을 통해 CreateZombieProcess 함수를 호출해 보겠습니다. 그리고 바로 컨테이너의 프로세스 상태를 'ps -ef' 명령어를 통해 확인해 보겠습니다.

$ docker exec -it zombie-process-demo ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 18:03 ?        00:00:00 ./main
root          17       1  0 18:03 ?        00:00:00 sh -c         # trap 'wait' EXIT        sleep 1000 &   
root          18      17  0 18:03 ?        00:00:00 sleep 1000
root          19      17  0 18:03 ?        00:00:00 sleep 2
root          20       0 33 18:03 pts/0    00:00:00 ps -ef

 먼저 'main'이 해당 컨테이너의 PID 1을 차지하고 있는 것을 확인할 수 있습니다. 그리고 PID 17로 sh이 실행되고 있으며 바로 이어서 PID 18이 백그라운드로 sleep 1000을 수행했습니다. 이때 PID 18의 PPID(Parent PID)를 보면 17인 것을 확인할 수 있십니다. 즉 sh의 자식 프로세스로 sleep 1000 &이 실행된 것이죠. (참고로 PID 19의 'sleep 2'는 프로세스의 상태를 확인하기 위한 약간의 지연 시간을 위한 장치입니다.)

 그다음은 api 호출이 끝나고 프로세스가 좀비가 된 상태를 확인해 봅시다. 약 2초 후 역시 'ps -ef' 명령어를 통해 컨테이너 내부의 프로세스 상태를 확인해 보겠습니다.

$ docker exec -it zombie-process-demo ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 18:03 ?        00:00:00 ./main
root          18       1  0 18:03 ?        00:00:00 [sleep] <defunct>
root          27       0 50 18:03 pts/0    00:00:00 ps -ef

$ docker exec -it zombie-process-demo ps aux

USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1 1229276 4184 ?        Ssl  18:03   0:00 ./main
root          18  0.0  0.0      0     0 ?        Z    18:03   0:00 [sleep] <defunct>
root          33  100  0.0   2524  1728 pts/0    Rs+  18:04   0:00 ps aux

  처음으로 확인할 수 있는 것은 PID 18의 PPID의 변화입니다. 고아가 된 PID 18은 커널에 의해 자신이 속한 네임스페이스의 PID 1에 입양된 것을 확인할 수 있습니다. 또한 'ps aux' 명령어를 통해 상태가 Z(zombie)인 것도 확인할 수 있습니다.

컨테이너 내에서 좀비 프로세스가 생성되는 과정

 

 위 그림은 다소 부정확한 부분이 있습니다. 사실 main이 있을 위치에 커널이 있는 게 조금 더 옳습니다만, 컨테이너 환경이라는 의미에서 위와 같이 표현해 보았습니다. 어쨌든 커널은 제 역할을 잘 수행했습니다. 리눅스 기본 설계 철학인 '책임과 권한의 명확성(Clear Responsibility and Authority)'을 구현하기 위해 부모가 없는 프로세스에게 부모를 찾아준 것이죠. 다만 부모 프로세스인 go app이 그 책임을 다 할 수 없었기 때문에(wait()을 호출하는 기능이 없으므로) 로컬 환경에서의 작동과는 전혀 다른 결과로 좀비 프로세스를 생성하게 된 것입니다.

 

프로세스 탈출과 보안 위협

그럼, 처음부터 컨테이너 내부에서 고아가 된 프로세스를 전체 시스템 레벨에서 PID 1인 systemd(init)가 입양해 가면 되는 거 아닌가? 그렇게 하면 좀비 프로세스도 생길일이 없잖아?

 당연하게도 위와 같은 질문을 할 수 있습니다. 그러나 이렇게 된다면 네임스페이스 격리가 깨져 아무런 의미가 없게 됩니다. 또한, 단순히 네임스페이스 격리가 깨졌다는 기술적인 의미뿐만 아니라 심각한 보안 문제를 야기할 수도 있습니다. 만약 누군가 악의적인 의도를 가지고 컨테이너 내의 프로세스를 고의적으로 고아 상태로 만들어 PID 1이 입양하도록 유도하여, 프로세스가 네임 스페이스의 경계를 넘어 호스트 시스템에 접근할 수도 있기 때문입니다.

 따라서 이러한 심각한 보안 문제를 방지하기 위해 리눅스 커널은 엄격한 네임스페이스 경계를 유지하며, 반드시 같은 네임스페이스의 PID 1이 고아 프로세스를 입양하게 하는 것입니다.

 

좀비 프로세스가 시스템에 주는 영향

 그렇다면, 이런 좀비 프로세스는 시스템에 어떤 영향을 주는지에 대해서 한번 논의해 봅시다. 사실 좀비 프로세스가 수 개 - 수십 개 정도는 시스템에 크게 영향이 없을 가능성이 큽니다. 그 이유는 실제로 좀비 프로세스는 이미 '종료된' 프로세스 이기 때문입니다. 따라서 특수한 경우를 제외한다면 리소스를 거의 점유하고 있지 않습니다. 그러나 한 가지 확실하게 점유하고 있는 것이 있는데 그것이 바로 앞서 언급했던 프로세스 테이블입니다. 여기에 프로세스는 자신의 상태를 저장해 두고 부모 프로세스의 wait() 시스템 콜을 통해 회수되기를 기다리며 프로세스 테이블을 점유하고 있습니다.

$ ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root       60043       1  0 18:03 ?        00:00:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 1e
root       60065   60043  0 18:03 ?        00:00:00 ./main
root       60330   60065  0 18:03 ?        00:00:00 [sleep] <defunct>

 실제로 위와 같이 노드 수준에서 'ps -ef' 명령어를 통해 컨테이너 내부의 프로세스를 조회할 수 있습니다. 컨테이너의 main 프로세스가 PID 60065로 수행 중이며, 좀비가 된 PID 60330은 입양이 되어 컨테이너 내부에서는 PID 1이었던 PID 60065를 부모 프로세스로 가지고 있다는 것을 확인할 수 있습니다.

 그런데 이 테이블에 등록될 수 있는 프로세스의 수에 한계가 있다는 것이 문제입니다. 실제로 이 테이블이 가득 차면 실제로 새로운 프로세스를 생성할 수 없는 경우가 발생하기도 합니다. 어떻게 보면 컨테이너로 격리된 프로세스들이 유일하게 공유하는 노드 자원이라고 볼 수도 있겠는데요. 여기에 문제가 생겨서 다른 프로세스를 실행할 수 없다면 이는 시스템 작동에 또 다른 예상치 못한 결과를 가지고 올 수 있습니다.

 

 실제로 저도 자사의 애플리케이션에서 이러한 현상을 발견하여 몇몇 기능이 제대로 작동하지 않는 것을 확인한 바가 있었고, 문제의 원인을 파악하다 보니 이렇게 리눅스 프로세스 레벨까지 파고들게 된 것입니다.

 역시 이번 포스팅도 최대한 상세하게 쓰려다 보니 상당히 길어졌군요. 다음 포스팅에서는 그럼 이러한 좀비프로세스를 어떻게 해결해야 하는지 그 해격책을 제시해 보도록 하겠습니다.


▼ Linux 프로세스 관리 - 좀비 프로세스에 관하여 [3편]
 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [3편]

지난 포스팅들을 통해서 리눅스에서의 프로세스 라이프사이클과 좀비 프로세스에 대해서 알아보았습니다. 특히 컨테이너 환경에서 발생할 수 있는 좀비 프로세스는 전체 시스템에 영향을 줄

tech-recipe.tistory.com

 

리눅스에서 지금 당장 아래 명령어를 입력해보자!

$ ps aux
$ pstree

 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재 프로세스의 상태를 출력해주죠. 이번 포스팅에서는 리눅스 프로세스에 대해서 한번 다뤄보려고 합니다. 특히 그 중에도 부모 - 자식 프로세스간의 관계좀비 프로세스에 관해서 말이죠.


Linux의 PID 1 - init, 그리고 systemd

 리눅스의 첫 번째 user space(사용자 공간) 프로세스인 init커널 부팅 후 첫 번째로 실행되는 프로세스입니다. 일반적으로 리눅스의 모든 프로세스들은 하나의 부모 프로세스를 가지게 되는데, 특이하게도 이 init은 부모 프로세스가 없습니다. 그 이유는 init이 최초의 프로세스이기 때문이죠. 게다가 init은 직, 간접적으로 다른 프로세스들의 부모 프로세스가 됩니다. 따라서 이 최초의 프로세스 init은 PID(Process ID) 1번을 부여받게 됩니다.
 PID 1은 절대로 종료되어서는 안 되며, 시스템이 실행되는 동안 항상 존재해야 합니다. 또한 시스템 재시작 없이는 이 PID 1을 교체할 수도 없죠. PID 1이 종료된다면 커널은 시스템을 종료할 것입니다.
 
 그런데, 사실 현대의 리눅스PID 1로 init이 아닌 systemd를 사용합니다. 자 한번 아래 명령어들을 따라서 입력하고 그 출력을 살펴보시죠.

$ ps aux

USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.2 166424 11776 ?        Ss   00:45   0:01 /sbin/init
root           2  0.0  0.0      0     0 ?        S    00:45   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   00:45   0:00 [rcu_gp]
root           4  0.0  0.0      0     0 ?        I<   00:45   0:00 [rcu_par_gp]
root           5  0.0  0.0      0     0 ?        I<   00:45   0:00 [slub_flushwq]
root           6  0.0  0.0      0     0 ?        I<   00:45   0:00 [netns]

 참고로 이번 포스팅에 사용되는 리눅스는 Ubuntu 22.04입니다. 어? 그런데 이상하죠? 방금 전에 제가 분명 PID 1은 init이 아닌 systemd를 사용한다고 했는데, 'ps aux' 명령어의 출력을 살펴보면 PID 1의 COMMAND가 init인 것을 알 수 있습니다. 이상하군요? 자 그럼... 다음 명령어를 한번 입력해 봅시다.

$ top

top - 16:22:33 up 15:37,  1 user,  load average: 0.00, 0.00, 0.00
Tasks: 212 total,   1 running, 211 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.3 sy,  0.0 ni, 99.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   3911.9 total,   2196.0 free,    286.8 used,   1429.1 buff/cache
MiB Swap:   3911.0 total,   3911.0 free,      0.0 used.   3368.2 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                     
      1 root      20   0  166424  11776   8404 S   0.0   0.3   0:01.79 systemd                                     
      2 root      20   0       0      0      0 S   0.0   0.0   0:00.01 kthreadd                                    
      3 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_gp                                      
      4 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_par_gp                                  
      5 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 slub_flushwq

 'top' 명령어는 전반적은 시스템의 상태를 확인할 수 있는 명령어입니다. 오! 그런데 이번에는 PID 1번의 COMMAND가 systemd라고 표시되어 있습니다. 도대체 무엇이 진실일까요? 이를 알아보기 위해 다음 명령어를 입력해 봅시다.

$ ls -l /sbin/init

lrwxrwxrwx 1 root root 20 Nov 21  2023 /sbin/init -> /lib/systemd/systemd

 네 맞습니다. 보시는 바와 같이 init은 심볼릭 링크이며 systemd를 가리키고 있음을 확인할 수 있습니다. 앞서 말씀드린 대로 현대 리눅스의 PID 1은 systemd가 차지하고 있습니다. systemd는 기존 init 시스템의 한계를 극복하기 위해 2010년에 개발된 새로운 init 시스템입니다. 다만, 기존 init 기반 스크립트와의 호환성 유지와 같은 측면에서 심볼릭 링크를 사용하는 형태를 하고 있었던 것입니다. 언젠가는 init과 systemd의 차이점을 다뤄보도록 하겠습니다.
 
 마지막으로 명령어 'pstree -p'를 입력해서 출력되는 내용을 살펴보죠. 이 명령어는 프로세스 간의 계층 구조를 시각적으로 나타내줍니다.

$ pstree -p

systemd(1)─┬─ModemManager(830)─┬─{ModemManager}(832)
           │                   └─{ModemManager}(843)
           ├─VGAuthService(742)
           ├─agetty(1027)
           ├─containerd(796)─┬─{containerd}(833)
           │                 ├─{containerd}(834)
           │                 ├─{containerd}(835)
           │                 ├─{containerd}(836)
           │                 ├─{containerd}(840)
           │                 ├─{containerd}(860)
           │                 └─{containerd}(2478)

 위와 같이 PID 1인 systemd를 시작으로 여러 프로세스가 계층적 구조를 이루고 있는 것을 알 수 있습니다. 여기서 systemd와 직접적으로 연결된 프로세스들은 모두 systemd의 자식 프로세스들이고, 당연히 systemd는 이들의 부모 프로세스가 됩니다.
 
 containerd(796)을 봅시다. contianerd는 systemd의 자식 프로세스이기도 하면서, 그 아래 여러 {containerd}들의 부모 프로세스이기도 합니다. systemd(1)의 입장에서 {containerd} (833)와 같은 프로세스를 손자 프로세스라고 부르기도 합니다. 리눅스에서는 이렇게 프로세스들이 서로 계층적인 구조를 지니면서 작동하고 있습니다.
 

프로세스 생명 주기와 회수 책임

 이제부터 본격적인 이야기를 하려고 합니다. 바로 프로세스 생명 주기회수 책임입니다. 아래 다이어그램을 한번 보도록 하겠습니다.

프로세스 생명 주기

 이는, 프로세스의 생명주기를 나타낸 것입니다. 프로세스의 생성, 실행, 그리고 종료 단계에서 일어나는 일들을 순차적으로 도식화한 것입니다. 한 가지 주목할 부분은 의외로 종료 단계에 많은 과정을 거친다는 점이죠. 특히 부모 프로세스는 자식 프로세스로부터 SIGCHLD 시그널을 전송받으면 wait() 시스템 콜을 통해 자식 프로세스의 종료에 대한 마지막 단계를 수행한다는 것입니다.
 즉, 이 wait() 시스템 콜을 통해 자식 프로세스에 대한 회수 책임을 다 하고 있는 것이죠. 이는 단순히, 프로세스 생명 주기에서 수행되는 단계의 수준이 아니라 부모 - 자식 프로세스라는 계층적인 구조를 가진 리눅스 운영체제의 설계 원칙이라 할 수 있습니다.
 
 그렇다면, 여기서 한 가지 질문을 해볼까요?

만약, 자식 프로세스가 종료되기 전 부모 프로세스가 먼저 종료된다면?

자식 프로세스에 대한 회수 책임을 가지고 있는 부모 프로세스가 사라졌습니다. 과연 이럴 때는 어떻게 될까요?
 
이에 대한 구체적인 이야기는 다음 시간에 마저 진행해 보겠습니다. 또한, 이러한 상황이 특히 컨테이너 환경에서는 어떤 영향을 미치는지에 대해서도 한번 알아보죠.


▼ Linux 프로세스 관리 - 좀비 프로세스에 관하여 [2편]
 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [2편]

이번 포스팅에서는 본격적으로 리눅스 좀비 프로세스에 대해서 이야기해보고자 합니다. 앞으로 논의할 내용을 이해하기 위해서는 리눅스 프로세스에 대한 기본적인 개념을 알아야 합니다. 이

tech-recipe.tistory.com

 

환경

  • Ubuntu 22.04.3 TLS
  • Kubernetes 1.28.2
  • Containerd 1.26.0
  • Crictl 1.26.0

증상

  • crictl 명령어 사용 시 아래와 같은 에러 메시지와 함께 실행 불가
WARN[0000] runtime connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock unix:///var/run/cri-dockerd.sock]. As the default settings are now deprecated, you should set the endpoint instead.
WARN[0000] image connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock unix:///var/run/cri-dockerd.sock]. As the default settings are now deprecated, you should set the endpoint instead.
E0214 06:30:26.114803 1106869 remote_runtime.go:390] "ListContainers with filter from runtime service failed" err="rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing dial unix /var/run/dockershim.sock: connect: no such file or directory\"" filter="&ContainerFilter{Id:,State:&ContainerStateValue{State:CONTAINER_RUNNING,},PodSandboxId:,LabelSelector:map[string]string{},}"
FATA[0000] listing containers: rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing dial unix /var/run/dockershim.sock: connect: no such file or directory"

[사진 1] runtime endpoint와 image endpoint에 대한 설정을 하라는 메시지와 함께 에러 발생

 

 

원인

  • Kubernetes에서 crictl로 호출하는 컨테이너 런타임의 Endpoint가 /var/run/dockershim.sock으로 설정되어 있음
  • 그러나 Kubernetes 1.24 버전부터는 Dockershim에 대한 지원이 중단된 상태(관련 내용 링크)

 

조치 (1)

  • crictl 명령어 옵션을 통해 runtime endpoint와 image endpoint를 수동으로 지정
sudo crictl -r unix:///run/containerd/containerd.sock -i unix:///run/containerd/containerd.sock ps

[사진 2] sudo 권한과 함께 -r, -i 옵션을 통해 runtime endpint, image endpoint를 지정하여 crictl 명령 실행

  • 단, 해당 옵션은 일회성으로 지속성이 없음

 

조치 (2)

  • crictl config 명령어를 사용하여 runtime endpoint와 image endpoint를 설정
sudo crictl config --set runtime-endpoint=unix:///run/containerd/containerd.sock --set image-endpoint=unix:///run/containerd/containerd.sock
# runtime endpoint와 image endpoint를 영구적으로 설정하는 명령어

sudo crictl ps

[사진 3] cirlctl config 명령어를 통해 runtime endpoint와 image endpoint를 영구적으로 지정

  • 위 명령어를 실행하고 나면 /etc/crictl.yaml 파일이 생성되며 내용은 아래와 같음
runtime-endpoint: "unix:///run/containerd/containerd.sock"
image-endpoint: "unix:///run/containerd/containerd.sock"
timeout: 0
debug: false
pull-image-on-create: false
disable-pull-on-run: false

[사진 4] /etc/crictl.yaml 파일 생성 확인

 

추가

  • crictl은 Kubernetes에서 제공하는 컨테이너 런타임 제어 명령
  • CRI(Container Runtime Interface) 규격을 만족하는 컨테이너 런타임에 대하여 제어가 가능
  • 단, crictl은 컨테이너 런타임 레벨에서의 Debug 용도로 사용하는 것을 권장
  • 이미 Kubernetes 1.24 버전에서 지원이 중단된 dockershim이 아직도 crictl의 runtime endpoint로 지정되어 있는 이유에 대해서는 더욱 스터디가 필요

 안녕하세요? 오래간만에 포스팅 올립니다.

 그동안 이것저것 좀 한다고 포스팅이 뜸했네요. 그동안 여전히 스터디를 지속하고 있었습니다. 특히 Kubernetes에 좀 집중하여 스터디를 진행했습니다. 이러던 중, 내가 얼마만큼 알고 있는지 알아보기 위해 자격시험을 하나 준비하게 되었습니다.

 

 바로 CKA(Certified Kubernetes Administrator)입니다. 포스팅을 한다는 것은 당연히 자격을 취득했다는 뜻이겠죠?😎

벼르고 벼르던 시험을 합격하게 되어 기쁜 마음으로 포스팅 해 봅니다.

 

 

CKA란?

 Linux Foundation에서 주관하는 시험으로, Kubernetes 관리자를 대상으로 합니다. Kubernetes에 대한 이해를 바탕으로 운영, 관리 그리고 가벼운 트러블 슈팅에 대한 능력을 평가합니다. 이를 통해 Kubernetes의 네트워킹, 스토리지, 보안, 유지 관리, 로깅 및 모니터링, 애플리케이션 수명 주기, 문제 해결, API 개체 기본 요소, 최종 사용자를 위한 기본 설정에 대한 개념과 같은 주요 개념들을 이해할 수 있습니다. 개인적으로는 다른 자격시험에 비해서 조금 더 실전적인 내용들을 위주로 한다는 점이 아주 좋았습니다. 자격증 공부를 하면서 Kubernetes에 대한 이해를 더 깊게 할 수 있었던 것 같습니다.

 

CKA 공식 링크

 

시험 정보

1. 가격

 우선 Linux Foundation에 회원 가입을 하고 CKA 시험 비용을 결제해야 합니다. 가격은 Exam Only가 395USD이고 트레이닝 코스와 Exam 번들이 595 USD입니다. 저는 395 USD짜리인 Exam Only를 결제했습니다. 현재 환율 기준으로 거의 한화 52만 원을 넘는군요. 상당히 사악한 가격입니다. 참고로, 11월 말에 진행하는 Cyber Monday라는 기간을 이용하면 50% 할인된 가격으로 시험에 응시할 수 있습니다. 저도 이 행사때 결재 하여 부담을 좀 낮출 수 있었지만 그럼에도 불구하고 20만 원 중반대의 가격은 만만찮은 가격입니다. 결제 후 1년 이내에 시험에 응시해야 합니다.

 

2. 학습을 위한 강의

 CKA의 바이블이라 할 수 있는 Udemy의 뭄샤드의 CKA 강의인프런의 그림으로 배우는 쿠버네티스를 수강했습니다. 뭄샤드의 강의는 이전에는 한글자막이 없어 영어가 안되면 보기에 좀 어려운 감이 있었는데, 다행이 한글 자막 지원으로 보기에 많이 편해졌습니다.(다만 아직 번역이 다소 어색한 부분이 존재해서 주의하여 들어야 합니다.)

 저는 먼저 그림으로 배우는 쿠버네티스를 수강한 후, 뭄샤드의 CKA 강의를 들었습니다. 사실 뭄샤드의 강의만 잘 들어도 충분하지만, 저는 회사에서 인프런 강의를 제공해 주어서 함께 수강을 했습니다. 간단하게 두 강의에 대한 평을 해 보자면, 우선 인프런의 경우 해당 강의 앞에 기초 강의가 하나 더 있어 이를 수강하지 않고 들으신다면 다소 이해가 가지 않는 부분도 있을 것으로 보입니다. 특히 최초 환경 설정과 관련하여서는 이전 강의를 수강했다는 가정을 가지고 가기 때문에 Kubernetes에 대해 아무것도 모르는 상태라면 더욱 어렵게 느껴질 수 있습니다. 그러나 한국어로 되어 있다는 점과 질문에 대한 답변이 비교적 빠르다는 점이 장점입니다. 또한 예제로 제공되는 여러 YAML파일들이 있어 실제 Kubernetes 클러스터를 구성해서 실습을 진행한다면 많은 도움이 된다는 것 역시 장점입니다.

 뭄샤드의 강의는 그 반대라 할 수 있겠습니다. 아쉬운 점은 역시 영어라는 점으로 ,물론 지금은 한글 자막이 제공되지만 앞서 말씀드린 대로 번역에 어색한 부분이 있어서 내용 전달에 다소 오해가 있을 수 있습니다. 그러나 KodeKloud라는 실습 환경을 제공해 주기 때문에 따로 Kubernetes를 설치할 필요가 없으며, Kubernetes 뿐만 아니라 인증서와 같은 조금 더 깊이 있는 내용들을 추가로 제공하기 때문에 조금 더 깊이 있게 학습할 수 있다는 장점이 있습니다. 강의 포커스가 좀 더 CKA에 집중되어 있으며, Mock Exam과 Lightning Labs와 같은 실제 시험과 비슷한 환경 역시 제공해 주기 때문에 시험 준비를 하는 입장에서는 조금 더 나은 점이 있습니다.

 

 개인적으로는, 시간과 금전적 여유가 된다면 두 강의 모두 들어 보시는 것을 추천드립니다. 두 강의가 지향하는 바가 살짝 다르기 떄문인데 스터디를 하는 입장에서는 양쪽 모두 배워야 할 부분이 많았습니다.

 

3. 공식 문서 및 실습

 앞선 강의를 어느정도 수강하고 이해도가 올라왔다면, Kubernetes 공식 문서를 자주 읽어보기를 추천드립니다. 그 이유는, CKA가 오픈북 테스트 이기 때문입니다. 내용을 이해하는 것도 중요하지만 공식 문서의 어느 부분에 내가 찾고자 하는 내용이 있는지 미리 알아두어야 시험에서 쉽게 찾을 수 있습니다.

 실습 역시 빼먹을 수 없는 부분입니다. 기본적으로 뭄샤드의 강이에서 제공하는 KodeKloud 환경을 사용하시면 크게 부족한 부분이 없으며 CKA 결제를 하게 되면 제공되는 Killer Shell도 잘 활용하면 좋습니다. 특히 Killer Shell 같은 경우에는 36시간동안 사용할 수 있는 시뮬레이터 환경을 2회 제공하는데 CKA 시험 환경과 가장 흡사한 환경이기 때문에 시험 전 마지막 실전 모의고사처럼 활용해 보실 수 있습니다. 난이도는 CKA보다 다소 어려운 수준으로 그 내용을 차분히 잘 이해한다면 CKA 취득은 충분히 가능합니다. 또한, 최근 업데이트로 단편적인 시나리오들을 무제한으로 실습해 볼 수 있는데 이것을 잘 활용하는 것도 좋은 방법일 것 같습니다.

 만약 가상머신과 리눅스를 좀 다룰 줄 안다면 실제로 Kubernetes 클러스터를 구축해서 실습하는 방법도 있습니다. 저는 이 방법도 활용했는데 여러모로 많은 도움이 되었습니다.

 

4. 시험 예약 및 준비물

 실제 시험을 치르기 위해서는 시험을 예약해야 합니다. Linux Foundation에 접속하여 우측 상단의 My Profile을 클릭합니다.

Linux Foundation 홈페이지

 그러면 아래와 같이 새로운 페이지로 이동하는데 살짝 아래로 드래그해 보면 Training and Cetifications라는 아이콘이 보입니다. 이를 클릭해 보면 CKA 시험을 예약할 수 있습니다.

My Profile에서 시험 예약을 진행할 수 있다.

 시험 예약을 위해 이름을 입력해야 하는데 이는 여권이나 국제 운전 면허증에 기제된 이름과 동일해야 합니다. 나중에 시험을 칠 때 신분증을 확인하여 대조하기 때문입니다. 저는 여권에 있는 이름과 동일하게 입력하였고 시험에도 여권을 지참하였습니다. 그리고 나면 여러 가지 주의사항도 읽어봐야 하고, 시험 환경도 테스트도 할 수 있습니다.

 

 시험시에는 여권이나 국제 운전 면허증과 같은 신분증이 필요합니다. 감독관이 한국사람이 아니기 때문에 한글만 있는 주민등록증은 확인이 어려울 수 있기 때문인데 가급적이면 여권을 준비하는 것을 권장합니다. 또한 시험 환경은 웹캠과 같이 내 모습을 전송할 수 있는 카메라가 있어야 하고, 마이크가 되어야 합니다. 이는 대부분의 노트북이면 무리 없이 환경을 준비할 수 있을 것 같습니다. 화면은 듀얼 모니터를 허용하지 않습니다. 따라서 노트북을 사용하신다면 가급적 화면이 큰 기기를 사용하시길 추천드립니다.

 

 시험 예약은 비교적 널널한 편입니다. 대부분의 날짜가 예약 가능하며 그 시간대도 30분 간격으로 촘촘하게 되어 있습니다. 저는 오후 6시에 예약하고 시험을 치렀습니다. 편한 날짜와 시간에 예약해서 시험을 치면 될 것 같습니다.

 

5. 실제 시험 환경 및 시험 진행

 시험 시간 30분 전 부터 시험에 입장할 수 있습니다. 시험에 입장하면 PSI Secure Browser라는 프로그램을 설치하고 환경을 테스트합니다. 저는 처음에는 회사 노트북으로 시험을 치르려고 했는데 이상하게 PSI 브라우저가 제 회사 노트북의 카메라를 인식하지 못해서 첫날 시험을 치를 수 없었습니다. 앞서 시험 환경 테스트는 크롬 브라우저 기반으로 하기 때문에 문제없이 통과가 되었는데 PSI 브라우저는 인식을 못하더라구요. 이렇게 되니까 아예 시험 입장을 할 수 없어 시험을 치를 수 없었습니다. 너무 당황해서 한참을 끙끙거리다가 해당 증상 스크린샷을 확보하고, 문의를 통해 해당 증상을 설명하는 티켓을 넣었습니다.

 거의 1주일만에 문의에 답변을 줬는데, PSI 브라우저가 카메라를 인식하지 못하는 경우는 자기네도 처음이지만 그래도 쿨하게 시험을 리셋시켜 줄 테니 다음에는 다른 기기로 시험을 보라고 답변이 왔습니다. 아마 회사 노트북의 보안 프로그램이 원인인 것 같아서 개인 노트북을 이용해 시험을 보았는데 아무 문제 없이 시험을 치를 수 있었습니다.

 

 시험은 2시간동안 진행되며 PSI 브라우저를 통해 가상머신 환경이 제공됩니다. 리눅스 데스크탑에 기본적으로 Kubernete 공식 문서가 브라우저에 띄워져 있고 왼쪽에 문제가 있습니다. 터미널을 열고 바로 문제를 풀어나가면 됩니다. 저는 총 17문제가 출제되었는데 대부분 17문제가 출제되는 것 같습니다. 앞서 실습을 충분히 진행했다면 시험 자체는 크게 어려울 게 없습니다만, 아직 한국어를 지원하지 않기에 영어 독해가 살짝 필요합니다. 저는 영어가 참 약해서... 살짝 애매한 부분이... 진짜 영어 공부 해야겠어요.

 

 문제 난이도는 뭄샤드 강의의 실습을 모두 한번씩한 번씩 해 보고, Killer shell에 대한 복습을 철저히 했다면 무난하게 풀 수 있습니다. 간단한 Pod 생성, 스케일링, 자원 사용량 조회 같은 문제부터, Ingress 설정, Volume, Role 및 Rolebinding과 같은 문제도 있고, etcd 백업과 리스토어, 클러스터 버전 업그레이드, Network Policy 설정, Kubelet 구동 트러블 슈팅과 같은 문제들이 출제되었습니다. 모두 한 번씩 실습을 통해 겪어본 문제들이었지만 그 디테일들은 다르기 때문에 공식 문서를 잘 참조해서 풀어야 합니다.

 

 참고로 저는 Network Policy 문제가 첫 분제였는데 문제 내용이 다소 디테일 하지 않아 조금 당황했습니다. 나중에 공식 문서를 꼼꼼히 살펴보니 해당 내용에 대한 방법이 안내되어 있더라구요. 그래서 진짜 공식문서를 꼼꼼히 읽어 보는 것을 추천합니다. 첫 문제에서 당황을 하다 보니 다른 문제들에서 약간씩 실수를 한 것 같았지만 대체로 큰 무리 없이 문제를 풀었습니다. 약 3문제 정도가 좀 애매했는데 배점이 좀 큰 문제이지 않나 싶어 걱정을 했는데 다행히 통과를 했습니다.

 

6. 소감

 다른 자격 시험에 비해서 실전적이 내용이라 시험이 제법 재미있었습니다. 물론 당황도 좀 했지만 말이죠. 하지만 해당 내용을 모르는 것은 아니라 실무에서 이런 상황을 만난다면 시간이 좀 걸릴 뿐 크게 어려울 것 같지 않다는 생각은 했습니다. 그리고 공식 문서가 정말 중요하다는 걸 다시 한번 느꼈습니다.

 물론 자격을 취득한다고 해서 Kubernetes 전문가라고 단언하기는 어렵겠지만 그럼에도 불구하고 자신감은 많이 올라간 것 같습니다. 시험을 준비하면서 더욱더 깊이 있게 Kubernetes에 대해 이해할 수 있었다는 점이 가장 큰 소득인 것 같습니다.

 

7. 참고사항

 2024년 4월 1일 부터는 CKA와 CKAD의 정책이 바뀌어서 자격 만료 기간이 3년에서 2년으로 줄어듭니다. 따라서 CKA나 CKAD를 취득하고자 하시는 분들은 3월 말까지 취득하는 게 조금이나마 이득이라고 할 수 있겠습니다. 저는 내친김에 CKAD도 준비 중인데, 3월 말 취득 목표로 스터디를 진행할 예정입니다.

 

'Study > Certification' 카테고리의 다른 글

CKAD 취득 후기  (1) 2025.01.28

환경

  • Ubuntu 22.04.3 LTS
  • Kubernetes 1.28.2
  • Prometheus Community Version

증상

  • 몇 개의 Target이 'Connection refused' 메시지와 함께 메트릭 수집 불가

[사진 1] 설정된 Prometheus Target 중 일부의 메트릭 값 수집 불가

원인

  • Kubernetes의 Static Pod YAML의 metric에 관한 기본 설정값이 localhost로 설정되어 있기 때문에 'connection refused'가 발생
  • 따라서 Static Pod의 YAML 파일 편집이 필요

 

조치

  • /etc/kubernetes/manifests/ 경로의 Static Pod의 YAML 값 변경

[사진 2] /etc/kubernetes/manifests/ 경로에 위치한 Static Pod의 YAML 파일

#etcd.yaml
#spec.contaienrs 필드의 값 중 - --listen-metrics-urls 주소와
#livenessProbe.httpGet.host, startupProbe.httpGet.host의 값을 모두 0.0.0.0으로 변경
#또는 Control Plane의 노드 IP 주소로도 변경해도 무방함

apiVersion: v1
kind: Pod
metadata:
  annotations:
    kubeadm.kubernetes.io/etcd.advertise-client-urls: https://10.110.0.11:2379
  creationTimestamp: null
  labels:
    component: etcd
    tier: control-plane
  name: etcd
  namespace: kube-system
spec:
  containers:
  - command:
    - etcd
    - --advertise-client-urls=https://10.110.0.11:2379
    - --cert-file=/etc/kubernetes/pki/etcd/server.crt
    - --client-cert-auth=true
    - --data-dir=/var/lib/etcd
    - --experimental-initial-corrupt-check=true
    - --experimental-watch-progress-notify-interval=5s
    - --initial-advertise-peer-urls=https://10.110.0.11:2380
    - --initial-cluster=alpha-k8s-cp=https://10.110.0.11:2380
    - --key-file=/etc/kubernetes/pki/etcd/server.key
    - --listen-client-urls=https://127.0.0.1:2379,https://10.110.0.11:2379
    - --listen-metrics-urls=http://0.0.0.0:2381   #기존 127.0.0.1을 0.0.0.0으로 변경
    - --listen-peer-urls=https://10.110.0.11:2380
    - --name=alpha-k8s-cp
    - --peer-cert-file=/etc/kubernetes/pki/etcd/peer.crt
    - --peer-client-cert-auth=true
    - --peer-key-file=/etc/kubernetes/pki/etcd/peer.key
    - --peer-trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
    - --snapshot-count=10000
    - --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
    image: registry.k8s.io/etcd:3.5.9-0
    imagePullPolicy: IfNotPresent
    livenessProbe:
      failureThreshold: 8
      httpGet:
        host: 0.0.0.0   #기존 127.0.0.1을 0.0.0.0으로 변경
        path: /health?exclude=NOSPACE&serializable=true
        port: 2381
        scheme: HTTP
      initialDelaySeconds: 10
      periodSeconds: 10
      timeoutSeconds: 15
    name: etcd
    resources:
      requests:
        cpu: 100m
        memory: 100Mi
    startupProbe:
      failureThreshold: 24
      httpGet:
        host: 0.0.0.0   #기존 127.0.0.1을 0.0.0.0으로 변경
        path: /health?serializable=false
        port: 2381
        scheme: HTTP
      initialDelaySeconds: 10
      periodSeconds: 10
      timeoutSeconds: 15
    volumeMounts:
    - mountPath: /var/lib/etcd
      name: etcd-data
    - mountPath: /etc/kubernetes/pki/etcd
      name: etcd-certs
  hostNetwork: true
  priority: 2000001000
  priorityClassName: system-node-critical
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  volumes:
  - hostPath:
      path: /etc/kubernetes/pki/etcd
      type: DirectoryOrCreate
    name: etcd-certs
  - hostPath:
      path: /var/lib/etcd
      type: DirectoryOrCreate
    name: etcd-data
status: {}
#kube-controller-manager.yaml
#spec.contaienrs 필드의 값 중 - ----bind-address 주소와
#livenessProbe.httpGet.host, startupProbe.httpGet.host의 값을 모두 0.0.0.0으로 변경
#또는 Control Plane의 노드 IP 주소로도 변경해도 무방함

apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: null
  labels:
    component: kube-controller-manager
    tier: control-plane
  name: kube-controller-manager
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-controller-manager
    - --authentication-kubeconfig=/etc/kubernetes/controller-manager.conf
    - --authorization-kubeconfig=/etc/kubernetes/controller-manager.conf
    - --bind-address=0.0.0.0   #기존 127.0.0.1을 0.0.0.0으로 변경
    - --client-ca-file=/etc/kubernetes/pki/ca.crt
    - --cluster-name=kubernetes
    - --cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
    - --cluster-signing-key-file=/etc/kubernetes/pki/ca.key
    - --controllers=*,bootstrapsigner,tokencleaner
    - --kubeconfig=/etc/kubernetes/controller-manager.conf
    - --leader-elect=true
    - --requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
    - --root-ca-file=/etc/kubernetes/pki/ca.crt
    - --service-account-private-key-file=/etc/kubernetes/pki/sa.key
    - --use-service-account-credentials=true
    image: registry.k8s.io/kube-controller-manager:v1.28.2
    imagePullPolicy: IfNotPresent
    livenessProbe:
      failureThreshold: 8
      httpGet:
        host: 0.0.0.0   #기존 127.0.0.1을 0.0.0.0으로 변경
        path: /healthz
        port: 10257
        scheme: HTTPS
      initialDelaySeconds: 10
      periodSeconds: 10
      timeoutSeconds: 15
    name: kube-controller-manager
    resources:
      requests:
        cpu: 200m
    startupProbe:
      failureThreshold: 24
      httpGet:
        host: 0.0.0.0   #기존 127.0.0.1을 0.0.0.0으로 변경
        path: /healthz
        port: 10257
        scheme: HTTPS
      initialDelaySeconds: 10
      periodSeconds: 10
      timeoutSeconds: 15
... (이하 생략)

 위와 같이 Metric 수집이 되지 않는 Prometheus Target의 YAML 파일을 수정하고 저장하면 정상적으로 작동하는 것을 확인할 수 있음.

[사진 3] 정상적으로 Metric 값을 수집

추가

  • Kubernetes의 Static Pod 요소들은 코드 레벨에서 이미 Metric 값을 노출할 수 있도록 정의되어 있어 위와 같이 설정만 변경해 주면 Prometheus에서 수집이 가능
  • Static Pod의 경우 YAML 파일을 오류 없이 수정하면 자동으로 해당 내용으로 재시작 되므로 따로 Pod를 재시작할 필요는 없음

+ Recent posts