- 다음과 같이 코드를 작성하고 터미널에서
go run main.go로 실행한 뒤에 종료를 위해 Ctrl+C를 눌렀을 때 비정상 종료인 1이 출력되는 것을 확인
// package main provides program.
package main
import (
"log"
"os"
"os/signal"
"syscall"
)
// main is the entry point of the program.
func main() {
// wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
log.Println("program running: [Ctrl+C] to exit")
<-quit
// return exit code 0 to os
os.Exit(0)
}
// echo $?
// 1
- 평소에는 Intellij나 Air로 Go 프로그램을 래핑된 상태에서 실행해서 전혀 예상하지 못한 결과였음
- 사실 위와 같은 간단한 코드가 아니라 아래처럼 웹 어플리케이션을 실행하기 위한 코드였기 때문에, 인라인 레벨에서 고민을 많이 해봤는데 완전히 잘못된 접근이였음
- 원래 main 함수에
app.Run()만 실행하였는데 exit code를 명시하지 않아 비정상 종료가 되지 않을까 싶어 반환값에 추가
- 고루틴 안에서 도는 웹 서버를 종료하는 과정에서 비정상 종료가 발생할 수 있다고 생각하여 타임아웃도 30초로 설정함
// Package app provides an app.
package app
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"pocj8ur4in/boilerplate/internal/app/server"
)
// Run runs the app.
func Run() (exitCode int) {
// recover from panic
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v", err)
exitCode = 1
}
}()
sv := server.New()
log.Printf("start server...")
// start server in goroutine
go func() {
if err := sv.Start(); err != nil {
log.Printf("server failed to start: %v", err)
}
}()
// wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
// create a deadline to wait for exit signal
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// attempt graceful shutdown
if err := sv.Shutdown(ctx); err != nil {
log.Printf("server forced to shutdown: %v", err)
return 1
}
log.Println("server stopped")
return
}
- 코드를 간단하게 작성해봐도 비정상 종료가 출력되는 것을 보고 환경을 의심하기 시작함
- 원래는 ubuntu에서 실행하였으나, 이후 MacOS에서도 동일한 결과를 반환되는 것을 확인
- 그런데
go build로 빌드한 바이너리 파일을 실행했을 때는 정상적으로 exit code가 0이 나옴
Ctrl+C는 하드웨어 입력을 통해 발생하는 시그널 인터럽트 (SIGINT)
Ctrl+C이 입력되면 SIGINT 시그널이 현재 foreground 프로세스 그룹 전체에 전송
- root만 실행 가능한 kernel mode의
kill -9과 달리, 일반 사용자 권한으로 가능한 user mode의 일반 명령
- 두 Go 프로그램에서 공통적으로 쓰이는
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)는 SIGINT 시그널을 캡쳐할 때까지 블로킹
- 그러나 프로그램에서 시그널을 캡처한 다음에
os.Exit(0)으로 종료하더러도, 셀은 이미 해당 프로세스 그룹에 SIGINT 시그널이 전송되었음을 인식하고 있음
- 프로세스가 foreground에서 SIGINT를 받으면 셀이 자체적으로 exit code를 1 (ERR) 혹은 130 (128 + SIGINT의 signum인 2)으로 처리함
- 즉, 셀의 시그널 처리 로직으로 발생한 문제임. 다만
go run main.go과 go build main.go && ./main의 동작 차이 또한 짚어볼 필요가 있음
go run main.go는 go tool이 임시 디렉터리에 소스 코드를 컴파일한 다음에 이 실행 파일을 새로운 프로세스로 실행함
- 셀의 자식 프로세스이면서
go run을 실행하는 주체는 go tool임. 그리고 실행되는 프로그램은 go tool의 자식 프로세스임
go build main.go는 go tool이 바이너리 파일만 빌드하고 종료. 그리고 ./main으로 바이너리가 셀의 자식 프로세스로 실행
Ctrl+C이 입력되면 SIGINT 시그널이 foreground 프로세스 그룹 전체에 전송되는 것은 동일하나, 래퍼 프로세스 없이 직접 셀에 프로세스 종료 코드 전달
- Intellij와 같은 IDE에서 실행할 때에는 런처 프로세를 만들어서
go tool 명령어를 실행함
- IDE에서
go run main.go 혹은 go build main.go && ./main을 실행
- 그 프로세스를 IDE가 생성한 가상 터미널 (pseudo terminal)에 연결하고 그 터미널의 입출력을 리다이렉션, 즉 IDE를 통해 시그널이 전달.
- Go에서 hot reload를 지원하는 Air는 Air 프로세스가 watcher 역할을 수행하면서 변경을 할 때마다
go build로 바이너리 생성
- 즉
go run처럼 래퍼 프로세스가 존재하며 Air 프로세스가 SIGINT를 받으면 자식 프로세스를 kill하고 자신도 종료
- 실제로
echo $?를 입력했을 때 130을 반환하였음
- 그러므로 개발 환경에서
go run을 쓰거나 Air를 활용할 때에 SIGINT 및 exit code가 기대한 것과 다르게 나올 수 있다는 점을 고려해야 함
- 우려되는 부분은 makefile이나 bash 스크립트에서
go run을 실행한 이후에 정상 종료 여부를 판별하는 것인데, exit code가 1 혹은 130인지 판별하는 조건 처리가 필요해보임
trap '' INT TERM; \
CGO_ENABLED=1 go run main.go; \
code=$$?; \
if [ "$$code" -eq 130 ] || [ "$$code" -eq 1 ]; then \
echo "Stopped by Ctrl+C"; \
exit 0; \
else \
exit $$code; \
fi