콘텐츠로 이동

System Call & Interrupt

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

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

시스템 콜(System Call)은 유저 프로그램이 운영체제 커널의 기능(파일, 네트워크, 메모리)을 안전하게 요청하는 공식 인터페이스이며, 인터럽트(Interrupt)는 CPU가 현재 작업을 멈추고 긴급한 이벤트를 처리하도록 하는 신호 메커니즘이다.


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

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

실모드(Real Mode) OS의 구조적 붕괴 — MS-DOS가 풀지 못한 문제

Intel 8086 기반 MS-DOS(1981)는 단일 권한 모드(real mode) 만 지원했다. 모든 프로그램이 OS와 동일한 권한으로 실행되었기 때문에 아래 문제가 구조적으로 해결 불가능했다.

  • 메모리 격리 부재: 1MB 주소 공간(앱 가용 영역 640KB)을 모든 프로그램이 직접 쓸 수 있어, 버그 있는 앱 하나가 BIOS·커널 데이터를 덮어쓰면 시스템 전체가 다운
  • 권한 경계 부재: cli(인터럽트 끄기), 디스크 컨트롤러 직접 제어 같은 위험 명령을 일반 앱이 호출 가능 — 안전한 멀티태스킹 자체가 성립 불가능
  • 폴링 강제: 키보드·NIC 입력을 CPU가 루프로 직접 감시해야 했음 → CPU가 다른 작업을 동시 처리할 수 없음

1982년 Intel 80286 protected mode → 1985년 80386이 해결한 메커니즘

  1. CPU Ring 분리(하드웨어 강제): Ring 0(커널)과 Ring 3(앱)이 CPU 명령어 수준에서 분리. 앱이 권한 명령을 직접 실행하면 General Protection Fault 발생 — 소프트웨어가 아닌 하드웨어가 경계를 강제하는 점이 본질
  2. syscall = 유일한 합법 Ring 통과 경로: 앱은 int 0x80(이후 syscall/sysenter 명령어)으로만 Ring 0 진입. 커널이 인자를 검증한 뒤 실행 — “검증되지 않은 경계 통과” 자체가 불가능해짐
  3. 인터럽트 = 하드웨어가 CPU를 깨우는 비동기 채널: NIC가 패킷을 받을 때 폴링 대신 인터럽트 신호로 CPU에 알림 → CPU가 다른 작업 중일 때도 즉시 반응 가능 (현대 epoll·io_uring 모두 이 채널 위에서 동작)

선행 prerequisite 토픽이 이 메커니즘 없이 깨지는 지점

  • [[process-thread]]의 프로세스 격리는 Ring 분리 + 가상 메모리 위에서만 성립. Ring 0/3 구분이 없으면 “한 프로세스가 다른 프로세스 메모리를 못 본다”는 가정 자체가 거짓이 됨
  • [[linux-basics]]의 read/write/ps는 모두 시스템 콜 wrapper. syscall이 없으면 “유저 명령으로 커널 자원 조작”이 불가능 — 쉘 자체가 존재 불가
  • [[nodejs-event-loop]]의 비동기 I/O는 NIC 인터럽트 → epoll → libuv 콜백 체인 위에서만 작동. 인터럽트 메커니즘이 사라지면 모든 I/O가 폴링으로 회귀하여 단일 코어로 동시 1만 connection을 다루는 패턴이 깨짐

📖 출처: Protected mode — Wikipedia (80286: 1982, 80386: 1985, Ring 0~3 도입)


  • Node.js가 파일을 읽거나 네트워크 통신을 할 때 반드시 시스템 콜을 사용한다. fs.readFile(), http.get() 같은 API들은 내부적으로 open, read, socket 시스템 콜을 호출한다.
  • 성능 문제의 근원을 이해할 수 있다. 시스템 콜은 유저 모드 ↔ 커널 모드 전환 비용이 발생한다. 너무 자주 호출하면 성능 병목이 된다.
  • Node.js의 비동기 I/O 동작 원리를 이해하는 핵심이다. epoll(Linux의 I/O 다중화 메커니즘)은 인터럽트를 기반으로 동작하며, 이것이 Node.js Event Loop의 근간이다.
  • 백엔드 엔지니어링의 공통 기반이다. Docker 컨테이너, AWS Lambda, Nginx 등 모든 시스템 소프트웨어가 시스템 콜 위에서 동작한다.
  • Too many open files 같은 운영 장애를 이해하고 해결하려면 파일 디스크립터(fd)와 시스템 콜의 관계를 알아야 한다.

레스토랑에서 손님(유저 프로그램)은 직접 주방(커널/하드웨어)에 들어갈 수 없다. 위생 규정과 안전을 위해 반드시 웨이터(시스템 콜)를 통해 주문을 넣어야 한다. 웨이터는 손님의 요청을 받아 주방에 전달하고, 결과(음식)를 손님에게 가져다 준다.

  • 손님(유저 프로그램): 직접 하드웨어에 접근 불가
  • 웨이터(시스템 콜): 커널에 공식적으로 요청하는 인터페이스
  • 주방(커널): 하드웨어를 직접 제어하는 특권 영역
  • 메뉴판(시스템 콜 테이블): 커널이 허용하는 요청 목록

CPU는 두 가지 실행 모드를 가진다.

구분User Mode (Ring 3)Kernel Mode (Ring 0)
권한제한적 (하드웨어 직접 접근 불가)무제한 (모든 명령 실행 가능)
동작일반 애플리케이션 코드 실행OS 커널, 드라이버 실행
충돌 시해당 프로세스만 종료시스템 전체 크래시 (커널 패닉)
예시Node.js, Python, ChromeLinux 커널, 디바이스 드라이버

시스템 콜 호출 흐름:

유저 프로그램 (Ring 3)
↓ syscall 명령어 실행 (소프트웨어 인터럽트)
CPU 모드 전환: Ring 3 → Ring 0
↓ 시스템 콜 번호로 커널 함수 실행
커널 (Ring 0): 요청 처리 (파일 읽기, 네트워크 등)
↓ 결과 반환, 모드 복귀
유저 프로그램 (Ring 3): 결과 수신

왜 Ring 0/3만 쓰나? x86 아키텍처는 Ring 0~3까지 4단계를 지원하지만, Linux는 단순성을 위해 Ring 0(커널)과 Ring 3(유저)만 사용한다. Ring 1, 2는 과거 OS/2 같은 시스템에서 디바이스 드라이버용으로 쓰였으나 현대 OS에서는 사용하지 않는다.

모드 전환 비용: 시스템 콜 1번 호출은 수백~수천 나노초의 오버헤드가 발생한다. CPU 레지스터 저장, 컨텍스트 전환, 커널 스택 전환 등의 작업이 필요하기 때문이다. 이 때문에 libuv는 I/O 작업을 배치로 처리한다.

