개발자 소식으로 돌아가기

프로덕션의 시그널: 위험과 함정

2022년 9월 27일제작:Chris Down

이 블로그 게시물에서는 Meta의 커널 엔지니어 Chris Down 씨가 Linux 프로덕션 환경에서 Linux 신호를 사용할 때의 함정과 개발자가 되도록 시그널 사용을 피해야 하는 이유를 설명합니다.

Linux 시그널이란 무엇인가요?

시그널은 Linux 시스템이 어떤 조건에 대응하여 생성하는 이벤트입니다. 시그널은 커널에서 프로세스로, 프로세스에서 다른 프로세스로 또는 원래의 프로세스로 전송될 수 있습니다. 시그널을 수신하면 프로세스가 작업을 수행할 수 있습니다.

시그널은 Unix류의 운영 환경의 핵심이며 존재한 지 매우 오래되었습니다. 시그널은 코어 덤핑, 프로세스 수명 주기 관리 등 운영 체제의 여러 핵심 구성 요소를 잇는 역할을 해 왔으며 지난 50여 년간 사용하는 동안 그다지 큰 문제가 없었습니다. 그래서 누군가 프로세스 간 통신(IPC)에 시그널을 사용하면 위험할 수 있다고 말하면 새로운 시도를 하고 싶어 안달이 난 사람의 횡설수설이라고 생각할 수도 있습니다. 하지만 이 글에서는 시그널이 프로덕션 이슈의 원인이 되었던 사례를 제시하고 이를 완화하고 대체할 만한 방법 몇 가지를 알려드리고자 합니다.

시그널은 표준화가 되어 있고 널리 사용되는 데다 운영 체제에서 제공하는 것 외에 추가적인 종속성이 필요하지 않다는 점 때문에 매력적으로 보일 수 있습니다. 그러나 시그널을 안전하게 사용하기는 어려울 수 있습니다. 시그널에는 많은 가정이 포함되므로 이런 가정의 요구 사항이 일치하는지 신중하게 검증해야 하고, 그렇지 않으면 올바르게 구성하려고 신중을 기해야 합니다. 하지만 널리 알려진 앱을 비롯한 많은 앱이 그렇지 못한 것이 현실이고, 이로 인해 나중에 디버깅하기 어려운 인시던트가 발생할 수 있습니다.

최근에 Meta 프로덕션 환경에서 일어났던 인시던트를 통해 시그널을 사용할 때의 함정에 대해 다시 한번 알아보도록 하겠습니다. 몇 가지 시그널의 역사를 간단히 살펴보고, 이런 시그널 덕분에 어떻게 지금까지 오게 되었는지 알아볼 것입니다. 그런 다음, 프로덕션에서 나타나는 현재의 요구 사항 및 이슈와 대조해 보겠습니다.

인시던트

먼저 시간을 약간 되감아 보겠습니다. 당시 LogDevice 팀이 코드베이스를 정리하고 사용하지 않는 코드와 기능을 제거했습니다. 사용 중단된 기능 중 하나는 서비스에서 수행하는 특정 작업을 기록하는 로그 유형이었습니다. 결과적으로 이 기능은 중복적이었고 소비자가 없었기 때문에 제거되었습니다. 변경 사항은 GitHub의 이곳에서 참조할 수 있습니다. 이때까지는 나쁘지 않았습니다.

그다지 큰 문제 없이 변경 사항이 통과되고 나서 얼마 동안 프로덕션은 계속 작동했고 평소처럼 트래픽을 서비스했습니다. 몇 주 지나서 서비스 노드가 엄청난 속도로 사라지고 있다는 신고가 들어왔습니다. 이는 새로운 릴리스가 출시된 것과 관련이 있었지만 정확히 무엇이 잘못되었는지는 알 수 없었습니다. 무엇이 달라졌기에 갑자기 문제가 생긴 걸까요?

해당 팀은 앞서 말씀드린 코드 변경 사항에서 로그를 사용 중단한 것이 문제라는 것을 알아냈습니다. 왜 그럴까요? 그 코드에 무슨 문제가 있었을까요? 답을 아직 모르신다면 한번 차이를 살펴보시고 무엇이 잘못되었는지 생각해 보세요. 눈에 드러나는 차이가 아닌 데다 누구나 저지를 수 있는 실수이기 때문입니다.

