Socket의 양은 생각보다 적다?

어떤 서버가 많은 양의 트래픽을 받는 상황을 상상해보자.

Linux에서는 네트워크 Connection을 Socket으로 표현하고, 프로세스는 이 Socket을 File Descriptor를 통해 다룬다.

즉 서버가 Connection 하나를 accept()할 때마다 새로운 Socket FD가 하나씩 생긴다고 생각하면 된다. 그렇다면 이것의 한계도 당연히 존재할 것이다.

ulimit -n을 통해 현재 프로세스가 열 수 있는 File Descriptor의 제한을 확인할 수 있다. 환경에 따라 Soft Limit이 1024로 설정되어 있는 경우가 있다.

물론 FD는 Socket만을 위해 사용되는 것이 아니라 stdin, stdout… 등의 다양한 파일을 위해 사용된다. 따라서 디테일하게 생각한다면 실제로 열 수 있는 Socket의 수는 1024보다 조금 적을 것이다.

File Descriptor의 limit

Soft Limit과 Hard Limit을 구분해야 한다.

  • Soft Limit: 현재 프로세스에 실제로 적용되는 제한
  • Hard Limit: 일반 프로세스가 Soft Limit을 올릴 수 있는 최대 범위

실제로 Soft Limit에 도달한 상태에서 새로운 Socket이나 File을 열려고 하면 에러가 발생한다. 그렇다면 최대 Connection은 아무리 많아도 1024개가 끝인 걸까? 절대 그렇지 않다.

다음 Go 프로그램을 보자.

package main

import (
	"fmt"
	"syscall"
)

func main() {
	var r syscall.Rlimit
	syscall.Getrlimit(syscall.RLIMIT_NOFILE, &r)

	fmt.Printf("soft=%d hard=%d\n", r.Cur, r.Max)
}

Shell에서는 분명 Soft Limit이 1024였는데 Go 프로세스 내부에서는 다음처럼 훨씬 큰 값이 나올 수 있다.

soft=1048575 hard=1048576

epoll과 select

왜 이런 결과가 나왔을까?

Go 1.19부터 Unix에서 os 패키지를 사용하는 Go 프로그램은 Soft Limit을 Hard Limit에 가깝게 자동으로 올리는 동작을 한다.

나는 여기서 왜 1024라는 작은 값을 사용했는지 문득 궁금해졌다. 이 숫자는 오래된 I/O API인 select()와 관련이 있다고 한다.

select()는 여러 FD 중 어떤 FD가 읽거나 쓸 수 있는 상태가 되었는지를 기다리는 I/O Multiplexing API이다. 이를 위해 FD Bitmap을 사용한다.

일반적인 Linux 환경에서는 이 Bitmap의 크기가 1024로 고정되어 있다. 따라서 FD 번호가 1024 이상이 되면 감시할 수 없다.

반면 epoll은 다르다. epoll은 고정된 FD Bitmap을 매번 넘기는 대신 감시하고 싶은 FD를 Kernel에 등록하고 오는 이벤트를 받는다.

따라서 select()처럼 고정된 제한이 없다시피 하다. (물론 /proc/sys/fs/epoll/max_user_watches에서 그 제한을 볼 수 있긴한데 압도적으로 크다.)

하지만 그렇다고 Soft Limit을 넘기는 수의 FD를 생성할 수는 없다.

여기서 Go가 1024개 이상의 Connection을 처리할 수 있는 이유가 나온다. Linux에서 Go는 select() 대신 epoll을 사용한다.

그리고 Soft Limit을 자신에게 허용된 Hard Limit 범위까지 높인다. 이러면 직접 관리할 수 있는 비트맵의 크기가 한정된 select()와 다르게 epoll을 사용하여 더 많은 FD를 감시할 수 있게 되는 것이다.

효율적인 thread 사용

하지만 여기서 또 생각이 드는 부분은 Thread이다. epoll 덕분에 많은 양의 Connection을 감시할 수 있게 되었는데, 그렇다면 이 수많은 Connection을 유지하기 위해 그만큼 많은 Thread가 필요한 것은 아닐까?

Go에서는 Connection마다 goroutine을 하나씩 둘 수 있지만, Connection마다 OS Thread를 하나씩 사용하는 것은 아니다.

대부분의 상황에서 각 Connection은 항상 데이터를 처리하는 것이 아니라, 다음 데이터가 들어오기를 기다리는 시간이 훨씬 많을 것이다.

그동안 epoll과 같은 이벤트 기반 I/O가 여러 Socket을 한꺼번에 감시하고, 특정 Socket에 데이터가 들어오면 해당 작업만 다시 실행된다.

즉 수십만 개의 Connection을 유지하더라도 수십만 개의 Thread가 필요한 것이 아니라, 실제로 처리할 일이 생긴 Connection만 적은 수의 Thread가 번갈아 처리하는 이벤트 루프 구조이다.

또한 커넥션을 ‘유지’ 하는 일 자체로는 풀링을 하는 것이 아닌 이상 Thread가 필요하지 않고, 실제로 요청을 처리할 때 필요하므로 효율적으로 사용할 수 있다.

메모리

그러나 아무리 많은 Connection을 처리할 수 있도록 FD 제한을 높이고 epoll을 사용하더라도 결국 메모리라는 물리적인 한계가 존재한다.

Connection 하나가 만들어지면 단순히 FD 번호 하나만 추가되는 것이 아닌 Buffer나 Connection 상태등도 추가되어야 하므로 추가적인 메모리가 소요된다.

특히 Reconnection Storm와 같은 상황이 발생하기라도 하면 서버는 한순간에 Idle 상태가 아닌 Connection 처리를 위해 일시적으로 훨씬 많은 메모리와 CPU를 사용하게 된다.

즉 대규모 Connection을 다룰 때는 단순히 ulimit을 높이고 많은 FD를 감시할 수 있게 만드는 것만 생각하는 것이아닌 메모리 비용, 그리고 많은 Connection이 동시에 활성화되었을 때 발생하는 순간적인 자원 사용량까지 함께 고려해야 할 것이다.