왜 시스템 콜이 이렇게 비싼가? 단순히 함수 호출과 다르게 시스템 콜은 다음 단계를 거친다:

  1. CPU 파이프라인 플러시 — 현재 실행 중인 명령어 최적화(분기 예측, 명령어 재배치)가 초기화된다
  2. 보안 검증 — Meltdown/Spectre 취약점 대응을 위한 KPTI(Kernel Page Table Isolation) 패치가 적용된 커널에서는 유저 모드와 커널 모드의 페이지 테이블이 완전히 분리되어 모드 전환 시 TLB 일부가 무효화된다
  3. 스택 전환 — 유저 스택에서 커널 스택으로 전환한다

이 비용을 줄이기 위해 Linux는 vDSO(virtual Dynamic Shared Object) 를 제공한다. gettimeofday(), clock_gettime() 같은 자주 호출되고 보안상 위험이 없는 시스템 콜을 유저 공간에 매핑하여, 실제 커널 모드 전환 없이 실행할 수 있다. vDSO를 통한 호출은 일반 시스템 콜 대비 10배 이상 빠르다.

Terminal window
# vDSO가 프로세스에 매핑되어 있는지 확인
cat /proc/self/maps | grep vdso
# 7fffa5ffe000-7fffa6000000 r-xp 00000000 00:00 0 [vdso]

📖 더 보기: What Makes System Calls Expensive: A Linux Internals Deep Dive — codingconfessions.com — 시스템 콜의 숨겨진 비용을 파이프라인, TLB, KPTI 관점에서 분석

io_uring — 시스템 콜 오버헤드의 근본 해결: Linux 5.1부터 도입된 io_uring은 유저 공간과 커널이 링 버퍼(ring buffer) 를 공유하여, 시스템 콜 없이 I/O 요청을 제출하고 결과를 수신할 수 있다. epoll은 “fd가 준비되었음”을 알려주면 유저가 다시 read()/write() 시스템 콜을 호출해야 하지만, io_uring은 커널이 I/O 자체를 완료한 후 결과만 전달한다. Node.js는 20.3.0부터 내부적으로 io_uring을 활용하기 시작했다.

epoll 방식: io_uring 방식:
1. epoll_wait() → 시스콜 1. SQ에 요청 쓰기 → 시스콜 없음
2. read() → 시스콜 2. 커널이 I/O 수행
3. 데이터 처리 3. CQ에서 결과 읽기 → 시스콜 없음
epoll: 2번의 모드 전환 io_uring: 0번의 모드 전환 (배치 시)

📖 더 보기: Optimizing Linux System Calls: Cutting Costs with io_uring and eBPF — WebProNews — io_uring과 eBPF로 시스템 콜 비용을 최소화하는 최신 기법

io_uring 도입 시 주의 사항: 성능 이점이 크지만 운영 환경에서는 다음을 반드시 확인해야 한다.

항목상세
최소 커널 버전Linux 5.1 (기본 기능). 고급 기능(multishot accept 등)은 5.19+, IORING_OP_SEND_ZC(Zero-Copy)는 6.0+ 필요
보안 취약점 이력CVE-2024-0582 (use-after-free, 커널 6.4~6.6.5 영향, LPE 가능). Google은 2022년 버그바운티에서 io_uring 관련 익스플로잇이 전체 커널 제출의 60%를 차지한다고 발표
비활성화 사례Google Production Servers, ChromeOS, Android에서 io_uring을 기본 비활성화. 컨테이너 환경에서는 Seccomp 프로파일로 제한
CQ 오버플로우Completion Queue가 가득 차면 이벤트가 드롭된다. io_uring_cq_has_overflow() 로 주기적 확인 필요
Terminal window
# 커널 버전 확인
uname -r # 5.1 미만이면 io_uring 사용 불가
# 컨테이너 환경에서 io_uring 허용 여부 확인
cat /proc/self/status | grep Seccomp
# Seccomp: 2 (filter 모드) → io_uring syscall이 차단될 수 있음

📖 출처: Google Limiting IO_uring Use Due To Security Vulnerabilities — Phoronix, CVE-2024-0582 상세 — oss-security