logrotate, 링에 오르다

logrotate는 Linux를 사용할 때 로그 로테이션에 사용하는 표준에 가까운 도구입니다. 이 도구가 출시된 지도 약 30년이 되었고 개념은 간단합니다. 로그를 로테이션하고 비워서 로그의 수명 주기를 관리하는 것입니다.

logrotate는 자체적으로는 시그널을 보내지 않기 때문에 Iogrotate 메인 페이지나 문서에서는 시그널에 대한 설명이 그다지 많지 않을 것입니다. 하지만 logrotate는 임의의 명령을 로그 로테이션 전이나 후에 실행할 수 있습니다. CentOS의 기본 Iogrotate 구성에 대한 기본 예제로 다음과 같은 구성을 볼 수 있습니다.

/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    sharedscripts
    postrotate
        /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
    endscript
}

다소 엉성하기는 하지만 이 구성이 의도대로 작동한다고 가정해 보겠습니다. 이 구성에 따르면 logrotate가 등록된 파일을 로테이션한 후에 SIGHUP/var/run/syslogd.pid에 있는 pid로 보내야 합니다. 이는 실행 중인 syslogd 인스턴스의 pid여야 합니다.

이는 syslog와 같은 안정적인 공개 API를 사용하는 경우라면 아무 문제가 없습니다. 하지만 SIGHUP의 구현이 언제든 바뀔 수 있는 내부적 구현 정보인 것과 같은 경우라면 어떻게 해야 할까요?

장애의 역사

문제 중 하나는 사용자 영역에서 포착할 수 없어서 한 가지 의미만 있는 시그널(예: SIGKILL, SIGSTOP)을 제외하고 시그널의 의미론적 의미가 앱 개발자와 사용자가 어떻게 해석하고 프로그래밍하느냐에 달려 있다는 것입니다. 거의 학문적으로만 구분할 수 있는 경우도 있습니다. 예를 들어 SIGTERM은 대체로 '최대한 신속하게 적절히 종료하라'는 의미로 이해됩니다. 하지만 SIGHUP의 경우 의미가 훨씬 불분명합니다.

SIGHUP은 직렬 회선용으로 개발되었고 원래는 상대편 연결에서 회선을 끊었다는 것을 나타내는 데 사용했습니다. 물론, 요즘에도 이 계보를 이어 가짜 또는 가상 터미널을 종료할 때 SIGHUP이 전송될 때가 있습니다(그래서 nohup과 같은 도구가 이를 마스킹합니다).

Unix가 나온 지 얼마 되지 않았을 무렵에는 대몬을 다시 읽어들이도록 구현해야 할 필요가 있었습니다. 이는 대개 적어도 구성/로그 파일을 다시 시작하지 않고 여는 작업으로 구성되어 있었고, 시그널은 종속성 없이 그 목적을 달성할 수 있는 수단처럼 보였습니다. 물론 이런 작업에 사용할 시그널은 없습니다만 이러한 대몬은 제어 터미널이 없어 SIGHUP을 수신할 필요가 없었기에 눈에 보이는 부작용 없이 편승할 수 있는 편리한 시그널처럼 보였습니다.

하지만 이 계획에는 작은 문제가 있습니다. 시그널의 기본 상태는 '무시됨'이 아니라 시그널에 따라 다릅니다. 예를 들어 프로그램은 SIGTERM을 수동으로 구성하지 않아도 앱을 종료할 수 있습니다. 다른 시그널 핸들러를 설정하지 않는 한 사용자 영역에 코드를 작성하지 않고도 커널이 아무런 대가 없이 단순하게 프로그램을 종료합니다. 편리하죠!

하지만 불편한 점이 하나 있다면 SIGHUP에도 프로그램을 즉시 종료하는 기본 동작이 있다는 것입니다. 이는 이런 앱이 더 이상 필요하지 않을 가능성이 큰 원래의 장애 사례에는 좋지만, 이 새로운 의미에는 그다지 적절하지 않습니다.

