Linux 구조
이번 포스터에서는 리눅스 구조에 대해 정리하고자 합니다. 리눅스는 널리 사용되는 운영 체제입니다. 서버, 임베디드 시스템, IoT 장치 등 다양한 장치에서 사용되는 운영 체제입니다. 또한 CI/CD 업무가 중요해지고 있는 만큼 리눅스에 대한 공부는 다다익선이라고 생각합니다. ‘그림으로 배우는 리눅스 구조’라는 책으로 학습했고, 제가 정리하려는 내용은 다음 사진의 내용과 같습니다.
리눅스 개요
-
커널
커널은 ‘커널 모드’로 동작하면서 프로세스에서는 불가능한 장치 제어, 시스템 자원 관리 및 배분 기능을 합니다. 사용자 모드와 커널 모드로 나눠서 작업하는 이유는 다음과 같습니다. 만약 여러 사용자가 프로세스를 통해 직접 장치와 메모리에 접근한다면 데이터간 모순성이 생길 수도 있고, 접근해서는 안되는 프로그램에 접근해서 문제가 생길 수도 있습니다. 이런 이유로 프로세스는 간접적으로 장치에 접근합니다.
-
시스템 콜
‘시스템 콜’은 프로세스가 커널에 처리를 요청하는 방법을 말합니다. 프로세스 생성 및 삭제, 메모리 확보 및 해체, 통신 처리, 파일 시스템 및 장치 조작 시스템 콜 등이 있습니다. 시스템 콜을 호출하면 CPU에서는 ‘예외(Exception)’라는 이벤트가 발생합니다. 이를 계기로 CPU모드가 커널 모드로 전환됩니다. 시스템 콜은 아키텍처에 의존하는 어셈블리 코드로 호출해야 하지만 내부적으로 시스템 콜을 사용하는 ‘시스템 콜 래퍼 함수’를 주로 사용합니다. sar 명령어를 통해 논리 CPU가 시스템 콜 처리하는 정도(%system 필드)를 알 수 있습니다.
-
라이브러리
라이브러리는 ‘정적 라이브러리’와 ‘동적 라이브러리’가 있습니다. 오브젝트 파일과 라이브러리를 링크해서 실행 파일을 만들 때, 정적 라이브러리는 라이브러리에 있는 함수를 프로그램에 집어 넣습니다. 반면 공유 라이브러리는 ‘라이브러리의 이런 함수를 호출한다’라는 정보만 실행 파일에 넣고 프로그램이 실행할 때 함수를 공유 라이브러리에서 가져옵니다. 각각 장단점이 있습니다. 공유는 공간 효율성이 좋고 수정하기 용이하지만, 의존 문제가 생길 수 있습니다. 정적은 공간 효율이 떨어지고 수정이 필요한 경우 수정한 후 다시 실행 파일로 만들어야 합니다. 장점은 정적은 하나의 실행 파일로 동작하기 때문에, 파일만 복사하면 다른 환경에서도 동작해서 사용하기 편하고 실행 시 따로 로드작업이 필요 없어서 공유 라이브러리보다 빠릅니다.
프로세스
프로세스를 생성하는 이유는 동일한 프로그램 처리를 여러 프로세스(멀티 프로세스, 커널 쓰레드)에 나눠서 처리하기 위함이거나 다른 프로그램을 생성하기 위해서 입니다. 프로세스 스케줄러 기능은 CPU 자원을 여러 프로세스에 할당하는 것을 말합니다.
-
프로세스 생성, 실행, 종료
fork() 함수를 사용해서 자식 프로세스를 생성하고, 다른 프로그램을 실행시키고 싶다면 execve()함수를 사용합니다. 생성된 자식 프로세스는 ‘exit_grou() 시스템 콜’을 통해 종료됩니다. 이때, 부모 프로세스는 wait() 계열 시스템 콜을 통해 자식 프로세스 상태를 얻을 수 있습니다. 만약, 자식 프로세스에서 종료가 되었고 부모 프로세스에서 wait() 계열 시스템 콜을 호출하지 않았다면, 자식 프로세스는 ‘좀비 프로세스’ 상태가 됩니다. wait을 호출하기 전에 부모 프로세스가 종료되면 자식 프로세스는 ‘고아 프로세스’가 됩니다. 그렇게 되면 고아 프로세스는 init에 붙게 되고, init은 정기적으로 wait() 계열 시스템 콜 호출을 통해 시스템 자원을 회수합니다.
-
세션과 프로세스 그룹
세션은 사용자가 단말 에뮬레이터나 ssh 등을 통해 로그인했을 때의 로그인 세션에 대응되는 개념입니다. 모든 세션에는 해당 세션을 제어하는 ‘단말(terminal)’이 존재하고 pty/0,1,.. 가상 단말이 각각의 세션에 할당됩니다. 이때, 세션 내부에는 ‘포그라운드 프로세스 그룹’과 ‘백그라운드 그룹’이 있고, 포그라운드 그룹은 세션당 하나만 존재하고(백그라운드 그룹은 여러 개가 있을 수 있음) 단말을 통해 직접 접근이 가능합니다. 백그라운드 프로세스를 단말을 통해 조작하기 위해서는 실행 중인 포그라운드 프로세스가 있다면 중지시키고 해당 백그라운드 프로세스를 포그라운드로 전환시키면 됩니다.(단말에 접근하기 위해서는 포그라운드가 되어야만 합니다.)
추가적으로 ‘데몬 프로세스’가 있습니다. 데몬 프로세스는 시스템이 시작부터 종료할 때까지 상주하는 프로세스로 부모 프로세스가 init 입니다. 입출력이 필요 없기 때문에, 단말이 할당되지 않고 독자적인 세션을 가집니다. ps 명령어를 통해 확인하면 TTY 필드가 ‘?’ 이고, PPID가 1인 것을 확인할 수 있습니다. -
논리 CPU와 멀티 프로세스
CPU는 여러 개의 논리 CPU를 가집니다. 논리 CPU 하나는 하나의 프로세스만 실행시킬 수 있습니다. 그래서, 하나의 논리 CPU에 여러 프로세스를 생성하여도 해당 논리 CPU의 ‘처리량’(경과 시간 당 프로세스 수, 이때 경과 시간은 프로세스 시작부터 종료하기까지 시간이고 사용 시간은 프로세스가 실제로 CPU를 사용한 시간을 말함)은 같습니다. 하나의 논리 CPU에서 여러 프로세스를 ‘타임 슬라이스’ 단위로 실행시킵니다. 이때, 프로세스가 전환되는 것을 ‘컨텍스트 스위치’라고 합니다.
-
통신
프로세스 간 통신을 하는 방법은 공유 메모리, 시그널, 파이프, 소켓이 있습니다. 공유 메모리는 멀티 프로세스에서 주로 사용합니다. ‘mmap() 시스템 콜’을 호출해서 공유 메모리를 확보해서 쓰기 작업을 실행합니다. SIGKILL처럼 용도가 정해진 시그널이 있지만 SIGUSR1과 SIGUSR2처럼 개발자가 자유롭게 용도를 정하면 되는 시그널이 있습니다. 이런 시그널을 사용해서 두 프로세스가 서로 시그널을 주고 받으면서 통신할 수 있습니다. 파이프는 같은 쉘에서 프로그램 끼리 처리 결과를 연계하는 것입니다. 소켓은 유닉스 도메인 소켓(같은 기기에 있는 프로세스 간), UDP/TCP 소켓(다른 기기에 있는 프로세스도 가능)이 있습니다.
메모리 관리
커널은 메모리 관리 시스템 기능을 사용해서 시스템에 설치된 메모리 전체를 관리합니다.
-
free
사용 가능한 메모리와 사용 중인 메모리는 free 명령어를 통해 확인 할 수 있습니다. 각각의 필드는 다음과 같습니다. total은 전체 메모리, free는 비어있는 메모리, buff/cache는 버퍼,페이지 캐시가 이용하는 메모리로 커널 메모리(커널이 사용하는 메모리)에 있습니다. used는 total에서 buff/cache와 free를 뺀 값입니다. available은 free값과 해제 가능한 buff/cache값을 더한 값입니다. 만약 메모리 부족이 생길 시 ‘스왑’을 이용합니다. 스왑은 사용 중인 프로세스 물리 주소를 저장 장치에 옮기는 작업으로 해당 페이지 테이블 엔트리 값이 이동된 저장 장치의 메모리를 가리키게 됩니다.(매우 느리다는 단점이 있음) 이렇게 해도 메모리 부족이 해결되지 않는다면, OOM상태가 돼 ‘OOM 킬러’라는 기능이 동작합니다.
-
가상 메모리
프로세스를 실행하기 위해서는 프로세스의 ‘메모리 맵’이 필요합니다. 메모리 맵은 프로세스의 엔트 포인트 주소와 오프셋 등을 저장하는 정보로 커널 메모리에 저장됩니다. 하지만 메모리 단편화, 멀티 프로세스 구현의 어려움, 비정상적인 메모리 접근의 이유로 프로세스에 해당하는 메모리 맵은 ‘물리 주소’(실제 메모리 주소)가 아닌 ‘가상 주소’입니다. 커널은 커널 메모리 내부에 저장된 ‘페이지 테이블’을 사용해서 가상 주소를 물리 주소로 변환시킵니다. 이때, 페이지 테이블에서 한페이지에 대응하는 레코드를 ‘페이지 테이블 엔트리’라고 부릅니다. 각각의 레코드는 가상 주소와 물리 주소 대응 정보를 포함하고 있습니다.
-
fork() 함수
프로세스를 생성하기 위해 fork() 함수를 호출할 때 ‘Copy on Wirte’(쓰기 작업이 있을 때, 복사하겠다) 방식을 사용합니다. fork() 함수로 새로운 프로세스를 생성하면 자식 프로세스는 부모 프로세스와 메모리 주소 공간을 공유합니다. 만일 자식 프로세스에서 데이터가 변경되면, ‘페이지 폴트’가 발생하고 커널 모드로 전환 후 ‘페이지 폴트 핸들러’를 통해 별도의 물리 메모리 공간을 확보합니다. 이때, 변경하려고 했던 물리 주소 공간에 해당하는 페이지 테이블 엔트리가 부모와 자식 모두 쓰기 권한으로 변경됩니다.
-
메모리 계층
다양한 기억 장치 중 레지스터, 캐시 메모리, 메모리, 저장 장치 순으로 접근 속도가 빠르지만, 용량이 작고 고가입니다. CPU는 명령을 읽고 메모리에서 레지스터로 데이터를 읽어 들이고 처리합니다. 이 과정에서 레지스터로 데이터를 읽어 들이는 시간이 처리하는 시간보다 많이 느립니다. 그래서, 이런 문제를 해결하기 위해 ‘캐시 메모리’가 생겼습니다. 메모리에서 레지스터로 데이터를 읽어 들일 때, 캐시 메모리에 ‘캐시 라인’이라고 부르는 단위로 데이터 읽어서 레지스터로 옮깁니다. 이때, 만약 메모리에서 읽어 들인 데이터가 변경되었다면, 캐시 라인에는 변경되었다는 것을 뜻하는 ‘더티 표시’가 붙습니다. 페이지 캐시는 파일 데이터를 메모리에 캐시합니다. 페이지 캐시는 페이지 단위로 데이터를 다룹니다. 또한 더티 상태를 표시하는 ‘더티 페이지’도 있습니다.
저장 장치
프로세스는 장치에 직접 접근할 수 없고, 커널이 장치에 접근합니다. 접근하는 방식은 다음과 같은 인터페이스를 사용합니다. 디바이스 파일을 조작하거나 블록 장치에 구축한 파일 시스템을 조작합니다. 또한, 소켓 구조를 이용해서 접근(책에서는 설명x)할 수도 있습니다.
-
디바이스 파일
디바이스 파일에는 두 종류가 있습니다. 하나는 ‘캐릭터 디바이스’이고, 다른 하나는 ‘블록 디바이스’ 입니다. 캐릭터는 읽고 쓰기만 가능하고, 블록은 탐색도 가능합니다. 캐릭터 장치의 예로는 단말, 키보드, 마우스 등이 있고, 블록 장치의 예로는 ssd와 hdd 같은 장치가 있습니다. 여러 장치가 연결되어 있다면 커널은 일정한 규칙에 따라 각각 다른 이름으로 디바이스 파일에(메이저 번호와 마이너 번호) 대응시킵니다. 이때 같은 접속 방식의 장치를 연결하는 경우, PC를 시작할 때 인식 순서에 따라 대응 관계가 바뀝니다.
-
파일 시스템
대부분의 저장 장치는 파일 시스템으로 접근합니다. 파일 시스템은 사용자에게 의미 있는 데이터 뭉치를 파일 단위로 관리하고 디렉터리를 사용해서 각 파일을 분류합니다. 파일 시스템에는 POSIX에서 정한 함수로 접근할 수 있습니다. 파일 시스템에는 오류 방지 기술이 있습니다. 대표적으로 ‘저널링 방식’과 ‘CoW’(Copy on write) 방식이 있습니다. 저널링 방식은 저널 영역에 갱신 목록(저널 로그)을 기록하고 저널 영역에 기록된 내용에 따라 실제로 파일 시스템 내용을 갱신합니다. CoW 방식은 데이터를 갱신할 때마다 다른 공간에 데이터를 쓰고나서 링크를 이동시킵니다. 그래서 처리 도중 전원이 끊겨도 그 전에 만들었던 중간 데이터를 삭제하면 오류는 발생하지 않습니다.
-
디바이스 드라이브
디바이스 파일이나 파일 시스템을 조작하게 되면, 커널 내부에 디바이스 드라이브라는 소프트웨어를 통해 장치에 접근하게 됩니다. 그리고, 디바이스 드라이브에서 작업이 완료되면, CPU에게 알립니다. 이때, CPU는 ‘폴링 방식’과 ‘인터럽트 방식’을 통해 처리 결과를 확인합니다.
-
성능
블록 장치의 성능에는 여러가지 종류가 있습니다. 대표적으로, ‘처리량’, ‘레이턴시’, ‘IOPS’가 있습니다. 처리량은 시간 당 전송량으로 만약 같은 용량의 데이터를 전송한다 했을 때, 처리량이 높을 수록 걸리는 시간이 짧습니다. 레이턴시는 입출력 1회 당 걸린 시간이고, IOPS는 시간 당 입출력 횟수입니다. 이 관계는 게임에서 패킷 전송 속도 및 프레임 관계와 비슷합니다. 레이턴시가 짧을 수록 많은 양의 입출력 횟수를 가질 수 있는 역수 관계입니다. 이때, IOPS는 레이턴시 이외에도 멀티 프로세스로 수치를 높일 수도 있습니다.
가상화 기능 및 컨테이너
가상화 기능에 대한 부분은 자세하게 읽어보지 않았습니다. 그래서 정리한다는 것보다 가상화 기능에 대해 짧은 설명과 컨테이너에 대해 정리하고자 합니다.
-
가상화 기능
가상화 기능은 PC나 서버 등의 물리적인 기기에서 ‘가상 머신을 동작시키는 소프트웨어’ 기능 및 그런 동작을 돕는 ‘하드웨어 기능’의 조합입니다. 가상화 소프트웨어는 물리 기기의 하드웨어 자원을 관리하고 가상 머신에 나눠 줍니다. 가상화를 지원하는 CPU 기능은 물리 기기 처리를 하는 VMX-root 모드(사용자, 커널)와 가상 머신 처리를 하는 VMX-nonroot 모드(사용자, 커널)가 있습니다. 하드웨어 접근이나 물리 기기 인터럽트를 통해서 VMX-root → VMX-nonroot 자동적으로 전환됩니다. 이러한 구조로 인해 가상 머신에서 저장 장치의 입출력 성능은 대폭 떨어집니다.
-
컨테이너
컨테이너는 컨테이너가 동작하는 호스트 OS와 커널을 공유합니다. 그래서 리눅스 컨테이너는 리눅스에서만 사용 가능합니다. 컨테이너가 실행되는 흐름은 다음과 같습니다. GRUB과 같은 부트 로더 및 커널 기동 없이 바로 init 프로세스를 기동됩니다. 종류로는 시스템 컨테이너와 애플리케이션 컨테이너가 있습니다. 시스템 컨테이너는 다양한 애플리케이션이 동작하는 환경을 가지고 애플리케이션 컨테이너는 하나의 애플리케이션만 구동할 수 있는 환경을 가집니다. 흔히 도커에서 말하는 컨테이너는 애플리케이션 컨테이너를 뜻합니다. 컨테이너는 커널의 ‘네임스페이스 기능’을 잘 활용해서 만들어집니다. 컨테이너는 독립된 네임스페이스를 가지고 다른 프로세스와 실행 환경이 나뉘는 하나 또는 여러 프로세스를 말합니다. 컨테이너가 되기 위한 네임스페이스 분리 방법은 명확하게 정해져 있지 않고, 컨테이너 런타임 제작자에 따라 달라집니다. 그리고, 네임스페이스는 프로세스ID 네임스페이스, 사용자 네임스페이스, 마운트 네임스페이스 등이 있으며 계속해서 새로 생기고 있습니다.