언제 io_uring 대신 epoll을 유지해야 하는가 — “최신이라 빠르다”는 잘못된 결정 기준이다. 워크로드 패턴에 따라 결과가 역전된다.

  • 요청-응답 1:1(ping-pong) 워크로드 — io_uring 채택: API 게이트웨이·gRPC 마이크로서비스처럼 짧은 메시지 + 다수 동시 connection에서 batching과 registered buffers 효과가 크다
  • streaming(지속 read/write, 큰 chunk) 워크로드 — epoll 유지 권장: liburing 트래커에 io_uring이 epoll보다 느린 사례가 보고됨(issue #189). 컨텍스트 전환 절감 효과보다 SQ/CQ 관리 비용이 더 큰 구간
  • registered buffers + batching 미사용 도입 — 보류: “epoll 자리에 그냥 io_uring 박기”는 ~10% 미만 개선에 그치는 경우가 흔함. 코드 복잡도와 보안 패치 추종 비용 대비 손해
  • 다양한 커널 버전(5.1 미만 포함) 지원 필요 — epoll만 가능

도입 결정 전에 측정해야 하는 지표 3개:

Terminal window
# 1. 시스콜/op 변화 — io_uring 도입 후 1/5 이하로 안 떨어지면 batching 미작동
strace -c -p $(pgrep node) 2>&1 | tail -5
# 2. p99 latency — 평균이 아닌 꼬리 분포가 실제로 개선되는지
wrk2 -t4 -c100 -d30s -R10000 http://localhost:3000/ --latency
# 3. 커널 버전 최소치
uname -r # 5.1 미만이면 자동 탈락

다음 단계: 위 3개 지표 모두 만족하면 hot path(예: HTTP accept→read→write)부터 io_uring으로 전환하고, 그 외(타이머, signal handling)는 기존 epoll 유지하는 하이브리드 도입이 안전한 출발점이다.

📖 출처: io_uring vs epoll — Alibaba Cloud, Event-Driven Services: epoll vs io_uring — beefed.ai

시스템 콜번호 (x86_64)기능
read0파일/소켓에서 데이터 읽기
write1파일/소켓에 데이터 쓰기
open2파일 열기 → fd 반환
close3파일 디스크립터 닫기
socket41소켓 생성
accept43TCP 연결 수락
fork57프로세스 복제
exec59새 프로그램 실행
mmap9메모리 매핑
epoll_create213epoll 인스턴스 생성

코드 예시: Node.js fs.readFile() 내부 시스템 콜

섹션 제목: “코드 예시: Node.js fs.readFile() 내부 시스템 콜”
// Node.js 코드
const fs = require("fs");
fs.readFile("/etc/hostname", "utf8", (err, data) => {
if (err) throw err;
console.log("호스트명:", data.trim());
});
console.log("readFile 호출 직후 (비동기이므로 먼저 실행됨)");

예상 출력:

readFile 호출 직후 (비동기이므로 먼저 실행됨)
호스트명: my-server

내부 실행 흐름:

fs.readFile() 호출
Node.js C++ 바인딩 → libuv에 작업 위임
libuv 스레드 풀(기본 4개 스레드) 중 하나가 작업 수행
↓ [시스템 콜 발생]
open('/etc/hostname', O_RDONLY) → fd=5 반환
read(5, buffer, 4096) → 읽은 바이트 수 반환
close(5) → fd 해제
작업 완료 → Event Loop 큐에 콜백 등록
메인 스레드가 콜백 실행 → 결과 전달
// read_file.c - 시스템 콜 직접 사용 (교육용)
#include <fcntl.h> // open()
#include <unistd.h> // read(), close(), write()
int main() {
char buf[256];
// 시스템 콜: open
int fd = open("/etc/hostname", O_RDONLY);
if (fd < 0) return 1;
// 시스템 콜: read
int n = read(fd, buf, sizeof(buf) - 1);
buf[n] = '\0';
// 시스템 콜: write (stdout = fd 1)
write(1, "호스트명: ", 14);
write(1, buf, n);
// 시스템 콜: close
close(fd);
return 0;
}

컴파일 및 실행:

Terminal window
gcc -o read_file read_file.c && ./read_file

예상 출력:

호스트명: my-server

공항에서 일반 승객(유저 모드)은 보안 검색대(시스템 콜)를 통과해야 관제탑 영역(커널 모드)에 접근할 수 있다. 직접 관제탑에 들어가면 항공 안전(시스템 안정성)이 위험해진다.

┌─────────────────────────────────────┐
│ Ring 3: 유저 모드 │ ← Node.js, Python, Chrome 실행
│ (User Applications) │
│ ┌─────────────────────────────┐ │
│ │ Ring 0: 커널 모드 │ │ ← Linux 커널, 디바이스 드라이버
│ │ (Kernel) │ │
│ │ - 하드웨어 직접 접근 │ │
│ │ - 모든 CPU 명령 실행 가능 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
↑↓ 시스템 콜로만 이동 가능

왜 분리하는가?

만약 유저 프로그램이 하드웨어에 직접 접근할 수 있다면:

  • 악성 프로그램이 다른 프로세스의 메모리를 읽을 수 있다 (보안 취약점)
  • 버그가 있는 프로그램 하나가 전체 시스템을 다운시킬 수 있다
  • 여러 프로그램이 동시에 디스크에 쓰면 데이터가 손상된다
Terminal window
# strace로 Node.js가 실제로 어떤 시스템 콜을 호출하는지 확인
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
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.000518 89 12 total

require('fs').readFileSync() 한 번 호출에 89개의 시스템 콜이 발생한다. 대부분은 Node.js 초기화(모듈 로드)에 사용된다.


응급실 의사(CPU)가 일반 진료(현재 작업)를 하고 있다가 응급 전화(인터럽트)가 오면, 하던 일을 멈추고 응급 처치(ISR: 인터럽트 서비스 루틴)를 먼저 처리한 뒤, 원래 진료로 복귀한다. 응급 전화는 환자가 직접 걸 수도 있고(하드웨어 인터럽트), 병원 내부 알람(소프트웨어 인터럽트)일 수도 있다.

구분하드웨어 인터럽트소프트웨어 인터럽트
발생 주체외부 장치 (키보드, NIC, 디스크)프로그램 내부 (시스템 콜, 예외)
비동기성언제든 발생 가능 (비동기)특정 명령 실행 시 발생 (동기)
예시키 입력, 네트워크 패킷 도착, 타이머int 0x80, syscall, 0으로 나누기
CPU 예측예측 불가예측 가능
1. 인터럽트 신호 발생 (예: 네트워크 패킷 도착)
2. CPU: 현재 실행 중인 명령어 완료 후 중단
3. 현재 레지스터 상태를 스택에 저장 (컨텍스트 저장)
4. IDT(Interrupt Descriptor Table)에서 ISR 주소 조회
5. ISR(Interrupt Service Routine) 실행
- NIC 드라이버: 패킷을 커널 버퍼에 복사
- 키보드 드라이버: 입력값을 버퍼에 저장
6. 저장했던 레지스터 상태 복원
7. 중단된 지점부터 실행 재개

실무 연결: 네트워크 패킷 → Node.js 콜백까지

섹션 제목: “실무 연결: 네트워크 패킷 → Node.js 콜백까지”
[네트워크 카드(NIC)] 패킷 수신
↓ 하드웨어 인터럽트 발생
[CPU] 현재 작업 중단, ISR 실행
↓ NIC 드라이버: 패킷을 커널 소켓 버퍼에 복사
[Linux epoll] 해당 소켓 fd에 이벤트 발생 감지
↓ epoll_wait() 반환
[libuv Event Loop] 이벤트 큐에 등록
↓ 메인 스레드에서 처리
[Node.js 콜백] req.on('data', callback) 실행

epoll 내부 구조: epoll은 단순한 API가 아니라 커널 내부에 복잡한 자료구조를 가진다.

  • Red-Black Tree: epoll_ctl()로 등록한 모든 fd를 관리. fd 추가/삭제가 O(log n)
  • Ready List: I/O가 준비된 fd를 연결 리스트로 유지. epoll_wait() 호출 시 이 리스트만 반환하므로 O(1)
  • Wait Queue: epoll_wait()에서 블로킹된 프로세스를 관리

이 구조 덕분에 epoll은 수만 개의 fd를 모니터링하면서도 실제로 이벤트가 발생한 fd만 효율적으로 반환한다. 반면 select()/poll()은 매번 모든 fd를 순회(O(n))해야 하므로 fd가 많아지면 급격히 느려진다.

epoll ET(Edge-Triggered) 모드 silent failure: EPOLLET 플래그를 사용하면 fd 상태가 변할 때만 이벤트가 한 번 발생한다. 이 이벤트 기회를 놓치면 커널이 다시 알려주지 않아 데이터가 버퍼에 있어도 epoll_wait()이 영원히 블로킹되는 silent hang 이 발생한다.

# ET 모드 silent failure 시나리오
1. 파이프에 2KB 데이터 수신 → epoll_wait() 이벤트 발생 (1회)
2. read()로 1KB만 읽고 루프 탈출 (버퍼에 1KB 잔류)
3. 다시 epoll_wait() 호출
→ 새로운 "변화"가 없으므로 이벤트 미발생 → 무한 블로킹
→ 남은 1KB 데이터는 읽히지 않음 (silent data isolation)

ET 모드 필수 패턴: 반드시 아래 두 조건을 지켜야 한다.

  1. fd는 O_NONBLOCK(논블로킹)으로 설정
  2. read()/write()EAGAIN을 반환할 때까지 루프로 읽기
Terminal window
# strace로 ET silent failure 진단
# EAGAIN 없이 epoll_wait으로 돌아가는 패턴 확인
strace -p <PID> -e trace=epoll_wait,read 2>&1 | grep -A2 "epoll_wait"
# 정상 패턴 (EAGAIN까지 읽음):
# read(5, ...) = 1024
# read(5, ...) = 1024
# read(5, ...) = -1 EAGAIN ← 여기서 루프 탈출
# epoll_wait(...) ← 그 다음 대기
# 비정상 패턴 (EAGAIN 없이 epoll_wait 복귀):
# read(5, ...) = 1024 ← 일부만 읽고
# epoll_wait(...) ← 바로 대기 → silent hang 위험

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

📖 더 보기: Mastering epoll: The Engine Behind High-Performance Linux Networking — Medium — epoll의 내부 자료구조와 동작 원리를 상세히 설명

코드 예시: epoll 기반 I/O 이벤트 (Node.js 관점)

섹션 제목: “코드 예시: epoll 기반 I/O 이벤트 (Node.js 관점)”
// Node.js HTTP 서버 - 내부적으로 epoll 사용
const http = require("http");
const server = http.createServer((req, res) => {
// 이 콜백은 다음 경로로 실행됨:
// 네트워크 패킷 도착 → 하드웨어 인터럽트 → NIC 드라이버
// → 커널 소켓 버퍼 → epoll 이벤트 → libuv → 이 콜백
res.writeHead(200);
res.end("Hello World\n");
});
server.listen(3000, () => {
console.log("서버 시작: http://localhost:3000");
});

예상 출력:

서버 시작: http://localhost:3000
Terminal window
# 다른 터미널에서 확인
curl http://localhost:3000
# 출력: Hello World
Terminal window
# epoll 시스템 콜 확인 (서버 실행 중)
strace -p $(pgrep node) -e epoll_wait,epoll_ctl 2>&1 | head -20

예상 출력:

epoll_wait(5, [{events=EPOLLIN, data={u32=16, u64=16}}], 1024, 0) = 1
epoll_ctl(5, EPOLL_CTL_ADD, 7, {events=EPOLLIN|EPOLLOUT|...}) = 0
epoll_wait(5, [], 1024, 0) = 0

3.4 IPC (Inter-Process Communication, 프로세스 간 통신)

섹션 제목: “3.4 IPC (Inter-Process Communication, 프로세스 간 통신)”

각 부서(프로세스)는 독립된 사무실(독립 메모리 공간)에서 일하며 서로 직접 접근할 수 없다. 소통 방법은 여러 가지다:

  • Pipe: 사내 파이프(문서함) — 한 방향으로만 전달 가능
  • Named Pipe: 공용 게시판 — 이름이 있어서 누구든 접근 가능
  • Socket: 전화 — 네트워크 너머 다른 건물과도 통신
  • Shared Memory: 공용 화이트보드 — 가장 빠르지만 동시 수정 주의 필요
  • Message Queue: 메시지함 — 순서대로 처리, 비동기 가능
IPC 방식방향속도특징사용 예시
Pipe단방향빠름부모↔자식 프로세스 전용child_process.spawn()
Named Pipe (FIFO)단방향빠름임의 프로세스 간 통신 가능쉘 파이프 (|)
Unix Domain Socket양방향매우 빠름동일 호스트 내 소켓 통신Docker API, Nginx
TCP Socket양방향보통네트워크 너머 통신HTTP, gRPC
Shared Memory양방향가장 빠름직접 메모리 공유, 동기화 필요Redis (같은 호스트)
Message Queue비동기빠름OS 레벨 큐 (mq_send)시스템 로그, RabbitMQ 아이디어 기반

실측 벤치마크(ipc-bench, Baeldung Linux IPC 비교)를 기반으로 한 선택 임계값이다.

판단 기준임계값권장 메커니즘
메시지 크기 < 4KB소형 메시지Named Pipe (최고 318 Mbps @ 100B)
메시지 크기 ≥ 64KB대형 메시지/스트림Unix Domain Socket (41,334 Mbps @ 1MB) 또는 Shared Memory
지연 요구 < 10μs극저지연Shared Memory + spinlock (복사 없음, 커널 개입 0)
프로세스 수 ≥ N:M (다대다)팬아웃 구조Message Queue 또는 Unix Socket (Pipe는 1:1 전용)
네트워크 경계 필요다른 호스트 간TCP Socket (유일한 선택)
같은 호스트, 서로 다른 프로세스로컬 IPCUnix Domain Socket (TCP 대비 20~40% 레이턴시 절감)

결정 흐름:

다른 호스트?
→ YES: TCP Socket
→ NO: 메시지 크기 ≥ 64KB이고 극저지연 필요?
→ YES: Shared Memory (동기화 구현 필수)
→ NO: 단방향 스트림(부모↔자식)?
→ YES: Pipe
→ NO: Unix Domain Socket (기본 선택)

📖 출처: IPC Performance Comparison — Baeldung on Linux, ipc-bench GitHub (goldsborough)

cluster-ipc.js
const cluster = require("cluster");
if (cluster.isPrimary) {
// 마스터 프로세스
const worker = cluster.fork();
// 워커에게 메시지 전송 (내부적으로 Pipe 사용)
worker.send({ type: "task", data: "작업 데이터" });
// 워커로부터 메시지 수신
worker.on("message", (msg) => {
console.log("[마스터] 워커 응답:", msg);
});
} else {
// 워커 프로세스
process.on("message", (msg) => {
console.log("[워커] 마스터 메시지 수신:", msg);
// 결과 반환
process.send({ type: "result", data: "처리 완료" });
});
}

예상 출력:

[워커] 마스터 메시지 수신: { type: 'task', data: '작업 데이터' }
[마스터] 워커 응답: { type: 'result', data: '처리 완료' }
unix-socket-server.js
const net = require("net");
const fs = require("fs");
const SOCKET_PATH = "/tmp/my-app.sock";
// 기존 소켓 파일 제거
if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
const server = net.createServer((socket) => {
socket.on("data", (data) => {
console.log("수신:", data.toString());
socket.write("응답: " + data.toString());
});
});
server.listen(SOCKET_PATH, () => {
console.log(`Unix Socket 서버 시작: ${SOCKET_PATH}`);
});
Terminal window
# 클라이언트로 테스트
echo "안녕하세요" | nc -U /tmp/my-app.sock

예상 출력:

Unix Socket 서버 시작: /tmp/my-app.sock
수신: 안녕하세요
응답: 안녕하세요

Unix Domain Socket은 TCP Socket보다 20~40% 빠르다. 네트워크 스택을 거치지 않고 커널 내에서 직접 데이터를 전달하기 때문이다.

왜 Unix Domain Socket이 TCP보다 빠른가? TCP Socket은 같은 호스트 내 통신이라도 전체 네트워크 스택을 거친다: 소켓 API → TCP 계층(시퀀스 번호, ACK, 체크섬 계산) → IP 계층(라우팅 테이블 조회) → 루프백 인터페이스. Unix Domain Socket은 이 모든 과정을 건너뛰고 커널 내부의 버퍼 간 직접 데이터 복사만 수행한다. 같은 서버에서 Redis, PostgreSQL에 연결할 때 Unix Socket을 사용하면 레이턴시가 눈에 띄게 줄어든다.

Terminal window
# 터미널 1: FIFO 생성 및 읽기
mkfifo /tmp/myfifo
cat /tmp/myfifo
# 터미널 2: 데이터 쓰기
echo "Hello from process 2" > /tmp/myfifo

예상 출력 (터미널 1):

Hello from process 2

3.99 새 권한/통신 경계 시스템 만났을 때 — 전이 분석 체크리스트

섹션 제목: “3.99 새 권한/통신 경계 시스템 만났을 때 — 전이 분석 체크리스트”

System call·interrupt의 핵심 원리(권한 분리, 비동기 알림, 컨텍스트 스위치)는 다른 영역에서도 반복된다.

원리OS syscall/interruptRDMA / GPU command queue메시지 브로커 (Kafka/RabbitMQ)Browser ↔ Worker
권한 경계 (Ring 분리)Ring 0 ↔ Ring 3, syscall ABIVerbs API → Kernel bypass(직접 NIC 접근)Producer/Consumer ↔ Broker, AuthN/AuthZpostMessage 경계, structured clone
비동기 알림인터럽트, signalCompletion Queue (CQ) pollingConsumer offset commit, ACKWorker→Main onmessage 이벤트
컨텍스트 스위치 비용1~5μs (syscall) + cache flushDMA로 우회 → 거의 0 costProducer batching으로 amortizepostMessage copy + serialization
버퍼링·큐잉소켓 버퍼, signal queueSend/Receive Queue (SQ/RQ)partition 큐, prefetch 버퍼postMessage 큐

Ring 분리 패턴 식별 — 4가지 진단 질문

새 시스템에서 권한 경계 또는 비동기 통신 구조를 만났을 때:

  1. 누가 권한 계층을 강제하는가? — 하드웨어(CPU Ring), 커널, 프레임워크, 또는 정책 엔진?
  2. 경계를 넘는 비용은 얼마인가? — syscall(μs), syscall bypass(거의 0), RPC(ms), 메시지 큐(ms~s)?
  3. 비동기 완료 알림 메커니즘은? — 인터럽트, 폴링, callback, Promise, Future?
  4. batch/bulk 처리로 비용을 amortize할 수 있는가? — readv/writev, io_uring, batching, pipelining?

이 4가지 질문은 새 OS API, 새 가속기(GPU/NPU/RDMA), 새 메시징 시스템, 새 IPC 메커니즘 어디에서든 동일하게 적용된다.

(참고: Linux io_uring man page, RDMA Verbs 공식 문서)

상황관련 시스템 콜/개념실무 영향
fs.readFile() 호출open, read, close파일 디스크립터 누수 주의
HTTP 서버가 요청 수신accept, epoll_wait인터럽트 → epoll → 콜백
child_process.spawn()fork, exec, pipe자식 프로세스 IPC
Docker 컨테이너 간 통신Unix Socket, TCP Socket같은 호스트면 Unix Socket이 빠름
Redis 연결TCP Socket (또는 Unix Socket)같은 호스트면 Unix Socket 권장
cluster 모듈 사용fork, Pipe, Signal멀티코어 활용
  • Lambda Cold Start: 새 컨테이너 프로세스 생성 = fork + exec 시스템 콜. Cold Start가 느린 이유 중 하나
  • ALB → EC2: 네트워크 패킷 수신 → NIC 인터럽트 → 커널 네트워크 스택 → Nginx → Node.js
  • ECS 컨테이너 간 통신: 같은 Task라면 Unix Socket, 다른 Task라면 TCP

// NestJS - 파일 업로드 처리
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// 내부적으로 발생하는 시스템 콜:
// 1. multipart 파싱 중 read() - 요청 바디 읽기
// 2. open() - 임시 파일 생성
// 3. write() - 파일 내용 저장
// 4. rename() - 최종 위치로 이동
// 5. close() - 파일 디스크립터 해제
return { filename: file.originalname, size: file.size };
}
// NestJS - 데이터베이스 쿼리 (TCP Socket → 시스템 콜)
@Injectable()
export class UserService {
constructor(private prisma: PrismaService) {}
async findUser(id: number) {
// 내부 흐름:
// 1. TCP Socket send() → PostgreSQL로 쿼리 전송
// 2. epoll_wait() → PostgreSQL 응답 대기
// 3. recv() → 응답 수신 (하드웨어 인터럽트 → epoll)
return this.prisma.user.findUnique({ where: { id } });
}
}