물론 SIGHUP을 프로그램에 보낼 만한 모든 위치를 제거했다면 괜찮을 것입니다. 규모가 크고 성숙한 코드베이스라면 그렇게 하기가 어렵다는 것이 문제입니다. SIGHUP은 코드베이스를 쉽게 검색(grep)할 수 있도록 엄격히 제어되는 IPC 호출과는 다릅니다. 시그널은 언제 어디에서나 올 수 있고 (가장 기본적인 "이 사용자이거나 CAP_KILL을 가지고 있는지 여부"를 제외한) 작업에 대한 검사는 몇 가지 없습니다. 즉, 시그널이 어디에서 오는지 알아내기 어렵지만 더 명시적인 IPC를 사용하면 이 시그널이 우리에게 아무 의미가 없으며 무시해야 한다는 것을 알게 됩니다.

장애에서 위험으로

지금쯤이면 무슨 일이 일어났는지 추측을 시작해보셨을 듯합니다. 어느 운명적인 날 오후에, 앞서 말씀드린 코드 변경이 포함된 LogDevice 릴리스가 시작되었습니다. 처음에는 아무것도 잘못된 것이 없어 보였지만 다음 날 자정에 이상하게도 모든 것이 잘못되기 시작했습니다. 그 이유는 컴퓨터의 Iogrotate 구성에 있는 다음의 스탠자가 처리하지 않은 (그래서 돌이킬 수 없는) SIGHUP을 logdevice 대몬으로 보내기 때문입니다.

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

Iogrotate 구성에서 스탠자 딱 한 개가 부족하기란 놀라울 정도로 쉽고 이런 일은 큰 기능을 제거해야 할 때 자주 발생합니다. 안타깝게도 스탠자의 가장 마지막 자취까지 제거되었는지도 확신하기 어렵습니다. 이보다 쉽게 검증할 수 있는 사례에서도 코드 정리를 할 때 잔여물을 잘못 남겨두는 실수가 흔히 발생합니다. 하지만 일반적으로는 파괴적인 결과는 일어나지 않습니다. 즉, 나머지 잔재는 그저 종료된 채로 있거나 무연산 코드일 뿐입니다.

개념적으로 인시던트 자체와 해결 방법은 간단합니다. SIGHUP을 보내지 않고 시간이 지남에 따라 더욱 LogDevice 작업을 멀리 분산시키는 것입니다(즉, 자정 정각에 실행하지 마세요). 그러나 우리가 집중해야 할 영역은 이 인시던트의 미묘한 차이뿐만이 아닙니다. 이 인시던트는 무엇보다도 가장 기초적인 필수 사례를 제외한 무엇에 대해서든 프로덕션에서 시그널 사용을 중단시키는 플랫폼 역할을 합니다.

시그널의 위험

시그널의 장점

먼저 시그널을 운영 체제의 프로세스 상태 변경에 영향을 주기 위한 메커니즘으로 사용하는 것에는 충분한 근거가 있습니다. 여기에는 시그널 핸들러를 설치할 수 없고 기대한 그대로 정확히 실행되는 SIGKILL과 같은 시그널과 SIGABRT, SIGTERM, SIGINT, SIGSEGVSIGQUIT 등의 커널 기본 동작이 포함되는데, 이들은 사용자와 프로그래머가 대체로 잘 알고 있습니다.

이러한 시그널의 공통점은 한번 수신되면 모두 커널 자체 내에서 터미널 종료 상태로 향한다는 것입니다. 즉, 사용자 영역 시그널 핸들러가 없는 SIGKILL 또는 SIGTERM을 받으면 사용자 영역 명령이 더 이상 실행되지 않습니다.

터미널 종료 상태가 중요한 이유는 이것이 일반적으로 현재 실행 중인 스택과 코드의 복잡성을 낮추는 방향으로 나아간다는 것을 의미하기 때문입니다. 다른 상태를 원하더라도 오히려 동시성과 코드 플로가 혼란스러워지면서 더욱 복잡성이 커지고 추론하기가 더욱 어려워지는 경우가 많습니다.

위험한 기본 동작

