분리 불안: Linux 네임스페이스로 시스템을 격리하기 위한 자습서
게시 됨: 2022-03-11Docker, Linux Containers 등과 같은 도구의 출현으로 Linux 프로세스를 자체의 작은 시스템 환경으로 분리하는 것이 매우 쉬워졌습니다. 이를 통해 단일 실제 Linux 시스템에서 전체 범위의 응용 프로그램을 실행할 수 있으며 가상 시스템을 사용하지 않고도 두 응용 프로그램이 서로 간섭하지 않도록 할 수 있습니다. 이러한 도구는 PaaS 제공업체에게 큰 도움이 되었습니다. 그러나 후드 아래에서 정확히 어떤 일이 발생합니까?
이러한 도구는 Linux 커널의 여러 기능과 구성 요소에 의존합니다. 이러한 기능 중 일부는 상당히 최근에 도입된 반면 다른 기능은 여전히 커널 자체를 패치해야 합니다. 그러나 Linux 네임스페이스를 사용하는 주요 구성 요소 중 하나는 2008년 버전 2.6.24가 릴리스된 이후 Linux의 기능이었습니다.
chroot
에 익숙한 사람은 이미 Linux 네임스페이스가 무엇을 할 수 있고 네임스페이스를 일반적으로 사용하는 방법에 대한 기본 아이디어를 가지고 있습니다. chroot
를 사용하면 프로세스가 임의의 디렉토리를 시스템의 루트로 볼 수 있는 것처럼(나머지 프로세스와 무관) Linux 네임스페이스를 사용하면 운영 체제의 다른 측면도 독립적으로 수정할 수 있습니다. 여기에는 프로세스 트리, 네트워킹 인터페이스, 마운트 지점, 프로세스 간 통신 리소스 등이 포함됩니다.
프로세스 격리에 네임스페이스를 사용하는 이유
단일 사용자 컴퓨터에서는 단일 시스템 환경이 적합할 수 있습니다. 하지만 여러 서비스를 운영하고자 하는 서버에서는 보안과 안정성을 위해 서비스가 최대한 격리되는 것이 필수입니다. 여러 서비스를 실행하는 서버 중 하나가 침입자에 의해 손상되었다고 상상해 보십시오. 이러한 경우 침입자는 해당 서비스를 악용하고 다른 서비스로 이동하고 전체 서버를 손상시킬 수도 있습니다. 네임스페이스 격리는 이러한 위험을 제거하는 안전한 환경을 제공할 수 있습니다.
예를 들어 네임스페이스를 사용하면 서버에서 임의의 또는 알 수 없는 프로그램을 안전하게 실행할 수 있습니다. 최근 HackerRank, TopCoder, Codeforces 등과 같은 프로그래밍 대회 및 "해커톤" 플랫폼의 수가 증가하고 있습니다. 그들 중 다수는 자동화된 파이프라인을 활용하여 참가자가 제출한 프로그램을 실행하고 검증합니다. 참가자 프로그램의 정체를 미리 알 수 없는 경우가 많으며 일부 프로그램에는 악성 요소가 포함될 수도 있습니다. 시스템의 나머지 부분과 완전히 격리된 상태로 네임스페이스가 지정된 이러한 프로그램을 실행하면 나머지 시스템을 위험에 빠뜨리지 않고도 소프트웨어를 테스트하고 검증할 수 있습니다. 마찬가지로 Drone.io와 같은 온라인 지속적 통합 서비스는 자동으로 코드 저장소를 가져와 자체 서버에서 테스트 스크립트를 실행합니다. 다시 말하지만, 네임스페이스 격리를 통해 이러한 서비스를 안전하게 제공할 수 있습니다.
Docker와 같은 네임스페이스 도구를 사용하면 프로세스의 시스템 리소스 사용을 더 잘 제어할 수 있으므로 이러한 도구는 PaaS 제공업체에서 사용하기에 매우 인기가 있습니다. Heroku 및 Google App Engine과 같은 서비스는 이러한 도구를 사용하여 동일한 실제 하드웨어에서 여러 웹 서버 애플리케이션을 격리하고 실행합니다. 이러한 도구를 사용하면 하나의 응용 프로그램이 너무 많은 시스템 리소스를 사용하거나 동일한 시스템에 배포된 다른 서비스를 방해 및/또는 충돌하는 것에 대해 걱정하지 않고 각 응용 프로그램(여러 다른 사용자가 배포했을 수 있음)을 실행할 수 있습니다. 이러한 프로세스 격리를 사용하면 격리된 각 환경에 대해 완전히 다른 종속성 소프트웨어(및 버전) 스택을 가질 수도 있습니다!
Docker와 같은 도구를 사용한 적이 있다면 이러한 도구가 프로세스를 작은 "컨테이너"로 격리할 수 있다는 것을 이미 알고 있습니다. Docker 컨테이너에서 프로세스를 실행하는 것은 가상 머신에서 프로세스를 실행하는 것과 같으며 이러한 컨테이너만 가상 머신보다 훨씬 가볍습니다. 가상 머신은 일반적으로 운영 체제 위에 있는 하드웨어 계층을 에뮬레이트한 다음 그 위에서 다른 운영 체제를 실행합니다. 이를 통해 실제 운영 체제와 완전히 격리된 상태에서 가상 머신 내에서 프로세스를 실행할 수 있습니다. 그러나 가상 머신은 무겁습니다! 반면 Docker 컨테이너는 네임스페이스를 비롯한 실제 운영 체제의 일부 주요 기능을 사용하고 유사한 수준의 격리를 보장하지만 하드웨어를 에뮬레이트하고 동일한 시스템에서 다른 운영 체제를 실행하지 않습니다. 이것은 그것들을 매우 가볍게 만듭니다.
프로세스 네임스페이스
역사적으로 Linux 커널은 단일 프로세스 트리를 유지했습니다. 트리에는 현재 상위-하위 계층에서 실행 중인 모든 프로세스에 대한 참조가 포함됩니다. 충분한 권한이 있고 특정 조건을 충족하는 프로세스는 추적자를 첨부하여 다른 프로세스를 검사하거나 심지어 종료할 수도 있습니다.
Linux 네임스페이스의 도입으로 여러 "중첩된" 프로세스 트리를 가질 수 있게 되었습니다. 각 프로세스 트리에는 완전히 격리된 프로세스 집합이 있을 수 있습니다. 이것은 하나의 프로세스 트리에 속한 프로세스가 다른 형제 또는 상위 프로세스 트리에 있는 프로세스를 검사하거나 종료할 수 없도록 보장할 수 있습니다.
Linux가 설치된 컴퓨터가 부팅될 때마다 프로세스 식별자(PID)가 1인 하나의 프로세스로 시작합니다. 이 프로세스는 프로세스 트리의 루트이며 적절한 유지 관리 작업을 수행하고 시작하여 나머지 시스템을 시작합니다. 올바른 데몬/서비스. 다른 모든 프로세스는 트리에서 이 프로세스 아래에서 시작됩니다. PID 네임스페이스를 사용하면 자체 PID 1 프로세스를 통해 새 트리를 분리할 수 있습니다. 이 작업을 수행하는 프로세스는 원래 트리의 부모 네임스페이스에 남아 있지만 자식을 자체 프로세스 트리의 루트로 만듭니다.
PID 네임스페이스 격리를 사용하면 자식 네임스페이스의 프로세스는 부모 프로세스의 존재를 알 방법이 없습니다. 그러나 부모 네임스페이스의 프로세스는 마치 부모 네임스페이스의 다른 프로세스인 것처럼 자식 네임스페이스의 프로세스를 완전히 볼 수 있습니다.
자식 네임스페이스의 중첩된 집합을 만드는 것이 가능합니다. 한 프로세스가 새 PID 네임스페이스에서 자식 프로세스를 시작하고 해당 자식 프로세스가 새 PID 네임스페이스에서 또 다른 프로세스를 생성하는 식입니다.
PID 네임스페이스의 도입으로 단일 프로세스는 이제 자신이 속한 각 네임스페이스에 대해 하나씩 연결된 여러 PID를 가질 수 있습니다. Linux 소스 코드에서 단일 PID만 추적하던 pid
라는 구조체가 이제 upid
라는 구조체를 사용하여 여러 PID를 추적하는 것을 볼 수 있습니다.
struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };
새 PID 네임스페이스를 생성하려면 특수 플래그 CLONE_NEWPID
를 사용하여 clone()
시스템 호출을 호출해야 합니다. (C는 이 시스템 호출을 노출하는 래퍼를 제공하며 다른 많은 인기 언어도 마찬가지입니다.) 아래에서 논의되는 다른 네임스페이스도 unshare()
시스템 호출을 사용하여 생성할 수 있는 반면 PID 네임스페이스는 새로운 시간에만 생성할 수 있습니다. 프로세스는 clone()
을 사용하여 생성됩니다. 이 플래그와 함께 clone()
이 호출되면 새 프로세스는 새 프로세스 트리 아래의 새 PID 네임스페이스에서 즉시 시작됩니다. 이것은 간단한 C 프로그램으로 증명할 수 있습니다.
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }
루트 권한으로 이 프로그램을 컴파일하고 실행하면 다음과 유사한 출력이 표시됩니다.
clone() = 5304 PID: 1
child_fn
내에서 인쇄된 PID는 1
이 됩니다.
위의 이 네임스페이스 튜토리얼 코드는 일부 언어에서 "Hello, world"보다 훨씬 길지 않지만, 많은 일이 뒤에서 일어났습니다. 예상대로 clone()
함수는 현재 프로세스를 복제하여 새 프로세스를 만들고 child_fn()
함수의 시작 부분에서 실행을 시작했습니다. 그러나 그렇게 하는 동안 원래 프로세스 트리에서 새 프로세스를 분리하고 새 프로세스에 대해 별도의 프로세스 트리를 만들었습니다.
고립 된 프로세스의 관점에서 부모 PID를 인쇄하려면 static int child_fn()
함수를 다음으로 교체하십시오.
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
이번에 프로그램을 실행하면 다음과 같은 결과가 출력됩니다.
clone() = 11449 Parent PID: 0
격리된 프로세스의 관점에서 부모 PID가 0이고 부모가 없음을 나타냅니다. 동일한 프로그램을 다시 실행해 보십시오. 하지만 이번에는 clone()
함수 호출 내에서 CLONE_NEWPID
플래그를 제거하십시오.
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
이번에는 상위 PID가 더 이상 0이 아님을 알 수 있습니다.
clone() = 11561 Parent PID: 11560
그러나 이것은 자습서의 첫 번째 단계일 뿐입니다. 이러한 프로세스는 여전히 다른 공통 또는 공유 리소스에 제한 없이 액세스할 수 있습니다. 예를 들어 네트워킹 인터페이스: 위에서 만든 자식 프로세스가 포트 80에서 수신 대기하는 경우 시스템의 다른 모든 프로세스가 포트에서 수신 대기할 수 없습니다.
리눅스 네트워크 네임스페이스
여기서 네트워크 네임스페이스가 유용해집니다. 네트워크 네임스페이스를 사용하면 이러한 각 프로세스에서 완전히 다른 네트워킹 인터페이스 집합을 볼 수 있습니다. 루프백 인터페이스도 네트워크 네임스페이스마다 다릅니다.
프로세스를 자체 네트워크 네임스페이스로 격리하려면 clone()
함수 호출에 다른 플래그를 도입해야 합니다. CLONE_NEWNET
;
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
산출:
Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
무슨 일이야? 물리적 이더넷 장치 enp4s0
은 이 네임스페이스에서 실행되는 "ip" 도구에 표시된 대로 전역 네트워크 네임스페이스에 속합니다. 그러나 물리적 인터페이스는 새 네트워크 네임스페이스에서 사용할 수 없습니다. 또한 루프백 장치는 원래 네트워크 이름 공간에서 활성화되지만 자식 네트워크 이름 공간에서는 "다운" 상태입니다.

