Linux 시스템 콜 — 커널 경계, 동작 원리, 분류, strace·ltrace, errno, vDSO, seccomp

이 글의 핵심

시스템 콜은 애플리케이션이 권한과 자원(파일·프로세스·소켓·메모리)에 접근하는 유일한 정식 경로입니다. 커널로의 진입·래퍼·errno·관측(strace)과, 최소 권한(seccomp)까지 한 흐름으로 이해하면 “왜 이 호출이 실패했는지”를 정확히 설명할 수 있습니다.

들어가며

Linux 시리즈 여섯 번째 글이다. 프로세스·스케줄러와 메모리를 먼저 봤다면, 이번에는 애플리케이션이 커널에 무언가를 요청하는 통로인 시스템 콜을 다룬다. Linux 커널 내부 동작에서 본 VFS와 커널 인터페이스 이야기와 이어서 읽으면 이해가 쉽다.

순서는 이렇다. 먼저 strace로 장애를 쫓는 상황에서 출발해, 시스템 콜이 무엇이고 어떻게 커널로 들어가는지 정리한다. 그다음 errno, 성능과 vDSO, seccomp, 실습 코드, 디버깅 순으로 이어 간다. 글 전체에 걸친 주장은 하나다. 일반 애플리케이션 코드에서 시스템 콜 번호를 직접 넣어 호출하는 코드는 쓰지 않는 편이 낫다.


strace로 장애를 쫓는 상황

프로덕션에서는 가끔 터지는데 로컬에서는 거의 재현되지 않는 장애가 있다. 로그에는 “timeout” 같은 메시지만 반복되고, 스택 트레이스로는 원인이 보이지 않는다. 이럴 때 strace -f -T로 프로세스를 따라가면 epoll_wait가 얼마나 오래 머무는지, read가 어디서 멈추는지, openat이 ENOENT를 몇 번 반환하는지가 한 줄씩 드러난다. 그러다 보면 경로 문제인지 락 문제인지가 보이기 시작한다. strace는 애플리케이션 코드가 아니라 애플리케이션이 커널에 실제로 무엇을 요청했는지를 보여 주는 도구다.

strace는 ptrace를 이용해 대상 프로세스가 어떤 시스템 콜을 어떤 인자로 호출했는지, 반환값과 에러, 걸린 시간을 시간순으로 출력한다. 코드는 멀쩡해 보이는데 권한, 경로, 락, 쿼터 때문에 막힌 경우라면 openat, futex, epoll_wait의 패턴이 결정적인 힌트가 된다.

자주 쓰는 형태는 다음과 같다. 기본은 strace ./app이고, strace -c로 어떤 시스템 콜이 호출 횟수와 시간을 가장 많이 차지하는지 집계한다. 출력이 너무 많으면 strace -e trace=openat,read,write처럼 범위를 좁힌다. fork나 exec를 하거나 멀티스레드인 프로그램은 -f를 빼면 자식 프로세스와 다른 스레드의 호출이 빠져서 부모만 이상하게 조용해 보이므로, 이런 경우에는 -f부터 확인한다. 운영 환경에서는 strace -f -T -p PID로 실행 중인 프로세스에 붙기도 하는데, 토큰이나 SQL, 쿠키 같은 민감한 값이 그대로 출력될 수 있고 추적 대상의 속도도 크게 떨어진다. 사내 정책과 승인 절차를 확인하고, 시간을 짧게 제한해서 쓰는 것이 좋다.

결과를 해석하는 요령도 있다. ENOENT나 EACCES가 보이면 코드보다 경로, 권한, SELinux, 마운트 쪽을 먼저 의심한다. futex 호출과 짧은 epoll_wait가 쏟아진다면 뮤텍스 경합이나 이벤트 루프 설계를 의심해 볼 만하지만, 정상적인 동작에서도 나오는 패턴이라 단정하면 안 된다. EINTR이 보이면 시그널이나 타이머와 함께 애플리케이션의 재시도 로직을 봐야 하고, 한 번의 read가 오래 걸리면 네트워크, 디스크, 프록시를 의심할 수 있다. strace는 관찰 대상의 실행을 매 시스템 콜마다 멈추기 때문에 부하가 크거나 지연에 민감한 환경에서는 관찰 자체가 증상을 바꿀 수 있다. 그런 곳에서는 perf나 eBPF 기반의 샘플링 도구가 더 적합하다.