기본적으로 종료되는 다른 시그널이 언급되지 않은 것을 발견하셨을 수도 있습니다. 기본적으로 종료되는 모든 표준 신호의 리스트는 다음과 같습니다(SIGABRT, SIGSEGV와 같은 코어 덤프 시그널은 모두 타당하므로 제외).

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

처음 보았을 때는 리스트에 이상한 점이 없어 보이지만 몇 가지 이상한 점이 있습니다.

  • SIGHUP: 원래의 의도대로만 사용한다면 기본값을 종료로 설정하는 것이 합리적입니다. 지금 뒤섞어 사용하고 있는 의미인 '파일 다시 열기'는 위험합니다.
  • SIGPOLLSIGPROF: '사용 중인 프로그램이 아니라 다른 표준 함수에서 내부적으로 처리해야 하는 시그널'에 속합니다. 하지만 해를 끼치지는 않겠지만 기본 동작을 종료로 설정하는 것은 적절하지 못합니다.
  • SIGUSR1SIGUSR2: 표면적으로는 원하는 대로 사용할 수 있는 '사용자 정의 시그널'입니다. 하지만 이들은 기본적으로 터미널이기 때문에 어떤 특정한 필요에 의해 USR1을 구현하고 나중에 필요 없어질 경우 코드를 바로 안전하게 제거할 수는 없습니다. 시그널을 명시적으로 무시할 방법을 의식적으로 생각해야 합니다. 이는 아무리 경험이 풍부한 프로그래머라도 찾아내기 어려운 답입니다.

이렇게 하면 벌써 터미널 시그널의 1/3에 가까운데, 이들은 좋게 보아도 의심스러운 수준이고 최악의 경우에는 프로그램을 변경해야 할 때 매우 위험한 역할을 합니다. 심지어 '사용자 정의'로 취급되는 시그널조차도 누군가 명시적으로 SIG_IGN하지 않으면 재난이 기다리고 있을 뿐입니다. 무해한 SIGUSR1이나 SIGPOLL조차도 인시던트를 일으킬 수 있습니다.

이는 단순히 친숙함의 문제가 아닙니다. 시그널이 어떻게 작동하는지 아무리 잘 알고 있더라도 처음에 시그널을 올바르게 넣은 코드를 작성하기는 매우 어렵습니다. 시그널은 겉보기와 달리 훨씬 더 복잡하기 때문입니다.

코드 플로, 동시성 및 SA_RESTART에 대한 오해

일반적으로 프로그래머는 온종일 시그널의 내부 작동 원리에 대해 생각하며 보내지 않습니다. 즉, 실제로 시그널 처리를 구현해야 할 때 약간씩 잘못된 일을 하는 경우가 많습니다.

여기서 이야기하는 것은 sig_atomic_t를 범핑하거나 C++의 원자 시그널 펜스를 사용하면 해결할 수 있는 시그널 핸들링 함수의 안전성과 같은 '사소한' 사례가 아닙니다. 이런 문제는 누구나 시그널 지옥을 한번 겪고 나면 매우 쉽게 검색하고 기억할 수 있는 함정입니다. 복잡한 프로그램이 시그널을 수신했을 때 프로그램 내에서 아주 적은 부분의 코드 플로에 대해 추론하는 것이 훨씬 더 어렵습니다. 그렇게 하려면 앱 수명 주기의 모든 부분에서 시그널에 대해 끊임없이 명시적으로 생각해야 합니다(예를 들어 EINTRSA_RESTART로 충분할까요? 이 시그널이 너무 이르게 종료되면 어떤 플로로 들어가야 할까요? 동시적 프로그램이 미치는 영향은 무엇일까요?). 아니면, 앱 수명 주기의 어떤 부분에 대해 sigprocmask 또는 pthread_setmask를 설정하고 코드 플로가 다시는 바뀌지 않기만을 기도해야 합니다(이는 빠르게 변화하는 개발 분위기에서 좋은 생각은 아닙니다). signalfd나 전용 스레드에서 실행 중인 sigwaitinfo는 여기에 다소 도움이 될 수 있지만 이 두 가지는 에지 사례와 사용성 우려 사항이 커서 추천하기가 어렵습니다.

