콘텐츠로 이동

System Call & Interrupt

System Call & Interrupt (시스템 콜 & 인터럽트)

섹션 제목: “System Call & Interrupt (시스템 콜 & 인터럽트)”

시스템 콜(System Call, syscall)은 유저 프로그램이 파일, 네트워크, 프로세스 같은 커널 자원을 안전하게 요청하는 공식 통로다. 인터럽트(Interrupt)는 CPU가 실행 중인 일을 잠시 멈추고 하드웨어나 예외 이벤트를 먼저 처리하게 만드는 신호다.

이 문서의 핵심은 “앱이 커널을 직접 만지지 않는다”는 감각이다. Node.js에서 fs.readFile()을 호출하거나 HTTP 요청을 받을 때도 실제 파일, 소켓, 네트워크 카드는 커널이 관리한다. 애플리케이션은 syscall로 요청하고, 커널은 파일 디스크립터(fd)와 epoll 같은 메커니즘으로 결과를 돌려준다.


1.5. 선행 기술의 한계 — 왜 syscall과 인터럽트가 등장했는가

섹션 제목: “1.5. 선행 기술의 한계 — 왜 syscall과 인터럽트가 등장했는가”

Intel 8086 기반 MS-DOS 같은 초기 PC 환경은 단일 권한 모드(real mode)에 가까웠다. 일반 프로그램과 OS가 같은 권한으로 실행되면 편하지만, 서버 운영에 필요한 격리와 안정성이 성립하지 않는다.

  • 메모리 격리 부재: 버그 있는 앱 하나가 OS 데이터나 다른 프로그램의 메모리를 덮어쓸 수 있다.
  • 권한 경계 부재: 일반 앱이 인터럽트 비활성화, 디스크 컨트롤러 직접 제어 같은 위험 명령을 실행할 수 있다.
  • 폴링 강제: CPU가 키보드, 디스크, 네트워크 카드 상태를 계속 물어봐야 하므로 다른 일을 효율적으로 처리하기 어렵다.

보호 모드(protected mode)와 현대 OS는 이 문제를 세 가지 경계로 풀었다.

  1. CPU Ring: CPU가 권한 레벨을 하드웨어로 나눈다. Linux는 주로 Ring 0을 커널 모드, Ring 3을 유저 모드로 쓴다.
  2. syscall: 유저 모드 코드가 커널 모드로 들어가는 합법적 입구다. 커널은 인자, 권한, 대상 자원을 검증한 뒤 실행한다.
  3. interrupt: 하드웨어가 CPU에게 비동기 이벤트를 알린다. 네트워크 패킷이 도착하면 NIC가 인터럽트를 발생시키고, 커널은 소켓 버퍼와 epoll ready list를 갱신한다.

선행 토픽과의 경계는 다음처럼 나뉜다.

  • content/topics/L4/linux-basics.mdx는 CLI, 파일, 프로세스, 시그널의 표면을 잡는다. 이 문서는 그 표면 명령이 내부적으로 어떤 커널 경계를 넘는지 설명한다.
  • content/topics/L4/process-thread.mdx는 프로세스, 스레드, fork()/clone()과 실행 단위 의미를 맡는다. 이 문서는 그 실행 단위가 커널 자원을 요청하는 통로를 맡는다.
  • content/topics/L0/nodejs-event-loop.mdx는 이벤트 루프의 런타임 흐름을 맡는다. 이 문서는 “패킷 도착 → 커널 → epoll → libuv 콜백”이라는 OS 쪽 다리를 연결한다.

출처: Protected mode — Wikipedia, CPU Rings, Privilege, and Protection — manybutfinite.com


아래 용어는 뒤에서 반복된다. 처음에는 내부 구현보다 “무엇을 구분하기 위한 말인가”를 잡으면 된다.