ltrace는 한 단계 위인 라이브러리 호출(malloc, fwrite 등)을 보여 준다. fwrite는 stdio 버퍼를 거치므로 strace에 보이는 write 횟수와 1:1로 대응하지 않을 수 있고, malloc 호출이 과도하게 많다면 힙 할당 패턴을 살펴볼 단서가 된다.


시스템 콜이란

시스템 콜은 일반 애플리케이션이 디스크, 소켓, 다른 프로세스 같은 자원에 정식으로 접근할 수 있도록 커널이 제공하는 요청 인터페이스다. C 라이브러리 함수와는 구분해야 한다. 흔히 read(2)라고 부르지만 실제로는 glibc 래퍼 함수가 ABI에 맞게 인자를 준비하고 결과를 errno로 정리하며, strace에는 커널이 받은 이름과 인자가 찍힌다. 코드에서는 open을 불렀는데 strace에는 openat이 보이는 것도 이 때문이다. 라이브러리 함수 한 번이 시스템 콜 한 번과 항상 대응하지는 않는다.

애플리케이션은 평소 사용자 모드에서 실행되다가, 파일을 열거나 소켓에 쓰려면 커널 모드로 전환해 권한 검사와 VFS, 네트워크, 메모리 서브시스템을 거쳐야 한다. 그 전환 지점이 시스템 콜이고, 전환에는 비용이 든다. 그래서 핫 루프에서 불필요한 시스템 콜을 반복하면 지연이 늘어난다. 반대로 정상적인 I/O는 반드시 이 경계를 넘어야 하며, 빠르게 하겠다고 경계를 우회하려는 시도는 보안, 이식성, 관측 가능성 면에서 결국 비용을 치르게 된다.

32비트 x86에서는 int 0x80 소프트웨어 인터럽트로 커널에 진입하는 방식이 교재에 많이 나왔다. x86-64 리눅스에서는 syscall 명령이, AArch64에서는 svc 명령이 그 역할을 한다. 요지는 사용자 코드가 ABI에 정해진 레지스터에 시스템 콜 번호와 인자를 넣고 특권 전환 명령을 실행하면, 커널이 시스템 콜 테이블에서 번호에 해당하는 핸들러를 찾아 실행한다는 것이다.

시스템 콜 번호는 아키텍처마다 번호 체계가 다르다. 리눅스 전체에서 공통인 상수라고 생각하면 안 된다. C에서는 syscall(SYS_read, …)처럼 SYS_ 또는 __NR_ 상수로 직접 부를 수 있지만, 같은 이름이라도 x86-64와 arm64에서 번호가 다르다. 그래서 일반 애플리케이션은 libc 함수를 쓰고, 번호를 직접 다루는 코드는 libc가 아직 래퍼를 제공하지 않는 새 시스템 콜을 쓰거나 특수한 런타임을 만드는 경우처럼 제한된 상황에서만 의미가 있다. 아래에 syscall() 예제를 싣지만 학습용이다.