대부분의 경험이 풍부한 프로그래머라면 아무리 가벼운 예시라도 스레드 안전 상태의 코드를 올바르게 작성하기란 매우 어렵다는 것을 알고 계실 것입니다. 스레드 안전 상태의 코드를 올바르게 작성하는 것이 어렵다고 생각하신다면 시그널은 훨씬 더 어렵다고 생각하시면 됩니다. 시그널 핸들러는 각각 원자적 데이터 구조를 가진 완전히 록(lock)이 없는 코드만 사용해야 합니다. 실행의 메인 플로가 중단되었을 때 어떤 록을 잡고 있는지 모르기 때문이고, 실행의 메인 플로가 원자적이 아닌 연산을 수행할 수 있기 때문입니다. 이들은 완전히 재진입성이어야 합니다. 즉, 시그널을 여러 번 보낼 경우 시그널 핸들러가 겹칠 수 있으므로 내부적으로 중첩할 수 있어야 합니다(시그널이 하나라도 SA_NODEFER를 사용). 그래서 printfmalloc과 같은 함수는 동기화에 전역 뮤텍스를 사용하기 때문에 시그널 핸들러에서 사용할 수 없습니다. 시그널을 받았을 때 해당 록을 잡고 있고 그 록이 다시 필요한 함수를 호출할 경우, 앱이 멈출 수 있습니다. 이는 추론하기가 대단히 어렵습니다. 그래서 많은 사람이 단순하게 시그널 핸들링에 아래와 같은 코드를 작성하는 것입니다.

static volatile sig_atomic_t received_sighup; 

static void sighup(int sig __attribute__((unused))) { received_sighup = 1; }

static int configure_signal_handlers(void) {
  return sigaction(
    SIGHUP,
    &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART},
    NULL);
}

int main(int argc, char *argv[]) {
  if (configure_signal_handlers()) {
       /* failed to set handlers */
  }

  /* usual program flow */

  if (received_sighup) {
    /* reload */
    received_sighup = 0;
  }

  /* usual program flow */
}

문제는 이런 상황에서 signalfd나 다른 비동기식 시그널 핸들링 시도가 상당히 단순하고 안정적으로 보일 수 있지만, 시그널을 받은 후 수행되는 작업만큼 중단 지점도 중요하다는 사실을 무시한다는 점입니다. 예를 들어 사용자 영역 코드가 I/O를 하고 있거나 커널(inode 또는 FD)에서 가져온 개체의 메타데이터를 변경한다고 생각해 보세요. 이 경우, 중단 시점에 커널 영역 스택 안에 있을 가능성이 높습니다. 예를 들어 파일 설명자를 닫으려고 시도하는 스레드는 다음과 같은 모습일 것입니다.

# cat /proc/2965230/stack
 [<0>] schedule+0x43/0xd0
 [<0>] io_schedule+0x12/0x40
 [<0>] wait_on_page_bit+0x139/0x230
 [<0>] filemap_write_and_wait+0x5a/0x90
 [<0>] filp_close+0x32/0x70
 [<0>] __x64_sys_close+0x1e/0x50
 [<0>] do_syscall_64+0x4e/0x140
 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

여기에서 __x64_sys_close는 파일 설명자를 닫는 close 시스템 호출의 x86_64 버전입니다. 이 실행 시점에 우리는 보조 스토리지가 업데이트되기를 기다립니다(즉, 이 wait_on_page_bit를 의미). 일반적으로 I/O 연산은 다른 연산에 비해 엄청나게 느리기 때문에 여기에서 schedule은 우리가 지연이 큰 작업(예: 디스크 또는 네트워크 I/O)을 수행할 예정이고 지금은 현재의 프로세스 대신 다른 프로세스를 예약하는 것이 낫다는 점을 자발적으로 커널 CPU 스케줄러에 알리는 수단입니다. 이 방법이 좋은 이유는 완료될 때까지는 진행할 수 없는 프로세스에서 시간이 오래 걸릴 수 있는 작업의 응답을 기다리느라 시간을 낭비하기보다는 실제로 CPU를 활용하는 프로세스를 선택하는 편이 낫다는 것을 커널에 알려줄 수 있기 때문입니다.