용어첫 이해왜 필요한가
유저 모드(User Mode)일반 애플리케이션이 제한된 권한으로 실행되는 CPU 상태앱 버그가 시스템 전체를 망가뜨리지 않게 한다
커널 모드(Kernel Mode)Linux 커널과 드라이버가 하드웨어와 모든 메모리 권한을 다루는 CPU 상태파일, 소켓, 메모리 같은 공유 자원을 중앙에서 검증한다
CPU RingCPU가 명령 실행 권한을 나누는 보호 레벨Ring 3 앱이 Ring 0 커널 명령을 임의로 실행하지 못하게 한다
syscall유저 모드 코드가 커널 기능을 요청하는 동기 호출read, write, socket, epoll_wait 같은 요청의 입구다
interruptCPU에게 지금 처리해야 할 이벤트가 왔음을 알리는 신호네트워크, 타이머, 키보드 같은 비동기 이벤트를 놓치지 않는다
fd(File Descriptor, 파일 디스크립터)프로세스가 열린 파일, 소켓, 파이프를 가리키는 작은 정수 핸들커널 객체를 직접 노출하지 않고 번호로 안전하게 참조한다
epollLinux가 많은 fd 중 I/O 준비가 된 fd만 깨워주는 이벤트 감시 메커니즘수천~수만 연결을 매번 전체 순회하지 않고 처리한다
IPC(Inter-Process Communication)서로 다른 프로세스가 데이터를 주고받는 방법프로세스 메모리는 격리되어 있으므로 별도 통신 경로가 필요하다
EINTRsyscall이 시그널 때문에 중간에 깨졌다는 errnoC/C++ 네이티브 코드에서 재시도하지 않으면 부분 처리로 끝날 수 있다
SIGPIPE / EPIPE끊긴 파이프나 소켓에 쓰려고 했다는 신호/오류상대가 이미 닫은 연결에 계속 쓰는 버그를 알려준다

3. syscall — 앱이 커널에 요청하는 방법

섹션 제목: “3. syscall — 앱이 커널에 요청하는 방법”

3.1 syscall은 함수 호출이 아니라 권한 경계 통과다

섹션 제목: “3.1 syscall은 함수 호출이 아니라 권한 경계 통과다”

일반 함수 호출은 같은 유저 모드 안에서 스택과 레지스터만 바꾸면 된다. syscall은 다르다. CPU가 Ring 3에서 Ring 0으로 전환하고, 커널 스택으로 넘어가고, 커널이 인자를 검증한 뒤 결과를 돌려준다.

User Mode와 Kernel Mode 경계

User Mode / Ring 3

Node.js, Python, Chrome 같은 일반 애플리케이션 코드가 실행되는 제한 권한 영역이다.

버그가 나도 해당 프로세스만 종료되어야 할 때

Kernel Mode / Ring 0

Linux 커널과 디바이스 드라이버가 하드웨어, 메모리, 파일, 네트워크를 직접 다루는 영역이다.

공유 자원을 검증하고 조작해야 할 때

syscall

유저 프로그램이 커널에게 작업을 요청하는 공식 입구다.

`open`, `read`, `write`, `socket`, `accept`, `epoll_wait` 같은 작업을 할 때
시스템 콜 호출 흐름
flowchart TD
User["유저 프로그램: Ring 3"] --> Call["syscall 명령어 또는 libc wrapper 호출"]
Call --> Switch["CPU 모드 전환: Ring 3 -> Ring 0"]
Switch --> Kernel["커널: 권한·인자·fd 검증 후 작업 수행"]
Kernel --> Result["결과값 또는 errno 설정"]
Result --> Return["Ring 3으로 복귀"]
Return --> App["유저 프로그램: 결과 처리"]

수치 감각: syscall 하나는 보통 수백 ns수 µs 수준의 비용을 가진다. 느린 것처럼 보이지 않을 수 있지만, 요청당 syscall이 수십수백 번 반복되고 p99 지연을 보는 서버에서는 누적 비용이 된다. 함수 호출처럼 “공짜에 가까운 호출”이 아니라 권한 경계를 건너는 작업이라고 봐야 한다.

첫 회독에서는 KPTI, TLB, vDSO 이름까지 외울 필요는 없다. syscall이 유저 모드와 커널 모드 사이의 권한 경계를 건너며, hot path에서 반복되면 비용이 누적된다는 점만 잡고 넘어가면 된다.

비용은 단지 함수 호출보다 명령어가 많아서가 아니다. CPU가 유저 스택에서 커널 스택으로 전환하고, 레지스터와 권한 상태를 저장/복원한다. 보안 패치 환경에서는 KPTI(Kernel Page Table Isolation)처럼 유저/커널 page table을 분리하는 기법 때문에 TLB locality가 깨질 수 있다. TLB는 가상 주소 -> 물리 주소 변환 결과를 저장하는 CPU 캐시이고, 이 캐시가 잘 맞지 않으면 메모리 접근 비용이 늘어난다. 그래서 clock_gettime처럼 안전하고 자주 호출되는 일부 기능은 vDSO(virtual Dynamic Shared Object)를 통해 커널 모드 진입 없이 유저 공간에 매핑된 코드에서 처리되기도 한다. 핵심은 syscall을 무조건 피하라는 뜻이 아니라, hot path에서 반복 호출되는 경계 통과를 버퍼링, 배치, 캐시, 비동기화로 줄이는 것이다.

