나만의 가상화 컨테이너 만들기 #4 PID 네임스페이스

📖 개요

오늘은 가상화 컨테이너 구현에 있어 가장 기초적이라고 할 수 있는 PID 네임스페이스 기술을 사용해 보겠습니다.

저번 게시글에서 언급했었던 JNI 코드 작성은, 모든 실습을 끝마치고 나서 진행하려 합니다. 또한 앞으로 언급될 개념들은 #2 기초 이론 편에서 이미 다뤘던 만큼, 자세히 설명되지 않은 것들을 중점으로 다루겠습니다.

📦 PID 네임스페이스란?

PID 네임스페이스에 대한 자세한 내용은 여기를 참고해주세요!

PID 네임스페이스는 특정 프로세스를 다른 프로세스들과 분리해, 스스로를 init 프로세스라고 인식하도록 합니다.

🔧 실습해보기

🖥 실습 환경 : Ubuntu 20.04 LTS, AMD64 아키텍처

1. unshare 명령어 사용

unshare -fp /bin/bash

위 명령어를 사용하게 되면, 새로운 bash 창이 열리게 됩니다.

  • unshare : 새 네임스페이스를 생성하고, 그곳에서 프로그램을 구동시켜주는 명령어
  • -fp
    • f 옵션 : 특정 프로그램이 unshare 프로세스의 자식 프로세스로 구동되도록 포크합니다
      • 해당 옵션이 없는 경우에는, unshare에서 특정 프로그램을 직접 구동함
    • p 옵션 : PID 네임스페이스를 생성합니다
  • /bin/bash : bash shell을 격리할 프로그램으로 지정합니다

2. 차이점 비교해보기

자, 이제 다른 네임스페이스에 격리된 bash shell이 실행되었습니다.
현재 상황에서 이 프로그램은 자신을 init 프로세스로 인식할테니, 한번 pstree 명령어를 통해 확인해 볼까요?

pstree

어? 그런데 이상하게도 bash shell이 아닌 다른 프로그램들도 같이 뜹니다..
뭐가 문제일까요?

여러분은 프로세스 격리에 실패했다고 생각하실 수도 있지만, 사실 이런 현상은 정상적인 상황입니다.

3. PID 네임스페이스의 문제점

우선 왜 이러한 현상이 일어나는가?를 알기 위해서는 PID 네임스페이스의 역할을 정확히 알고, pstree의 작동법 또한 이해하고 있어야 합니다.

먼저 PID 네임스페이스는 앞서 언급한 것처럼, 단순히 특정 프로세스가 스스로를 init 프로세스라고 인식하도록(다른 프로세스들에 접근할 수 없도록) 격리하는 기능일 뿐입니다.

그리고, pstree나 ps 명령어와 같은 프로세스 조회 명령어들은 /proc이라는 디렉토리에서 프로세스들을 인식해 출력합니다.

혹시 무언가 눈치채셨나요?

네, 저희는 앞서 bash shell의 PID 네임스페이스만 격리했습니다.
이로 인해 bash shell로부터 실행된 pstree 명령어는 네임스페이스 밖 기준에서의 /proc 디렉토리를 스캔하게 되었고, bash shell 기준이 아닌 외부 기준의 pstree가 출력된 것입니다.

조금 더 쉽게 말하자면요..

  1. bash의 PID 네임스페이스만 격리함(즉, 마운트 네임스페이스는 기존 것을 사용중!)
  2. bash에서 pstree가 실행됨
  3. 마운트 네임스페이스는 격리되지 않았기 때문에, pstree는 외부(기존 파일시스템)에 있는 /proc 디렉토리에 접근함
  4. 외부 기준에서의 /proc 디렉토리기 때문에, 외부 기준에서의 pstree가 그려짐
  5. 우리는 이것을 보고, 격리에 실패한 것으로 오인할 수 있음!

그러면 이것을 어떻게 해결할 수 있을까요?

4. mount-proc 옵션

사실 리눅스 개발자들도 이러한 문제점을 알고 있었기에, 이를 해결할 수 있는 옵션 하나를 별도로 만들어 두었습니다.

바로 mount-proc이라는 옵션인데요, 이 옵션의 근간(?)이라고도 할 수 있는 mount 옵션(마운트 네임스페이스)에 대해서 저희는 아직 배우지 않았기 때문에 간단하게만 설명드리겠습니다.

문제의 /proc 폴더만 다른 마운트 네임스페이스로 격리시켜주는 옵션

이라고 생각하시면 될 거 같습니다.

그렇다면 이 옵션을 이용해 다시 bash shell을 열어보겠습니다.

unshare -fp --mount-proc /bin/bash

이제 다시 pstree 명령어를 입력해볼까요?

pstree

아마 다음과 같은 결과를 확인하실 수 있을겁니다!

bash───pstree

이제 격리된 bash shell 기준에서의 pstree를 얻는 것도 성공했으니, C언어를 통해 격리를 자동화하는 프로그램을 작성해보도록 하겠습니다.

⚙️ 프로그램 작성 & 실행

코드 작성하기

앞서 Linux의 명령어 쉘 환경에서는 unshare 명령을 통해서 namespace를 관리할 수 있었습니다. 그럼 C 언어에서는 어떨까요?

C 언어에서는 sched.h라는 헤더파일을 통해서 이를 사용할 수 있습니다. 다음과 같이 코드 파일을 작성해 봅시다 :

ℹ️ 안내: 아래 코드는 Github의 util-linux/util-linux, weary/unsharepp 레포지토리의 코드를 참고해 작성되었습니다.

#define _GNU_SOURCE

#include <stdio.h> // 표준 입출력
#include <sched.h> // unshare() 메소드 및 flags
#include <unistd.h> // execl() 메소드
#include <sys/wait.h> // wait() 메소드
#include <sys/mount.h> // mount() 메소드