실행 중이었던 프로세스로 시그널을 보낸다고 생각해 보세요. 우리가 보낸 시그널은 수신하는 스레드에 사용자 영역 핸들러가 있으므로 사용자 영역에서 재개됩니다. 이런 경쟁이 벌어지면 여러 가지 결과가 나올 수 있는데, 그중 하나로 커널이 schedule 밖으로 나와서 스택을 더욱 풀고(unwind) 결과적으로는 사용자 영역에 ESYSRESTART 또는 EINTR 오류가 반환되어 실행이 중단된 사실을 알리게 되는 경우도 있습니다. 하지만 종료를 위해 얼마나 많이 진행했을까요? 지금 파일 설명자의 상태는 무엇일까요?

이제 사용자 영역으로 돌아왔으므로 시그널 핸들러를 실행할 것입니다. 시그널 핸들러가 종료되면 사용자 영역 libc의 close 래퍼와 앱에 차례로 오류를 전달합니다. 이 경우 앱은 이론적으로는 어떤 상황이 일어났을 때 대응이 가능합니다. '이론적'이라고 말한 이유는 시그널과 관련하여 일어나는 이런 여러 가지 상황에서 대응하는 방법을 알기란 매우 어렵고 프로덕션의 많은 서비스가 이런 에지 사례를 그다지 잘 처리하지 못하기 때문입니다. 데이터 무결성이 크게 중요하지 않은 일부 앱에서는 괜찮을 수 있습니다. 하지만 프로덕션 앱에서 데이터 일관성과 데이터 무결성을 중요하게 여길 경우 이는 상당한 문제가 됩니다. 커널은 얼마나 많이 진행되었는지, 무엇을 했고 하지 않았는지, 실제로 상황에 어떻게 대처해야 하는지 이해할 수 있는 세부적인 방법을 제시하지 않습니다. 게다가 이제 closeEINTR로 반환될 경우 파일 설명자 상태가 지정되지 않습니다.

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

앱에서 파일 설명자를 안전하게 보안을 갖춰서 처리할 방법을 찾으려면 상당히 고민스러울 겁니다. 일반적으로 동작이 잘 되는 syscall에서 EINTR을 처리하는 것만 해도 복잡합니다. SA_RESTART로 충분하지 않은 이유는 대체로 여러 가지 미묘한 문제들로 구성됩니다. 다시 시작할 수 없는 시스템 호출도 있고 모든 앱 개발자가 각 호출 사이트에서 모든 syscall에 대해 신호를 가져오는 것의 깊고 미묘한 차이를 이해하고 완화할 것을 기대한다면 앱을 중단하라는 것과 같습니다. man 7 signal:

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

마찬가지로 sigprocmask를 사용하면서 코드 플로가 고정되어 있기를 바라는 것은 화를 자초하는 것과 같습니다. 일반적으로 개발자는 시그널 처리의 한계와 시그널이 올바른 코드를 작성하거나 보존하는 방법을 고민하며 인생을 보내지 않기 때문입니다. sigwaitinfo로 전용 스레드에서 시그널을 처리할 때도 마찬가지입니다. 그러면 GDB와 같은 도구에서 프로세스를 디버깅하지 못하게 되기 십상입니다. 미묘하게 잘못된 코드 플로나 오류 핸들링은 버그, 충돌, 손상 디버깅 문제, 교착 상태 등의 문제를 발생시켜 금세 평소 자주 사용하던 인시던트 관리 도구에 도움을 청하게 될 것입니다.

멀티 스레드 환경의 높은 복잡성

동시성, 재진입성, 원자성에 대한 이야기만으로도 머리가 복잡하신가요? 여기에 멀티 스레드까지 더하면 더욱더 복잡해집니다. 여러 가지 복잡한 앱이 별도의 스레드를 묵시적으로 실행한다는 점(예: jemalloc, GLib 등)을 고려하면 이 점은 특히 중요합니다. 이들 중 일부 라이브러리는 시그널 핸들러를 직접 설치하기도 해서 문제를 더욱 복잡하게 합니다.