BackOps 엔지니어로서 실용적 포인트

섹션 제목: “BackOps 엔지니어로서 실용적 포인트”
  1. 파일 디스크립터 모니터링: 각 연결(HTTP, DB, Redis)은 fd를 소비한다. lsof -p $(pgrep node) | wc -l로 모니터링
  2. IPC 성능 최적화: 같은 서버에서 Redis 연결 시 Unix Socket 사용으로 레이턴시 단축
  3. cluster 모듈 사용 시: Worker 프로세스 간 통신은 Pipe 기반임을 인지하고 대용량 데이터 전송 시 주의

구분System CallLibrary CallAPI Call
실행 위치커널 모드유저 모드유저 모드
예시read(), write()fread(), printf()axios.get(), fs.readFile()
비용높음 (모드 전환)낮음낮음 (내부적으로 시스콜 포함)
추상화 레벨최하위 (OS 레벨)중간 (C 표준 라이브러리)최상위 (애플리케이션 레벨)

fread()는 C 표준 라이브러리 함수로, 내부적으로 read() 시스템 콜을 호출한다. fs.readFile()은 libuv를 통해 read() 시스템 콜을 호출한다.

Hardware Interrupt vs Software Interrupt vs Exception

섹션 제목: “Hardware Interrupt vs Software Interrupt vs Exception”
구분발생 원인예시비동기 여부
하드웨어 인터럽트외부 장치 신호키보드 입력, 패킷 수신, 타이머비동기
소프트웨어 인터럽트프로그램 명령int 0x80, syscall동기
예외(Exception)오류 상황0으로 나누기, 페이지 폴트, 세그폴트동기
방식성능구현 복잡도네트워크 가능Node.js 지원
Pipe★★★★낮음불가child_process
Unix Socket★★★★★중간불가net 모듈
TCP Socket★★★중간가능net, http
Shared Memory★★★★★높음불가SharedArrayBuffer
Message Queue★★★★중간불가외부 라이브러리 필요

