ecsimsw

k8s Rolling update 무중단 배포 / 내장 톰캣 Graceful shutdown 동작 원리 본문

k8s Rolling update 무중단 배포 / 내장 톰캣 Graceful shutdown 동작 원리

JinHwan Kim 2024. 5. 31. 11:30

k8s Rolling update 배포 Down time 문제

서버가 운영되는 도중 배포시 Down time 이 발생하고 있다. 배포는 Kubernetes deployment rolling update로, 새 버전의 파드가 생성되고 기존 버전의 파드가 다운되길 반복한다. 서비스 운영 중 파드가 생성되고 제거되며 발생할 수 있는 Down time을 확인하고 해결한다.

 

문제 여지 1 : 이미 삭제된 Pod에 요청이 전달되는 경우 

파드가 삭제되면 Kublet은 Container를 종료하고, 동시에 Endpoint controller (KubeProxy)는 IpTable routing rule 에서 해당 파드를 제거한다. 만약 IpTable이 업데이트되기 이전에 Container가 먼저 삭제되면, 요청을 처리할 수 있는 Container가 존재하지 않는 문제가 발생한다.

 

spec:
  containers:
  - name: "example-container"
    image: "example-image"
    lifecycle:
      preStop:
        exec:
          command: ["sh", "-c", "sleep 10"]

 

preStop으로 container 종료 시그널 전 N초를 대기한다.

Iptable 업데이트보다 Container 종료가 더 느림을 보장한다.

 

REF, Spring docs, kubernetes container-lifecycle

 

문제 여지 2 : 요청이 처리되는 중에 Container가 종료되는 경우

Pod 종료 요청이 들어오면 Kubelet 은 아래와 같은 흐름으로 Container 를 종료한다.

 

1. Kubelet -> Container 에 종료 시그널 (SIGTERM/15)
2. 유예 기간 동안 대기 (기본 30초)
3. Kubelet -> 유예 기간동안 정상 종료되지 않으면 강제 종료 시그널 (SIGKILL/9)

 

만약 Container 종료 시그널에 Spring boot가 바로 종료된다면 처리 중인 요청은 비정상 종료될 것이다. Spring boot 가 처리 중인 요청까지는 정상 응답 후 종료될 수 있도록 Graceful shutdown 옵션을 추가한다.

 

server.shutdown=graceful

 

대신 Kubelet도 종료 시그널 이후 Container의 정상 종료를 무한히 기다리진 않는다. 약간의 유예 시간을 두고 대기하다가 유예 시간이 지났는데 Container 가 실행되고 있으면 강제 종료 처리한다. 따라서 반드시 유예 대기 시간보다 Graceful shutdown 처리 시간이 작도록 한다.

spring.lifecycle.timeout-per-shutdown-phase=20s

 

처리 중인 요청을 처리하는 과정에서 IpTable 이 업데이트 되어 해당 Pod로 요청이 더 추가되진 않는다. 처리되고 있는 요청의 응답은 정상 처리된다.

 

문제 여지 3 : 요청 처리가 준비되지 않았는데 Routing rule 에 추가되는 경우

Container 에서 요청을 처리할 준비가 안되었다면, 해당 Pod 로 요청이 전달되어선 안된다.

ReadinessProbe 으로 요청 처리 가능 여부를 확인 후에 IpTable에 추가될 수 있도록 한다.

 

테스트

5분 동안 300 vUser로 API 요청을 반복한다. 그 동안 Deployment restart 를 반복하며 파드가 생성되고 삭제되는 상황에서 비정상 응답이 존재하는지 확인한다.

 

5번의 전체 파드가 업데이트, 약 8만건의 요청이 있었지만 응답 실패는 단 한건도 발생하지 않았다. 👍

 

내장 톰캣 Graceful shutdown 동작 원리

종료 시그널이 오면 Spring boot의 Web server manager에서 사용 중인 웹 서버의 shutDownGracefully() 메서드를 호출한다. 이때 callback이 함께 전달되는데, 내부에서 비동기 작업이 수행되고 이를 끝 마치고 결과 반환을 위해 사용될 것임을 예상할 수 있다.

 

class WebServerManager {
    void shutDownGracefully(GracefulShutdownCallback callback) {
        this.webServer.shutDownGracefully(callback);
    }
}

 

shutDownGracefully 는 사용하는 내장 웹 서버 구현체에 따라 동작 방식이 다르고, 아래는 Tomcat 의 코드이다. 스레드를 하나 생성하고, CountDownLatch와 함께 doShutdown() 를 실행한다.

 

public void shutDownGracefully(GracefulShutdownCallback callback) {
    CountDownLatch shutdownUnderway = new CountDownLatch(1);
    new Thread(() -> doShutdown(callback, shutdownUnderway), "tomcat-shutdown").start();
    try {
        shutdownUnderway.await();
    }
    catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
    }
}

 

doShutdown() 안에서는 Connectors 들을 모두 Close() 하고 넘겨 받은 CountDownLatch를 1 낮추는데, 초기화된 Count 값이 1이었기에 이로써 latch의 await()는 블록킹을 마치고, shutDownGracefully()는 종료된다.

 

Tomcat 의 shutDownGracefully()는 Connectors 의 종료까지는 대기하되, 그 이후 작업들은 비동기로 남겨둘 수 있게 된 것이다.

 

private void doShutdown(GracefulShutdownCallback callback, CountDownLatch shutdownUnderway) {
    try {
        List<Connector> connectors = getConnectors();
        connectors.forEach(this::close);
        shutdownUnderway.countDown();
        awaitInactiveOrAborted();
        if (this.aborted) {
            logger.info("Graceful shutdown aborted with one or more requests still active");
            callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
        }
        else {
            logger.info("Graceful shutdown complete");
            callback.shutdownComplete(GracefulShutdownResult.IDLE);
        }
    }
    finally {
        shutdownUnderway.countDown();
    }
}

 

다른 비동기 처리되고 있는 스레드에선 처리 남은 요청을 확인한다. 처리 중인 요청이 더 없는지를 50ms 마다 확인하며 요청 처리가 마무리 되길 대기한다.

 

그리고 모두 정상 처리 또는 Abort 되었음을 확인하면 맨 처음 전달받은 callback 을 통해 Shutdown 결과를 알린다.

 

private void awaitInactiveOrAborted() {
    try {
        for (Container host : this.tomcat.getEngine().findChildren()) {
            for (Container context : host.findChildren()) {
                while (!this.aborted && isActive(context)) {
                    Thread.sleep(50);
                }
            }
        }
    }
    catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
    }
}

 

REF, Github - Spring boot projects

 

한줄 요약 

먼저 커넥션 종료를 처리하여 더 이상 요청을 받지 않도록 하고, 비동기로 n초마다 처리 중인 요청의 종료를 확인하여 모두 처리를 마치면 callback 으로 결과를 알림.

 

Comments