Node.js 코드는 한 줄이지만 커널 관점에서는 여러 syscall로 쪼개진다.

const fs = require("fs");
fs.readFile("/etc/hostname", "utf8", (err, data) => {
if (err) throw err;
console.log("호스트명:", data.trim());
});
console.log("readFile 호출 직후");

실행 흐름은 다음과 같다.

fs.readFile() 호출
-> Node.js C++ binding
-> libuv thread pool에 파일 I/O 위임
-> open("/etc/hostname") = fd 5
-> read(5, buffer, 4096) = 읽은 바이트 수
-> close(5)
-> 완료 콜백이 Event Loop 큐로 이동

여기서 중요한 점은 fs.readFile() 자체가 커널 함수가 아니라는 것이다. Node.js와 libuv가 syscall을 대신 호출하고, 커널은 fd 5라는 핸들을 통해 열린 파일을 식별한다.

같은 모델은 C 코드에서도 그대로 보인다. 아래는 include, 버퍼 선언, 에러 처리를 생략한 축약 예시다. C 문법을 읽지 못해도 괜찮다. open -> read -> write -> close 순서로 커널에 요청이 들어간다는 흐름만 보면 된다. 언어와 런타임이 달라도 커널 경계에서는 결국 fd를 얻고, 읽고, 닫는다.

int fd = open("/etc/hostname", O_RDONLY);
ssize_t n = read(fd, buf, sizeof(buf));
write(1, buf, n);
close(fd);

readFileSync()도 마찬가지다. 아래처럼 strace -c로 보면 Node.js 초기화와 파일 읽기 과정에서 openat, read, close, mmap 같은 syscall이 여러 번 발생한다.

Terminal window
strace -c node -e "require('fs').readFileSync('/etc/hostname')"

예상 출력의 형태:

% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
45.23 0.000234 12 19 mmap
23.11 0.000120 10 12 openat
15.67 0.000081 8 10 read
8.45 0.000044 5 9 close

3.3 syscall과 API call을 구분하는 기준

섹션 제목: “3.3 syscall과 API call을 구분하는 기준”

초심자가 자주 놓치는 지점은 fs.readFile()도, read()도 모두 “호출”이라는 점이다. 하지만 둘은 경계가 다르다.

이름실행되는 곳예시커널 모드 전환 여부
API call애플리케이션/런타임 추상화fs.readFile(), axios.get()직접은 아님. 내부에서 syscall 가능
library call유저 공간 라이브러리fread(), printf()직접은 아님. 필요하면 syscall 호출
system call유저 공간에서 커널로 넘어가는 입구read(), write(), socket()예. Ring 3에서 Ring 0으로 전환

반례로 console.log()는 화면에 글자를 찍는 API처럼 보이지만, 최종적으로 stdout(fd 1)에 write()를 호출한다. 반대로 순수 계산 함수나 문자열 조작 함수는 아무리 많이 호출해도 커널 자원이 필요 없으면 syscall이 아니다.

syscall 비용을 줄이는 방향은 세 가지다.

방식핵심 아이디어이 문서에서의 위치
버퍼링작은 read/write를 모아서 syscall 횟수를 줄인다파일 I/O, 네트워크 송수신의 기본 최적화
이벤트 감시(epoll)준비된 fd만 깨워서 불필요한 반복 확인을 줄인다Node.js 네트워크 I/O의 핵심 다리
공유 링(io_uring)유저 공간과 커널이 큐를 공유해 요청/완료를 배치한다고급 주제이며 커널·보안 조건을 확인해야 한다

io_uring은 syscall 자체를 줄이는 강력한 방법이지만, 이 문서의 중심은 아니다. Linux 커널 버전, seccomp 정책, 보안 패치 이력, 워크로드 패턴을 함께 봐야 하므로 “epoll을 무조건 대체하는 최신 기술”로 외우면 안 된다. 여기서는 syscall 비용을 줄이려는 고급 경계 기술 정도로만 잡고 넘어간다.


4. fd — 커널 객체를 번호로 빌려 쓰기

섹션 제목: “4. fd — 커널 객체를 번호로 빌려 쓰기”

파일 디스크립터(fd)는 프로세스가 커널 객체를 가리키는 정수 번호다. 이름은 파일 디스크립터지만 실제로는 일반 파일뿐 아니라 소켓, 파이프, epoll 인스턴스, 터미널도 fd로 표현된다.

기본 fd 세 개는 거의 모든 프로세스가 갖고 시작한다.

fd이름의미
0stdin표준 입력
1stdout표준 출력
2stderr표준 에러 출력