// unshare 함수를 위한 인자
// PID 네임스페이스(p 옵션), FS 네임스페이스(mount를 위한 옵션)
const int flags = CLONE_NEWPID | CLONE_NEWNS;

// bash를 실행하는 함수
void spawnShell() {
    execl("/bin/bash", "bash", (char*)NULL);
}

// '/proc' 디렉터리를 재마운트하는 함수
int remountProc() {
    if(mount("none", "/proc", "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL)) return -1;
    return 0;
}

int main() {
    // 네임스페이스 구성
    if(unshare(flags) == -1) {
        printf("Unshare failed!\n");
        return -1;
    }
    printf("unshared process successfully\n");
    
    // 자식 프로세스 생성(분기점)
    int forkResponse = fork();
    
    // 현 프로세스의 종류에 따라 기능 분리
    switch(forkResponse) {
        case 0: // 자식 프로세스일 때
            printf("현재 자식 프로세스입니다 / PID : %d\n", getpid());

            if(remountProc() == -1) {
                printf("Mounting /proc failed!");
                return -1;
            }
            printf("Mount successed!\n");
            
            printf("Spawning /bin/bash :\n");
            spawnShell(); // '/bin/bash' 실행

            break;
        case -1: // 부모 프로세스에서 오류 발생 시
            printf("fork() 실행 중 오류 발생\n");
            break;
        default: // 부모 프로세스일 때(자식 PID 반환)
            printf("부모(PID %d)로부터 자식 프로세스(PID %d) 생성\n", getpid(), forkResponse);
            wait(NULL); // 중요: 자식 프로세스(/bin/bash)가 종료될 때까지 대기함.
            
            printf("Child process has exited.\n\n");
    }

    return 0;
}

핵심 코드

unshare의 flag

// unshare 함수를 위한 인자
// PID 네임스페이스(p 옵션), FS 네임스페이스(mount를 위한 옵션)
const int flags = CLONE_NEWPID | CLONE_NEWNS;
...


int main() {
    // 네임스페이스 구성
    if(unshare(flags) == -1) {
    	...

보시는 것처럼 unshare 메소드의 호출에는 flags라는 인자값이 넘어갑니다. 이 인자는 unshare을 수행할 때, 어떤 네임스페이스들을 생성할지를 결정하게 됩니다.

그런데 flags의 초기화 값을 보시면, 여러 상수들을 비트 연산하는 것을 보실 수 있습니다. 왜일까요?

JAVA와 같이 객체지향 언어를 다루시는 분들은, 주로 메소드에 Bool만으로 이루어진 여러 설정값을 넘길 때 클래스를 만들어서 넘기기 마련입니다. 다음과 같은 형식으로 말이죠:

class Config {
	boolean CLONE_NEWPID;
    boolean CLONE_NEWNS;
}

하지만 C 언어 같은 경우에는 클래스를 지원하지 않을 뿐더러, 대체재로서 구조체가 존재하기는 하지만 구현의 특성 상 메모리의 낭비가 심한 편입니다. 따라서 C 언어에서는 bit 연산을 통한 flag를 주로 사용하고 있습니다.

일반적으로 정수형은 4byte이니, 정수형 하나는 32bit의 크기를 가집니다. 따라서 총 32개의 Bool(True/False) 필드를 나타낼 수도 있는 것이죠.

예제 코드에서는 CLONE_NEWPIDCLONE_NEWNS 옵션이 flag에 들어가게 됩니다. 두 옵션을 Bit OR 연산하는 것이니, 최종적으로 flag 값은 다음과 같이 나오게 됩니다 :

  00100000000000000000000000000000 (CLONE_NEWPID)
+ 00000000000000100000000000000000 (CLONE_NEWNS)
  ―――――――――――――――――――――――――――――――――――――
= 00100000000000100000000000000000

fork 호출

// 자식 프로세스 생성(분기점)
int forkResponse = fork();
    
// 현 프로세스의 종류에 따라 기능 분리
switch(forkResponse) {
	...

fork() 메소드는 프로세스 스스로의 메모리를 복사해, 자식 프로세스를 생성하는 기능을 가집니다. 이후 부모 프로세스에서는 생성된 자식 프로세스의 PID를 반환하고, 자식 프로세스에서는 0을 반환합니다.

이것을 그림으로 표현하자면 다음과 같습니다 :

예제 실행해보기

이후 다음과 같은 명령어로, 코드를 컴파일하고 실행해봅시다 :

sudo su & gcc -o myContainer 코드파일명.c && ./myContainer

이렇게 실행하고 나면, 다음과 같은 화면이 표시됩니다.

unshared process successfully
부모(PID <부모 PID>)로부터 자식 프로세스(PID <자식 PID>) 생성
현재 자식 프로세스입니다 / PID : 1
Mount successed!
Spawning /bin/bash :
root@<호스트명>:/<디렉터리명># 

이제 한번 pstree 명령을 사용해 볼까요?

bash───pstree

앞서 unshare 명령어로 구현했던 것과 동일하게, bash가 init 프로세스로 인식된 것을 확인할 수 있습니다!

👏 마무리하며

이번 글 작성에 약 3주라는 긴 시간이 걸린 점 사과드립니다. 사실 글 작성 자체는 오래 걸리지 않았는데, 학교 내 영상편집 업무와 제 게으름으로 인해 완성이 늦었던 거 같습니다. 앞으로는 너무 긴 시간동안 지체되지 않도록 하겠습니다.

다음 게시글에서는 FS 네임스페이스를 본격적으로 다뤄보도록 하겠습니다.
감사합니다.

좋은 웹페이지 즐겨찾기