파일 업로드 도중 배포를 진행해도 괜찮을까? — Graceful shutdown 적용편

Jake
18 min readMar 13, 2024

--

Summary

  • 업로드가 진행 중인 애플리케이션을 종료할 때, 업로드가 모두 완료된 이후에 종료시킬 수 있다. (Graceful shutdown)
  • Golang 의 Context 는 사용자의 요청의 유무와 상관없이 일괄적으로 Timeout 으로 설정한 시간만큼 기다려야 한다.
  • tylerb/graceful.v1 을 사용하면 요청의 유무 및 업로드 완료 여부에 따라 종료 시간을 조정할 수 있다.

파일 업로드를 하는 도중 배포를 해도 괜찮을까요?

파일 업로드 기능은 웹 애플리케이션에서 제공되는 일반적인 기능 중 하나입니다. 개발자는 HTML Form 태그를 통해 multipart/form-data 형식으로 업로드를 할 수 있거나, REST API를 사용하여 HTTP Post 요청으로 업로드 기능을 구현할 수 있습니다.

그러나 사용자가 파일을 업로드하는 중에 배포로 인해 애플리케이션이 재시작되면 어떻게 될까요?

위의 이미지처럼 애플리케이션은 HTTP 에 대한 응답을 주지 못하기 때문에 사용자는 HTTP 응답을 기다리는 현상이 발생하게 됩니다.

이 문제에 대응하기 위해 애플리케이션을 종료할 때 stop_grace_period 와 같은 옵션을 설정하여 기존 요청을 마무리하고 종료할 수 있도록 합니다. 그리고 로드 밸런싱을 통해 새로운 트래픽이 새로운 애플리케이션으로 유도되는 방식으로 무중단 배포를 지원합니다.

그러나 파일 업로드의 경우, 특히 큰 파일의 경우에는 업로드 시간이 길어질 수 있기 때문에 간단한 타임아웃 정책만으로는 graceful한 무중단 배포를 진행하기 어려울 수 있습니다.

예시로 10G 파일을 업로드하는 경우를 고려해서 context.WithTimeout 을 무한정 길게 부여할 수는 없기 때문입니다.

우리가 바라는 graceful 하게 종료되는 것은 파일의 업로드가 종료됐을 때 애플리케이션이 종료되는 것입니다. 바로 아래의 이미지와 같이 말이죠!

(물론 Timeout 을 설정해주는 것은 중요합니다.)

업로드 진행율을 나타낼 수 있는 애플리케이션 구현

지난 시간에 Golang 의 Gin Framework 를 활용하여 업로드 서버를 구현하였습니다. 업로드를 진행 중임을 알기 위해 업로드하는 파일의 진행율을 출력하는 로직을 구성하였습니다.

업로드 진행율 나타내는 로직

var read int64
length := c.Request.ContentLength
buffer := make([]byte, 1024*1024)
for {
bytes, readErr := part.Read(buffer)
read = read + int64(bytes)
p := int32(float32(read) / float32(length) * 100)
if 0 != p && 0 == p%next {
log.Printf("upload processing : %s\\n", strconv.Itoa(int(p)))
}
}
  • 전체 ContentLength 의 값을 구합니다.
  • 1MB 크기를 갖는 슬라이스를 생성하여 파일 데이터를 1MB 씩 담도록 합니다.
  • read 변수에는 현재까지 업로드를 진행한 length 값을 더해줍니다.
  • p 변수로 업로드 진행률을 표시합니다.

💡 그러면 이제 파일을 업로드하는 도중 Application 을 종료해보도록 하겠습니다.

위의 로그를 보면 알 수 있듯이 애플리케이션을 종료함과 동시에 업로드가 종료되었습니다.

이는 애플리케이션을 배포하게 되면 진행 중인 업로드가 도중 중단되는 것을 의미합니다.

만일, 사용자의 업로드를 중단되지 않으면서 배포하길 원한다면 배포 담당자는 배포 대상 서버의 트래픽을 모니터링하면서 배포를 진행하게 됩니다.

Graceful Shutdown 이란?