HTTP 서버가 포트 3000을 listen하면 커널은 서버 소켓 fd를 만든다. 클라이언트가 연결될 때마다 accept()가 새 연결 소켓 fd를 돌려준다. DB 연결, Redis 연결, 업로드 파일, 로그 파일도 모두 fd를 소비한다.

프로세스 fd table
0 -> /dev/pts/0
1 -> /dev/pts/0
2 -> /dev/pts/0
5 -> anon_inode:[eventpoll]
7 -> socket:[LISTEN 0.0.0.0:3000]
8 -> socket:[client connection]
9 -> /var/log/app.log

fd 번호는 시스템 전체에서 전역으로 유일하지 않다. 프로세스 A의 fd 5와 프로세스 B의 fd 5는 서로 다른 커널 객체를 가리킬 수 있다. 그래서 로그에서 “fd 7”만 보면 부족하고, 항상 “어느 PID의 fd 7인가”까지 함께 봐야 한다.

EMFILE: too many open files는 “파일 시스템이 고장났다”가 아니다. 한 프로세스가 가질 수 있는 fd 한도를 넘었다는 뜻이다.

Error: EMFILE: too many open files, open '/var/log/app.log'

작은 서버에서 soft limit이 1024라면 대략 이런 계산이 가능하다.

사용처fd 개수 감각
표준 입출력3개
HTTP listen socket1개
동시 HTTP 연결 800개연결마다 대략 1개
DB pool 20개연결마다 대략 1개
Redis pool 10개연결마다 대략 1개
로그/임시 파일열린 파일마다 1개

이 상태에서 업로드 파일이나 외부 API 연결이 늘면 1024 한도에 쉽게 닿는다. 임시로 ulimit -n을 올리는 것은 증상을 늦출 뿐이고, 진짜 원인은 다음 둘 중 하나다.

  • 정상 트래픽 대비 fd limit이 너무 낮다.
  • 파일이나 소켓을 닫지 않아 fd가 계속 누수된다.

코드 레벨에서는 close() 보장이 핵심이다.

const fd = fs.openSync("/var/log/app.log", "r");
try {
fs.readSync(fd, buffer, 0, 1024, 0);
} finally {
fs.closeSync(fd);
}

운영에서 먼저 볼 최소 관찰값은 fd 수의 추세다.

Terminal window
ulimit -n
ls /proc/$(pgrep -n node)/fd | wc -l

트래픽이 줄었는데도 fd 수가 계속 증가하면 limit 문제가 아니라 누수 가능성이 높다.


5. interrupt와 epoll — 네트워크 이벤트가 콜백이 되기까지

섹션 제목: “5. interrupt와 epoll — 네트워크 이벤트가 콜백이 되기까지”

5.1 interrupt는 CPU를 깨우는 신호다

섹션 제목: “5.1 interrupt는 CPU를 깨우는 신호다”

인터럽트는 CPU가 지금 하던 일을 잠시 멈추고 이벤트 처리 루틴으로 이동하게 만드는 신호다.

커널에서 이 신호를 받아 실제로 실행되는 처리 함수를 ISR(Interrupt Service Routine)이라고 부른다. ISR은 가능한 한 짧게 하드웨어 상태를 확인하고, 오래 걸리는 처리는 커널의 다른 단계로 넘겨 전체 시스템 지연을 줄인다.

구분발생 주체예시성격
하드웨어 인터럽트NIC, 디스크, 키보드, 타이머네트워크 패킷 도착, 타이머 tick비동기
소프트웨어 인터럽트/트랩실행 중인 프로그램 또는 CPU 예외syscall, 0으로 나누기, page fault대체로 동기

네트워크 패킷이 들어오는 경로를 서버 관점에서 보면 다음과 같다.

네트워크 패킷에서 Node.js 콜백까지
sequenceDiagram
participant NIC as Network Card
participant CPU
participant Kernel as Kernel / NIC Driver
participant Socket as Socket Buffer
participant Epoll as epoll Ready List
participant Libuv as libuv Event Loop
participant Node as Node.js Callback

NIC->>CPU: hardware interrupt
CPU->>Kernel: interrupt handler 실행
Kernel->>Socket: 패킷을 소켓 버퍼에 적재
Kernel->>Epoll: 해당 fd를 ready 상태로 표시
Epoll-->>Libuv: epoll_wait() 반환
Libuv-->>Node: JS 콜백 실행

Node.js 콜백은 커널 내부에서 직접 실행되지 않는다. 커널은 “이 fd에서 읽을 수 있다”는 상태를 만들고, libuv가 epoll_wait()에서 깨어난 뒤 JS 콜백을 실행한다.

