애플리케이션 격리, NGINX Unit 앱 서버

NGINX Unit 기능 중 하나는 Linux Namespace를 통해 구현된 애플리케이션 격리 지원입니다.

Linux Namespace에 대한 간략한 요약부터 시작하겠습니다. 기본적으로 이는 프로세스 그룹이 다른 프로세스 그룹이 공유하는 리소스와 별도로 여러 유형의 시스템 리소스를 공유할 수 있도록 하는 커널(Kernel) 메커니즘입니다. 커널은 Namespace의 프로세스가 해당 Namespace에 할당된 리소스에만 액세스하도록 보장합니다. 서로 다른 두 Namespace의 프로세스가 일부 리소스를 공유할 수 있지만, 다른 리소스는 다른 Namespace의 프로세스에 “보이지 않습니다”. Namespace에서 격리할 수 있는 리소스 유형은 OS에 따라 다르지만 프로세스 및 사용자 ID, 프로세스 간 통신 엔터티(Entity), 파일 시스템의 Mount 지점, 네트워킹 개체 등을 포함합니다.

약간 단조롭게 들리나요? 특히 운영 체제 기술에 익숙하지 않은 경우 특히 그렇습니다. 그러나 Namespace는 컨테이너화 혁명의 핵심 요소 중 하나입니다. 단일 OS 인스턴스 내에서 애플리케이션 프로세스를 분리 및 격리하면 컨테이너에서 애플리케이션을 실행하는 데 필요한 중요한 보안 및 확장 메커니즘이 가능합니다.

목차

1. 아이디어
2. 구성
3. 사용자 및 그룹 ID 매핑
4. 기본 애플리케이션 격리 방법
5. 네트워킹을 위한 애플리케이션 격리 방법
6. 결론

1. 아이디어

이제 Namespace가 있으면 좋을 것 같다는 사실을 확인했습니다. 하지만 이 문제에 대한 NGINX Unit의 견해는 무엇입니까? 더 진행하기 전에 Tiago 의견을 듣고 배경을 간략하게 설명하겠습니다.

애플리케이션의 트래픽을 모니터링하고 가로채기 위한 더 나은 옵션을 조사하고 있었습니다. 여가 시간에 NGINX Unit의 내부를 연구하고 프로세스 격리가 적합할 수 있다고 생각했습니다. 그러나 아직 최선의 접근 방식이 무엇인지 확신하지 못했습니다. 이전에는 eBPF를 고려하고 커널 수준에서 패킷을 Redirect하는 방법을 조사했지만 다른 생각이 들었습니다. NGINX Unit은 컨테이너 런타임과 유사한 방식으로 애플리케이션을 실행하고 관리하므로 NGINX Unit에 대한 애플리케이션 격리 지원을 추가하고 런타임 대신 사용하는 것이 NGINX Unit팀이 미래에 대해 구상한 것 중 하나였습니다.

클러스터에서 컨테이너 런타임은 애플리케이션을 시작하고 중지하므로 클러스터에서 실행 중인 모든 것을 인식합니다. 한편, NGINX Unit의 아키텍처는 동일하지만 기본적으로 트래픽 모니터링 및 가로채기를 구현합니다. 애플리케이션에 도달하는 유일한 방법은 NGINX Unit의 공유 메모리 모델입니다. 흥미로운 점은 컨테이너 내부의 인터페이스 설정을 건너뛰는 것과 유사하게 네트워크를 격리할 수도 있지만, 비용이 많이 드는 네트워킹 해킹 없이도 애플리케이션이 NGINX Unit과 메모리를 공유하여 [외부 세계와] 통신할 수 있다는 것입니다.

2. 구성

구성 측면에서 볼 때 모든 것은 애플리케이션 개체 내에서 Namespace 관련 설정을 정의하는 새로운 격리 개체로 귀결됩니다.

Namespace로 분리할 수 있는 리소스 유형이 OS마다 다르기 때문에 격리 개체의 Namespace 옵션은 시스템에 따라 다릅니다. 다음은 애플리케이션에 대한 별도의 사용자 ID 및 Mount 지점 Namespace를 생성하는 기본 예입니다.

{  
   "applications": {  
      "isolation_app": {  
         "type": "external",
         "executable": "/tmp/go-app",
         "isolation": {  
            "namespaces": {  
               "credential": true,
               "mount": "true"
            }
         }
      }
   }
}