Graceful shutdown 은 단어 그대로 애플리케이션을 우아하게 중지한다는 의미입니다.

예기치 못한 장애로 인한 종료 또는 의도된 종료가 발생할 시 진행 중이던 작업을 완료한 후 종료되는 로직을 의미합니다.

자원에 대한 connection 을 모두 닫는 동작이나, 업로드와 같은 애플리케이션이 진행 중인 작업을 모두 완료 후에 애플리케이션을 종료할 수 있습니다.

이러한 로직은 Application 로직에서 구현되어야 하며, docker 나 kubernetes 에도 적용하기 위해서필요한 옵션 및 설정들이 있습니다.

그러면 Application 부터 알아보도록 하겠습니다!

Application 에서의 Graceful Shutdown

A안) Gin Web Framework 에서 제공하는 Graceful

Go 언어의 Gin Framework 기반으로 업로드 애플리케이션을 만들었기 때문에 Gin 에서 권장하는 Graceful 방식으로 진행하였습니다.

https://gin-gonic.com/docs/examples/graceful-restart-or-stop/
package main

import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/gin-gonic/gin"
)

func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "Welcome Gin Server")
})

srv := &http.Server{
Addr: ":8080",
Handler: router,
}

go func() {
// service connections
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\\n", err)
}
}()

// Wait for interrupt signal to gracefully shutdown the server with
// a timeout of 5 seconds.
quit := make(chan os.Signal)
// kill (no param) default send syscanll.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall. SIGKILL but can"t be catch, so don't need add it
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutdown Server ...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err)
}
// catching ctx.Done(). timeout of 5 seconds.
select {
case <-ctx.Done():
log.Println("timeout of 5 seconds.")
}
log.Println("Server exiting")
}

위의 로직을 보면 체크해야 하는 내용이 아래와 같습니다.

  • gin server 를 띄울 때에는 고루틴을 사용한다.
    -> srv.ListenAndServer() 함수가 선언되면 선언된 쓰레드는 종료신호를 받기 전까지 대기하기 때문이다.
  • gin server 는 http.ErrServerClosed 을 통해 애플리케이션이 종료신호를 받게 되면 ListenAndServe() 함수를 종료하게 된다.
  • 메인쓰레드는 종료신호를 받기 전까지 ←quit 에서 대기하게 된다.
  • 종료신호를 받고 나서 지정된 Timeout 시간 초과 후에 종료되게 된다.
    -> GET 요청이 최대 5초 걸리기 때문에, Timeout 이 5초로 지정되었다.
  • ←ctx.Done 신호는 고루틴 내에서 선언되었을 때 cancel() 함수를 통해 명령을 받고 종료하는 로직이다.
  • kill -9 는 강제종료 신호로 애플리케이션이 처리하지 못하고 즉시 종료하는 신호이다.

아쉬운점

그러나 위처럼 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) 을 사용하였을 때에는 종료 신호를 받고 무조건 60초를 경과한 후에 종료가 된다는 점입니다.

아무런 요청이 없어도 우리는 애플리케이션이 종료가 되기를 기다려야 한다는 것입니다. 특히 이번 업로드 애플리케이션 같은 경우 대용량 파일의 업로드 서비스를 제공하기 때문에 Timeout 시간을 다른 API 서버보다 많이 부여할 예정입니다.

만일 우리가 Timeout 을 10분으로 부여한다고 하였을 때에 애플리케이션에 아무런 요청이 없어도 10분을 기다리고 종료되는 상황이 벌어질 것입니다.

B안) tylerb/graceful.v1 을 사용

Go 언어에서 Graceful shutdown 을 지원하는 라이브러리로 tylerb/graceful.v1 이 있습니다.

https://pkg.go.dev/gopkg.in/tylerb/graceful.v1@v1.2.15

설치방법

go get gopkg.in/tylerb/graceful.v1

사용방법

import (
"context"
"gopkg.in/tylerb/graceful.v1"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/gin-gonic/gin"
)

func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "Welcome Gin Server")
})