5.2 epoll은 많은 fd 중 준비된 것만 알려준다

섹션 제목: “5.2 epoll은 많은 fd 중 준비된 것만 알려준다”

select()poll()은 호출할 때마다 감시 대상 fd 전체를 훑는 구조에 가깝다. fd가 10개면 괜찮지만, 연결이 10,000개이고 실제로 데이터가 온 fd가 20개뿐이라면 매번 10,000개를 확인하는 비용이 커진다.

epoll은 커널 안에 감시 목록과 ready list를 유지한다.

내부 요소역할
interest listepoll_ctl()로 등록한 fd 목록
ready list지금 읽거나 쓸 준비가 된 fd 목록
wait queueepoll_wait()에서 잠든 프로세스/스레드를 깨우기 위한 대기열

핵심은 “등록은 한 번, 깨어날 때는 준비된 fd 중심”이라는 점이다. 그래서 Node.js HTTP 서버는 수많은 연결을 하나의 이벤트 루프에서 다룰 수 있다.

const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end("Hello World\n");
});
server.listen(3000);

이 서버가 요청을 받을 때 내부적으로는 accept(), read(), write(), epoll_wait() 같은 syscall이 오간다.

Terminal window
strace -p $(pgrep -n node) -e trace=epoll_wait,epoll_ctl,accept,read,write

epoll의 Edge-Triggered(ET) 모드는 상태 변화가 생겼을 때만 이벤트를 한 번 준다. 이 모드를 쓰면 “조금 읽고 나중에 또 깨워주겠지”라는 직관이 틀린다.

1. 파이프에 2KB 데이터 도착
2. epoll_wait()이 한 번 깨어남
3. read()로 1KB만 읽고 루프 종료
4. 버퍼에 1KB가 남았지만 새 상태 변화가 없음
5. 다시 epoll_wait()으로 들어가면 영원히 깨어나지 않을 수 있음

ET 모드에서는 두 규칙이 필수다.

  1. fd를 O_NONBLOCK으로 설정한다.
  2. read()write()EAGAIN을 반환할 때까지 반복한다.
정상 패턴:
read(5, ...) = 1024
read(5, ...) = 1024
read(5, ...) = -1 EAGAIN
epoll_wait(...)
위험 패턴:
read(5, ...) = 1024
epoll_wait(...) # 버퍼에 데이터가 남았는데 다시 잠듦

이 반례는 epoll을 API 이름으로만 외우면 놓치기 쉽다. epoll은 데이터를 대신 읽어주는 기능이 아니라 “읽을 준비가 됐다”는 신호를 주는 기능이다.

출처: epoll(7) Linux manual page — man7.org, The edge-triggered misunderstanding — LWN.net


6. IPC — 격리된 프로세스 사이를 연결하기

섹션 제목: “6. IPC — 격리된 프로세스 사이를 연결하기”

IPC(Inter-Process Communication)는 서로 다른 프로세스가 데이터를 주고받는 방법이다. process-thread.mdx에서 본 것처럼 프로세스는 메모리 주소 공간이 분리되어 있으므로, 한 프로세스가 다른 프로세스의 Heap을 그냥 읽을 수 없다. 그래서 커널이 중간에 서는 통신 경로가 필요하다.

IPC 방식적합한 상황주의할 점
Pipe부모-자식 사이의 단방향 바이트 스트림1:1 흐름에 적합하고 버퍼가 유한하다
Named Pipe(FIFO)이름 있는 단방향 로컬 통신파일처럼 보이지만 실제 데이터 저장 파일은 아니다
Unix Domain Socket같은 호스트 안의 양방향 소켓 통신TCP보다 빠르지만 호스트 밖으로 나갈 수 없다
TCP Socket다른 호스트와 통신네트워크 스택, 지연, 장애 처리를 감수한다
Shared Memory극저지연 대용량 공유동기화가 어렵고 데이터 경합을 직접 막아야 한다
Message Queue생산자/소비자 비동기 큐큐 크기, 재시도, 순서 보장을 별도로 설계한다
질문선택 방향
다른 호스트와 통신해야 하는가?TCP Socket
같은 호스트에서 DB/Redis/Nginx와 통신하는가?가능하면 Unix Domain Socket 검토
부모-자식 프로세스 사이의 작은 제어 메시지인가?Pipe 또는 Node.js process.send()
64KB 이상 큰 payload를 자주 넘기는가?Pipe에 직접 싣기보다 파일, Redis, shared memory 검토
10µs 이하 지연이 중요한가?Shared Memory 가능성은 있지만 동기화 비용까지 계산