소켓에서 read가 블로킹되면 해당 스레드는 잠들고, 커널 쪽 태스크 상태와 스케줄링(#05)의 문제로 넘어간다. 디스크나 NIC가 일으키는 하드웨어 인터럽트와, 사용자 프로그램이 커널에 요청하는 시스템 콜은 서로 다른 축이라는 점도 구분해 둘 필요가 있다.

flowchart LR
  A[userspace: libc 래퍼] -->|ABI 레지스터 배치| B[특권 전환 + syscall 진입]
  B --> C[번호로 테이블 조회]
  C --> D[VFS/네트워크/메모리/스케줄...]
  D -->|성공/실패| E[userspace: errno/반환]

주요 시스템 콜 분류

파일 I/O의 중심은 파일 디스크립터(fd)다. open/openat은 경로 해석과 권한 검사, O_NOFOLLOW, O_CLOEXEC 같은 플래그를 처리한다. read/write는 파이프, 터미널, 시그널, 논블로킹 모드에서 요청한 것보다 적게 읽고 쓰는 부분 처리가 정상 동작이라고 봐야 한다. close는 fd 재사용, dup2, exec와 얽힌다. 작은 write를 초당 수만 번 호출하면 호출 비용이 쌓이므로 버퍼링과 배치 처리를 함께 고려한다.

프로세스 관련으로는 복사-온-라이트(COW)로 동작하는 fork, 주소 공간을 새 프로그램으로 교체하는 execve, 좀비 프로세스를 거두는 waitpid가 있다. exit(3)은 atexit 핸들러와 stdio 버퍼 정리를 한 뒤 커널의 _exit(2)를 부르는 라이브러리 함수다. 자세한 내용은 #05에서 다룬다.

메모리 관련으로는 힙 끝을 옮기는 brk, 파일이나 익명 메모리를 매핑하는 mmap/munmap, JIT나 샌드박스에서 페이지 권한을 바꾸는 mprotect가 있다. #06 메모리 글과 이어진다.

소켓은 socket, bind, listen, accept, connect, send/recv로 이어지고, 대규모 연결은 epoll이나 io_uring 기반 이벤트 루프로 처리한다. I/O 다중화 글도 함께 보면 좋다.

시그널은 kill로 보내고 sigaction으로 처리 방식을 등록한다. 새 코드에서는 signal()보다 sigaction을 쓴다. 한 가지 자주 헷갈리는 점은 SIGKILL을 보내도 프로세스가 곧바로 사라지지 않는 경우다. 프로세스가 중단 불가능한 대기(D 상태)에 있으면 그 대기가 끝날 때까지 시그널이 처리되지 않으므로, 커널의 어느 지점에서 무엇을 기다리는지 확인해야 한다.

이 밖에 poll, epoll, eventfd, inotify는 여러 fd를 한꺼번에 기다리는 용도이고, pipe와 dup2는 셸 파이프라인의 기반이다. ioctl은 터미널, 디바이스, 소켓 설정에 두루 쓰이는 범용 인터페이스라서 strace에 ioctl이 많이 보이면 어떤 fd에 어떤 요청 코드를 보내는지부터 좁혀 본다.


glibc 래퍼와 아키텍처 차이

glibc 래퍼는 인자를 ABI에 맞는 레지스터에 배치하고 커널로 진입한 뒤, 실패하면 -1을 반환하고 errno를 설정한다. 래퍼가 하는 일은 glibc 버전에 따라 조금씩 다를 수 있다. 앞서 말한 open이 내부적으로 openat을 부르는 것도 그런 예다.

x86-64에서는 시스템 콜 번호를 rax에, 인자를 rdi, rsi, rdx, r10, r8, r9 순서로 넣는다. 일반 함수 호출 규약에서는 네 번째 인자가 rcx인데 시스템 콜에서는 r10인 이유는, syscall 명령이 복귀 주소를 rcx에, 플래그 레지스터를 r11에 저장하면서 두 레지스터를 덮어쓰기 때문이다. 인라인 어셈블리나 JIT에서 직접 시스템 콜을 낼 때 이 점을 놓치면 값이 깨진다. AArch64에서는 번호를 x8에, 인자를 x0부터 x5에 넣는다.

이런 레지스터 배치를 표로 외울 필요는 없지만, 왜 r10을 쓰는지, rcx가 왜 위험한지 알아 두면 디버거나 eBPF로 시스템 콜 진입점을 볼 때 도움이 된다. 성능을 위해 libc를 건너뛰고 시스템 콜을 직접 호출해 봐야 얻는 이득은 대개 미미하고 실수할 여지만 늘어난다. 기본은 libc를 쓰고, 정말 필요한 경우에만 따로 다루는 편을 권한다.

syscall()로 직접 호출하는 최소 예 (학습용)

sys/syscall.h의 SYS_ 상수와 unistd.h의 syscall() 함수를 쓴다.

#include <sys/syscall.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

int main(void) {
  int fd = syscall(SYS_openat, AT_FDCWD, "/tmp/syscall-smoke.txt",
                   O_WRONLY|O_CREAT|O_TRUNC, 0644);
  if (fd < 0) {
    return 1; /* errno 설정됨 */
  }
  const char msg[] = "from syscall() path\n";
  (void) syscall(SYS_write, fd, msg, (long)(sizeof msg - 1));
  (void) syscall(SYS_close, fd);
  return 0;
}

arm64처럼 open 시스템 콜 자체가 없고 openat만 있는 아키텍처도 있기 때문에 예제는 SYS_openat을 썼다. 실제 애플리케이션 코드에서는 open/read 래퍼를 쓰면 되고, 위 코드는 커널이 무엇을 받는지 한 번 눈으로 확인하는 용도다.


errno: 마지막 실패의 이유

errno는 스레드마다 따로 있는 값이다. 성공한 호출은 errno를 0으로 되돌려 놓지 않으므로, 이전 실패의 값이 남아 있을 수 있다. 반환값으로 실패를 확인한 직후에 바로 errno를 읽거나 저장하는 습관이 중요하다. 중간에 printf 같은 다른 함수를 부르면 값이 바뀔 수 있다.

read/write는 -1(실패), 0(read의 경우 EOF), 양수(처리한 바이트 수, 요청보다 적을 수 있음)를 반환하므로, 전부 쓰려면 루프가 필요하다.

#include <errno.h>
#include <unistd.h>

/* 완성할 때까지 반복(인터럽트·부분쓰기·일부 EAGAIN은 상황에 맞게) */
static ssize_t write_all(int fd, const void *buf, size_t len) {
  const char *p = (const char *)buf;
  size_t off = 0;
  while (off < len) {
    ssize_t n = write(fd, p + off, len - off);
    if (n < 0) {
      if (errno == EINTR) continue; /* SA_RESTART·환경에 따라 다름 */
      return -1;
    }
    if (n == 0) return (ssize_t)off;
    off += (size_t)n;
  }
  return (ssize_t)off;
}

perror와 strerror는 개발 중이나 로그에는 편하지만, 운영 환경의 에러 처리 파이프라인에서는 에러 코드를 별도로 정규화해 다루는 경우가 많다.


성능, vDSO, io_uring

시스템 콜은 모드 전환과 커널 경로 실행, 캐시 오염 비용을 수반하므로, 초고빈도로 read/write/getsockopt를 부르면 병목이 될 수 있다. 그래서 readv/writev, sendmmsg, io_uring, 애플리케이션 버퍼링 같은 배치 기법이 쓰인다. 하지만 호출 수를 줄인다고 처리량이 항상 따라오지는 않는다. TCP 윈도우, 애플리케이션 락, GC, 원격 DB, 디스크가 병목이라면 시스템 콜을 튜닝해도 효과가 제한적이다. strace -c나 perf로 먼저 측정하는 것이 순서다.

vDSO는 커널이 모든 프로세스의 주소 공간에 매핑해 주는 작은 공유 객체로, ldd 출력에 linux-vdso.so.1로 보인다. clock_gettime이나 gettimeofday는 대개 vDSO 안에서 커널 진입 없이 끝나기 때문에 매우 빠르다. 다만 가상화 환경에서 클록 소스가 vDSO를 지원하지 않으면 실제 시스템 콜로 떨어져 느려질 수 있어, 벤치마크 결과가 환경에 따라 크게 달라진다. vDSO가 파일이나 소켓 I/O를 대신해 주는 것은 아니다. KPTI 같은 보안 완화 기법도 시스템 콜 진입 비용 자체를 바꾸므로, 커널 버전이나 부팅 옵션이 다르면 측정값이 흔들린다.

io_uring(리눅스 5.1부터)은 사용자 공간과 커널이 공유하는 제출 큐와 완료 큐를 통해 여러 I/O 요청을 한꺼번에 넘기고 받는 인터페이스다. 시스템 콜 횟수를 크게 줄일 수 있지만, 초기화, 권한 설정, 부분 완료와 에러 처리 같은 복잡도는 그대로 남는다. 도입 전에 병목이 정말 시스템 콜인지, 아니면 I/O 장치나 프로토콜인지를 먼저 가려야 한다.


seccomp: 허용된 시스템 콜만 쓰게 하기

seccomp는 BPF 필터로 프로세스가 호출할 수 있는 시스템 콜을 제한하는 기능이다. Docker와 Kubernetes의 기본 seccomp 프로파일, 크롬 샌드박스 등이 이를 쓴다. 필터는 호출마다 허용, 에러 반환, 프로세스 종료, 로그 기록, 사용자 공간 알림(user_notif) 같은 동작을 정할 수 있다. 필요한 시스템 콜을 빠뜨리면 프로세스가 SIGSYS로 즉시 종료되는 경우가 많아서, 조용한 실패보다는 갑작스러운 크래시로 나타난다.

seccomp 필터를 비특권 프로세스가 설치하려면 먼저 prctl(PR_SET_NO_NEW_PRIVS, 1, …)을 설정해야 하고, 필터는 fork와 execve 이후에도 상속된다. 그래서 같은 애플리케이션이라도 Docker나 Kubernetes에서 어떤 프로파일로 실행했느냐에 따라 “갑자기 특정 시스템 콜이 막혔다”는 문제가 생긴다. 현실적인 접근은 정상 워크로드를 strace 등으로 관찰해 필요한 시스템 콜 목록을 모으고, 그 목록을 기준으로 허용 목록을 조금씩 좁혀 가는 것이다. 최소 권한은 보안뿐 아니라 실수의 영향 범위를 줄이는 설계이기도 하다.


실습: SYS_openat로 파일 쓰기

#include <linux/unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>

int main(void) {
  int fd;
  const char *path = "/tmp/seccomp-example.txt";
  const char *text = "hello via SYS_openat/write/close + errno path\n";

  fd = syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
  if (fd < 0) {
    (void) fprintf(stderr, "openat: %s\n", strerror(errno));
    return 1;
  }

  ssize_t n = syscall(SYS_write, fd, text, (long) strlen(text));
  if (n < 0) {
    (void) fprintf(stderr, "write: %s\n", strerror(errno));
    (void) syscall(SYS_close, fd);
    return 1;
  }

  (void) syscall(SYS_close, fd);
  return 0;
}

cc -O2 -Wall -Wextra -o t t.c && ./t && strace -c ./t로 빌드하고 실행해 보면, 직접 작성하지 않은 mmap, mprotect, set_tid_address 같은 시스템 콜이 함께 찍힌다. 동적 링커와 libc 초기화 과정이 부르는 것들이다. 내 코드가 부르는 시스템 콜만 실행된다고 생각하면 seccomp 프로파일을 만들 때 바로 문제가 생긴다.


시스템 콜 번호 확인하는 법

번호는 아키텍처와 커널에 따라 다르므로, 표를 외우기보다 대상 환경에서 확인하는 방법을 알아 두는 편이 낫다. 믿을 만한 출처는 대상 시스템의 asm/unistd_64.h 같은 헤더, auditd 패키지의 ausyscall(8), man 2 syscalls, 그리고 strace 출력이다.

예를 들어 x86-64에서는 read가 0번, openat이 257번이고, arm64(generic 번호 체계)에서는 read가 63번, openat이 56번이다. 같은 C 코드라도 크로스 컴파일하면 __NR_ 상수값이 바뀌므로, 번호를 직접 다루는 코드가 있다면 CI에 여러 아키텍처를 포함하는 것이 좋다.

ausyscall x86_64 read
ausyscall x86_64 openat

read, openat, close, mmap, socket, execve, exit, epoll_create1 같은 이름은 개념 지도로 기억하고, 번호는 필요할 때 도구로 확인한다. openat2, faccessat2, pidfd_open, io_uring_setup 같은 새 시스템 콜은 커널 버전마다 추가되며, glibc·musl·Bionic이 래퍼를 제공하는 시점도 제각각이다. 툴체인 헤더와 실행 커널 버전이 어긋나지 않는지도 함께 확인해야 한다.


증상별 점검 포인트

  • EACCES: 파일 권한, SELinux 레이블, 마운트 옵션을 본다. ls -lZ, namei -l 경로, systemd 유닛의 User= 설정이 단서가 된다.
  • EMFILE / ENFILE: 프로세스별 fd 한도(EMFILE)나 시스템 전체 한도(ENFILE)에 걸렸거나 fd가 누수되고 있다. ulimit -n, lsof, /proc/<pid>/fd, systemd LimitNOFILE을 확인한다.
  • EINTR 반복: 시그널이나 타이머 때문이다. read/write를 루프로 재시도하고, SA_RESTART로 재시작되지 않는 호출은 man 페이지로 확인한다.
  • 논블로킹 fd의 EAGAIN: 아직 준비되지 않았다는 뜻이다. 이벤트 루프로 기다려야 하며, 바쁜 대기로 다시 부르면 CPU만 쓴다.
  • EIO: 스토리지, 파일시스템, 가상화 계층 문제일 수 있으므로 dmesg를 함께 본다.
  • ETIMEDOUT: TCP 연결이나 프록시 구간 문제다. 네트워크 경로와 MTU를 확인한다.
  • strace에 작은 read/write가 수만 줄: 버퍼링이나 배치가 부족하다. writev나 애플리케이션 버퍼링을 검토한다.
  • futex가 많고 지연이 큼: 락 경합이다. 락 범위와 순서, 샤딩을 검토한다.
  • SIGSYS로 종료: seccomp가 막은 시스템 콜을 호출했다. 컨테이너 런타임의 seccomp 프로파일을 확인한다.
  • 시간 조회가 느림: 가상화 환경에서 클록 소스가 vDSO를 지원하지 않는 경우다. /sys/devices/system/clocksource/clocksource0/current_clocksource를 확인한다.
  • mmap의 ENOMEM: 주소 공간 한도나 cgroup 메모리 제한을 본다. 메모리 글과 dmesg를 참고한다.
  • EPIPE / SIGPIPE: 상대편이 연결을 닫은 뒤 쓰려고 했다. 서버라면 SIGPIPE를 무시하고 EPIPE를 처리하도록 한다.
  • strace에 restart_syscall 반복: nanosleep 등이 시그널로 중단된 뒤 커널이 자동으로 재시작하는 흔적이다.

strace / ltrace 명령 모음

strace -f -o /tmp/s.txt ./app arg1
strace -c -f -o /dev/null ./app
strace -e trace=openat,read,write,close,stat,fsync -f ./app
strace -f -T -p 12345 2> /tmp/s-p.txt

ltrace는 예를 들어 ltrace -f -S -e malloc+free+realloc -o /tmp/l.txt ./app처럼 쓴다. -S를 주면 시스템 콜도 함께 출력하지만, 라이브러리 호출과 시스템 콜을 동시에 추적하면 매우 느려지므로 개발 환경에서만 쓰는 것이 좋다. 앞서 말했듯 민감한 데이터가 출력될 수 있다는 점도 잊지 말자.


ptrace, GDB, /proc/<pid>/syscall

strace와 GDB는 모두 ptrace 시스템 콜을 쓴다. PTRACE_SYSCALL은 대상 프로세스가 시스템 콜에 들어갈 때와 나올 때 멈추게 하는 요청으로, strace가 이 방식으로 동작한다. CAP_SYS_PTRACE 권한이 없거나 Yama의 ptrace_scope 설정, SELinux 정책이 막으면 attach 시 Permission denied가 난다. 전면적인 strace는 재현 환경에서는 유용하지만 운영 중인 핫 경로에서는 증상을 악화시킬 수 있으니, 대상과 시간을 제한해서 쓴다.

cat /proc/<pid>/task/<tid>/syscall은 그 스레드가 지금 어떤 시스템 콜 안에 있는지 순간적인 스냅샷을 보여 준다. stack, wchan과 함께 보면 #05에서 다룬 D 상태의 원인을 좁히는 데 도움이 된다.


perf, eBPF, tracepoint

perf trace나 eBPF 도구로 raw_syscalls:sys_enter, syscalls:sys_enter_* tracepoint를 보면 strace보다 훨씬 적은 부하로 시스템 콜을 집계하고 비교할 수 있다. 운영 환경에서 쓰려면 권한과 감사 정책을 먼저 확인해야 한다. read나 futex 누적만으로 CPU나 락 문제의 원인이 잡히지 않으면 perf record와 flame graph, BPF 샘플링을 함께 쓴다.


운영에서 점검하는 순서

  1. 느리다면 strace -c로 read, write, futex, epoll_wait 등 누적 상위 항목부터 본다.
  2. 호출이 실패한다면 EACCES, EAGAIN 같은 에러 종류를 먼저 가르고, SELinux, cgroup, seccomp를 함께 확인한다. 한 축만 보면 오판하기 쉽다.
  3. fd 문제라면 ulimit, lsof, /proc/<pid>/fd, LimitNOFILE을 본다.
  4. EINTR이라면 SA_RESTART의 한계와 애플리케이션의 재시도 로직이 맞는지 확인한다.
  5. 관측 부담이 크면 strace 대상 시간과 이벤트를 제한하거나 eBPF 기반 도구로 바꾼다.

이 글에서는 eBPF 프로그램 작성법, vDSO의 어셈블리 수준 구현, WSL2나 gVisor 같은 환경의 차이는 다루지 않았다. 더 깊이 보려면 man 2 intro, man 2 syscalls, Michael Kerrisk의 The Linux Programming Interface, 대상 커널의 Documentation/을 참고하면 된다. 프로세스와 메모리, I/O는 #05, #06 메모리, I/O 다중화로 이어진다.


같이 보면 좋은 글