소켓/파일 디스크립터는 OS에서 메모리를 얼만큼 차지할까?
- 카테고리 없음
- 2024. 9. 1.
들어가기 전
많은 TCP Connection이 생성되면, 파일 디스크립터가 많이 생성되어 서버에 영향을 준다라는 생각을 했다. 그런데 TCP Connection이 1개 생겼을 때 얼마만큼의 리소스를 사용하기 때문에 서버에 영향을 미치는지는 잘 알지 못했다. 이 부분을 잘 이해해보려고 글을 작성한다.
파일 디스크립터란?
파일 디스크립터는 OS가 프로세스 내에서 파일 / 다른 입출력 자원을 가리키는 역할을 하는 정수값이다. DB로 생각하면 PK 값으로 생각할 수 있고, 파일 디스크립터 번호로 조회하면 이 파일과 연결된 파일 엔트리를 확인할 수 있게 되어있다.
파일 디스크립터는 0보다 큰 정수로 표현되며, 파일디스크립터 0~2는 stdin, stdout, stderr로 예약되어있다. 즉, 사용자가 특정 파일을 열거나 리소스를 사용하게 되면 파일 디스크립터는 3번부터 차례대로 배정되게 된다.
각 프로세스는 자신의 파일 디스크립터 테이블을 이용해 파일 디스크립터를 관리한다. DB와 비슷한 느낌으로 상상해보면 위의 파일 디스크립터 테이블을 각 프로세스가 관리한다고 보면 된다.
파일 디스크립터와 관련된 것들은?
앞서서 파일 디스크립터에 대해 간단히 확인했다. 그러면 파일 디스크립터와 관련된 테이블들은 어떤 것들이 있을까? 조사 해보았을 때는 총 세 가지 테이블이 관련이 되어있다.
- 파일 디스크립터 테이블(프로세스 별) : 파일 디스크립터 번호, 파일 테이블의 엔트리 포인터.
- 파일 테이블(전체 공유) : 파일 상태 정보, 접근 모드, 파일 포인터 위치, indoe 포인터 등을 관리.
- inode 테이블(전체 공유) : 파일과 관련된 메타 데이터를 관리. Size, 소유자, 업데이트 시점, 실제 파일 위치 등.
전체적인 그림은 다음과 같이 이해해 볼 수 있다.
- 프로세스마다 파일 디스크립터 테이블을 가진다.
- 파일 디스크립터는 자신과 관련된 File Table에 있는 Entry를 가리킨다.
- File Table은 자신과 관련된 Inode Table의 Entry를 가리킨다.
각 테이블은 이런 형태로 단방향으로 연쇄적으로 서로를 참조하고 있다. 특정 프로세스가 이미 존재하는 파일을 열게 된다면, 파일 디스크립터 1개가 더 만들어지게 될 것이다.
한 가지 주목할 부분은 특정 프로세스에서 서로 다른 파일 디스크립터 번호를 이용해 동일한 파일을 참조할 수 있다. 예를 들어 위 이미지에서 FD 0, FD 3은 동일한 A 라는 파일을 참조한다. 이것은 각 프로세스에서 서로 다른 쓰레드가 동시에 파일을 편집할 수 있도록 하기 위함이 아닐까? 라고 생각된다.
파일 디스크립터, 파일 테이블, inode 테이블를 살펴볼 수 있는 방법은?
내가 위에서 그린 그림처럼 DB에 SQL을 날린 것처럼 나오면 좋겠지만, 그런 방법은 존재하지 않는 듯 하다. 대신에 위의 내용들을 파편화 해서 하나의 정보를 만들어 낼 수 있도록 리눅스에서는 여러 명령어를 이용할 수 있다. 아래 명령어를 이용하면 파일 디스크립터 테이블, 파일 테이블, inode 테이블등을 간접적으로 살펴볼 수 있다.
# 파일 디스크립터 테이블을 볼 수 있는 방법
$ ls /proc/<PID>/fd/
# 파일 테이블은 lsof를 이용해 간접적으로 확인 가능
$ lsof -p <PID>
# inode는 stat 명령어를 이용해 확인 가능
$ stat <FILENAME>
위 명령어가 실제로 동작하는지 실습을 통해서 알아보고자 한다.
주의사항
엄밀히 말하면 /proc/<PID>/fd는 파일 디스크립터 테이블은 아니다. 커널에서 파일이 열리면 파일 객체가 생성되고, 파일 객체에 대한 포인터가 파일 디스크립터 테이블에 저장된다. /proc/에 제공되는 파일 디스크립터 정보는 커널의 메모리에 있는 파일 디스크립터 테이블을 참조해서 만들어진다. 커널이 제공하는 인터페이스를 통해 메모리의 정보를 가상 파일 시스템으로 나타내는 것이다.
# File Descriptor : /proc/<PID>/fd/<FD_NO>
$ ls -l /proc/15/fd/3
l-wx------ 1 root root 64 Sep 1 09:49 3 -> /my.log
# 특정 프로세스가 사용하는 File Table Entry 보기 : lsof -p <PID>
$ lsof -p 15
COMMAND PID USER FD TYPE ... NODE NAME
...
my-pro 15 root 3w REG ... 268752954 /my.log
# Inode Table의 Entry 보기
$ stat /my.log
File: '/my.log'
Size: 5xxxx Blocks: 104 IO Block: 4096 regular file
Device: ...
Inode: 268752954
Links: 1
Access: ...
Uid: ...
Gid: ...
Access: 2024-09-01 ...
Modify: 2024-09-01 ...
Change: 2024-09-01 ...
- 첫번째 명령어를 이용해 PID가 15인 프로세스의 3번 파일 디스크립터의 정보를 살펴봤다. 이 녀석은 /my.log라는 파일 테이블 엔트리를 가리킨다.
- lsof -p 15를 이용해서 파일 테이블 엔트리의 값을 살펴보자. 여기서 NODE는 inode를 가리킨다. 대략 파일 타입이 무엇인지, 쓰고 있는 파일 디스크립터는 무엇인지 등을 확인할 수 있다.
- stat /my.log를 이용해보면 inode 테이블에 있는 정보를 확인할 수 있게 된다.
위 코드 실습을 통해서 파일 디스크립터 테이블, 파일 테이블, inode 테이블이 서로를 어떻게 참조하고, 어떻게 연결되는지를 살펴볼 수 있었다.
파일 디스크립터와 리소스
그렇다면 파일 디스크립터가 하나 열릴 때, 얼마만큼의 리소스가 필요한 것일까? 앞서 이야기했던 것처럼 프로세스가 파일을 열면, 커널에서는 File 객체가 생성되고, 그 객체의 포인터가 파일 디스크립터 테이블에서 관리된다고 했다. 리눅스 커널 코드에는 아래와 같이 files_struct가 선언되어있다.
struct files_struct {
...
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};
여기서 살펴볼 점은 두 가지다.
- fd_array에는 file 구조체의 포인터를 저장할 수 있다.
- fd_array는 초기 길이 NR_OPEN_DEFAULT로 지정된 배열이다.
이를 종합해보면 파일 디스크립터가 하나 생성되었을 때, 얼마만큼의 커널 메모리를 사용하는지를 계산 해볼 수 있다.
- 포인터 크기 : 4바이트 (32비트 시스템) / 8바이트(64비트 시스템)
- fd_array의 동적 확장이 필요한 경우 : 새로운 배열을 하나 더 만든 후 정렬해야 하므로 2배의 메모리가 필요
64비트 시스템의 경우 파일 디스크립터가 하나 생성되었을 때 총 8~16바이트의 커널 메모리가 필요하다는 것을 알 수 있다.
소켓 생성과 리소스
위에서 파일 디스크립터가 하나 생성되었을 때 8~16바이트의 커널 메모리가 필요한 것을 알았다. 그렇다면 실제로 소켓이 하나 생긴다면 얼마만큼의 커널 메모리가 필요하게 될까? 우선 아래 리눅스 커널 코드들을 참고해보자.
- https://github.com/torvalds/linux/blob/master/include/linux/net.h#L117-L129
- https://github.com/torvalds/linux/blob/6cd90e5ea72f35fa40f971c419e16142cd8272bf/net/socket.c#L629-L647
struct socket *sock_alloc(void)
{
...
sock = SOCKET_I(inode);
...
return sock;
}
struct socket {
socket_state state;
short type;
unsigned long flags;
struct file *file;
struct sock *sk;
const struct proto_ops *ops; /* Might change with IPV6_ADDRFORM or MPTCP. */
struct socket_wq wq;
};
새로운 소켓이 생성되면 socket 구조체가 하나 생성될텐데, 내부적으로 가지고 있는 필드가 여러 개가 있는 것을 볼 수 있다.
- File 구조체
- Inode 구조체
코드 상에서 Socket 구조체는 대략적으로 이런 녀석들이 필요하다고 이야기한다. 그러면 이전의 배경 지식과 이 코드들을 고려해보면, 소켓이 하나 생성되었을 때 다음이 생성되어야 하는 것을 이해할 수 있다.
- 파일 디스크립터 생성
- 소켓 관련 파일 테이블 엔트리 생성
- 소켓 구조체 생성
- 소켓의 Recv/Send 버퍼 생성
그리고 각각 필요한 대략적인 메모리를 계산하면 다음과 같다.
- 파일 디스크립터 생성 : 8~16바이트
- 소켓 관련 파일 테이블 엔트리 생성: 수십 ~ 수백 바이트.
- 소켓 구조체 생성 : 수백 바이트.
- 소켓의 Recv/Send 버퍼 생성: 수십 ~ 수백 KB
가장 Dominant한 부분은 소켓의 Recv/Send Buffer들이고, 소켓 1개가 생성되었을 때 수십~수백 KB의 커널 메모리를 차지하게 되고, Buffer들은 생성하는 즉시 할당(Lazy Loading이 아님)된다고 하므로 소켓이 1개 생성되었을 때 마다 커널 메모리의 수십~수백 KB가 점유되게 된다.
파일 디스크립터를 열고 닫는 작업은 시스템 콜이다
알고 있듯이 파일 디스크립터를 열고 닫는 작업인 open(), close()는 시스템 콜이다. 즉, 프로세스에서 사용하던 제어권이 커널로 넘어와야하기 때문에 Process -> Kernel로 Context Switching이 발생함을 의미한다. 잦은 파일 디스크립터의 열고 닫는 작업은 그만큼의 Context Switching Cost가 필요하다.
정리
이번 글에서 이야기 한 내용을 정리하면 아래와 같다.
- 소켓 / 파일을 열면 파일 디스크립터가 생성된다.
- 프로세스별로 파일 디스크립터 테이블을 관리한다.
- 파일 디스크립터 테이블은 커널에서 공유되는 파일 테이블의 엔트리를 참조한다.
- 파일 테이블의 엔트리는 inode를 참조한다.
- 소켓이 하나 생성되면 파일 디스크립터, 파일 엔트리, inode, Recv / Send Buffer 등이 생성된다. 이 때, Recv/Send Buffer에 의해 소켓 1개당 수십 KB ~ 수백 KB의 커널 메모리를 차지하게 된다.
- 소켓 / 파일 디스크립터 생성하고 닫는 작업은 시스템 콜이므로 Application - OS간의 Context Switching Cost가 필요하다.
소켓이 너무 많이 열리거나, 적절히 닫아 주지 않아 File Descriptor Leak가 발생하기 시작하면 커널 메모리 사용량이 늘어나며 커널이 안정적으로 동작하지 않게 될 것이다.