자식 네임스페이스에서 사용 가능한 네트워크 인터페이스를 제공하려면 여러 네임스페이스에 걸쳐 있는 추가 "가상" 네트워크 인터페이스를 설정해야 합니다. 이 작업이 완료되면 이더넷 브리지를 만들고 네임스페이스 간에 패킷을 라우팅할 수도 있습니다. 마지막으로, 모든 것이 작동하도록 하려면 물리적 인터페이스에서 트래픽을 수신하고 적절한 가상 인터페이스를 통해 올바른 자식 네트워크 네임스페이스로 라우팅하기 위해 전역 네트워크 네임스페이스에서 "라우팅 프로세스"가 실행되어야 합니다. 이 힘든 일을 모두 처리해 주는 Docker와 같은 도구가 왜 그렇게 인기가 있는지 알 수 있을 것입니다!
이를 직접 수행하려면 상위 네임스페이스에서 단일 명령을 실행하여 상위 네임스페이스와 하위 네임스페이스 사이에 가상 이더넷 연결 쌍을 생성할 수 있습니다.
ip link add name veth0 type veth peer name veth1 netns <pid>
여기서 <pid>
는 부모가 관찰한 자식 네임스페이스에 있는 프로세스의 프로세스 ID로 대체되어야 합니다. 이 명령을 실행하면 이 두 네임스페이스 간에 파이프와 같은 연결이 설정됩니다. 상위 네임스페이스는 veth1
장치를 유지하고 veth0
장치를 하위 네임스페이스로 전달합니다. 두 개의 실제 노드 간의 실제 이더넷 연결에서 예상하는 것처럼 끝 중 하나에 들어가는 모든 것이 다른 끝을 통해 나옵니다. 따라서 이 가상 이더넷 연결의 양쪽에 IP 주소가 할당되어야 합니다.
마운트 네임스페이스
Linux는 또한 시스템의 모든 마운트 지점에 대한 데이터 구조를 유지 관리합니다. 여기에는 마운트된 디스크 파티션, 마운트된 위치, 읽기 전용인지 여부 등과 같은 정보가 포함됩니다. Linux 네임스페이스를 사용하면 이 데이터 구조를 복제할 수 있으므로 서로 다른 네임스페이스에 있는 프로세스가 서로 영향을 미치지 않고 마운트 지점을 변경할 수 있습니다.
별도의 마운트 네임스페이스를 만드는 것은 chroot()
를 수행하는 것과 유사한 효과가 있습니다. chroot()
는 좋지만 완전한 격리를 제공하지 않으며 그 효과는 루트 마운트 지점에만 제한됩니다. 별도의 마운트 네임스페이스를 생성하면 이러한 분리된 각 프로세스가 전체 시스템의 마운트 지점 구조에 대해 원래의 것과 완전히 다른 보기를 가질 수 있습니다. 이렇게 하면 격리된 각 프로세스에 대해 다른 루트와 해당 프로세스에 특정한 다른 마운트 지점을 가질 수 있습니다. 이 튜토리얼에 따라 주의해서 사용하면 기본 시스템에 대한 정보가 노출되는 것을 방지할 수 있습니다.
이를 달성하는 데 필요한 clone()
플래그는 CLONE_NEWNS
입니다.
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
처음에 자식 프로세스는 부모 프로세스와 똑같은 마운트 지점을 봅니다. 그러나 새 마운트 네임스페이스 아래에 있는 자식 프로세스는 원하는 엔드포인트를 마운트하거나 마운트 해제할 수 있으며 변경 사항은 부모의 네임스페이스나 전체 시스템의 다른 마운트 네임스페이스에 영향을 미치지 않습니다. 예를 들어, 상위 프로세스의 루트에 마운트된 특정 디스크 파티션이 있는 경우 격리된 프로세스는 처음에 루트에 마운트된 똑같은 디스크 파티션을 보게 됩니다. 그러나 변경 사항은 격리된 마운트 네임스페이스에만 영향을 미치기 때문에 분리된 프로세스가 루트 파티션을 다른 것으로 변경하려고 할 때 마운트 네임스페이스를 분리하는 이점이 분명합니다.
흥미롭게도 이것은 실제로 CLONE_NEWNS
플래그를 사용하여 대상 자식 프로세스를 직접 생성하는 것은 좋지 않습니다. 더 나은 접근 방식은 CLONE_NEWNS
플래그로 특별한 "초기화" 프로세스를 시작하고 해당 "초기화" 프로세스가 원하는 대로 "/", "/proc", "/dev" 또는 기타 마운트 지점을 변경하도록 한 다음 대상 프로세스를 시작하는 것입니다. . 이것은 이 네임스페이스 자습서의 끝 부분에서 조금 더 자세히 설명합니다.
기타 네임스페이스
이러한 프로세스를 격리할 수 있는 다른 네임스페이스, 즉 사용자, IPC 및 UTS가 있습니다. 사용자 네임스페이스를 사용하면 프로세스가 네임스페이스 외부의 프로세스에 대한 액세스 권한을 부여하지 않고 네임스페이스 내에서 루트 권한을 가질 수 있습니다. IPC 네임스페이스로 프로세스를 분리하면 System V IPC 및 POSIX 메시지와 같은 자체 프로세스 간 통신 리소스가 제공됩니다. UTS 네임스페이스는 시스템의 두 가지 특정 식별자인 nodename
과 domainname
을 분리합니다.
UTS 네임스페이스가 격리되는 방법을 보여주는 빠른 예는 다음과 같습니다.
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }
이 프로그램은 다음 출력을 생성합니다.
Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT
여기에서 child_fn()
은 nodename
을 인쇄하고, 이를 다른 것으로 변경하고, 다시 인쇄합니다. 당연히 변경은 새 UTS 네임스페이스 내에서만 발생합니다.
모든 네임스페이스가 제공하고 분리하는 것에 대한 자세한 정보는 여기 튜토리얼에서 찾을 수 있습니다.
네임스페이스 간 통신
종종 부모와 자식 네임스페이스 간에 일종의 통신을 설정해야 합니다. 이것은 격리된 환경 내에서 구성 작업을 수행하기 위한 것일 수도 있고 단순히 외부에서 해당 환경의 상태를 엿볼 수 있는 기능을 유지하기 위한 것일 수도 있습니다. 이를 수행하는 한 가지 방법은 해당 환경 내에서 SSH 데몬을 계속 실행하는 것입니다. 각 네트워크 네임스페이스 내부에 별도의 SSH 데몬을 가질 수 있습니다. 그러나 여러 SSH 데몬을 실행하면 메모리와 같은 귀중한 리소스가 많이 사용됩니다. 이것은 특별한 "초기화" 프로세스를 갖는 것이 다시 좋은 생각임을 증명하는 곳입니다.
"초기화" 프로세스는 부모 네임스페이스와 자식 네임스페이스 사이에 통신 채널을 설정할 수 있습니다. 이 채널은 UNIX 소켓을 기반으로 하거나 TCP를 사용할 수도 있습니다. 두 개의 서로 다른 마운트 네임스페이스에 걸쳐 있는 UNIX 소켓을 만들려면 먼저 자식 프로세스를 만든 다음 UNIX 소켓을 만든 다음 자식을 별도의 마운트 네임스페이스로 격리해야 합니다. 그러나 어떻게 먼저 프로세스를 생성하고 나중에 분리할 수 있습니까? Linux는 unshare()
를 제공합니다. 이 특별한 시스템 호출을 통해 프로세스는 부모가 처음에 자식을 격리하도록 하는 대신 원래 네임스페이스에서 자신을 격리할 수 있습니다. 예를 들어 다음 코드는 네트워크 네임스페이스 섹션에서 이전에 언급한 코드와 정확히 동일한 효과를 가집니다.
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
그리고 "초기화" 프로세스는 사용자가 고안한 것이므로 먼저 필요한 모든 작업을 수행하도록 만든 다음 대상 자식을 실행하기 전에 시스템의 나머지 부분에서 자신을 격리할 수 있습니다.
결론
이 자습서는 Linux에서 네임스페이스를 사용하는 방법에 대한 개요입니다. Linux 개발자가 Docker 또는 Linux 컨테이너와 같은 도구 아키텍처의 필수적인 부분인 시스템 격리를 구현하는 방법에 대한 기본 아이디어를 제공해야 합니다. 대부분의 경우 이미 잘 알려져 있고 테스트를 거친 이러한 기존 도구 중 하나를 사용하는 것이 가장 좋습니다. 그러나 어떤 경우에는 고유한 사용자 정의 프로세스 격리 메커니즘을 갖는 것이 합리적일 수 있으며 이 경우 이 네임스페이스 자습서가 큰 도움이 될 것입니다.
이 기사에서 다룬 것보다 더 많은 일이 내부적으로 진행 중이며 추가 안전과 격리를 위해 대상 프로세스를 제한할 수 있는 더 많은 방법이 있습니다. 그러나 이것이 Linux를 사용한 네임스페이스 격리가 실제로 작동하는 방식에 대해 더 알고자 하는 사람에게 유용한 출발점이 될 수 있기를 바랍니다.