트러블슈팅 1: EMFILE: too many open files

섹션 제목: “트러블슈팅 1: EMFILE: too many open files”

증상:

Error: EMFILE: too many open files, open '/var/log/app.log'
at Object.openSync (node:fs:600:3)
at Object.readFileSync (node:fs:468:35)

원인: 리눅스는 프로세스당 열 수 있는 파일 디스크립터(fd) 수를 제한한다. 기본값은 1024개다. HTTP 연결, DB 연결, 파일 핸들이 누적되어 한계에 도달하거나, close()를 호출하지 않아 fd가 누수되면 발생한다.

해결 방법:

Terminal window
# 1. 현재 제한 확인
ulimit -n
# 출력: 1024
# 2. 현재 Node.js 프로세스가 사용 중인 fd 수 확인
lsof -p $(pgrep node) | wc -l
# 3. 임시 해결: 현재 세션에서 제한 늘리기
ulimit -n 65536
# 4. 영구 해결: /etc/security/limits.conf 수정
sudo vi /etc/security/limits.conf
# 아래 내용 추가:
# * soft nofile 65536
# * hard nofile 65536
# 5. systemd 서비스로 실행 중인 경우
# /etc/systemd/system/my-app.service 에 추가:
# [Service]
# LimitNOFILE=65536
sudo systemctl daemon-reload && sudo systemctl restart my-app
# 6. fd 누수 여부 확인 (fd 수가 계속 증가하는지 모니터링)
watch -n 1 'lsof -p $(pgrep node) | wc -l'