요컨대 man 7 signal이 이와 관련하여 다음과 같은 말을 했습니다.

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

좀 더 간단히 말해서 "대부분의 시그널의 경우 커널은 sigprocmask로 시그널을 차단하지 않은 스레드로 시그널을 전송합니다." SIGSEGV, SIGILL 등은 트랩과 유사하고 시그널을 문제의 스레드에 명시적으로 향하게 합니다. 그러나 생각과 달리, 대부분 시그널은 tgkill 또는 pthread_kill을 사용하더라도 스레드 그룹에 있는 하나의 스레드에 명시적으로 전송될 수 없습니다.

즉, 스레드 세트가 생기는 즉시 전체적인 시그널 핸들링 특징을 소소하게 변경할 수 없다는 의미입니다. 서비스가 메인 스레드에서 sigprocmask로 시그널을 정기적으로 차단해야 할 경우 시그널을 처리하는 방법을 외부적인 수단으로 다른 스레드에 어떻게든 전달해야 합니다. 그렇지 않으면 시그널이 다른 스레드에 삼켜져서 다시 볼 수 없게 됩니다. 물론 하위 스레드에서 시그널을 차단하면 이런 현상을 피할 수 있지만 자체적인 시그널 처리가 필요할 경우 waitpid와 같은 기본적인 것에도 상황이 복잡해질 것입니다.

여느 문제와 마찬가지로 이런 문제도 기술적으로 해결하지 못할 것은 아닙니다. 그러나 이를 올바르게 처리하는 데 필요한 동기화가 부담스러울 정도로 복잡하고 버그, 혼란을 비롯한 더욱 심각한 문제의 단초가 될 수 있다는 점을 무시하는 것은 태만이나 마찬가지입니다.

성공/실패의 정의 및 전달 부족

시그널은 커널에서 비동기식으로 전파됩니다. kill syscall은 대기 중인 시그널이 프로세스나 스레드에서 문제의 task_struct에 대해 기록되자마자 반환됩니다. 따라서 시그널이 차단되지 않았더라도 시기적절하게 전달된다는 보장은 없습니다.

심지어 시그널을 시기적절하게 전달했더라도 시그널 발급자에게 작업 요청의 상태가 어떤지 다시 알릴 방법이 없습니다. 따라서 의미 있는 작업이라면 시그널로 전달해서는 안 됩니다. 시그널은 실행하고 끝나는 방식만 구현하여 전달과 이후의 작업에 대한 성공과 실패를 보고하는 실제 메커니즘이 없습니다. 앞서 보았듯이 무해해 보이는 시그널이라도 사용자 영역에 구성하지 않으면 위험할 수 있습니다.

Linux를 오래 사용해본 사람이라면 어떤 프로세스를 종료하고 싶은데 해당 프로세스가 SIGKILL과 같이 항상 치명적으로 여겨지는 시그널에도 반응이 없는 상황을 경험하게 되기 마련입니다. 문제는 kill(1)의 목적이 프로세스를 종료하는 것이 아니라 누군가 어떤 작업을 요청했다는 내용의 요청을 커널 대기열에 올리는 것이라는 점입니다(서비스될 시점에 대한 언급 없음).

kill syscall은 커널의 태스크 메타데이터에서 시그널이 대기 중인 것으로 표시하는 역할을 합니다. 이는 SIGKILL 태스크가 유휴 상태가 아닐 때도 성공적으로 수행됩니다. 특히 SIGKILL의 경우 커널은 사용자 모드 명령이 더 이상 실행되지 않도록 보장합니다. 하지만 데이터 손상을 일으키거나 리소스를 해제할 수 있는 작업을 완료하기 위해 커널 모드에서 명령을 실행해야 할 수도 있습니다. 이와 같은 이유로 상태가 D(중단 불가능한 슬립)일 경우에도 성공합니다. kill 자체는 실패하지 않습니다. 단, 잘못된 시그널을 제공했거나 해당 시그널을 전송할 권한이 없거나, 시그널을 전송하도록 요청한 pid가 존재하지 않아서 터미널이 아닌 상태를 앱에 안정적으로 전달하기에 유용하지 않을 경우는 예외입니다.