64KB는 절대 기준이 아니라 “작은 제어 메시지”와 “큰 payload”를 가르는 학습용 기준이다. Pipe나 cluster IPC는 커널 버퍼와 직렬화 비용의 영향을 받으므로 큰 JSON, 이미지 버퍼, 대량 row를 계속 넘기면 송신자와 수신자가 서로의 처리 속도에 묶인다. 이런 경우 메시지에는 위치나 ID만 담고 payload는 파일, object storage, shared memory 같은 별도 경로에 두는 편이 안정적이다.

Unix Domain Socket은 같은 호스트 안에서 TCP 대비 레이턴시를 줄일 수 있다. 벤치마크와 payload 크기에 따라 폭은 달라지지만, 일부 측정에서는 20~40% 개선도 나온다. 이유는 간단하다. TCP는 루프백이라도 TCP/IP 계층의 시퀀스 번호, ACK, 라우팅, 체크섬 경로를 거치지만 Unix Domain Socket은 커널 내부 버퍼 간 전달에 더 가깝다.

반대로 Shared Memory는 가장 빠를 수 있지만 가장 위험하다. 복사 비용을 줄이는 대신 동시 쓰기, 부분 업데이트, 락 설계를 애플리케이션이 책임져야 한다. 데이터 무결성이 중요하고 메시지 크기가 작다면 Unix Domain Socket이나 메시지 큐가 더 나은 선택일 수 있다.

출처: IPC Performance Comparison — Baeldung on Linux, ipc-bench GitHub

Node.js cluster의 primary와 worker는 서로 다른 프로세스다. Heap은 공유되지 않으므로 메시지는 IPC 채널을 통해 이동한다.

const cluster = require("cluster");
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.send({ type: "task", id: 42 });
worker.on("message", (msg) => {
console.log("worker reply:", msg);
});
} else {
process.on("message", (msg) => {
process.send({ type: "done", id: msg.id });
});
}

작은 제어 메시지는 이 방식이 자연스럽다. 하지만 큰 JSON이나 이미지 버퍼를 계속 process.send()로 넘기면 serialization 비용과 pipe buffer 한계가 병목이 된다. worker가 죽었거나 IPC 채널이 끊긴 뒤에도 durable queue처럼 믿고 보내면 메시지 유실이나 callback error가 상위 로직의 부분 처리로 이어질 수 있다. 이때는 메시지에는 ID만 담고 실제 payload는 파일, Redis, object storage, shared memory 같은 별도 경로에 두는 편이 낫다.


7. 실패 신호로 배우는 syscall 경계

섹션 제목: “7. 실패 신호로 배우는 syscall 경계”

운영 런북을 길게 외우기보다 “이 증상이 어떤 경계가 깨진 신호인가”를 먼저 잡는다.

실패 신호의미먼저 확인할 것코드/설계 원칙
EMFILE: too many open files프로세스 fd 한도 초과 또는 fd 누수ulimit -n, /proc/<pid>/fd 수 추세파일·소켓은 finally/close handler로 닫는다
EPIPE 또는 SIGPIPE상대가 닫은 pipe/socket에 쓰기 시도socket error 이벤트, peer disconnect 로그쓰기 전 연결 상태를 가정하지 말고 에러를 처리한다
EINTRsyscall이 시그널 때문에 중간에 깨어남네이티브 코드의 retry 처리, SIGCHLD 폭주 여부C/C++에서는 errno == EINTR이면 재시도 루프를 둔다
epoll_wait()에서 깨어나지 않는 hangET 모드에서 버퍼를 끝까지 비우지 않았을 수 있음read()EAGAIN까지 반복되는지nonblocking fd + EAGAIN까지 drain
특정 syscall이 수 초 걸림파일, DNS, 네트워크 연결이 커널 경계에서 블로킹됨strace -T의 느린 connect, read, openat애플리케이션 timeout과 backpressure를 둔다

EMFILE을 볼 때는 한도를 두 층으로 나눠 본다. ulimit -Sn은 현재 프로세스가 열 수 있는 fd soft limit이고, /proc/sys/fs/file-nr은 시스템 전체 file table 사용량을 보는 단서다. 한 프로세스만 새 소켓을 못 열면 전자, 서버 전체에서 파일 열기가 흔들리면 후자까지 같이 본다.

느린 syscall은 strace -T의 시간 표기가 직관적이다.

connect(42, ...) = -1 ETIMEDOUT (Connection timed out) <5.003456>

이 한 줄은 JavaScript 함수가 느린 것이 아니라 커널 경계의 connect()가 약 5초 동안 블로킹됐음을 보여준다.

