소켓/파일 디스크립터는 OS에서 메모리를 얼만큼 차지할까?

    반응형

    들어가기 전

    많은 TCP Connection이 생성되면, 파일 디스크립터가 많이 생성되어 서버에 영향을 준다라는 생각을 했다. 그런데 TCP Connection이 1개 생겼을 때 얼마만큼의 리소스를 사용하기 때문에 서버에 영향을 미치는지는 잘 알지 못했다. 이 부분을 잘 이해해보려고 글을 작성한다. 

     


    파일 디스크립터란? 

    파일 디스크립터는 OS가 프로세스 내에서 파일 / 다른 입출력 자원을 가리키는 역할을 하는 정수값이다. DB로 생각하면 PK 값으로 생각할 수 있고, 파일 디스크립터 번호로 조회하면 이 파일과 연결된 파일 엔트리를 확인할 수 있게 되어있다. 

    파일 디스크립터는 0보다 큰 정수로 표현되며, 파일디스크립터 0~2는 stdin, stdout, stderr로 예약되어있다. 즉, 사용자가 특정 파일을 열거나 리소스를 사용하게 되면 파일 디스크립터는 3번부터 차례대로 배정되게 된다. 

    각 프로세스는 자신의 파일 디스크립터 테이블을 이용해 파일 디스크립터를 관리한다. DB와 비슷한 느낌으로 상상해보면 위의 파일 디스크립터 테이블을 각 프로세스가 관리한다고 보면 된다. 

     


    파일 디스크립터와 관련된 것들은?

    앞서서 파일 디스크립터에 대해 간단히 확인했다. 그러면 파일 디스크립터와 관련된 테이블들은 어떤 것들이 있을까? 조사 해보았을 때는 총 세 가지 테이블이 관련이 되어있다. 

    • 파일 디스크립터 테이블(프로세스 별) : 파일 디스크립터 번호, 파일 테이블의 엔트리 포인터.
    • 파일 테이블(전체 공유) : 파일 상태 정보, 접근 모드, 파일 포인터 위치, indoe 포인터 등을 관리.
    • inode 테이블(전체 공유) : 파일과 관련된 메타 데이터를 관리. Size, 소유자, 업데이트 시점, 실제 파일 위치 등.

    전체적인 그림은 다음과 같이 이해해 볼 수 있다.

    1. 프로세스마다 파일 디스크립터 테이블을 가진다.
    2. 파일 디스크립터는 자신과 관련된 File Table에 있는 Entry를 가리킨다. 
    3. 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 ...
    1. 첫번째 명령어를 이용해 PID가 15인 프로세스의 3번 파일 디스크립터의 정보를 살펴봤다. 이 녀석은 /my.log라는 파일 테이블 엔트리를 가리킨다.
    2. lsof -p 15를 이용해서 파일 테이블 엔트리의 값을 살펴보자. 여기서 NODE는 inode를 가리킨다. 대략 파일 타입이 무엇인지, 쓰고 있는 파일 디스크립터는 무엇인지 등을 확인할 수 있다.
    3. stat /my.log를 이용해보면 inode 테이블에 있는 정보를 확인할 수 있게 된다.

    위 코드 실습을 통해서 파일 디스크립터 테이블, 파일 테이블, inode 테이블이 서로를 어떻게 참조하고, 어떻게 연결되는지를 살펴볼 수 있었다. 

     


    파일 디스크립터와 리소스

    그렇다면 파일 디스크립터가 하나 열릴 때, 얼마만큼의 리소스가 필요한 것일까? 앞서 이야기했던 것처럼 프로세스가 파일을 열면, 커널에서는 File 객체가 생성되고, 그 객체의 포인터가 파일 디스크립터 테이블에서 관리된다고 했다. 리눅스 커널 코드에는 아래와 같이 files_struct가 선언되어있다.

    struct files_struct {
       ...
       struct file __rcu * fd_array[NR_OPEN_DEFAULT];
    };

    여기서 살펴볼 점은 두 가지다.

    1. fd_array에는 file 구조체의 포인터를 저장할 수 있다.
    2. fd_array는 초기 길이 NR_OPEN_DEFAULT로 지정된 배열이다.

    이를 종합해보면 파일 디스크립터가 하나 생성되었을 때, 얼마만큼의 커널 메모리를 사용하는지를 계산 해볼 수 있다.

    1. 포인터 크기 : 4바이트 (32비트 시스템) / 8바이트(64비트 시스템)
    2. fd_array의 동적 확장이 필요한 경우 : 새로운 배열을 하나 더 만든 후 정렬해야 하므로 2배의 메모리가 필요

    64비트 시스템의 경우 파일 디스크립터가 하나 생성되었을 때 총 8~16바이트의 커널 메모리가 필요하다는 것을 알 수 있다. 

     


    소켓 생성과 리소스

    위에서 파일 디스크립터가 하나 생성되었을 때 8~16바이트의 커널 메모리가 필요한 것을 알았다. 그렇다면 실제로 소켓이 하나 생긴다면 얼마만큼의 커널 메모리가 필요하게 될까? 우선 아래 리눅스 커널 코드들을 참고해보자.

    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가 필요하다. 

     


    정리

    이번 글에서 이야기 한 내용을 정리하면 아래와 같다. 

    1. 소켓 / 파일을 열면 파일 디스크립터가 생성된다.
    2. 프로세스별로 파일 디스크립터 테이블을 관리한다. 
    3. 파일 디스크립터 테이블은 커널에서 공유되는 파일 테이블의 엔트리를 참조한다.
    4. 파일 테이블의 엔트리는 inode를 참조한다.
    5. 소켓이 하나 생성되면 파일 디스크립터, 파일 엔트리, inode, Recv / Send Buffer 등이 생성된다. 이 때, Recv/Send Buffer에 의해 소켓 1개당 수십 KB ~ 수백 KB의 커널 메모리를 차지하게 된다.
    6. 소켓 / 파일 디스크립터 생성하고 닫는 작업은 시스템 콜이므로 Application - OS간의 Context Switching Cost가 필요하다. 

    소켓이 너무 많이 열리거나, 적절히 닫아 주지 않아 File Descriptor Leak가 발생하기 시작하면 커널 메모리 사용량이 늘어나며 커널이 안정적으로 동작하지 않게 될 것이다. 

     


    관련 글

    댓글

    Designed by JB FACTORY