Node.js 코드 레벨 방어:

// 잘못된 코드 - fd 누수 가능
const fd = fs.openSync("/var/log/app.log", "r");
const data = fs.readSync(fd, buffer, 0, 1024, 0);
// close 누락 시 fd 누수!
// 올바른 코드
const fd = fs.openSync("/var/log/app.log", "r");
try {
const data = fs.readSync(fd, buffer, 0, 1024, 0);
} finally {
fs.closeSync(fd); // 반드시 close
}

트러블슈팅 2: strace로 느린 시스템 콜 추적

섹션 제목: “트러블슈팅 2: strace로 느린 시스템 콜 추적”

증상: NestJS 서비스가 특정 요청에서 응답 시간이 간헐적으로 수 초 이상 걸림. 코드 레벨에서는 원인 불명.

원인: 파일 I/O, 네트워크 연결, 또는 시스템 콜 자체가 블로킹되고 있을 가능성.

해결 방법:

Terminal window
# 1. 실행 중인 Node.js에 strace 붙이기
strace -p $(pgrep -f "node dist/main") -T -e trace=network,file 2>&1 | head -100
# -T: 각 시스템 콜 소요 시간 표시
# -e trace=network,file: 네트워크/파일 관련 시스콜만 표시
# 2. 특정 시간 이상 걸린 시스콜 필터링
strace -p $(pgrep node) -T 2>&1 | awk -F'<' '{if($2+0 > 0.1) print}'
# 0.1초 이상 걸린 시스콜만 출력
# 3. 시스콜 통계 수집 (30초간)
timeout 30 strace -p $(pgrep node) -c 2>&1

예상 출력 (느린 시스콜 발견):

connect(5, {sa_family=AF_INET, sin_port=htons(5432), ...}, 16) = -1 ETIMEDOUT <5.003456>

→ PostgreSQL 연결 타임아웃이 5초 발생. 연결 풀 설정 문제 확인.


트러블슈팅 3: 프로세스 간 통신(IPC) 메시지 손실

섹션 제목: “트러블슈팅 3: 프로세스 간 통신(IPC) 메시지 손실”

증상: cluster 모듈로 실행 중인 서버에서 Worker 프로세스에게 process.send()로 전송한 메시지가 간헐적으로 수신되지 않음.

원인: process.send()는 내부적으로 Pipe를 사용하며, Pipe 버퍼 크기는 64KB(Linux 기본값)로 제한된다. 대용량 메시지를 빠르게 전송하거나 Worker가 죽은 상태에서 send를 호출하면 메시지가 유실된다.

해결 방법:

// 잘못된 코드 - Worker 상태 확인 없이 send
cluster.workers[id].send(largeData);
// 올바른 코드 - 상태 확인 + 에러 처리
const worker = cluster.workers[id];
if (worker && !worker.isDead()) {
worker.send(data, (err) => {
if (err) {
console.error("IPC 전송 실패:", err.message);
// 재시도 로직 또는 대체 처리
}
});
}
// 대용량 데이터는 Pipe 대신 Shared Memory 또는 Redis 활용
// worker.send({ type: 'task', id: taskId }); // ID만 전송
// 실제 데이터는 Redis에서 taskId로 조회
Terminal window
# Pipe 버퍼 크기 확인 (Linux)
cat /proc/sys/fs/pipe-max-size
# 출력: 1048576 (1MB, 커널 4.x 이상)
# Pipe 버퍼 크기 증가 (일시적)
echo 2097152 > /proc/sys/fs/pipe-max-size

트러블슈팅 4: SIGPIPE: broken pipe 오류

섹션 제목: “트러블슈팅 4: SIGPIPE: broken pipe 오류”

증상:

Error: write EPIPE
at Socket.write (node:net:799:40)
...

원인: Unix Socket 또는 TCP Socket으로 데이터를 쓰려 했는데, 상대방이 이미 연결을 끊은 상태다. 커널이 SIGPIPE 신호를 보내고, 기본 동작은 프로세스 종료다.

해결 방법:

// SIGPIPE 무시 (Node.js는 기본적으로 이미 처리하지만 명시적으로 추가 가능)
process.on("SIGPIPE", () => {
// 조용히 무시
});
// 소켓 에러 핸들링
socket.on("error", (err) => {
if (err.code === "EPIPE" || err.code === "ECONNRESET") {
console.log("클라이언트가 연결을 끊었습니다.");
socket.destroy();
} else {
throw err;
}
});

트러블슈팅 5: EINTR 미처리 — 에러 로그 없는 partial read (silent failure)

