本以为这么成熟的东西应该很简单,但是没想到,坑深不见底,所以只从用法来,原理层得有更多的沉淀才能写出来。
完全搞定的话起码得掌握如下知识:
golang
namespace
cgroup
capabilities
桥接网络
unionfs(overlay)
这个是虚拟化
的核心知识,也是kernel
中关于资源隔离的实现,内核中提供的隔离方式有如下几种:
#define CLONE_NEWNS 0x00020000 /* New mount namespace group */ 文件系统
#define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */ 物理资源限制
#define CLONE_NEWUTS 0x04000000 /* New utsname namespace */ 主机名和域名
#define CLONE_NEWIPC 0x08000000 /* New ipc namespace */ 信号量,消息队列和共享内存
#define CLONE_NEWUSER 0x10000000 /* New user namespace */ 用户和用户组
#define CLONE_NEWPID 0x20000000 /* New pid namespace */ 进程号
#define CLONE_NEWNET 0x40000000 /* New network namespace */ 网络设备,网络栈,端口等网络资源
简单来阐述一下就是,namespace
可以看作是进程的一个属性,进程享有当前namespace
中的资源,而一个namespace
中可以有多个进程,他们共享这个namespace
的资源,而当你修改了namespace
中的资源后,也只会影响当前namespace
下的进程。
为了控制namespace
linux自然提供了相应的API用在开发时使用:
clone()
setns()
unshare()
三种API分别对应了三种情况:
- 创建进程时同时创建新的
namespace
- 将进程加入到一个已经存在的
namespace
- 在一个已经存在的进程上进行
namespace
隔离
写一份代码简单测试一下:
/*==============================================================================
# Author: lang [email protected]
# Filetype: C source code
# Environment: Linux & Archlinux
# Tool: Vim & Gcc
# Date: 2019.09.17
# Descprition: namespace learning
================================================================================*/
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#define STACK_SIZE (1024*1024) /* Stack size for cloned child */
static char child_stack[STACK_SIZE];
int child_main(){
printf("进入子进程\n");
char *arg[] = {"/bin/bash",NULL};
char *newhostname = "UTSnamespace";
sethostname(newhostname,sizeof(newhostname));
execv("/bin/bash",arg);
return 1;
}
int main(void){
printf("创建子进程\n");
int child_pid = clone(child_main,child_stack+STACK_SIZE,CLONE_NEWUSER | CLONE_NEWIPC | CLONE_NEWCGROUP | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD,NULL);
waitpid(child_pid,NULL,0);
printf("退出子进程\n");
return 0;
}
按照最后的效果来说,实际上也只有network
和rootfs
上有些问题,可以通过执行ifconfig
和ps
来验证这两个问题。
但是继续使用的时候就会发现user
其实也是一个问题,因为按照道理来说,一个虚拟化空间中,使用的初始用户应该是root
才是,也就是一个在虚拟化进程中具有所有资源访问权限的用户,但是为了安全,这个用户在其父user namespace
应该为一个普通权限。但是就像如上代码运行的结果一样,只是单纯的新建一个user namespace
,会导致虚拟化进程user namespace
中的user
为如下这种情况:
[nobody@g0dA lang]$ id
uid=65534(nobody) gid=65534(nobody) 组=65534(nobody)
现在很多发行版默认禁用了非特权用户命名空间:
kernel.unprivileged_userns_clone
,因此可以执行sysctl kernel.unprivileged_userns_clone=1
此刻的userid
和groupid
因为没有映射,使用的是由/proc/sys/kernel/overflowuid(overflowgid)
提供出来的默认映射ID。
首先要知道linux当进程要去读取/写入文件的时候,内核都会检查该进程user namespace
的uid
和gid
,以确认是否具有权限,简单来说内核注重的是uid
和gid
,而只有通过映射
后,才能控制一个user namespace
的用户在其余user namespace
中的权限,这点的重要性主要体现在诸如给其余user namespace中进程发送信号
或者是访问其余user namespace
的文件。
关于进程的user namespace
的映射需要用到/proc/PID/uid_map(gid_map)
,两个内容格式相同:
ID-inside-ns ID-outside-ns length
为了安全性考虑,对于两个文件的写入也有着严格的权限限制:
- 两个文件只允许拥有该
user namespace
中CAP_SETUID
权限的进程写入一次且不允许修改 - 写入的进程必须是
user namespace
的父namespace
或者是子namespace
- 最后一个字段通常填
1
表示只映射一个,如果填写大于1则按照顺序一一映射
那如果用宿主机的root
映射user namespace
中的root
会有问题吗?
答案是不会,其实docker
就是这种映射方式,然而当子user namespace
的用户访问父user namespace
的资源的时候,启动进程的capabilities
都为空,所以虽然是root
映射,然而子user namespace
的root
在父user namespace
中只相当于一个普通用户。
Linux 3.19后对
gid_map
做了更改,需要先向/proc/PID/setgroups
文件中写入deny
才能修改gid_map
,这点是为了安全性考虑。因为在user namespace
中,一个普通的帐号的新的user namespace
中有了所有的capabilities
,就可以通过调用setgroups
让自己获取更大的权限
修改一下代码:
/*==============================================================================
# Author: lang [email protected]
# Filetype: C source code
# Environment: Linux & Archlinux
# Tool: Vim & Gcc
# Date: 2019.09.17
# Descprition: namespace learning
================================================================================*/
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/capability.h>
#define STACK_SIZE (1024*1024) /* Stack size for cloned child */
int parent_uid;
int parent_gid;
static char child_stack[STACK_SIZE];
//[...]
void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) {
char path[256];
sprintf(path, "/proc/%d/uid_map", pid);
FILE* uid_map = fopen(path, "w");
fprintf(uid_map, "%d %d %d", inside_id, outside_id, length);
fclose(uid_map);
}
void set_gid_map(pid_t pid, int inside_id, int outside_id, int length) {
/* 3.19之后需要先修改/proc/PID/setgroups
* 将内容从allow修改为deny
* 否则无法修改gid_map的内容
* */
char path2[256];
sprintf(path2,"/proc/%d/setgroups",pid);
FILE* setgroups = fopen(path2,"w");
fprintf(setgroups, "deny");
fclose(setgroups);
char path[256];
sprintf(path, "/proc/%d/gid_map", pid);
FILE* gid_map = fopen(path, "w");
fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
fclose(gid_map);
}
int child_main(){
printf("进入子进程:%d\n",getpid());
cap_t caps;
set_uid_map(getpid(), 0, parent_uid, 1);
set_gid_map(getpid(), 0, parent_gid, 1);
caps = cap_get_proc();
printf("capabilities: %s\n",cap_to_text(caps,NULL));
char *arg[] = {"/bin/bash",NULL};
char *newhostname = "UTSnamespace";
//sethostname(newhostname,sizeof(newhostname));
execv("/bin/bash",arg);
return 1;
}
int main(void){
printf("创建子进程\n");
parent_uid = getuid();
parent_gid = getgid();
int child_pid = clone(child_main,child_stack+STACK_SIZE,CLONE_NEWUSER | CLONE_NEWIPC | CLONE_NEWCGROUP | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | SIGCHLD,NULL);
waitpid(child_pid,NULL,0);
printf("退出子进程\n");
return 0;
}
当
user namespace
被创建后,第一个进程
被认定为init
进程,需要被赋予该namespace
中的全部capabilities
,这样才能完成所有必要的初始化工作。
这个点得结合mount namespace
一起说。mount namespace
会隔离文件系统的挂载点,使得不同的mount namespace
拥有独立的挂载信息,并且不会相互影响,当clone
或者unshare
创建新的mount namespace
的时候,新创建的namespace
会拷贝一份老namespace
的挂载列表,从此之后相互之间的挂载卸载不会相互影响,这些对于构建专属文件系统目录非常有用。
- 深入理解Docker容器引擎runC执行框架
- Docker背后的内核知识——Namespace资源隔离
- RunC 是什么?
- runc source code——network
- managing-containers-runc
- master/config.md
- Linux的capability深入分析(2)
- runc 启动容器过程分析(附 CVE-2019-5736 实现过程)
- linux-capabilities机制
- Docker技术原理之Linux UnionFS(容器镜像)
- 理解Docker(3):Docker 使用 Linux namespace 隔离容器的运行环境
- Linux Namespace系列(07):user namespace (CLONE_NEWUSER) (第一部分)
- linuxea:了解uid和gid如何在docker容器中工作
- linux namespace
- Linux Namespaces in operation記錄 - part 5