쿠버네티스에서 파드가 생성되는 과정에 대해서는 많은 글에서 참고할 수 있습니다.
특히 쿠버네티스 컴포넌트들의 동작 과정 중심으로 추상화된 파드 생성 과정은 많이 볼 수 있습니다.
마스터 노드와 워커 노드에 각 컴포넌트들과 파드의 생성 과정에 대해 처음이라면 해당 블로그를 참고하면 될 것 같습니다. 참고
이번 글은 쿠버네티스에서 graceful shutdown의 과정을 기록하기 위하여 조금 더 이와 관련된 파드 생성 과정부터 간략히 적어보려고 합니다. 그 이유는 파드의 생성 과정과 반대로 종료 과정이 이루어지기 때문입니다.
다음과 같은 파드가 생성된다고 가정해보겠습니다.
pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
containers:
- name: web
image: nginx
ports:
- name: web
containerPort: 80
아래 명령을 입력하면 API Server에 요청을 하면 Pod의 Definition에 대한 inspect 과정을 거친 후 etcd에 기록될 것입니다.
kubectl apply -f pod.yaml
그 다음 새로운 파드는 스케줄러의 대기열에 추가되고, 스케줄러는 이를 적합한 노드에 배치하기 위해 라벨, Affinity/AntiAffinity, Taint/Toleration, 자원 요청 등을 고려한 필터링을 수행합니다. 이후 각 노드에 대한 스코어링을 진행하여 가장 적합한 노드를 선택하고, 해당 정보를 etcd에 기록하게 됩니다. 참고
이 상태에서 파드는 Pending 상태로 변경되고 etcd에 존재하게 될 것이지만 아직 실제 파드가 존재하지는 않은 상황입니다.
실제 파드 생성은 워커 노드의 kubelet이 담당하게 됩니다. 마스터노드(Control Plane)에게 계속 업데이트를 폴링하고 새로운 파드 생성에 대한 요청이 있으면 kubelet이 파드를 생성하게 됩니다.
정확히는 kubelet이 파드 생성에 필요한 여러 작업들을 각 다른 구성 요소들(애드온과 같은)에게 위임하게 됩니다.
예시를 들면 다음과 같습니다.
파드의 컨테이너 생성은 CRI(Container Runtime Interface)가 작업하며,
컨테이너를 클러스터 네트워크에 연결하고 IP 주소를 할당하는 것은 CNI(Container Network Interface),
컨테이너 볼륨을 마운트하기 위해 CSI(Container Storage Interface)에게 작업들을 위임하게 됩니다.
파드의 네트워크 생성과 동작 과정에 대해 조금 더 자세한 내용은 이전 글을 참고하시면 될 것 같습니다.
CNI는 파드에 대한 유효한 IP 주소를 생성하고 컨테이너를 나머지 네트워크에 연결하고 작업이 끝나면 파드는 해당 IP 주소가 할당될 것입니다.
문제는 kubelet은 CNI를 호출했기에 파드의 IP 주소를 알고 있지만 아직 마스터 노드에는 할당된 파드의 IP 주소를 알리지 않았습니다. 따라서 kubelet은 IP 주소와 파드의 세부 정보를 수집하여 이를 마스터 노드의 API server를 통해 etcd에 저장하게 됩니다. 이후부터는 etcd를 검사하면 파드가 실행 중인 위치와 IP 주소를 알 수 있게 되었고 파드가 Running 상태로 준비가 될 것입니다.
보통 파드로 직접 호출하기보다 서비스라는 논리적 객체를 만들어 생성될 때마다 변경되는 파드의 주소를 서비스의 주소로(DNS)로 호출할 것입니다.
다음 처럼 Service 객체를 생성하면 Probe를 통과한 파드에 대해서 name:app 과 같은 레이블을 가진 모든 파드를 찾아 해당 IP 주소를 수집할 것입니다.
service.yaml
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
ports:
- port: 80
targetPort: 3000
selector:
name: app
그런 다음 모든 서비스에 대해 Endpont 객체를 생성합니다. Endpoint는 단순히 파드의 IP 주소 + 포트 번호라고 생각하시면 됩니다.
$ kubectl get services,endpoints
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
service/my-service-1 ClusterIP 10.105.17.65 <none> 80/TCP
service/my-service-2 ClusterIP 10.96.0.1 <none> 443/TCP
NAME ENDPOINTS
endpoints/my-service-1 172.17.0.6:80,172.17.0.7:80
endpoints/my-service-2 192.168.99.100:8443
파드가 생성되거나 삭제되거나 라벨이 수정되는 경우 Endpoint 객체도 같이 최신화됩니다.
정확히는 kubelet이 IP 주소를 etcd에 기록하게 되면 변경 사항이 반영됩니다.
Endpoint 객체 이외에도 IP 주소의 변경으로 인한 여파는 Endpoint Controller에 의해 다른 곳에 전파됩니다.
몇가지 예시를 들어보면, 워커 노드에 kube-proxy는 Endpoint를 사용하여 해당 노드에 iptables 규칙을 설정합니다. iptables의 nat 규칙은 여기를 참고하시면 됩니다.
kube-proxy가 서비스 주소로 호출 시 적절한 파드로 네트워킹하기 때문에 이를 참고하는 iptables를 업데이트하는 것입니다.
Ingress 컨트롤러 또한 동일한 Endpoint 목록을 사용합니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
spec:
rules:
- http:
paths:
- backend:
service:
name: my-service
port:
number: 80
path: /
pathType: Prefix
Ingress는 실제로 서비스를 건너뛰고 트래픽을 파드로 직접 라우팅하기에 Ingress 컨트롤러는 Endpoint의 변경에 따라 재구성합니다.
이 뿐만 아니라 Endpoint의 변경 사항을 구독하는 컴포넌트들의 예는 많습니다. CoreDNS의 경우 Headless Service를 사용하면 Enpoint가 변경이 발생하면 자체를 재구성해야합니다. 이외 Istio나 Linkerd와 같은 서비스 메시에서도 이를 참고할 것입니다.
정리하자면 다음과 같습니다.
(파드)
파드는 etcd에 저장됩니다.
스케줄러는 노드를 할당하고, etcd에 노드를 기록합니다.
kubelet은 새로운 예약된 파드에 대해 알림을 받습니다.
kubelet은 컨테이너 생성을 컨테이너 런타임 인터페이스(CRI)에 위임합니다.
kubelet은 컨테이너를 컨테이너 네트워크 인터페이스(CNI)에 연결하도록 위임합니다.
kubelet은 컨테이너의 볼륨 마운트를 컨테이너 스토리지 인터페이스(CSI)에 위임합니다.
컨테이너 네트워크 인터페이스는 IP 주소를 할당합니다.
kubelet은 IP 주소를 마스터 노드에 보고합니다.
IP 주소는 etcd에 저장됩니다.
(서비스)
kubelet은 성공적인 Readiness 프로브를 기다립니다.
모든 관련 엔드포인트(객체)에 변경 사항이 알림됩니다.
엔드포인트는 목록에 새로운 엔드포인트(IP 주소 + 포트 쌍)를 추가합니다.
Kube-proxy는 엔드포인트 변경 알림을 받습니다. Kube-proxy는 모든 노드의 iptables 규칙을 업데이트합니다.
Ingress 컨트롤러는 엔드포인트 변경 알림을 받습니다. 컨트롤러는 새 IP 주소로 트래픽을 라우팅합니다.
CoreDNS는 엔드포인트 변경 알림을 받습니다. 서비스가 Headless 유형인 경우 DNS 항목이 업데이트됩니다.
클러스터에 설치된 모든 서비스 메시는 엔드포인트 변경에 대해 알림을 받습니다.
이외에도 엔드포인트를 구독한 다른 모든 것들에게 전파됩니다.
이전에 설명드렸던 것처럼 파드 삭제 과정은 생성의 역순으로 생각하시면 될 것 같습니다.
1.
먼저 Endpoint 객체에서 해당 파드 주소를 제거해야 합니다. 파드 삭제 시 Endpoint Controller는 API server를 통해 Endpoint에 상태를 변경할 것이고 해당하는 모든 이벤트가 이전에 설명드린 Ingress 컨트롤러, DNS, 서비스 메시 등으로 전송됩니다. 해당 구성 요소들은 변경된 Endpoint를 기반으로 삭제할 파드의 IP 주소로의 트래픽 라우팅이 중단될 것입니다. 하지만 각 구성요소가 어떤 작업을 수행 중일 수 있고 내부 상태를 업데이트하는 시간을 보장하기는 힘들 것으로 보입니다.
2.
동시에 etcd의 파드 상태가 Terminating으로 변경되고 kubelet은 CSI로 모든 볼륨을 언마운트하고 컨테이너를 네트워크에서 분리하고 IP 주소를 CNI에 해제하며 CRI에 대한 컨테이너를 종료합니다.
파드를 생성할 때 작업이 삭제할 때 반대로 이루어지지만 문제는 위 1번과 2번 작업이 동시에 이루어집니다.
파드 생성 시에는 kubelet이 IP 주소를 보고할 때까지 기다린 다음 Endpoint 전파가 시작하지만 파드 삭제 시에는 이벤트가 병렬로 시작됩니다.
그림으로 표현하면 다음과 같습니다.
이로 인해 다양한 경쟁 조건이 발생할 수 있습니다.
2번 과정에서 이미 파드가 삭제가 완료되었음에도 불구하고 iptables에 남게되면 여전히 삭제된 파드의 IP 주소로 트래픽이 라우팅됩니다. 이런 경우 해당 파드는 종료되어 502, 504 같은 에러가 발생할 수 있습니다. (502는 pod가 종료되어 세션이 끊길 때, 504는 pod 개수가 0개 일 때 발생하는 것으로 보입니다.)
위 케이스를 그림으로 표현해보면 다음과 같습니다.
그렇다면 쿠버네티스는 어떻게 파드를 Graceful하게 종료할 수 있을까요?
Ingress 컨트롤러, kube-proxy, CoreDNS 등 IP 주소를 제거할 시간이 충분하다면, 엔드포인트 목록을 최신화하는 시간이 충분하다면 문제가 되지 않을 것 같습니다.
따라서 위 작업이 충분히 수행된 후 파드가 삭제되도록 특정 옵션을 주면 됩니다.
그 때 사용되는 옵션이 terminationGracePeriodSeconds, 그리고 preStop 입니다.
이 옵션을 사용하기전에 OS 시그널링에 대해 알고 넘어갈 필요가 있습니다. OS 시그널은 프로세스에 어떤 event가 발생했음을 알리는 간단한 메시지를 비동기적으로 보내는 것입니다. 시그널이 발생했을 때 실행되는 동작을 시그널 핸들러라고 합니다. OS 시그널에 대해서는 깊게 설명하지 않겠습니다. kill -l 을 통해 다양한 시그널 종류를 확인할 수 있습니다.
두 가지 SIGTERM, SIGKILL 시그널에 대해서 자주 보이는 그림이 있습니다. 간단히 설명하자면 SIGKILL은 강제로 프로세스를 죽이는 것이고 SIGTERM은 종료를 권고하고 무사히 프로세스가 종료하는 쪽에 가깝습니다. 또한 SIGTERM 시그널은 시그널 핸들러를 통해 시그널을 핸들링할 수 있습니다. 물론 특정 시간이 지나면 SIGKILL 시그널을 보내게 됩니다.
쿠버네티스에서는 kubelet이 파드를 종료하기 위해 SIGTERM 시그널을 보내고 SIGKILL을 보내기 전에 30초간 기다립니다. 이 30초를 컨트롤 할 수 있는 설정이 terminationGracePeriodSeconds 입니다.
다음 처럼 terminationGracePeriodSeconds 설정을 늘릴 수 있습니다.
apiVersion: v1
kind: Pod
metadata:
name: busybox
namespace: default
spec:
containers:
- args:
- sleep
- infinity
image: busybox
name: busybox
terminationGracePeriodSeconds: 40 ## 40초 설정
하지만 파드가 종료될 때 종료 로직을 제대로 구현하지 않아 Graceful한 종료를 하지 못하는 경우가 있는데, 예를 들면 종료 전 데이터베이스 연결을 종료하고 트랜잭션을 완료하여 종료해야 하는 상황이나, 파일 시스템을 정리하고 필요한 데이터와 상태를 저장하고 종료해야 하는 여러 상황이 있을 수 있습니다. 이때는 terminationGracePeriodSeconds만으로 Graceful 한 종료를 보장할 수 없습니다. kubelet이 이미 SIGTERM 시그널을 보냈고 애플리케이션에서 이미 처리가 모두 깔끔하게 끝났다는 것을 보장할 수 없는 환경일 경우로 예상됩니다. terminationGracePeriodSeconds가 시작되고 15초 후에는 충분히 엔드포인트 제거가 전파되었을 경우로 예상해도 코드에서 SIGTERM을 제대로 처리하지 않는다면, 애플리케이션이 강제로 종료되거나 중간에 불완전하게 종료될 수 있습니다.
이런 경우를 위해 SIGTERM이 호출되기전에 파드의 preStop Hook을 활용할 수 있습니다.
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
containers:
- name: web
image: nginx
ports:
- name: web
containerPort: 80
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
preStop Hook은 파드 LifeCycle Hook 중 하나입니다. 참고
preStop Hook은 SIGTERM이 애플리케이션에 전송되기 전에 호출됩니다. 위 yaml 파일 예시를 보면 물론 sleep 이기 때문에 애플리케이션 입장에서는 자신이 곧 종료될 것이라는 것을 알 수 없습니다. 따라서 엔드포인트 반영이 모두 완료되는 최대 시간을 15초라는 가정 하에 모든 트래픽을 정상 수행할 수 있고 preStop에 정의한 15초가 지난 시점에 SIGTERM 시그널을 받고 Graceful하게 종료할 수 있습니다. 만약 엔드포인트의 모든 변경 사항이 전파되는데 걸리는 시간이 15초보다 긴 경우 sleep을 더 걸어주면 됩니다.
참고로 preStop Hook의 시간은 terminationGracePeriodSeconds에 포함됩니다. preStop Hook에서 15초를 사용하였으면 terminationGracePeriodSeconds은 15초가 남은 것입니다. 15초 이상의 요청을 처리하는 건이 있다면 기간을 늘리거나 다른 방법을 고민해볼 필요가 있습니다. 이 부분은 밑에서 다시 설명하겠습니다.
다음 그림을 보시면 이해가 수월할 것입니다.
파드가 정상 삭제가 되면 API server를 통해 etcd에 반영될 것입니다.
만약 WebSocket이나 대용량 비디오를 트랜스코딩하는 등 장기 연결을 제공하는 경우는 terminationGracePeriodSeconds를 무수히 늘려도 몇 가지 생각나는 단점이 있습니다.
예를 들면 Prometheus를 사용하는 경우, 엔드포인트로 수집하기에 기존 파드의 메트릭을 수집할 수 없게 됩니다. 또한 kubelet이 liveness probe 같은 것들을 체크하지 않는다고 합니다. 그래서 이런 경우 terminationGracePeriodSeconds을 늘리는 대신 새로운 Deployment 버전을 만드는 레인보우 배포라는 것도 있다고 합니다. 관심 있으시다면 해당 글을 참고해주시기 바랍니다.
지금까지 쿠버네티스에서 어떻게 파드가 생성되고 삭제되는지 과정을 살펴봤고 Graceful하게 삭제되는 방법을 설명드렸습니다.
추가로 애플리케이션에서 SIGTERM 시그널을 받았을 때 애플리케이션 스스로 서비스를 Graceful하게 종료하는 설정을 추가한다면 더 깔끔하게 정리할 수 있을 것 같습니다. 이전에 말씀드린 것처럼 SIGTERM에 대해서는 시그널 핸들러 함수를 적용할 수 있습니다.
JVM에서는 shutdown hook 이라는 것을 제공합니다. JVM이 정상적인 종료를 위해 직전 등록된 작업들을 처리할 수 있도록 합니다.
예시 코드를 보는 것이 아무래도 이해가 쉬울 것입니다.
Thread printHookThread = new Thread(() -> System.out.println("shutdown hook"));
Runtime.getRuntime().addShutdownHook(printHookThread);
Springboot에서도 graceful shutdown을 지원합니다. 버전마다 내부 동작 과정이 다를 수 있습니다. 스프링 공식 문서
2.3 버전 이상부터 쉽게 적용할 수 있습니다. 스프링 부트 내장 WAS인 Tomcat, Jetty, Undertow, Netty 모두 적용 가능하다고 합니다.
server:
shutdown: graceful
graceful shutdown이 적용되면 실제로 kill -15로 SIGTERM 시그널을 보내도 Thread.sleep으로 걸려있는 요청이 모두 수행된 이후 종료되는 것을 확인할 수 있습니다. 이를 적용하지 않으면 Thread.sleep이 걸려있어도 애플리케이션은 종료됩니다.
물론 Spring Boot에서 shutdown에 대한 만료 시간을 제공해줍니다. 작업에 락이 걸리거나 예상치 못하게 오래 걸리는 경우 사용할 수 있습니다.
spring:
lifecycle:
timeout-per-shutdown-phase:10s
내부 동작 과정에 대해 한번 살펴보겠습니다.
shotdown 설정 값이 graceful이라면 GracefulShotdown 객체를 생성하게 됩니다.
package org.springframework.boot.web.embedded.tomcat;
public class TomcatWebServer implements WebServer {
private final GracefulShutdown gracefulShutdown;
public TomcatWebServer(Tomcat tomcat, boolean autoStart, Shutdown shutdown) {
Assert.notNull(tomcat, "Tomcat Server must not be null");
this.tomcat = tomcat;
this.autoStart = autoStart;
this.gracefulShutdown = (shutdown == Shutdown.GRACEFUL) ? new GracefulShutdown(tomcat) : null;
initialize();
}
...
}
Tomcat이 종료된다면 shutDownGracefully 메소드가 호출되게 됩니다. doShutdown 메소드를 호출해서 connector들을 닫고 while 문 안에서 50ms를 주기로 현재 처리 중인 요청을 확인하여 없다면 shutdownComplete을 수행하게 됩니다.
final class GracefulShutdown {
private static final Log logger = LogFactory.getLog(GracefulShutdown.class);
private final Tomcat tomcat;
private volatile boolean aborted = false;
// ...
void shutDownGracefully(GracefulShutdownCallback callback) {
logger.info("Commencing graceful shutdown. Waiting for active requests to complete");
new Thread(() -> doShutdown(callback), "tomcat-shutdown").start();
}
private void doShutdown(GracefulShutdownCallback callback) {
List<Connector> connectors = getConnectors();
// connector close 과정
connectors.forEach(this::close);
try {
for (Container host : this.tomcat.getEngine().findChildren()) {
for (Container context : host.findChildren()) {
// 50ms를 주기로 현재 처리 중인 요청을 확인하고 shutdownComplete 수행
while (isActive(context)) {
//timeout-per-shutdown-phase:10s 이상 수행된 경우 aborted = true
if (this.aborted) {
logger.info("Graceful shutdown aborted with one or more requests still active");
callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
return;
}
Thread.sleep(50);
}
}
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
logger.info("Graceful shutdown complete");
callback.shutdownComplete(GracefulShutdownResult.IDLE);
}
...
}
처리 중인 요청들에 대해서는 50ms 를 주기로 계속 폴링하면서 확인하는 방식으로 구현되어 있는 것을 확인할 수 있습니다. while문 중간에 aborted는 위에서 설정한 timeout-per-shutdown-phase:10s 의 값이 넘어가면 true로 변경하여 동일하게 shutdownComplete 메소드를 수행하도록 구현되어 있습니다.
이렇게 SIGTERM 시그널을 받은 이후 스프링 애플리케이션에서 Graceful하게 종료하는 방법에 대해서도 알아보았습니다.
PreStop Hook을 통해 IP에 대한 엔드포인트 반영 전 요청을 모두 수행하고 안전하게 애플리케이션을 종료하면 목표한 graceful 종료가 가능할 것입니다. 이 과정에서 필자가 잘못 해석한 부분이 있을 수 있으니 편하게 댓글로 피드백 주시면 확인 후 반영하도록 하겠습니다.
감사합니다.
'IT' 카테고리의 다른 글
쿠버네티스 자원 할당에 대한 고려 (2) | 2025.04.20 |
---|---|
쿠버네티스 Istio protocol error: unsupported transfer encoding(Feign Client chunked) (1) | 2025.03.04 |
쿠버네티스 파드 네트워킹 정리 (0) | 2025.02.26 |
쿠버네티스[EKS] gp2, gp3 마이그레이션 (0) | 2025.02.25 |
쿠버네티스[EKS] Cluster, Node, Add ons Upgrade (1) | 2025.02.21 |