현재 NGINX Unit은 Linux 커널에서 지원하는 7가지 Namespace 격리 유형 중 6가지 구성을 지원합니다. 해당 구성 옵션은 cgroup, credential, pid, mount, network 및 uname입니다. 마지막 유형인 ipc는 예정되어 있습니다.

기본적으로 모든 격리 유형은 비활성화되어 있습니다(옵션이 false로 설정됨). 즉, 애플리케이션이 NGINX Unit의 Namespace에 있음을 의미합니다. 해당 옵션을 true로 설정하여 애플리케이션에 대해 특정 격리 유형을 활성화하면 NGINX Unit이 애플리케이션에 대해 해당 유형의 별도 Namespace를 생성합니다. 따라서 예를 들어, 애플리케이션은 자체에 별도의 Mount 또는 자격 증명 Namespace가 있다는 점을 제외하고는 NGINX Unit과 동일한 Namespace에 상주할 수 있습니다.

Note: 작성 당시 모든 애플리케이션은 NGINX Unit과 동일한 ipc Namespace를 사용해야 합니다. 이는 공유 메모리 메커니즘에 필요합니다. 구성에 ipc 옵션을 포함할 수 있지만 해당 설정은 적용되지 않습니다. 이 상황은 향후 릴리스에서 변경될 수 있습니다.

3. 사용자 및 그룹 ID 매핑

NGINX Unit의 애플리케이션 격리에는 자격 증명 격리가 활성화된 경우(애플리케이션이 별도의 자격 증명 Namespace에서 실행됨을 의미) 구성할 수 있는 UID 및 GID 매핑에 대한 지원이 포함됩니다. 애플리케이션의 Namespace(컨테이너 Namespace 함)의 ID 범위를 애플리케이션 상위 프로세스의 자격 증명 Namespace(호스트 Namespace라고 함)에 있는 동일한 길이의 ID 범위로 매핑할 수 있습니다.

예를 들어 권한이 없는 사용자 자격 증명으로 애플리케이션을 실행한 다음 자격 증명 격리를 활성화하여 애플리케이션에 대한 컨테이너 Namespace를 생성한다고 가정합니다. NGINX Unit을 사용하면 호스트 Namespace에서 권한이 없는 사용자의 UID를 컨테이너 Namespace 내부의 UID 0(root)에 매핑할 수 있습니다. 설계상 모든 Namespace의 UID 0은 해당 Namespace에 대한 전체 권한을 갖지만 호스트 Namespace에서 매핑된 해당 항목의 권한은 제한됩니다. 따라서 애플리케이션에 root 기능이 있는 것처럼 보이지만 해당 Namespace 내의 리소스에 대해서만 가능합니다. GID 매핑에도 동일한 고려 사항이 적용됩니다.

여기에서 호스트 Namespace의 UID 500에서 시작하는 UID의 10개 항목 범위를 컨테이너 Namespace(호스트: 500–509, 컨테이너: 0–9)의 UID 0에서 시작하는 UID 범위에 매핑합니다. 마찬가지로 호스트 Namespace의 GID 1000에서 시작하는 GID의 20개 항목 범위를 컨테이너 Namespace의 GID 0에서 시작하는 범위(호스트: 1000–1019, 컨테이너: 0–19)에 매핑합니다.

{  
   "applications": {  
      "isolation_app": {  
         "type": "external",
         "executable": "/bin/app",
         "isolation": {  
            "namespaces": {  
               "credential": true
            },
            "uidmap": [  
               {  
                  "container": 0,
                  "host": 500,
                  "size": 10
               }
            ],
            "gidmap": [  
               {  
                  "container": 0,
                  "host": 1000,
                  "size": 20
               }
            ]
         }
      }
   }
}

명시적 UID 및 GID 매핑을 생성하지 않으면 기본적으로 호스트 Namespace에서 권한이 없는 NGINX Unit 프로세스의 현재 유효 UID(EUID)가 컨테이너 Namespace의 root UID에 매핑됩니다. 또한 UID/GID 매핑은 호스트 OS가 사용자 Namespace를 지원하는 경우에만 사용할 수 있습니다. 지금까지 NGINX Unit에서 실행되는 애플리케이션에 대한 애플리케이션 격리의 영향을 계속 알아보겠습니다.

4. 기본 애플리케이션 격리 방법

기본 사항부터 시작하여 런타임에 기능이 어떻게 작동하는지 살펴보겠습니다. 이를 위해 공식 repository의 Go 애플리케이션 중 하나를 사용합니다.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"unit.nginx.org/go"
	"os"
	"strconv"
)