SIGPIPE는 끊긴 pipe나 socket에 쓰려고 할 때 커널이 보내는 신호다. C 프로그램에서는 기본 동작이 프로세스 종료일 수 있고, Node.js에서는 보통 EPIPE 에러로 관찰한다.

socket.on("error", (err) => {
if (err.code === "EPIPE" || err.code === "ECONNRESET") {
socket.destroy();
return;
}
throw err;
});

핵심은 “write가 성공할 것”이라고 가정하지 않는 것이다. 네트워크나 IPC의 반대편은 언제든 먼저 닫을 수 있다.

EINTR은 syscall이 시그널 처리 때문에 중간에 중단되었음을 뜻한다. Node.js의 표준 fs, net, http API는 libuv가 대부분 처리하므로 자주 보이지 않는다. 그러나 N-API 애드온, C/C++ 네이티브 코드, 외부 C 라이브러리 바인딩에서는 직접 처리해야 한다.

ssize_t n;
do {
n = read(fd, buf, sizeof(buf));
} while (n < 0 && errno == EINTR);
if (n < 0) {
return -1;
}
process(buf, n);

EINTR 자체가 읽은 바이트를 삼키는 것은 아니다. 문제는 호출 측이 재시도와 부분 처리 상태를 관리하지 않고 “작업이 끝났다”고 상위 로직을 진행시키는 경우다. 그러면 커널 버퍼에는 데이터가 남아 있는데 애플리케이션 관점에서는 데이터 일부가 사라진 것처럼 보이는 silent partial read가 생긴다.

출처: EINTR — Android Bionic docs, When to Check for EINTR — linuxvox


8. 경계의 고급 주제 — io_uring, eBPF, seccomp

섹션 제목: “8. 경계의 고급 주제 — io_uring, eBPF, seccomp”

이 세 주제는 syscall과 interrupt의 확장 지점이지만, 이 문서 수준에서는 경계만 알면 충분하다.

주제한 줄 이해언제 다시 깊게 볼까
io_uring유저 공간과 커널이 submission/completion ring을 공유해 I/O syscall 횟수를 줄이는 방식고성능 네트워크/스토리지 서버에서 epoll 한계가 측정됐을 때
eBPF커널 안에서 안전하게 작은 프로그램을 실행해 관측·필터링하는 기술strace 오버헤드 없이 프로덕션 syscall/네트워크를 관찰해야 할 때
seccomp프로세스가 호출할 수 있는 syscall을 제한하는 Linux 보안 필터컨테이너가 특정 syscall을 못 써서 기능이 막히거나 보안 정책을 설계할 때

strace는 모든 syscall마다 프로세스를 멈추는 ptrace 기반 도구라 프로덕션 장시간 관찰에는 부담이 크다. eBPF 도구(opensnoop, tcpconnect, runqlat)는 같은 질문을 더 낮은 오버헤드로 볼 수 있다. 반대로 seccomp는 관찰 도구가 아니라 차단 도구다. Docker와 Kubernetes의 기본 seccomp 프로파일은 ptrace, mount, kexec_load 같은 위험 syscall을 제한할 수 있다.

최신 기술을 먼저 도입하는 것보다 중요한 기준은 측정이다. io_uring이 syscall 횟수와 p99 latency를 실제로 낮추는지, 커널 버전과 seccomp 정책이 허용하는지, 보안 패치 추적 비용을 감당할 수 있는지 확인해야 한다.

도입 판단은 최소한 아래 질문에 답할 수 있을 때만 한다.

질문의미
syscall/op가 실제로 줄었는가?요청 하나를 처리하는 경계 통과 횟수가 줄었는지 본다.
p99 latency가 낮아졌는가?평균이 아니라 꼬리 지연이 개선됐는지 본다.
커널 버전과 seccomp가 허용하는가?기능이 있어도 컨테이너 보안 정책이 막을 수 있다.
장애 대응 비용을 감당할 수 있는가?고급 경계 기술은 디버깅과 보안 패치 추적 비용도 늘린다.

epoll은 준비된 fd를 알려주고 애플리케이션이 다시 read()/write() syscall을 호출하는 모델이다. 반면 io_uring은 submission queue에 I/O 요청을 넣고 completion queue에서 완료 결과를 읽는 모델이라, “준비 알림”과 “요청/완료 큐”의 차이를 구분해야 한다.

출처: Linux io_uring man page, Docker’s default seccomp profile — Docker 공식 문서, BPF Performance Tools — Brendan Gregg


아래 실습은 개념 관찰용 최소 예시다. 운영 절차로 외우기보다 출력이 어떤 커널 경계를 보여주는지 확인한다.