섹션 제목: “트러블슈팅 5: EINTR 미처리 — 에러 로그 없는 partial read (silent failure)”

증상: 시그널을 자주 받는 환경(타이머, child process 종료, SIGCHLD)에서 네이티브 애드온이나 C 확장 모듈의 read()/write()가 간헐적으로 짧은 바이트만 반환하고, 일부 데이터가 사라진 듯 보이는데 에러 로그는 없음. Node.js 표준 API에서는 거의 발생하지 않지만 N-API 애드온 작성 시 빈번하다.

원인: 블로킹된 시스템 콜이 시그널로 깨어나면 read()-1을 반환하고 errno=EINTR로 설정된다. 이 경우를 “정상 완료” 또는 “에러”로 둘 다 잘못 처리할 수 있다 — 커널 버퍼에는 데이터가 남아 있는데 호출 측은 “다 읽었다”고 판단하고 다음 단계로 진행하는 silent partial read가 가장 흔하다.

진단:

Terminal window
# strace로 EINTR 발생 빈도 확인
strace -p $(pgrep node) -e read,write 2>&1 | grep EINTR | wc -l
# 단위 시간당 10건 이상이면 시그널 폭주 의심
# 어느 시그널이 깨우는지 추적
strace -p $(pgrep node) -e signal 2>&1 | head -20
# --- SIGCHLD {si_signo=SIGCHLD, ...} --- 가 자주 보이면 child_process 정리 누락

예상 출력 (정상 vs 비정상 패턴):

# 정상: EINTR 시 재시도하여 결과 확보
read(7, ...) = -1 EINTR (Interrupted system call)
read(7, "data...", 4096) = 1024 ← 재시도 후 성공
# 비정상: 한 번 EINTR 후 호출 자체를 포기
read(7, ...) = -1 EINTR
write(2, "error\n", 6) = 6 ← 데이터 안 읽고 에러 처리

해결 (C/C++ 네이티브 코드):

// 잘못된 코드 - EINTR 미구분 → silent partial read
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) return n; // EINTR이면 버퍼에 데이터 남아있는데 종료로 처리
process(buf, n);
// 올바른 코드 - EINTR 시 자동 재시도
ssize_t n;
do {
n = read(fd, buf, sizeof(buf));
} while (n < 0 && errno == EINTR); // glibc TEMP_FAILURE_RETRY 매크로 동등
if (n < 0) return -1; // 진짜 에러
process(buf, n);

Node.js 핵심 모듈(fs/net/http)은 libuv가 내부에서 EINTR을 자동 재시도하므로 일반 코드에서는 거의 노출되지 않는다. 하지만 N-API 네이티브 애드온, child_process에 시그널을 직접 보내는 코드, 또는 외부 C 라이브러리 바인딩에서는 직접 처리해야 한다.

이 패턴은 [[concurrency-sync]]에서 다루는 race condition과 다른 종류의 “조용한 실패”다 — race는 두 스레드 사이의 순서 문제, EINTR은 시스템 콜이 외부 시그널로 중간에 깨지는 문제. 둘 다 에러로 보이지 않는다는 공통점이 있다.

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


  • 시스템 콜이 무엇인지, 왜 필요한지 한 문장으로 설명할 수 있다
  • Kernel Mode(Ring 0)와 User Mode(Ring 3)의 차이를 설명할 수 있다
  • 모드 전환(User → Kernel)이 언제 발생하고, 비용이 얼마나 드는지 이해한다
  • 하드웨어 인터럽트와 소프트웨어 인터럽트의 차이를 설명할 수 있다
  • 인터럽트 처리 과정(ISR 실행 → 복귀) 흐름을 순서대로 말할 수 있다
  • fs.readFile()이 내부적으로 어떤 시스템 콜을 호출하는지 설명할 수 있다
  • 네트워크 패킷이 도착해서 Node.js 콜백이 실행되기까지의 흐름을 설명할 수 있다
  • IPC 5가지 방식(Pipe, Named Pipe, Socket, Shared Memory, Message Queue)의 특징과 차이를 안다
  • strace 명령어로 프로세스의 시스템 콜을 추적할 수 있다
  • EMFILE: too many open files 에러 원인과 해결 방법을 안다
  • ulimit -n 명령어로 fd 제한을 확인하고 변경할 수 있다
  • NestJS 프로젝트에서 시스템 콜이 어디서 발생하는지 연결할 수 있다

키워드설명
System Call유저 프로그램이 커널 기능을 요청하는 인터페이스
Ring 0 / Ring 3CPU 보호 레벨: 0=커널 모드, 3=유저 모드
Mode Switch유저↔커널 모드 전환, 시스템 콜 시 발생
InterruptCPU 현재 작업을 중단시키는 신호
ISRInterrupt Service Routine, 인터럽트 처리 함수
IDTInterrupt Descriptor Table, 인터럽트 번호→ISR 매핑 테이블
epollLinux의 비동기 I/O 이벤트 감시 메커니즘
File Descriptor (fd)열린 파일/소켓의 정수 식별자
IPCInter-Process Communication, 프로세스 간 통신
Pipe단방향 바이트 스트림, 부모↔자식 프로세스 통신
Unix Domain Socket동일 호스트 내 소켓 통신, TCP보다 빠름
Shared Memory가장 빠른 IPC, SharedArrayBuffer
libuvNode.js의 비동기 I/O 라이브러리, 시스콜 추상화
strace프로세스의 시스템 콜을 추적하는 디버깅 도구
ulimit프로세스 자원 제한 설정 명령어
EMFILEToo many open files 에러 코드

eBPF — 시스템 콜 모니터링의 현대적 방법

섹션 제목: “eBPF — 시스템 콜 모니터링의 현대적 방법”

eBPF(extended Berkeley Packet Filter) 는 커널을 수정하거나 모듈을 로드하지 않고 커널 내부에서 프로그램을 실행할 수 있는 기술이다. strace보다 훨씬 낮은 오버헤드로 시스템 콜을 추적할 수 있다.

strace 방식: eBPF 방식:
ptrace() → 모든 syscall마다 eBPF 프로그램 → 커널 내부에서
프로세스 중단 → 오버헤드 큼 직접 실행 → 오버헤드 매우 작음
(프로덕션 사용 위험) (프로덕션 사용 가능)
Terminal window
# bpftrace로 특정 프로세스의 시스템 콜 추적
# (bpftrace 설치 필요: apt install bpftrace)
bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid == 12345/ {
printf("%s\n", probe);
}'
# opensnoop: 어떤 파일을 여는지 실시간 추적
opensnoop -p $(pgrep node)
# PID COMM FD ERR PATH
# 12345 node 15 0 /app/config.json
# 12345 node 16 0 /tmp/upload_abc123
# execsnoop: 새로운 프로세스 실행 추적
execsnoop
# 특정 시스템 콜 레이턴시 분포 측정
bpftrace -e 'tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
@latency = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'