type (
	NS struct {
		USER   uint64
		PID    uint64
		IPC    uint64
		CGROUP uint64
		UTS    uint64
		MNT    uint64
		NET    uint64
	}

	Output struct {
		PID        int
		UID        int
		GID        int
		NS         NS
		FileExists bool
	}
)

func abortonerr(err error) {
	if err != nil {
		panic(err)
	}
}

// returns: [nstype]:[4026531835]
func getns(nstype string) uint64 {
	str, err := os.Readlink(fmt.Sprintf("/proc/self/ns/%s", nstype))
	if err != nil {
		return 0
	}

	str = str[len(nstype)+2:]
	str = str[:len(str)-1]
	val, err := strconv.ParseUint(str, 10, 64)
	abortonerr(err)
	return val
}

func handler(w http.ResponseWriter, r *http.Request) {
	pid := os.Getpid()
	out := &Output{
		PID: pid,
		UID: os.Getuid(),
		GID: os.Getgid(),
		NS: NS{
			PID:    getns("pid"),
			USER:   getns("user"),
			MNT:    getns("mnt"),
			IPC:    getns("ipc"),
			UTS:    getns("uts"),
			NET:    getns("net"),
			CGROUP: getns("cgroup"),
		},
	}

	err := r.ParseForm()
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	if fname := r.Form.Get("file"); fname != "" {
		_, err = os.Stat(fname);
		out.FileExists = err == nil
	}

	data, err := json.Marshal(out)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.Header().Add("Content-Type", "application/json")

	w.Write(data)
}

func main() {
	http.HandleFunc("/", handler)
	unit.ListenAndServe(":7080", nil)
}

이 코드는 /proc/self/ns/ 디렉토리의 콘텐츠를 열거하는 애플리케이션 프로세스 및 Namespace ID의 JSON 형식 인벤토리로 요청에 응답합니다. 지금은 격리 개체를 생략하고 NGINX Unit에서 애플리케이션을 구성해 보겠습니다.

{  
   "listeners": {  
      "*:8080": {  
         "pass": "applications/go-app"
      }
   },
   "applications": {  
      "go-app": {  
         "type": "external",
         "executable": "/tmp/go-app"
      }
   }
}

실행 중인 애플리케이션 인스턴스의 HTTP 응답:

$ curl -X GET http://localhost:8080

{  
   "PID": 5778,
   "UID": 65534,
   "GID": 65534,
   "NS": {  
      "USER": 4026531837,
      "PID": 4026531836,
      "IPC": 4026531839,
      "CGROUP": 4026531835,
      "UTS": 4026531838,
      "MNT": 4026531840,
      "NET": 4026531992
   }
}

이제 애플리케이션 격리를 활성화하기 위해 격리 개체를 추가합니다. 격리 메커니즘을 적용하려면 애플리케이션을 다시 시작해야 합니다. 편리하게도 NGINX Unit이 뒤에서 이를 처리하므로 최종 사용자의 관점에서 업데이트가 매우 투명합니다.

{  
   "listeners": {  
      "*:8080": {  
         "pass": "applications/go-app"
      }
   },
   "applications": {  
      "go-app": {  
         "type": "external",
         "user": "root",
         "executable": "/tmp/go-app",
         "isolation": {  
            "namespaces": {  
               "cgroup": true,
               "credential": true,
               "mount": true,
               "network": true,
               "pid": true,
               "uname": true
            },
            "uidmap": [  
               {  
                  "host": 1000,
                  "container": 0,
                  "size": 1000
               }
            ],
            "gidmap": [  
               {  
                  "host": 1000,
                  "container": 0,
                  "size": 1000
               }
            ]
         }
      }
   }
}

사용자 옵션이 root로 설정되어 있습니다. 이는 컨테이너 Namespace에서 UID/GID 0에 대한 매핑을 활성화하는 데 필요합니다.

다음 명령을 다시 실행합니다.

$ curl -X GET http://localhost:8080

{  
   "PID": 1,
   "UID": 0,
   "GID": 0,
   "NS": {  
      "USER": 4026532180,
      "PID": 4026532184,
      "IPC": 4026531839,
      "CGROUP": 4026532185,
      "UTS": 4026532183,
      "MNT": 4026532181,
      "NET": 4026532187
   }
}

이제 애플리케이션 격리를 활성화했으므로 Namespace ID가 변경되었습니다. 이제 호스트 Namespace가 아닌 컨테이너 Namespace에 있는 ID입니다. 위에서 설명한 이유로 IPC만 동일하게 남아 있습니다.

