原文作者:Artem Konev - Senior Technical Writer
原文链接:使用 NGINX Unit 实施应用隔离
转载来源:NGINX 中文官网
NGINX 唯一中文官方社区 ,尽在 nginx.org.cn
NGINX Unit 特性集的最新动态之一是支持应用隔离,该特性于 1.11.0 版中引入,通过 Linux 命名空间实施。
下面我们先简单回顾一下 Linux 命名空间:它本质上是一种内核机制,支持一个进程组将多种系统资源与其他进程组共享的资源分开共享。内核能够确保命名空间中的进程仅访问分配给该命名空间的资源。尽管两个不同命名空间中的进程可以共享一些资源,但其他资源对于另一个命名空间中的进程“不可见”。可在命名空间中隔离的资源类型因操作系统而异,包括进程和用户 ID、进程间通信实体、文件系统中的挂载点、网络对象等。
听起来有点乏味?也许如此,特别是在您不了解操作系统技术的情况下。但命名空间是容器化变革背后的关键因素之一,在单个操作系统实例中隔离应用进程可实现在容器中运行应用所需的关键安全和扩展机制。
现在我们已经确定命名空间可能是个好东西,但 NGINX Unit 拿它有何用呢?在进一步阐释前,我们先概括介绍相关背景信息,了解一下 Tiago 本人的想法:
“我正在研究更好的方法以有效监控和拦截来自应用的流量。闲暇之余,我一直在研究 NGINX Unit 的内部机制,并认为进程隔离可能非常适用。但是,我还不确定是否是最佳方案。之前,我曾考虑 eBPF并研究它如何在内核级别重定向数据包,但后来我有了不同的想法。由于 NGINX Unit 以类似于容器运行时的方式运行并管理应用,那么如果我们为 NGINX Unit 添加应用隔离支持并使用它代替运行时,将会怎样?这一想法与 NGINX Unit 团队未来构思设计之一不谋而合。
在集群中,容器运行时启动和停止应用,因此我们了解集群中运行的一切。NGINX Unit 架构不仅做了同样的事情,而且还默认实施流量监控和拦截:到达应用的唯一途径是 NGINX Unit 的共享内存模型。值得注意的是,我们甚至能够隔离网络,类似于跳过容器内的接口设置,但应用仍可通过与 NGINX Unit 共享内存来与外界通信,而不会遭遇任何代价高昂的网络攻击。”
从配置的角度来看,一切都离不开全新 isolation 对象,它定义了应用对象中的命名空间相关设置。
isolation 对象中的命名空间选项取决于系统,因为可隔离到命名空间的资源类型因操作系统而异。下面是为应用创建单独的用户 ID 和挂载点命名空间的基本示例:
{
"applications": {
"isolation_app": {
"type": "external",
"executable": "/tmp/go-app",
"isolation": {
"namespaces": {
"credential": true,
"mount": "true"
}
}
}
}
}
目前,NGINX Unit 支持配置 Linux 内核支持的 7 种命名空间隔离类型中的 6 种。相应配置选项为 cgroup、credential、pid、mount、network 及 uname。暂不支持最后一个类型 ipc。
默认情况下,禁用所有隔离类型(选项设置为 false),这意味着应用驻留在 NGINX Unit 命名空间中。当您通过将其选项设置为 true 来为应用启用特定隔离类型时,NGINX Unit 将为该应用创建这一类型的单独命名空间。例如,除了自身有一个单独的 mount 或 credential 命名空间外,应用还可与 NGINX Unit 位于同一命名空间。
有关 isolation 对象中选项的更多详细信息,请参阅 NGINX Unit 文档。
注:在撰写本文时,所有应用均需使用与 NGINX Unit 相同的 ipc 命名空间;此为共享内存机制要求。您可将 ipc 选项添加至配置中,但其设置无效。这一要求可能会在未来版本中发生变化。
NGINX Unit 中的应用隔离包括支持 UID 和 GID 映射,如果启用 credential 隔离(这意味着您的应用在单独的凭证命名空间中运行),则可对其进行配置。您可以将应用命名空间(我们称之为 “container(容器)命名空间”)中的一系列 ID 映射至该应用父进程凭证命名空间(我们称之为 “host(主机)命名空间”)中的相同长度 ID 范围。
例如,假设您的一款应用采用非特权用户凭证运行,并启用了 credential 隔离,以便为该应用创建一个容器命名空间。NGINX Unit 支持您将主机命名空间中非特权用户的 UID 映射到容器命名空间中的 UID 0 (root)。根据设计,任何命名空间中取值为 0 的 UID 在该命名空间中拥有全部权限,而其在主机命名空间中映射对应 UID 的权限仍然受限。因此,该应用似乎拥有 root 功能,但仅可用于其命名空间内的资源。GID 映射也是如此。
此处,我们将主机命名空间中从 UID 500 开始的 10 项 UID 范围值映射到容器命名空间中从 UID 0 开始的 UID 范围值(主机:500-509,容器:0-9)。同样,我们将主机命名空间中从 GID 1000 开始的 20 项 GID 范围值映射到容器命名空间中从 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 映射,默认情况下,主机命名空间中非特权 NGINX Unit 进程的当前有效 UID (EUID) 将映射到容器命名空间中的 root UID。另请注意,仅当主机操作系统支持用户命名空间时,UID/GID 映射才可用。说到这里,让我们继续了解一下应用隔离对 NGINX Unit 中运行的应用的影响。
下面我们从基础开始,了解该特性运行时的行为。为此,我们将采用我们官方存储库中的一个 Go 应用(在测试新版本时运行):
package main
import (
"encoding/json"
"fmt"
"net/http"
"nginx/unit"
"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
}
)
func abortonerr(err error) {
if err != nil {
panic(err)
}
}
func getns(nstype string) uint64 {
// readlink returns: [nstype]:[4026531835]
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"),
},
}
data, err := json.Marshal(out)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(data)
}
func main() {
http.HandleFunc("/", handler)
unit.ListenAndServe(": 7080", nil)
}
这段代码使用应用进程和命名空间 ID 的 JSON 格式清单响应请求,枚举 /proc/self/ns/ 目录的内容。下面我们在 NGINX Unit 中配置应用,暂时忽略 isolation 对象:
{
"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
}
}
现在我们添加 isolation 对象,以启用应用隔离。隔离机制需要重启应用才可生效。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
}
]
}
}
}
}
请注意,user 选项设置为 root。启用映射到容器命名空间中的 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
}
}
我们现已启用应用隔离,命名空间 ID 已变更 — 它们现在是容器命名空间中的 ID,而非主机命名空间中的 ID。唯一保持不变的是 IPC,原因如上所述。
为进行深入了解,下面我们将探讨应用隔离对网络的实际影响,这对 Web 应用而言非常重要。我们为此选择的工具是 nsenter,它适用于 NGINX Unit 支持的许多操作系统发行版。该实用程序允许我们在进程命名空间内运行任意命令,我们将使用它来演示由前面配置的同一 Go 应用 isolation 对象中的不同设置引起的变化。首先,我们找出主机 PID:
更多代码详情查看NGINX社区官网
确定 PID 后,我们可以进入容器命名空间并查看其内部组成
请注意,仅环回接口可用;但该应用完全能够通过 NGINX Unit 处理外部 HTTP 请求。接下来,我们将从配置的命名空间列表中删除 network 选项,以查看禁用网络隔离的应用的最终网络接口配置
然后,我们重复执行上述相同步骤
现在还有应用进程在启动时沿用 NGINX Unit 的接口 (eth0)。
NGINX 唯一中文官方社区 ,尽在 nginx.org.cn
更多 NGINX 相关的技术干货、互动问答、系列课程、活动资源: 开源社区官网 | 微信公众号