소수의 베어메탈 서버 또는 가상 머신(VM)의 경우에 수동으로 OS를 설치하는 작업은 크게 어렵지 않습니다. 베어메탈의 서버의 경우 부팅 USB를 통해서, VM의 경우 ISO 파일을 Import 하는 형식으로 합니다. 그러나, 다수의 베어메탈 서버나 VM을 대상으로는 이러한 방식이 매우 비효율적이 됩니다. 이런 경우 동시에 여러 대의 노드에 OS를 설치할 수 있는 방법이 필요한데 이번 포스팅에서는 그 방법 중 하나인 PXE 부팅에 대해서 알아보겠습니다.
이미지 출처: https://www.milesweb.in/blog/hosting/what-is-bare-metal-server/
일반적인 부팅 프로세스
PXE 부팅에 대해서 학습하기 전에, 우선 일반적인 컴퓨터의 부팅 프로세스에 대해서 알아 보겠습니다.
일반적인 부팅 프로세스
1. 전원 공급(Power-On)
컴퓨터의 전원이 켜지면 CPU, 메모리, 저장 장치 등 하드웨어에 전기가 공급됩니다. 이때 CPU는 초기화되고, 어디서부터 명령을 실행할지 찾기 시작합니다.
2. BIOS/UEFI 실행
전원이 들어오면 가장 먼저 실행되는 건 BIOS(Basic Inpout/Output System) 또는 UEFI(Unifed Extensible Firmware Interface)입니다. (최근의 컴퓨터의 경우에는 대부분 UEFI를 사용하지만, 이를 BIOS라 칭하기도 합니다.) 이는 컴퓨터 메인보드에 내장된 작은 소프트웨어로, 하드웨어를 초기화하고 기본적인 동작을 점검합니다. 이를 POST(Power-On Self-Test)라고 하는데, 참고로 일반 사용자 PC의 경우에 이 과정이 상당히 짧지만, 서버의 경우에는 수십 초에서 수분이 걸리기도 합니다.
3. 부트로더 로드
BIOS/UEFI는 부팅할 디스크를 찾아서 그 안에 있는 부트로더(Bootloader)를 메모리에 적재합니다. 이 부트로더는 운영체제(OS)를 실행하기 위한 준비를 하는 작은 프로그램으로 Windows는 Windows Boot Manager, 리눅스의 경우에는 GRUB이 그 역할을 합니다. 부트로더는 보통 저장 장치의 특정 영역(MBR 또는 EFI 파티션)에 저장되어 있습니다.
4. 운영체제 커널 로드
부트로더가 실행되면 운영체제의 핵심 부분인 커널(Kernel)을 메모리에 불러옵니다. 커널은 하드웨어와 소프트웨어를 연결하는 역할을 하며, 파일 시스템, 네트워크, 프로세스 관리 등을 준비합니다.
5. 운영체제 초기화 및 사용자 환경 로드
커널이 기본 설정을 마치면 나머지 운영체제 구성 요소(드라이버, 서비스 등)를 로드하고, 마지막으로 로그인 화면이나 데스크톱 같은 사용자 인터페이스를 띄웁니다.
OS 설치 절차
OS 설치 절차 역시 초기 과정은 부팅과 거의 흡사합니다. 다만 부팅에 사용되는 매체가 USB와 같은 외부 장치라는 점에서 차이가 발생합니다. 최초에 OS를 설치하기 위해서는 BIOS/UEFI에서 부팅 순서를 USB와 같은 설치할 OS가 담겨 있는 매체로 변경합니다. 일반적으로 컴퓨터의 전원을 켰을 때 F2, Del, F12와 같은 키를 누르면 BIOS/UEFI로 진입하게 되는데 여기에서 부팅 순서를 변경할 수 있습니다.
OS를 설치하는 과정은 일반적인 부팅 프로세스의 첫 두 단계(1. 전원 공급, 2. BIOS/UEFI 실행)까지는 동일합니다. 하지만 부팅 순서를 미리 USB와 같은 부팅 매체가 첫 번째가 되도록 변경하였기 때문에, 해당 매체의 부팅 섹터(MBR 또는 EFI 파티션)에 있는 부트로더를 읽어 메모리로 로드합니다. 그리고 커널을 로드하고 ISO 이미지를 통해 설치 작업을 수행하게 되죠. 사실상 일반적인 부팅 순서와 크게 다르지 않습니다. 부트로더를 읽는 매체가 바뀌었을 뿐이죠.
Concept of PXE Booting
PXE란 Preboot Execution Environment의 줄임말입니다. 우리 말로는 '사전 부팅 실행 환경' 정도로 해석해 볼 수 있을 것 같습니다. 그래서 PXE 부팅은 로컬 디스크나 USB 대신 네트워크 서버에서 부팅 이미지를 가져와 운영체제를 부팅하는 방식을 의미합니다.
이 기술은 이미 나온 지가 제법 된 기술입니다. 1990년대 후반 인텔 주로도 만들어졌으며, 네트워크 기반의 시스템 관리와 배포를 목적으로 설계되었습니다. 앞서 살펴본 것처럼 부팅에 필요한 모든 파일(부트로더, 커널, OS 이미지 등)을 네트워크 서버에서 가져와 메모리로 로드한 뒤 실행하도록 합니다. 쉽게 말해, PXE는 '네트워크로 컴퓨터를 부팅하는 방법'이라고 생각할 수 있습니다.
PXE 부팅의 상세 작동 원리
대략적인 PXE 부팅 프로세스 흐름도
1. PXE 클라이언트 준비
(1) 하드웨어 요구사항
PXE 기능이 지원되는 NIC(대부분의 현대의 NIC는 PXE를 지원함)
BIOS/UEFI에서 PXE 부팅 옵션이 활성화
(2) 초기 동작
전원이 켜지면 BIOS/UEFI가 POST를 거친 후, PXE 부팅을 시도하도록 설정된 NIC의 PXE 코드를 실행
이 PXE 코드는 네트워크를 통해 외부 서버와 통신할 수 있는 최소한의 네트워크 스택을 제공
2. DHCP와의 상호작용
(1) DHCP의 역할
PXE 클라이언트가 네트워크에 접속하려면 IP 주소가 필요하고, 이를 위해 DHCP 서버에 요청을 보냄
일단 DHCP 요청과 달리, PXE 요청은 자신이 PXE 클라이언트임을 알리는 플래그(Option 60: PXEClient)를 포함
(2) DHCP 응답
IP 주소, 서브넷 마스크, 게이트웨이 제공
추가로 부팅서버(TFTP 서버) IP와 부팅 파일 이름(예: pxelinux.0)을 알려줌(Option 66, 67)
3. TFTP를 통한 부트로더 전송
(1) TFTP란?
Trivial File Transfer Protocol은 가볍고 단순한 파일 전송 프로토콜로, PXE에서 부팅 파일을 빠르게 가져오는 데 사용됨
TFTP는 속도가 빠르고 UDP 기반이라 초기 부팅에 적합하나, 대용량 파일 전송에는 한계가 있어 주로 부트 로더만 전달
(2) 동작
PXE 클라이언트는 DHCP가 알려준 TFTP 서버에 접속해 부팅 파일을 다운로드
이 부팅 파일은 네트워크 부팅용 부트로더로, 메모리에 로드된 후 실행
4. 부트로더의 역할과 설정
(1) 부트로더 종류
PXE 환경에서는 PXELINUX(SYSLINUX 계열)나 네트워크 지원 GRUB이 자주 사용됨
(2) 설정 파일
부트로더는 TFTP 서버에서 추가 설정 파일(예: pxelinux.cfg/default)을 가져옴
이 파일에 커널 위치, 초기 RAM eltmzm(initrd), 부팅 옵션이 정의되어 있음
apt update의 0% [Waiting for headers] 행, 그러나 웹 브라우저 작동에는 문제 없는 현상 발생
apt update, apt install 등 apt 관련 명령어 사용 시 작동 안되는 현상
[Waiting for headers] 문구와 함께 행 걸림
물리적으로 서버의 외장 USB NIC을 업링크로 사용하는 분산 스위치의 포트 그룹에서는 행 걸림 현상 발생
웹 브라우저를 통한 인터넷은 문제 없이 잘 작동
속도 테스트, 설치 파일 다운로드와 같은 동작은 문제 없이 작동
DNS 문제 없음
nslookup, dig 모두 잘 작동
대조군 설정 및 테스트
문제의 VM
대조군 VM
스펙
4vCore/8GB RAM/60GB Storage
4vCore/8GB RAM/60GB Storage
네트워크
외장 USB NIC을 업링크로 사용하는 분산 스위치의 포트 그룹 사용
서버 내장 NIC을 업링크로 사용하는 분산 스위치의 포트 그룹 사용
대조군 VM의 apt 명령어 및 인터넷 사용 문제 없음
문제의 VM의 경우 인터넷 사용 되지만 속도가 다소 느림
특히 다운로드 속도가 업로드 속도에 절반 가량으로 비대칭 현상도 나타남
문제의 VM의 네트워크 어뎁터를 서버 내장 NIC을 업링크로 사용하는 분산 스위치의 포트 그룹으로 옮기고 나서 apt update 실행하면 문제 없이 실행, 다시 이것을 외장 USB NIC을 업링크로 사용하는 분산 스위치의 포트 그룹으로 옮기고 나면 apt update는 잘 실행되나 apt install과 같은 것은 0% [Waiting for headers] 문구와 함께 행 걸림
원인(추정)
외장 USB NIC에 문제이거나, VMware Flings 커뮤니티에서 제공하는 USB 드라이버의 문제일 수 있음
VLAN TAG로 인해 MTU가 1500바이트를 넘기는데 VM의 MTU 사이즈는 1500이라 발생하는 문제라 추정 함
다만 웹 브라우저가 작동이 잘 되는것은? - 스터디 필요
USB NIC 드라이버는 소프트웨어적으로 구현되어 VLAN TAG 4 바이트에 대한 오프로드 기능이 없어 그럴 수 있다는 추정 - From Claude Sonet 3.5
실제로도 ping -s를 통해 테스트 아래와 같은 결과 확인 가능
서버 내장 NIC을 업링크로 사용하는 분산 스위치 포트 그룹에 연결된 VM에서는 MTU 최대 사이즈인 1500까지 문제 없이 작동
최대 1500 바이트까지 전송이 가능하며, 아마 상단에 VLAN TAG의 경우 하드웨어 레벨의 오프로드 기능을 통해 핸들링할 것으로 예상 됨
외장 USB NIC의 경우 이보다 4 바이트 적은 ping -s 1468 까지 작동 하는것 확인
4 바이트는 VLAN TAG의 크기로, USB NIC에서는 오프로드 기능이 작동하지 않는 것으로 보임
# 서버 내장 NIC을 업링크로 사용하는 분산 스위치의 포트 그룹에 연결된 VM
# 1472 바이트 + ICMP 헤더 8 바이트 + IP 헤더 20 바이트 = 1500 바이트
$ ping -s 1472 google.com
PING google.com (142.250.76.142) 1472(1500) bytes of data.
1480 bytes from kix07s06-in-f14.1e100.net (142.250.76.142): icmp_seq=1 ttl=116 time=28.8 ms
1480 bytes from kix07s06-in-f14.1e100.net (142.250.76.142): icmp_seq=2 ttl=116 time=29.2 ms
# 외장 USB NIC을 업링크로 사용하는 분산 스위치의 포트 그룹에 연결된 VM
# 1468 바이트 + ICMP 헤더 8 바이트 + IP 헤더 20 바이트 = 1496 바이트
# VLAN TAG를 위해 4바이트를 비운 상태에서 정상 작동
$ ping -s 1468 google.com
PING google.com (142.250.206.206) 1468(1496) bytes of data.
1476 bytes from kix07s07-in-f14.1e100.net (142.250.206.206): icmp_seq=1 ttl=117 time=35.8 ms
1476 bytes from kix07s07-in-f14.1e100.net (142.250.206.206): icmp_seq=1 ttl=117 time=36.1 ms
조치
Command를 통한 MTU 사이즈 조절
# 네트워크 인터페이스의 MTU를 1450으로 설정
sudo ip link set dev [인터페이스명] mtu 1450
Netplan 파일 수정
# 또는 netplan을 사용하는 경우 /etc/netplan/의 설정 파일에:
network:
ethernets:
[인터페이스명]:
mtu: 1450
지난 포스팅에 이어 바로 2편을 작성하지 않고, 먼저 Kubernetes Cluster 구축 방법에 대한 내용으로 포스팅을 진행했습니다. 이유는, 단일 Control Plane(이전에는 Master 노드라고 이야기 해였습니다. 'Master'가 주종관계를 나타내는 단어라 앞으로는 Control Plane으로 사용하기로 하겠습니다. 여기서 또 PC가...)으로 Kunbernetes 환경을 구성하는 방법을 알아야 그 다음에 2대 이상으로 이루어진 멀티 Control Plane 구성이 가능하기 때문입니다. 그리고... 사실 제가 바쁘기도 했고 또, Home Lab 환경에 큰 변화가 있었던 것도 그 이유 중에 하나입니다. (그래서, IP 주소도 변화가 있었습니다.) 최대한 내용이 이어질 수 있도록 포스팅을 진행 하도록 하겠습니다. 너른 양해 부탁드립니다.
지난 포스팅에서는 HAProxy와 Keepalived를 통해 가상 IP 주소에 대한 HA 기능을 구현해 보았습니다. 이 가상 IP를 앞으로 구성하게 될 Kubernetes API Server의 End-Point로 활용할 것입니다. 그리고 한 가지 더 구성해야 할 것이 있는데 바로 멀티 Control Plane을 가진 Kubernetes Cluster입니다. 이는 아래 포스팅을 통해 확인할 수 있습니다.
해당 포스팅도 1편부터 보시는걸 추천드리지만, 만약 VM으로 Ubuntu 설치가 어렵지 않으시다면 2편부터 보셔도 무방합니다. 링크된 포스팅의 중반부 '5) Kubeadm을 통한 Kubernetes 클러스터 구성'에 보시면 아래와 같은 명령어를 실행합니다.
sudo kubeadm init --apiserver-advertise-address=192.168.0.150
#sudo kubeadm init --apiserver-advertise-address=[Kubernetes Cluster의 Control Plane 노드의 IP 주소]
단일 Control Plane으로 Kubernetes Cluster를 구성할 시에는 위와 같은 명령어를 사용하면 되지만, 멀티 Control Plane 노드를 통해 Cluster 형식으로 Kubernetes API Server를 구축할 때는 명령어가 달라집니다. 여러 대의 Control Plane 노드 중 하나에 아래와 같은 명령어를 사용하시면 됩니다.(이전 포스팅 기준으로, Kubernetes Master 01인 172.16.11.151번 IP를 가진 노드에서 해당 명령어를 실행해 주었습니다.)
참고로, 이전 포스팅에서 가상 IP 주소를 172.16.11.150으로 사용하기로 했기 때문에, 위 명령어의 IP 주소가 172.16.11.150이 되었습니다. 구성하시는 환경에 맞도록 해당 IP 주소를 바꿔주면 됩니다. 포트번호는 Control Plane의 API 서버가 사용하는 번호를 사용했는데 다른 것으로 바꾸셔도 무방합니다. 그러면 아래와 비슷한 문구가 출력되는 것을 확인할 수 있습니다.
You can now join any number of control-plane node by running the following command on each as a root:
kubeadm join 172.16.11.150:6443 --token 9vr73a.a8uxyaju799qwdjv --discovery-token-ca-cert-hash sha256:7c2e69131a36ae2a042a339b33381c6d0d43887e2de83720eff5359e26aec866 --control-plane --certificate-key f8902e114ef118304e561c3ecd4d0b543adc226b7a07f675f56564185ffe0c07
#위 명령어를 꼭 확인할 것! 해당 명령어를 다른 Control Plane 노드에 입력해 주어야 함.
#kubeadm join [가상 IP 주소:포트번호] --token [출력된 토큰 값] --discovery-token-ca-cert-hash sha256:[출력된 해시 값]
Please note that the certificate-key gives access to cluster sensitive data, keep it secret!
As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use kubeadm init phase upload-certs to reload certs afterward.
Then you can join any number of worker nodes by running the following on each as root:
kubeadm join 192.168.0.200:6443 --token 9vr73a.a8uxyaju799qwdjv --discovery-token-ca-cert-hash sha256:7c2e69131a36ae2a042a339b33381c6d0d43887e2de83720eff5359e26aec866
위에서 보는 것과 같이, 멀티 노드로 Control Plane을 구성하게 되면 기존의 단일 노드로 Control Plane을 구성할 때와는 다른 명령어를 하나 확인할 수 있습니다. 바로 Control Plane으로 구성될 노드에 입력해야할 명령어입니다. 해당 명령어를 다른 노드에 입력하면 됩니다.
sudo kubeadm join 172.16.11.150:6443 --token 9vr73a.a8uxyaju799qwdjv --discovery-token-ca-cert-hash sha256:7c2e69131a36ae2a042a339b33381c6d0d43887e2de83720eff5359e26aec866 --control-plane --certificate-key f8902e114ef118304e561c3ecd4d0b543adc226b7a07f675f56564185ffe0c07
#Master 02와 Master 03에 위 명령어를 각각 입력해 줍니다. 이때 토큰 값과 해시 값은 다를 것이기 때문에 실제 화면에 출력된 값을 복사해서 입력해 줍니다.
일반 사용자 계정이기 때문에, root 권한으로 명령어를 실행하기 위해 앞에 sudo를 붙여 주는 것을 잊지 않아야 합니다. 위 명령어를 각각 남아 있는 Control Plane 노드에 입력해주면 그 뒤 내용은 동일합니다. Worker 노드를 Join 시켜주면 클러스터 구성이 마무리됩니다.
2. API End-Point를 통해 Kubernetes Cluster 제어하기
위와 같은 방식으로 Kubernetes Cluster 구성이 마무리되었으면 이제 외부에서 API 서버를 제어할 Console이 필요할 것입니다. 아래 그림과 같은 형식이 될 것인데요.
[그림 1] Kubernetes Cluster와 외부 Console
이렇게 구성하는 것이 타당해 보입니다. 우선 가상 IP 주소를 통해 API Server를 조작하기 때문에 로드밸런서의 기능도 활용하게 되고, 직접적으로 Control Plane 노드에 접속하지 않기 때문에 보안상 이점도 가져갈 수 있습니다.
콘솔은 kubectl이 설치된 Linux 계열의 호스트라면 어떤 것이든 상관이 없습니다. 이제 Control Plane에서 Kubernetes Cluster에 대한 Context만 가져오면 됩니다. Control Plane 노드에서 아래와 같은 내용을 조회합니다.
이 부분에 대해서는 Kubernetes의 인증과 인가에 따로 다루는 포스팅을 게시하도록 하겠습니다. 현재는 우선 Kubernetes Cluster에 대한 모든 권한을 가진 admin 권한의 Context를 사용하는 방법을에 대해서 알려드립니다.
위 명령어로 ~/.kube/config 파일의 위치를 확인했다면, 해당 경로에 위치한 config 파일의 내용을 복사하여 Console에 같은 위치로 같은 이름의 파일명을 통해 해당 내용을 붙여 넣어도 좋고, 아니면 파일 자체를 복사해도 됩니다. 그리고 Console에서 kubectl 명령어를 실행하면 구성된 Kubernetes Cluster를 제어할 수 있음을 확인할 수 있습니다.
3. 마무리
두 편의 걸친 포스팅과, Kubernetes Cluster 구성에 관한 포스팅을 통해 Kubernetes API Server의 Cluster 구성과, HA, LB 기능에 대해 알아보았습니다. 물론 현업에서는 퍼블릭 클라우드에서 제공하는 Kubernetes 서비스와 같이 제품화된 서비스를 사용하는 경우가 많아 위와 같은 구성을 직접 할 경우는 적겠지만, 해당 내용을 통해 Kubernetes에 대한 이해가 한층 더 높아졌기를 바랍니다. 감사합니다.
마지막으로, 관련 내용에 대한 Kubernetes 공식 문서 링크도 남겨 드립니다. 학습에 참고가 되길 바랍니다.
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 1과 LB 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 자격시험 준비 과정과 시험 내용에 대해서 간단히 포스팅해보고자 합니다.
우선 CKAD란 Certified Kubernetes Application Developer의 줄임말로 이름에서도 알 수 있듯 개발자 입장에서 Kubernetes를 다루는 능력을 평가하는 시험입니다. 시험 내에서 실제로 애플리케이션을 개발하는 것은 아니지만, 그래도 CKA에 비해서 조금 더 개발자적인 부분을 다룹니다.
시험 준비는 역시나 CKA 취득때와 마찬가지로 우리 뭄형(영상으로 자주 보다 보니 내적 친밀감이...)의 Udemy 강의[링크]를 기본으로 하였습니다. 내용은 CKA와 많이 비슷합니다. Kubernetes에 기본적으로 Pod나 Deployment 등을 실행하는 것을 기본으로 하고 있어서 크게 어렵지는 않았습니다. 만약 CKA를 무리 없이 취득하신다면 CKAD도 무리 없이 취득할 수 있을 것입니다. 그럼에도 불구하고 CKAD에만 다루는 몇 가지 요소가 있습니다. 이를 중점적으로 준비한다면 크게 어렵지 않을 것입니다. 추가 요소 중 생각나는건 아래와 같습니다.
Job/CronJob 설정
Helm 차트 다루기
Ingress 트러블 슈팅
또한, CKA에서 다루는 etcd 백업이라던가, 클러스터 버전 업그레이드 등과 같은 운영적인 요소는 빠져서 저는 오히려 조금 더 수월하다고 느꼈습니다.
시험 등록 방법이나 일정을 정하는 방법 등은 CKA와 완벽히 동일합니다. 따라서 해당 내용은 이전 포스팅을 참고해 주셔도 좋을 것 같습니다.
다만 이번에도 시험용 노트북에 문제가 있었습니다. 지난번 CKA는 윈도우 노트북에서 문제가 발생하더니 이번에는 Macbook에서 문제가 발생했습니다. 허가되지 않은 스크린 캡처 프로그램이 있는데 이것이 작동하고 있어서 시험에 사용되는 PSI 브라우저 실행이 안 되는 것이었습니다. 하지만 아무리 찾아봐도 해당 프로세스를 종료할 수 없었고, 그래서 또 급하게 회사에 있는 Window 노트북을 사용했는데 문제없이 작동하여 이걸 가지고 시험을 치렀습니다.
시험 전에 PSI 브라우저를 테스트해 볼 수 있으면 참 좋을 텐데, 이 부분이 너무 아쉽습니다. 시험 가격도 높은 편인데 사전에 미리 테스트를 해볼 수 없고, 시험 30분 전에야 이를 확인할 수 있는데 이번처럼 대체할 수 있는 노트북이 있어서 다행이지 그게 아니었으면...
어쨌든 이번 시험은 CKA때보다는 쉽다 느꼈던 게 가장 큰 것 같습니다. 아무래도 현업에서 Kubernetes를 많이 다루다 보니 그런 것도 있을 것이고, 내용 중복이 많아서 그런 것도 있지 않나 싶습니다. 최종적으로 CKS를 취득할지 말지는 조금 고민해 봐야 할 것 같습니다. 이제 저도 주니어 레벨에서 조금은 벗어나고 있는터라 자격증보다는 실무 능력이 중요하지 않나 하는 생각이 많이 드는 요즘입니다.
이미 코드에 있었던 "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 명령어를 통해서 한번 알아보죠.
출력되는 정보를 보면 PID 1번이 tini로 실행되고 있는 것을 알 수 있습니다. 그리고 그 자식 프로세스로 PID 6의 go 애플리케이션 main이 실행되고 있는 것을 알 수 있습니다. 실제로 이전 포스팅에서 사용했던 api를 호출해 보면 아래와 같이 tini - main - sh - sleep 순으로 부모 - 자식 관계를 형성하고 있는것을 알 수 있습니다.
이 외에도 dump-init과 같은 경량 init을 사용할 수 도 있습니다. 그러나 tini나 dump-init과 같은 것에도 단점은 있습니다. 프로세스 계층 구조에 새로운 레이어가 추가되는 점에서 프로세스 추적이 조금 더 복잡해질 수도 있고, 로그 확인과 같은 트러블 슈팅 상황이 조금 더 복잡해 질수 있습니다.
Docker의 '--init' 옵션 사용
다른 방식으로는 Docker로 컨테이너 실행 시 --init 옵션을 사용하는 것입니다. 아래 출력 정보를 보면 docker-init이 PID 1로 등록되어 있는 것을 알 수 있습니다. 역시 좀비 프로세스를 생성하는 api를 호출하여도 프로세스가 더 이상 좀비로 남지 않게 됩니다.
그러나 이는 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
}
}
}
}()
// 메인 애플리케이션 로직
// ...
}
다시 한번 말씀드리지만 위 코드들은 테스트를 하지 않아 실제로 잘 작동하는지 여부는 확인이 필요합니다.
어쨌든, 이렇게 리눅스 프로세스 관리와 좀비 프로세스에 대해서 그 원인을 파악해 보고, 해결책까지 논의해 보았습니다. 저도 이번 시리즈 포스팅을 작성하면서 더 깊이 있게 리눅스에 대해서 이해할 수 있는 좋은 기회였던 것 같습니다. 아무쪼록 현업에 계시는 개발자 분들, 혹은 리눅스와 컨테이너를 학습하고 계신 분들께 도움이 되기를 바라면서 긴 포스팅 마치도록 하겠습니다. 감사합니다.
이름에서 알 수 있듯, 고아(Orphan) 프로세스란 부모 프로세스가 자식 프로세스보다 먼저 종료되어 부모를 잃은 프로세스를 의미합니다. 하지만 이 고아 상태는 매우 일시적입니다. 기본적으로 리눅스의 커널이 고아가 된 프로세스를 감지하고 PID 1인 systemd(init) 프로세스의 자식 프로세스로 재설정 하기 때문이죠. 이 작업은 우선순위가 높은 커널 작업이기 때문에 대부분의 경우 프로세스가 다음 명령어를 실행하기 전에 이미 완료됩니다.
또한, 특별히 고아 프로세스가 부모 프로세스를 호출해야 하는 경우와 같은 상황이 아니라면, 그 자체의 작업 수행에는 크게 영향을 받지 않습니다. 그리고 앞서 말한 데로 곧바로 systemd(init)에 입양되므로 다시금 부모 - 자식 관계를 형성하며 안정적인 상태가 되죠.
좀비 프로세스?
그렇다면 좀비 프로세스는 무엇일까요? 이를 이해하기 위해서는 사실 리눅스 커널에 대해서 깊은 이해가 필요합니다. 그러나 이를 모두 설명하기에는 너무 긴 내용이므로 간략하게 이야기해 보겠습니다. 커널에는 task_struct라는 모든 프로세스의 상세한 정보를 담고 있는 구조체가 있습니다. 그리고 프로세스 테이블이라는 task_struct 구조체에 대한 색인과도 같은 요소도 존재합니다.(정확한 설명은 아니자만, 대략적으로...)
어쨌든, 프로세스는 실제로 종료되기 전 모든 리소스를 정리하고 그다음에 EXIT_ZOMBIE 상태가 됩니다. 그리고 프로세스 테이블에 PID, 종료 상태(exit status), 리소스 사용 통계 정도만 남겨두게 됩니다. 그리고 부모 프로세스가 wait() 시스템 콜을 통해 회수하기를 기다리게 되는데 프로세스 종료와 회수 직전의 상태가 바로 좀비 프로세스 상태인 것입니다.
시스템 호출을 통해 종료 과정은 완료되었지만, 여전히 프로세스 테이블에 남아있는 상태
systemd(init)의 고아 프로세스와 좀비 프로세스 핸들링
드디어 서론이 끝난 기분이군요. 본격적으로 좀비 프로세스, 그리고 특히 컨테이너 환경에서의 좀비 프로세스에 대해서 논의할 수 있게 된 것 같습니다. 고아 프로세스와 좀비 프로세스는 서로 같은 개념은 아니자만, 둘 다 모두 부모프로세스의 상태나 동작에 영향을 받습니다. 게다가 이를 컨테이너 내에서 의도적으로 잘 조작하면, 좀비 프로세스가 계속 프로세스 테이블에 남아 있도록 할 수 있습니다.
// 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'으로 스스로를 종료시킵니다.
이 과정에서 일어나는 일들을 앞서 이야기한 프로세스 생명주기에 관한 내용을 통해 유추해 봅시다. 아마 아래와 같은 단계를 거칠 것입니다.
종료 신호를 받은 sleep 1000(PID 301)은 자신의 리소스를 정리하고 EXIT_ZOMBIE 상태가 됨
프로세스 테이블에 sleep 1000의 PID와 종료 상태, 리소스 사용량 통계만 남음
이때 sh(PID 201)은 자신의 자식 프로세스에 대한 회수를 수행하지 않은 채로 exit 0를 통해 종료
PID 301은 부모가 없어지면서 좀비 이면서 고아 상태가 됨
go app(PID 101)은 자신의 자식프로세스에 대해서만 회수 책임이 있으므로 sh의 종료만을 처리
systemd가 PID 301이 고아 상태임을 파악하고 입양하여 고아 상태를 해소, 회수 책임이 발생
프로세스 상태 확인하고 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' 명령어를 통해 확인해 보겠습니다.
먼저 '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' 명령어를 통해 컨테이너 내부의 프로세스 상태를 확인해 보겠습니다.
처음으로 확인할 수 있는 것은 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' 명령어를 통해 컨테이너 내부의 프로세스를 조회할 수 있습니다. 컨테이너의 main 프로세스가 PID 60065로 수행 중이며, 좀비가 된 PID 60330은 입양이 되어 컨테이너 내부에서는 PID 1이었던 PID 60065를 부모 프로세스로 가지고 있다는 것을 확인할 수 있습니다.
그런데 이 테이블에 등록될 수 있는 프로세스의 수에 한계가 있다는 것이 문제입니다. 실제로 이 테이블이 가득 차면 실제로 새로운 프로세스를 생성할 수 없는 경우가 발생하기도 합니다. 어떻게 보면 컨테이너로 격리된 프로세스들이 유일하게 공유하는 노드 자원이라고 볼 수도 있겠는데요. 여기에 문제가 생겨서 다른 프로세스를 실행할 수 없다면 이는 시스템 작동에 또 다른 예상치 못한 결과를 가지고 올 수 있습니다.
실제로 저도 자사의 애플리케이션에서 이러한 현상을 발견하여 몇몇 기능이 제대로 작동하지 않는 것을 확인한 바가 있었고, 문제의 원인을 파악하다 보니 이렇게 리눅스 프로세스 레벨까지 파고들게 된 것입니다.
역시 이번 포스팅도 최대한 상세하게 쓰려다 보니 상당히 길어졌군요. 다음 포스팅에서는 그럼 이러한 좀비프로세스를 어떻게 해결해야 하는지 그 해격책을 제시해 보도록 하겠습니다.
아마도 리눅스를 조금 다루어 보았다면 '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를 사용합니다. 자 한번 아래 명령어들을 따라서 입력하고 그 출력을 살펴보시죠.
참고로 이번 포스팅에 사용되는 리눅스는 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'를 입력해서 출력되는 내용을 살펴보죠. 이 명령어는 프로세스 간의 계층 구조를 시각적으로 나타내줍니다.
위와 같이 PID 1인 systemd를 시작으로 여러 프로세스가 계층적 구조를 이루고 있는 것을 알 수 있습니다. 여기서 systemd와 직접적으로 연결된 프로세스들은 모두 systemd의 자식 프로세스들이고, 당연히 systemd는 이들의 부모 프로세스가 됩니다.
containerd(796)을 봅시다. contianerd는 systemd의 자식 프로세스이기도 하면서, 그 아래 여러 {containerd}들의 부모 프로세스이기도 합니다. systemd(1)의 입장에서 {containerd} (833)와 같은 프로세스를 손자 프로세스라고 부르기도 합니다. 리눅스에서는 이렇게 프로세스들이 서로 계층적인 구조를 지니면서 작동하고 있습니다.
프로세스 생명 주기와 회수 책임
이제부터 본격적인 이야기를 하려고 합니다. 바로 프로세스 생명 주기와 회수 책임입니다. 아래 다이어그램을 한번 보도록 하겠습니다.
프로세스 생명 주기
이는, 프로세스의 생명주기를 나타낸 것입니다. 프로세스의 생성, 실행, 그리고 종료 단계에서 일어나는 일들을 순차적으로 도식화한 것입니다. 한 가지 주목할 부분은 의외로 종료 단계에 많은 과정을 거친다는 점이죠. 특히 부모 프로세스는 자식 프로세스로부터 SIGCHLD 시그널을 전송받으면 wait() 시스템 콜을 통해 자식 프로세스의 종료에 대한 마지막 단계를 수행한다는 것입니다. 즉, 이 wait() 시스템 콜을 통해 자식 프로세스에 대한 회수 책임을 다 하고 있는 것이죠. 이는 단순히, 프로세스 생명 주기에서 수행되는 단계의 수준이 아니라 부모 - 자식 프로세스라는 계층적인 구조를 가진 리눅스 운영체제의 설계 원칙이라 할 수 있습니다.
그렇다면, 여기서 한 가지 질문을 해볼까요?
만약, 자식 프로세스가 종료되기 전 부모 프로세스가 먼저 종료된다면?
자식 프로세스에 대한 회수 책임을 가지고 있는 부모 프로세스가 사라졌습니다. 과연 이럴 때는 어떻게 될까요?
이에 대한 구체적인 이야기는 다음 시간에 마저 진행해 보겠습니다. 또한, 이러한 상황이 특히 컨테이너 환경에서는 어떤 영향을 미치는지에 대해서도 한번 알아보죠.