결론

  • 시그널은 사용자 영역 핸들러 없이 커널 내에서만 순수하게 터미널 상태에 대해 처리할 때는 괜찮습니다. 프로그램을 즉시 종료해 주기를 원하는 시그널의 경우 커널이 처리하도록 두세요. 즉, 커널이 작업에서 이르게 나가서 프로그램 리소스를 더욱 빠르게 해제하는 한편, 사용자 영역 IPC 요청은 사용자 영역 부분이 다시 실행되기를 기다려야 합니다.
  • 시그널 처리에 문제가 생기지 않도록 하려면 아예 처리하지 않으면 됩니다. 그러나 SIGTERM과 같은 사례에 대해 무언가 해야 하는 앱 핸들링 상태 처리의 경우 많은 워트(wart)가 이미 더욱 직관적으로 바뀐 folly::AsyncSignalHandler와 같은 높은 레벨 API를 사용하는 것이 좋습니다.

  • 시그널로 앱 요청을 전달하지 마세요. 앱을 중단시키는 대신 앱 수명 주기의 전용 부분으로 자체 관리형 알림(예: inotify) 또는 사용자 영역 RPC를 사용하여 처리할 수 있습니다.
  • 가능한 경우 sigprocmask로 시그널 범위를 프로그램이나 스레드의 하위 섹션으로 제한하여 시그널이 올바른지 정기적으로 검사해야 하는 코드의 양을 줄일 수 있습니다. 코드 플로나 스레딩 전략이 변경될 경우 마스크가 원하는 효과를 내지 못할 수 있다는 점을 기억하세요.
  • 대몬 시작 시 균일하게 이해되지 않아서 프로그램의 어느 지점에 목적을 변경할 수 있는 터미널 시그널을 마스킹하여 커널 기본 동작으로 폴백하지 않도록 합니다. 제 제안은 다음과 같습니다.
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

시그널 동작은 매우 잘 작성된 프로그램에서도 추론하기 어렵고 시그널을 사용하면 다른 대체 방법을 사용할 수 있을 경우 앱에 불필요한 위험이 생깁니다. 일반적으로 프로그램의 사용자 영역 부분과 통신하는 데는 시그널을 사용하지 마세요. 대신 프로그램이 투명하게 이벤트 자체를 처리하도록 하거나(예: inotify), 발급자에게 오류를 다시 신고할 수 있고 컴파일 시점에 열거가 가능하며 표시가 가능한 사용자 영역 통신(예: Thrift, gRPC)을 사용하세요.

이 글을 통해 시그널이 표면적으로는 단순해 보이지만 실제로는 전혀 그렇지 않다는 점을 이해하셨기를 바랍니다. 시그널이 주는 단순성의 미학으로 인해 사용자 영역 소프트웨어에서 API로 사용하는 경우가 많은데, 이로 인해 요즘 시대의 대부분 프로덕션 사용 사례에는 걸맞지 않은 일련의 암시적 설계 결정을 내리게 됩니다.

물론, 시그널에 유효한 사용 사례는 있습니다. 시그널은 사용자 영역 구성 요소가 없을 경우 바람직한 프로세스 상태에 대해 전달하는 커널과의 기본 통신에는 적절합니다(예: 프로세스를 종료해야 하는 경우). 그러나 시그널은 사용자 영역에만 머무는 것이 정상인 상태에서 시그널에 알맞은 코드를 처음부터 작성하기란 어렵습니다.

시그널은 표준화되어 있고 널리 사용되고 있으며 종속성이 없어서 매력적으로 보일지 몰라도 상당히 함정이 많아서 프로젝트 규모가 커지면 우려도 커질 것입니다. 다행히 이 글을 통해 더욱 안전하고 덜 복잡하면서도 더욱 직관적인 방식으로 목표를 달성할 수 있는 완화 및 대체 전략을 알게 되셨기를 바랍니다.

Meta Open Source에 대해 자세히 알아보려면 오픈 소스 사이트를 방문하여 YouTube 채널을 구독하거나 Twitter, FacebookLinkedIn을 팔로우하세요.