실습 1: Node.js 파일 읽기의 syscall 보기

섹션 제목: “실습 1: Node.js 파일 읽기의 syscall 보기”
Terminal window
cat > /tmp/test-syscall.js << 'EOF'
const fs = require('fs');
fs.readFileSync('/etc/hostname');
EOF
strace -e trace=openat,read,close node /tmp/test-syscall.js 2>&1 | grep -E "openat|read\\(|close\\("

관찰 포인트: JS 코드는 파일 경로를 넘겼지만, 커널 경계에서는 openat으로 fd를 얻고 read로 바이트를 읽는다.

Terminal window
node -e "require('http').createServer().listen(3000)" &
NODE_PID=$!
ls -la /proc/$NODE_PID/fd
ls /proc/$NODE_PID/fd | wc -l

관찰 포인트: anon_inode:[eventpoll]은 epoll 인스턴스 fd이고, socket:[...]은 서버 소켓 또는 연결 소켓이다.

Terminal window
strace -p $NODE_PID -e trace=epoll_wait,epoll_ctl,accept 2>&1 | head -20
kill $NODE_PID

관찰 포인트: 실습 2에서 띄운 Node 서버를 종료하기 전에 붙어야 한다. Node.js 이벤트 루프가 바쁠 때도 네트워크 이벤트 대기는 epoll_wait()에서 보인다.

Terminal window
cat /proc/self/status | grep Seccomp

관찰 포인트: Seccomp: 2는 filter 모드가 적용되어 있음을 뜻한다. 컨테이너에서 특정 syscall이 막히는 문제를 볼 때 첫 단서가 된다.


System Call & Interrupt 복습 체크

  • syscall이 일반 함수 호출이 아니라 유저 모드에서 커널 모드로 넘어가는 권한 경계임을 설명할 수 있다.
  • CPU Ring, 유저 모드, 커널 모드가 왜 프로세스 격리와 보안의 기반인지 설명할 수 있다.
  • fd가 파일뿐 아니라 socket, pipe, epoll 인스턴스까지 가리키는 정수 핸들임을 이해한다.
  • `fs.readFile()`이 내부적으로 `open`/`read`/`close` syscall과 fd를 거친다고 설명할 수 있다.
  • 네트워크 패킷이 인터럽트, 커널 소켓 버퍼, epoll, libuv를 거쳐 Node.js 콜백으로 이어지는 흐름을 말할 수 있다.
  • epoll이 데이터를 대신 읽는 것이 아니라 준비된 fd를 알려주는 메커니즘임을 이해한다.
  • Pipe, Unix Domain Socket, TCP Socket, Shared Memory의 선택 기준을 간단히 비교할 수 있다.
  • `EMFILE`, `EPIPE`/`SIGPIPE`, `EINTR`, epoll ET hang이 각각 어떤 경계 실패 신호인지 설명할 수 있다.
  • io_uring, eBPF, seccomp를 고급/경계 주제로 분리해서 과도하게 일반화하지 않는다.

키워드설명
System Call / syscall유저 프로그램이 커널 기능을 요청하는 공식 인터페이스
Ring 0 / Ring 3CPU 보호 레벨. Linux에서는 주로 커널 모드와 유저 모드 구분
Mode Switchsyscall 등으로 유저 모드와 커널 모드가 전환되는 과정
InterruptCPU가 비동기 이벤트나 예외를 처리하도록 깨우는 신호
ISRInterrupt Service Routine. 인터럽트 처리 함수
fd열린 파일, 소켓, 파이프, epoll 인스턴스를 가리키는 정수 핸들
epollLinux의 대규모 fd readiness 감시 메커니즘
IPC격리된 프로세스 사이의 통신 방식
EINTRsyscall이 시그널 때문에 중단되었음을 나타내는 errno
SIGPIPE / EPIPE닫힌 pipe/socket에 쓰려고 했음을 나타내는 신호/오류
seccomp프로세스가 호출할 수 있는 syscall을 제한하는 보안 필터
eBPF커널 내부 이벤트를 낮은 오버헤드로 관측·처리하는 기술


유저 프로그램은 syscall로만 커널 자원에 접근하고, 커널은 fd로 열린 자원을 추적하며, 하드웨어 이벤트는 interrupt로 커널을 깨우고 epoll을 통해 Node.js 이벤트 루프까지 전달된다. IPC는 격리된 프로세스 사이를 연결하는 선택지이고, EMFILE·EPIPE·EINTR 같은 에러는 이 경계 중 어디가 깨졌는지 알려주는 신호다.