BCC Tools 활용: bcc(BPF Compiler Collection) 패키지에는 수십 개의 실무용 eBPF 도구가 포함되어 있다.

도구기능
opensnoop파일 open 추적
tcpconnectTCP 연결 시도 추적
runqlatCPU Run Queue 대기 시간 분포
biolatency디스크 I/O 레이턴시 분포
profileCPU 프로파일링 (flame graph용)

📖 더 보기: BPF Performance Tools — Brendan Gregg — eBPF/BPF 기반 성능 분석 도구의 권위 있는 레퍼런스. 저자가 Netflix 수석 엔지니어 (고급)

Seccomp(Secure Computing Mode) 는 프로세스가 호출할 수 있는 시스템 콜을 제한하는 Linux 보안 기능이다. Docker와 Kubernetes는 기본적으로 Seccomp 프로파일을 적용하여 컨테이너가 위험한 시스템 콜을 사용하지 못하게 막는다.

Terminal window
# Docker 기본 Seccomp 프로파일 확인
docker inspect <container_id> | grep -A5 Seccomp
# "SecurityOpt": ["seccomp=..."]
# Seccomp 프로파일 없이 실행 (보안 취약)
docker run --security-opt seccomp=unconfined ...
# 현재 프로세스의 Seccomp 상태 확인
cat /proc/self/status | grep Seccomp
# Seccomp: 2 (0=없음, 1=strict, 2=filter 모드)

Docker가 기본 차단하는 주요 시스템 콜:

시스템 콜이유
ptrace다른 프로세스 디버깅/제어 — 컨테이너 탈출에 악용 가능
reboot호스트 재시작 방지
kexec_load커널 교체 방지
mount파일시스템 마운트 방지
create_module커널 모듈 로드 방지

Kubernetes securityContext 설정:

apiVersion: v1
kind: Pod
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # 런타임 기본 Seccomp 프로파일 적용
containers:
- name: api
securityContext:
allowPrivilegeEscalation: false # setuid 바이너리 실행 방지
readOnlyRootFilesystem: true # 루트 파일시스템 읽기 전용
runAsNonRoot: true # root로 실행 금지
capabilities:
drop:
- ALL # 모든 Linux capabilities 제거
add:
- NET_BIND_SERVICE # 1024 이하 포트 바인딩만 허용

📖 더 보기: Docker’s default seccomp profile — Docker 공식 문서 — Docker가 기본으로 차단하는 44개 시스템 콜 목록과 커스텀 프로파일 작성법 (중급)



실습 1: strace로 Node.js 시스템 콜 추적

섹션 제목: “실습 1: strace로 Node.js 시스템 콜 추적”
Terminal window
# 간단한 파일 읽기 스크립트 작성
cat > /tmp/test-syscall.js << 'EOF'
const fs = require('fs');
fs.readFileSync('/etc/hostname');
EOF
# strace로 시스템 콜 추적
strace -e trace=openat,read,close,write node /tmp/test-syscall.js 2>&1 | grep -v "^strace:"

예상 출력 (일부):

openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
...
openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 16
read(16, "my-server\n", 4096) = 10
close(16) = 0

실습 2: 현재 열린 파일 디스크립터 확인

섹션 제목: “실습 2: 현재 열린 파일 디스크립터 확인”
Terminal window
# Node.js 서버 실행 후 (백그라운드)
node -e "require('http').createServer().listen(3000)" &
NODE_PID=$!
# fd 목록 확인
ls -la /proc/$NODE_PID/fd
# fd 수 카운트
ls /proc/$NODE_PID/fd | wc -l
# 정리
kill $NODE_PID

예상 출력:

total 0
dr-x------ 2 user user 0 Apr 2 10:00 .
dr-xr-xr-x 9 user user 0 Apr 2 10:00 ..
lrwx------ 1 user user 64 Apr 2 10:00 0 -> /dev/pts/0
lrwx------ 1 user user 64 Apr 2 10:00 1 -> /dev/pts/0
lrwx------ 1 user user 64 Apr 2 10:00 2 -> /dev/pts/0
lrwx------ 1 user user 64 Apr 2 10:00 5 -> anon_inode:[eventpoll]
lrwx------ 1 user user 64 Apr 2 10:00 7 -> socket:[12345]
...

→ fd 5가 eventpoll임을 확인 (epoll 인스턴스). fd 7이 TCP 서버 소켓.

Terminal window
# 현재 프로세스 fd 제한 확인
ulimit -n
# 출력: 1024
# 소프트/하드 제한 모두 확인
ulimit -Sn # 소프트 제한
ulimit -Hn # 하드 제한
# 시스템 전체 fd 제한
cat /proc/sys/fs/file-max
# 출력: 9223372036854775807 (Linux 5.x 이상 거의 무제한)
# 현재 열린 fd 수 (시스템 전체)
cat /proc/sys/fs/file-nr
# 출력: 현재열린수 0 최대값
# 예: 3456 0 9223372036854775807
Terminal window
# Node.js child process IPC 테스트
node -e "
const { spawn } = require('child_process');
const child = spawn('node', ['-e', \"
process.on('message', (m) => {
console.error('워커 수신:', JSON.stringify(m));
process.send({ reply: 'ok' });
});
\"], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
child.stderr.on('data', d => process.stdout.write(d));
child.on('message', m => console.log('마스터 수신:', JSON.stringify(m)));
child.send({ hello: 'world' });
setTimeout(() => child.kill(), 500);
"

예상 출력:

워커 수신: {"hello":"world"}
마스터 수신: {"reply":"ok"}

실습 5: Unix Domain Socket 성능 비교

섹션 제목: “실습 5: Unix Domain Socket 성능 비교”
Terminal window
# TCP vs Unix Socket 성능 비교 (nc 사용)
# TCP
time (for i in $(seq 1 1000); do echo "test" | nc -q0 127.0.0.1 3000; done) 2>&1
# Unix Socket (서버가 /tmp/test.sock에서 대기 중이라면)
time (for i in $(seq 1 1000); do echo "test" | nc -q0 -U /tmp/test.sock; done) 2>&1
# Unix Socket이 보통 20~40% 빠름

유저 프로그램은 시스템 콜이라는 공식 창구를 통해서만 커널(Ring 0)에 접근할 수 있고, 하드웨어 이벤트(네트워크 패킷, 키 입력)는 인터럽트로 CPU에 알려지며, 프로세스 간 통신(IPC)은 Pipe/Socket/Shared Memory 중 상황에 맞는 방식을 선택해야 한다. Node.js의 모든 비동기 I/O는 이 세 가지 원리 위에서 동작한다.