srv := &graceful.Server{
Timeout: 30 * time.Second,
ConnState: func(conn net.Conn, state http.ConnState) {},
Server: &http.Server{
Addr: ":8080",
Handler: router,
},
}

go func() {
// service connections
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()

// Wait for interrupt signal to gracefully shutdown the server with
// a timeout of 5 seconds.
quit := make(chan os.Signal)
// kill (no param) default send syscanll.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall. SIGKILL but can"t be catch, so don't need add it
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutdown Server ...")

if err := srv.Shutdown(context.Background()); err != nil {
log.Fatal("Server Shutdown:", err)
}
log.Println("Server exiting")
}

위의 로직을 적용하게 되면 다음과 같은 작업이 수행될 수 있습니다.

  1. Keepalive 연결 비활성화 (클라이언트와 서버 간 연결을 종료합니다.
  2. 수신 소켓을 닫아서 다른 프로세스가 해당 포트를 사용할 수 있게 합니다.
  3. Timeout 기간 동안 서버가 진행 중인 요청에 대해 완료를 기다립니다.
  4. Timeout 이 완료되면 모든 연결을 종료합니다.
  5. stopChan 을 닫아 블로킹된 고루틴을 깨웁니다.
  6. 함수로 반환하여 서버가 종료될 수 있도록 합니다.

[주의] 업로드 세션을 유지하기 위해 Timeout 을 0으로 설정

Timeout 을 0으로 설정하게 되면 서버에 대한 모든 연결이 끊어질 때까지 기다린 후 종료됩니다.

대용량 같은 파일을 업로드할 수 있기 때문에 위와 같은 Timeout 을 0으로 설정할 수 있으나, 이는 클라이언트의 연결을 무기한 유지하기 때문에 악의적인 공격에 영향을 받을 수 있습니다.

따라서 Timeout 을 0이 아닌 합리적인 값으로 설정하는 것이 중요합니다.

tylerb/graceful.v1 로직을 적용하게 되면 종료 시그널을 수신하더라도 업로드를 모두 완료한 이후에 종료가 되게 됩니다.

또한 타임아웃을 5초로 설정하게 되면 종료신호를 받은 이후 5초 후에 애플리케이션을 종료하게 됩니다. 이를 통해 업로드 애플리케이션을 고려하여 Max Timeout 을 고려하면 우리는 안정적으로 서버를 종료할 수 있음을 알 수 있습니다.

궁금증. 그렇다면 어떻게 활성된 요청이 있는지 여부와 그 요청이 종료되었는지를 알 수 있었는가?

gopkg.in/tylerb/graceful.v1 — shutdown

func (srv *Server) shutdown(shutdown chan chan struct{}, kill chan struct{}) {
// Request done notification
done := make(chan struct{})
shutdown <- done

srv.stopLock.Lock()
defer srv.stopLock.Unlock()
if srv.Timeout > 0 {
select {
case <-done:
case <-time.After(srv.Timeout):
close(kill)
}
} else {
<-done
}

위의 로직에서는 shutdown 채널을 통해 종료 신호를 받은 이후에, 만약 Timeout 값이 0보다 크다면 **time.After(srv.Timeout)**을 통해 주어진 시간 동안 대기합니다. 그 후에는 kill 채널을 닫아서 강제 종료 이벤트를 전달합니다.

만약 Timeout 값이 0보다 작거나 같다면, 종료 신호가 올 때까지 무한정 대기를 진행합니다.

이때 shutdown 채널과 kill 채널은 manageConnections 함수에서 사용됩니다. 이 함수는 서버의 연결을 관리하고, 서버의 종료 시그널을 받아들이며, Graceful한 종료와 강제 종료를 구분하여 처리합니다.

gopkg.in/tylerb/graceful.v1 — manageConnections

func (srv *Server) manageConnections(add, idle, active, remove chan net.Conn, shutdown chan chan struct{}, kill chan struct{}) {
var done chan struct{}
srv.connections = map[net.Conn]struct{}{}
srv.idleConnections = map[net.Conn]struct{}{}
for {
select {
case conn := <-add:
srv.connections[conn] = struct{}{}
srv.idleConnections[conn] = struct{}{} // Newly-added connections are considered idle until they become active.
case conn := <-idle:
srv.idleConnections[conn] = struct{}{}
case conn := <-active:
delete(srv.idleConnections, conn)
case conn := <-remove:
delete(srv.connections, conn)
delete(srv.idleConnections, conn)
if done != nil && len(srv.connections) == 0 {
done <- struct{}{}
return
}
case done = <-shutdown:
if len(srv.connections) == 0 && len(srv.idleConnections) == 0 {
done <- struct{}{}
return
}
// a shutdown request has been received. if we have open idle
// connections, we must close all of them now. this prevents idle
// connections from holding the server open while waiting for them to
// hit their idle timeout.
for k := range srv.idleConnections {
if err := k.Close(); err != nil {
srv.logf("[ERROR] %s", err)
}
}
case <-kill:
srv.stopLock.Lock()
defer srv.stopLock.Unlock()

srv.Server.ConnState = nil
for k := range srv.connections {
if err := k.Close(); err != nil {
srv.logf("[ERROR] %s", err)
}
}
return
}
}
}

위의 함수에서 net.Conn 에 대한 이벤트를 각각 select 로 분기하여 처리하고 있음을 알 수 있습니다.

add 채널:

  • add 채널에서 새로운 연결(conn)이 수신되면, 이 연결은 srv.connections 맵에 추가됩니다.
  • 또한, 새로운 연결은 처음에는 활성 상태가 아니므로 srv.idleConnections 맵에도 추가됩니다. 이로써 새로운 연결은 "idle" 상태로 시작됩니다.

idle 채널:

  • idle 채널에서 연결(conn)이 수신되면, 해당 연결은 srv.idleConnections 맵에 추가됩니다.
  • 이 연결은 이미 추가된 상태이므로 srv.connections 맵에는 변화가 없습니다.

active 채널:

  • active 채널에서 연결(conn)이 수신되면, 해당 연결은 srv.idleConnections 맵에서 제거됩니다.
  • 이는 연결이 활성 상태로 전환되었음을 의미합니다.

remove 채널:

  • remove 채널에서 연결(conn)이 수신되면, 해당 연결은 srv.connections 맵과 srv.idleConnections 맵에서 모두 제거됩니다.
  • 만약 done 채널이 nil이 아니고(done != nil), 현재 서버에 연결이 하나도 없다면(len(srv.connections) == 0), 종료 신호가 전송됩니다.

위의 이벤트 생명주기로 인해 모든 conn 이 종료상태가 되기를 기다리며, 완료 되었을 때 case done = <-shutdown: 로 서버의 종료 로직을 수행합니다.

만일 Timeout 이나 강제종료가 발생할 때에는 case <-kill: 에서 conn 이 모두 종료되기까지 기다리지 않고 conn.close() 를 통해 모두 종료시킵니다.

결론

이로써 우리는 사용자가 대용량 파일을 업로드를 진행할 때 애플리케이션을 중지시키더라도 업로드가 모두 완료되고 중지되게 하는 로직을 구현할 수 있었습니다. 이때 주의할 점은 바로 Timeout 을 0으로 지정하여 업로드가 완료되는 것을 무한히 대기하는 것을 지양해야 한다는 점이었습니다. 만일 악의적인 사용자로 인해 자사의 서버가 종료되지 못할 수 있기 때문입니다.

Golang 의 Context 를 활용하면 설정한 Timeout 동안 대기를 하지만, 사용자의 요청이 없을 때에도 일괄적으로 대기를 해야한다는 단점이 있었습니다. 때문에 대용량 파일 업로드 서버의 경우 Timeout 을 10분으로 잡아놓게 될 때에는 배포 때마다 무조건적으로 10분을 대기해야하는 점이 있었습니다.

이에 tylerb/graceful.v1 을 사용하게 되면 업로드 요청이 없을 때에는 즉각 종료되며, 요청이 진행 중일 때에는 설정한 Timeout 만큼 대기한 후에 종료됨을 확인할 수 있었습니다.

--

--