chroot
chroot是Linux上一个比较古老的系统调用,其作用是将用户的根目录设定到制定目录下,限制用户的文件访问权限。 早期有很多人利用chroot和busybox搭建一个”隔离”的沙盒迷你系统,这个沙盒和现在容器的理念十分接近。 不过chroot虽然在存储上限制了用户的访问行为,但是和现代的容器隔离相比,这种简单粗暴的方式无疑是显得”原始”了点。 Linux后来引入了namespace用于管理和隔离进程。
Namespace介绍
什么是Namespace?
namespace是Linux内核提供的进程隔离机制,用于将进程(组)的资源进行隔离。 涉及namespace的系统调用有3个:
clone() -> 创建一个新的进程, 按照制定参数创建隔离级别
setns() -> 使进程加入到某个namespace
unshare() -> 使进程脱离某个namespace
至今为止,Linux Namespace拓展的所有类别如下:
Name | 说明 | 调用参数 | 内核发布版本 |
---|---|---|---|
Mount Namespace | 隔离各进程的挂载点视图 | CLONE_NEWNS | Linux 2.4.19 |
UTS Namespace | 隔离nodename、domainname等 | CLONE_NEWUTS | Linux 2.6.19 |
IPC Namespace | 隔离进程间通讯方式 | CLONE_NEWIPC | Linux 2.6.19 |
PID Namespace | 隔离进程id | CLONE_NEWPID | Linux 2.6.24 |
Network Namespace | 隔离容器网络 | CLONE_NEWNET | 2.6.24 ~ 2.6.29 |
User Namespace | 隔离用户和用户组Id | CLONE_NEWUSER | 2.6.23 ~ 3.8 |
查看进程Namespace信息
我们都知道,linux/proc
路径下存放的是当前系统进程的运行时信息,进程的namespace信息就在/proc/[pid]/ns
路径下。
root@slave:~ $ll /proc/1/ns
total 0
lrwxrwxrwx 1 root root 0 Mar 16 01:03 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Mar 8 23:23 ipc -> 'ipc:[4026532558]'
lrwxrwxrwx 1 root root 0 Mar 8 23:23 mnt -> 'mnt:[4026532629]'
lrwxrwxrwx 1 root root 0 Jan 27 20:31 net -> 'net:[4026532561]'
lrwxrwxrwx 1 root root 0 Jan 27 20:31 pid -> 'pid:[4026532631]'
lrwxrwxrwx 1 root root 0 Mar 16 01:03 pid_for_children -> 'pid:[4026532631]'
lrwxrwxrwx 1 root root 0 Mar 16 01:03 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Mar 8 23:23 uts -> 'uts:[4026532630]'
- 输出信息的格式是
ns_type:[ns_id]
,即Namespace的类型和Namespace的inode number。 - 若两个进程某个Namespace的inode number相关,则说明他们属于同一个namespace。
- 大部分情况下,若Namespace中所有进程都退出了,该Namespace会被删除。
Namespace系统调用详解
UTS (Unix Time-Sharing) Namespace
UTS Namespace主要用于隔离hostname和domainName。
- hostname: 主机名,用于表示主机。
- NIS domain name: 主机加入NIS网络使用的标识名。 对于hostname和NIS具体的细节我们这里不多表述,不熟悉且感兴趣的同学可自行查阅资料。
我们用一段代码来新建一个bash进程并创建一个Namespace。
package main
import (
"log"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("bash")
// 这里我们先只创建UTS Namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err.Error())
}
}
编译运行测试
root@master:~ $ echo `hostname`,`domainname`
master,(none)
root@master:~ $ ./namespace
root@master:~ $ echo `hostname`,`domainname`
master,(none)
root@master:~ $ hostname -b test_hostname
root@master:~ $ domainname -b test_domainname
root@master:~ $ echo `hostname`,`domainname`
test_hostname,test_domainname
root@master:~ $ exit
exit
root@master:~ $ echo `hostname`,`domainname`
master,(none)
在新建的bash进程中,修改hostname和domainname并不会影响到全局设置。当我们推出bash进程后,我们看到的仍是全局的主机名。
IPC(InterProcess) Namespace
要创建IPC Namespace, 只需要在clone flag参数上加上IPC的标记:
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
进入namespace, 测试ipc隔离:
root@master:~ $ ./namespace
root@master:~ $ ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
root@master:~ $ ipcmk -Q
Message queue id: 0
root@master:~ $ ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0xd63f036f 0 root 644 0 0
root@master:~ $ exit
exit
root@master:~ $ ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
在namespace内创建的mq只在当前namespace内可见。
Mount Namespace
mount namespace用于隔离进程所见的挂载视图。在不同的Namespace中,调用mount和umount仅仅影响当前namespace下的挂载,不会影响到全局系统。
说到这,你们是不是想起来文章开头所说的chroot
,mount namespace也可以实现这一功能,而且更加灵活和安全。
Mount Namespace
是Linux第一个实现的Namespace,他的系统调用参数是NEWNS(New Namespace)。
当时的社区工作者们没有意识到后续会有其他类型的namespace加入到Linux内核中,左右直接就用NEWNS来表示这个Namespace了。
同样需要加上系统调用参数
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
全局的mount namespace number:
root@master:~ $ readlink /proc/$$/ns/mnt
mnt:[4026531840]
我们先创建几个目录:
root@master:~ $ tree -L 2
.
├── info
│ ├── data1
│ └── data2
├── mount
│ └── info
└── namespace
将data1挂载到: mount --bind /root/info/data1 /root/mount/info
。
好,我们现在运行程序创建新的namespace:
root@master:~ $ readlink /proc/$$/ns/mnt
mnt:[4026532212]
可以看到在fork出的bash进程下,mnt namespace的inode number发生了变化。
现在我们运行程序新建namespace,修改/data/mount/info
的挂载:
root@master:~ $ ls mount/info/
test1
root@master:~ $ umount /root/mount/info/
root@master:~ $ mount --bind /root/info/data2 /root/mount/info/
root@master:~ $ ls mount/info/
test2
root@master:~ $ exit
exit
root@master:~ $ ls /root/mount/info/
test2
这里是不是发现有点不对劲呢?在新namespace下的mount改动在全局下也生效了。
要了解发生了什么我们还得再说到mount namespace
的事件传播机制,linux下一共定义了4种类型:
MS_SHARED
: 同一个peer group成员共享mount事件MS_PRIVATE
: 私有传播, 不发送和接受其他的mount事件MS_SLAVE
: mount group种的master事件会传播到slave,反之则不行MS_UNBINDABLE
: 不发送、接受mount事件,且不可被mount --bind
而默认的mnt事件传播机制就是shared,我们验证一下:
root@master:~ $ findmnt -o TARGET,PROPAGATION /
TARGET PROPAGATION
/ shared
这里我们将mount类型改为private: mount --make-private /
,再试一次上述的挂载试验。
这时新bash下的mount操作就不会被传播到外部了,同步我们修改的mount propagation
类型也只在新的namespace下生效。
如果用go进行操作的话,在创建mount namespace
后需要修改一下传播模式:
func mount() {
// 将namespace内rootfs下传播模式改为private
mountFlags := syscall.MS_REC | syscall.MS_PRIVATE
err := syscall.Mount("", "/", "", uintptr(config.MountFlagsPrivate), "")
if err != nil {
return err
}
}
PID Namespace
PID Namespace
对进程id(pid)进行隔离,添加系统调用参数后:
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID
,
运行进程,我们就会发现新的bash进程的pid变成了1:
root@master:~ $ ./namespace
root@master:~ $ echo $$
1
新创建的bash进程的进程id变成了1,而全局下Linux中pid为1的应该是上帝进程(systemd)。 我们打开另一个bash窗口,我们可以追踪到该bash进程的真实pid:
root@master:~ $ pstree -pl
systemd(1)─┬─sshd(728)─┬─sshd(7427)───bash(7468)───namespace(7606)─┬─bash(7611)
│ ├─{namespace}(7607)
│ ├─{namespace}(7608)
│ ├─{namespace}(7609)
│ └─{namespace}(7610)
└─sshd(7621)───bash(7661)───pstree(7675)
在linux中,进程之间具有父子关系,而当运行中的父子进程中父进程退出了,那么在namespace中子进程会被namespace中的init进程接管,而非全局的init进程。 当namespace的init进程退出,那么他会向当前namespace中所有其他进程发送终止Signal。结果就是namespace的所有进程退出,namespace被销毁。
User Namespace
User Namespace
用于隔离linux user的权限相关资源,这是所有namespace中实现最复杂的一个。
user namespace支持嵌套(内核限制32层),所有user namespace父子关系最上层都是系统默认的user namespace。
当一个进程调用unshare
或clone
创建一个新的user namespace时,当前进程所在的用户空间就成为了父空间,所有的user namespace形成树形结构。
每个其他类型的namespace,都有一个自己所属的user namespace。
struct uts_namespace {
struct kref kref;
struct new_utsname name;
struct user_namespace *user_ns;
struct ns_common ns;
};
比如我们看uts namespace
的结构中,有一个指针指向了一个user namespace,其他的namespace也类似,都会和一个user namespace绑定。
接下来,我们通过unshare创建一个bash进程和user namespace。
root@ecs-slave2:~ $unshare --user /bin/bash
nobody@ecs-slave2:~ $whoami
nobody
nobody@ecs-slave2:~ $id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
nobody@ecs-slave2:~ $exit
exit
root@ecs-slave2:~ $id
uid=0(root) gid=0(root) groups=0(root)
当我们新建了一个user namespace后,shell显示的用户名变成了nobody,用户(组)也变成了65534,这是没有指定用户映射时的系统默认值。
虽然显示用户是nobody
,但是仍然可以访问root用户的目录:
root@master:/root$ unshare --user /bin/bash
nobody@master:/root$ ls /root
test
我们添加一个临时账号做同样的操作,却发现权限不够:
root@master:~ $ su test
test@master:/root$ unshare --user /bin/bash
nobody@master:/root$ ls /root
ls: cannot open directory '/root': Permission denied
说明账号之间存在某一种映射关系
那账号之间是如何映射的呢?
在创建了新的user namespace后,可以通过往/proc/{pid}/uid_map
和/proc/{pid}/gid_map
制定用户的映射。
文件的格式为:
inside-ns-id-bigin outside-ns-id-beigin length
通过指定子namespace和父namespace的起始userid,将整段父空间中的用户映射到子空间中。需要注意的是,该文件只能被写一次,之后会被设置为不可写入状态。
Network Namespace
Docker利用network namespace隔离网络设备、ip、端口号等,所以每一个容器都可以拥有独立的网络设备、端口号等,然后通过NAT与外部通讯。
root@master:~ $ unshare --uts --net /bin/bash
root@master:~ $ ifconfig
root@master:~ $ ip link set lo up
root@master:~ $ ifconfig
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (Local Loopback)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
在新的namespace中,网络配置被重置,在新空间中修改也不会影响外部配置。新创建的namespace不具备任何的联网功能,在后续的文章中,我们会介绍如何让network namespace与外界进行通讯。