5. 네트워킹을 위한 애플리케이션 격리 방법

좀 더 자세히 알아보기 위해 웹 애플리케이션에 중요한 네트워킹을 위한 애플리케이션 격리의 실질적인 의미를 살펴보겠습니다. 이를 위해 우리가 선택한 도구는 NGINX Unit이 지원하는 많은 OS 배포판에서 사용할 수 있는 Nsenter입니다. 이 유틸리티(Utility)를 사용하면 프로세스 Namespace 내에서 임의의 명령을 실행할 수 있으며, 위에서 구성한 동일한 Go 애플리케이션의 격리 개체에서 다른 설정으로 인해 발생하는 변경 사항을 수정할 수 있습니다. 먼저 호스트 PID를 확인합니다.

# ps aux | grep go-app
1000      5795  0.0  0.3 424040  7380 ?        Sl   14:51   0:00 /tmp/go-app

PID를 알면 컨테이너 Namespace에 들어가 내부를 탐색할 수 있습니다.

# nsenter --all -t 5795 /bin/sh
# ip a
1: lo:  mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
# id
uid=0(root) gid=0(root) groups=0(root)

루프백(Loopback) 인터페이스만 사용할 수 있습니다. 그러나 이 애플리케이션은 NGINX Unit을 통해 외부 HTTP 요청을 처리할 수 있습니다. 다음으로 구성의 Namespace 목록에서 네트워크 옵션을 제거하여 네트워크 격리가 비활성화된 애플리케이션의 네트워크 인터페이스 구성 결과를 확인합니다.

{  
   "listeners": {  
      "*:8080": {  
         "pass": "applications/go-app"
      }
   },
   "applications": {  
      "go-app": {  
         "type": "external",
         "user": "root",
         "executable": "/tmp/go-app",
         "isolation": {  
            "namespaces": {  
               "cgroup": true,
               "credential": true,
               "mount": true,
               "pid": true,
               "uname": true
            },
            "uidmap": [  
               {  
                  "host": 1000,
                  "container": 0,
                  "size": 1000
               }
            ],
            "gidmap": [  
               {  
                  "host": 1000,
                  "container": 0,
                  "size": 1000
               }
            ]
         }
      }
   }
}

그런 다음 위와 같은 단계를 반복합니다.

# ps aux | grep go-app
nobody    7615  0.0  0.4 403552  8356 ?        Sl   15:12   0:00 /tmp/go-app
# nsenter --all -t 7615 /bin/sh
# ip a
1: lo:  mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0:  mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 52:34:01:6d:37:22 brd ff:ff:ff:ff:ff:ff
    inet 192.168.128.41/21 brd 192.168.134.255 scope global dynamic eth0
       valid_lft 600225sec preferred_lft 600225sec
    inet6 fe80::5054:ff:fe6e:3621/64 scope link
       valid_lft forever preferred_lft forever

이제 애플리케이션 프로세스가 시작 시 NGINX Unit(eth0)에서 상속되는 인터페이스도 있습니다.

6. 결론

우리는 사용자들이 의문을 제기할 것으로 예상하기 때문에 여러분 중 많은 사람들이 다음과 같이 생각하고 있을 것입니다. 그게 다야? 당연히 아닙니다. 구현 초기 단계에서 애플리케이션 격리는 다소 낮은 수준이므로 NGINX Unit은 사용자가 그 이점을 완전히 누리기 전에 다른 기능이 필요합니다. 예를 들어 애플리케이션 구성에서 개별 컨테이너 관련 옵션을 유지 관리할 필요가 없으므로 설정이 간소화되고 오류 발생 가능성이 줄어듭니다.

현재 우리는 애플리케이션을 파일 시스템 디렉토리에 안전하게 제한하기 위해 애플리케이션 격리 구현에 rootfs 기능을 추가하는 작업을 하고 있습니다. 이 디렉토리는 애플리케이션의 관점에서 볼 때 파일 시스템 root가 되며 모든 실용적인 목적을 위해 애플리케이션을 쉽게 구성 가능한 컨테이너로 만들 수 있습니다. 애플리케이션 컨테이너화(NGINX Unit 방식)를 구현하기 위해 빠르게 전진하고 있습니다. 언제나 그렇듯이 계속 지켜봐 주시고 시간을 내어 새로운 기능을 사용해 보세요! 또한 NGINX Unit의 최신 소식을 빠르게 전달 받고 싶으시면 아래 뉴스레터를 구독하세요.