# Container From Scratch
## Namespaces Overview
Linux 提供了一套 Namespaces 的机制,用来封装全局系统资源,并使其对于其中的进程来说,像是在完全隔离的全局资源实例上。对全局资源的修改只能被相同 Namespaces 下的进程所观测,而对其他 Namespaces 的进程,这些修改是不可见的。因此,Namespaces 一个最经典的应用实例就是 Containers(容器化),容器是一种用于轻量级虚拟化(以及其他目的)的工具,它为一组进程提供了它们是系统上唯一进程的错觉,Docker、Podman 等工具均基于 Namespaces 实现。
Namespace 有多种类型,这些 Namespaces 分别用来隔离不同的系统资源,如下表所示:
Namespace Flag Page Isolates Cgroup CLONE_NEWCGROUP cgroup_namespaces(7) Cgroup root directory IPC CLONE_NEWIPC ipc_namespaces(7) System V IPC, POSIX message queues Network CLONE_NEWNET network_namespaces(7) Network devices, stacks, ports, etc. Mount CLONE_NEWNS mount_namespaces(7) Mount points PID CLONE_NEWPID pid_namespaces(7) Process IDs Time CLONE_NEWTIME time_namespaces(7) Boot and monotonic clocks User CLONE_NEWUSER user_namespaces(7) User and group IDs UTS CLONE_NEWUTS uts_namespaces(7) Hostname and NIS domain name
与 Namespaces API 相关的 system calls 有:
clone(2)
clone
syscall 创建一个新进程(事实上 fork
也是对 kernel_clone
的一个封装,可参考内核源码 kernel/fork.c#L2879
),如果调用参数中的 flag
包含了上述 Namespaces 的 Flag,即 CLONE_NEW*
,内核会为子进程进程创建 Namespaces,并将子进程置于这些新创建的 Namespaces 中。(事实上这只是 clone
的一小方面的用例)
setns(2)
setns
syscall 允许进程加入现有的 Namespaces。Namespaces 用 /proc/<pid>/ns
下的文件的文件描述符表示
unshare(2)
unshare
syscall 将当前进程移入一个新的 Namespace,和 clone
类似,可用 flag 指定要创建的 Namespaces。
ioctl(2)
## Namespaces via procfs
每个进程都有一个对应的 /proc/<pid>/ns
目录,包含了此进程当前所属的 Namespaces,可以通过 ls -l | awk '{print $1, $9, $10, $11}'
,输出如下:
lrwxrwxrwx cgroup -> cgroup:[4026532908]
lrwxrwxrwx ipc -> ipc:[4026532906]
lrwxrwxrwx mnt -> mnt:[4026532904]
lrwxrwxrwx net -> net:[4026531840]
lrwxrwxrwx pid -> pid:[4026532907]
lrwxrwxrwx pid_for_children -> pid:[4026532907]
lrwxrwxrwx time -> time:[4026531834]
lrwxrwxrwx time_for_children -> time:[4026531834]
lrwxrwxrwx user -> user:[4026531837]
lrwxrwxrwx uts -> uts:[4026532905]
打开此目录中的文件之一(或 bind mount 这些文件之一的文件)将返回由 pid 指定的进程的相应命名空间的文件句柄。只要此文件描述符保持打开状态,命名空间就会保持活动状态,即使命名空间中的所有进程都终止也是如此。文件描述符可以传递给 setns
。
/proc/sys/user/max_*_namespaces
提供了一套接口,用于限制 Namespaces 的资源上限。
## UTS Namespaces
如上文所述,UTS (UNIX Time-Sharing) Namespace 的主要用途是设置容器内部 hostname。
创建 Namespaces 的常见方法之一是 clone()
函数,其原型为: int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
,示例代码如下
# define _GNU_SOURCE
# include < sched.h >
# include < signal.h >
# include < stdio.h >
# include < stdlib.h >
# include < sys/wait.h >
# include < unistd.h >
int child_main ( void * arg ) {
printf ( " child process \n " ) ;
char * argv [] = { " /bin/sh " , ( char * ) 0 } ;
char * envp [] = {( char * ) 0 } ;
sethostname ( " container-from-scratch " , 22 ) ;
execve ( " /bin/sh " , argv , envp ) ;
return 0 ;
}
int main () {
void * stack = malloc ( 4096 ) + 4096 ;
int child = clone ( child_main , stack , CLONE_NEWUTS | SIGCHLD , NULL ) ;
waitid ( P_PID , child , NULL , WEXITED | WSTOPPED ) ;
if ( child < 0 ) {
perror ( " Error " ) ;
}
return 0 ;
}
请注意,这里并没有使用 unprivileged namespace,而 sethostname
是需要特权的,因此需要使用 sudo
执行。
这里通过 clone
函数和 CLONE_NEWUTS
flag 创建了一个新的 UTS Namespace,在内部修改了 hostname 后,创建了一个 shell,可以通过 shell 命令来确认 hostname 是否已被修改。退出 shell 后,可以发现,容器外部的 hostname 并没有被修改,说明 UTS Namespace 隔离了 hostname 这一全局系统资源。如果在新的 UTS Namespace 内部没有设置 hostname 的话会继承调用方的 hostname 的。
## PID Namespaces
Linux 的进程是一个树,所有进程(除 pid 1 进程,又称为 init 进程,是由内核直接启动的)都有一个父进程,因此作为所有进程的父进程的 init 进程,则有了很多特权,因此要实现进程的隔离,则要创建出 pid 为 1 的进程。PID Namespace 正是用来实现这一目标的。
20,21c20
< int child =
< clone(child_main, stack, CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL);
---
> int child = clone(child_main, stack, CLONE_NEWUTS | SIGCHLD, NULL);
但要注意的是,这里如果使用 ps
top
等命令,均无法正常显示容器内的进程,因为此时只是实现了 pid 的隔离,此类工具均是以读取 /proc
实现的,因此获取的还是外部的信息
## IPC Namespaces
常见的 IPC (Inter Process Communication) 方式有共享内存、信号量、消息队列等。当使用 IPC Namespace 把 IPC 隔离起来之后,只有同一个 Namespace 下的进程才能相互通信。
20,21c20,21
< int child = clone(child_main, stack,
< CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | SIGCHLD, NULL);
---
> int child =
> clone(child_main, stack, CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL);
## Mount Namespaces
Mount Namespace 可以让容器有自己的 root 文件系统。但是需要注意的是,在通过 CLONE_NEWNS
创建 Mount Namespace 之后,父进程会把自己的文件结构复制给子进程中。所以当子进程中不重新挂载的话,子进程和父进程的文件系统结构是一样的。因此想要改变容器内进程的挂载,一定需要重新挂载。
这里需要准备一个 rootfs,我使用 Alpine Linux 作为示例:
curl https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz -o alpine-minirootfs-3.20.0-x86_64.tar.gz && \
mkdir -p rootfs && \
tar xvf alpine-minirootfs-3.20.0-x86_64.tar.gz -C rootfs
修改之前的代码,加入 CLONE_NEWNS
flag,并挂载必要的分区后,chroot 进入刚刚准备的rootfs
# define _GNU_SOURCE
# include < sched.h >
# include < signal.h >
# include < stdio.h >
# include < stdlib.h >
# include < sys/mount.h >
# include < sys/wait.h >
# include < unistd.h >
int child_main ( void * arg ) {
printf ( " child process \n " ) ;
char * argv [] = { " /bin/sh " , ( char * ) 0 } ;
char * envp [] = {( char * ) 0 } ;
sethostname ( " container-from-scratch " , 22 ) ;
if ( chroot ( " ./rootfs " ) != 0 ) {
perror ( " Error Occurred while chroot " ) ;
}
if ( mount ( " proc " , " /proc " , " proc " , 0 , NULL ) != 0 ) {
perror ( " Error Occurred while mounting /proc " ) ;
}
if ( mount ( " sysfs " , " /sys " , " sysfs " , 0 , NULL ) != 0 ) {
perror ( " Error Occurred while mounting /sysfs " ) ;
}
if ( mount ( " udev " , " /dev " , " devtmpfs " , 0 , NULL ) != 0 ) {
perror ( " Error Occurred while mounting /dev " ) ;
}
if ( mount ( " devpts " , " /dev/pts " , " devpts " , 0 , NULL ) != 0 ) {
perror ( " Error Occurred while mounting /dev/pts " ) ;
}
if ( mount ( " shm " , " /dev/shm " , " tmpfs " , 0 , NULL ) != 0 ) {
perror ( " Error Occurred while mounting /dev/shm " ) ;
}
if ( mount ( " none " , " /tmp " , " tmpfs " , 0 , NULL ) != 0 ) {
perror ( " Error Occurred while mounting /tmp " ) ;
}
execve ( " /bin/sh " , argv , envp ) ;
return 0 ;
}
int main () {
void * stack = malloc ( 4096 ) + 4096 ;
int child = clone (
child_main , stack ,
CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWNS | SIGCHLD , NULL ) ;
if ( child < 0 ) {
perror ( " Error Occurred while creating child process " ) ;
}
waitid ( P_PID , child , NULL , WEXITED | WSTOPPED ) ;
return 0 ;
}
## User Namespace
要实现 User Namespace 的隔离,需要对 uid 和 gid 进行映射。首先需要检查主机的 /etc/subuid
和 /etc/subgid
,格式为 <user/group>:<mapped id>:<length>
cat /etc/subuid
# koito:524288:65536
cat /etc/subgid
# koito:524288:65536
然后对子进程的 /proc/<child_pid>/uid_map
和 /proc/<child_pid>/gid_map
进行修改,以进行映射,格式为: <id inside ns> <id outside ns> <map length>
,此处我们将容器内部的 uid 0 映射到容器外部的 uid 1000
# define _GNU_SOURCE
# include < sched.h >
# include < signal.h >
# include < stdio.h >
# include < stdlib.h >
# include < sys/mount.h >
# include < sys/wait.h >
# include < unistd.h >
# define no_failure ( value , message ) \
do { \
if ( value ) { \
perror ( message ) ; \
exit ( 1 ) ; \
} \
} while ( 0 )
int child_main ( void * arg ) {
sleep ( 1 ) ;
printf ( " child process \n " ) ;
no_failure ( sethostname ( " container-from-scratch " , 22 ), " Error Occurred while setting hostname " ) ;
no_failure ( chroot ( " ./rootfs " ), " Error Occurred while chroot " ) ;
no_failure ( mount ( " proc " , " /proc " , " proc " , 0 , NULL ), " Error Occurred while mounting /proc " ) ;
no_failure ( mount ( " sysfs " , " /sys " , " sysfs " , 0 , NULL ), " Error Occurred while mounting /sysfs " ) ;
// no_failure(mount("udev", "/dev", "devtmpfs", 0, NULL), "Error Occurred while mounting /dev"); // disabled for unprivileged container
// no_failure(mount("devpts", "/dev/pts", "devpts", 0, NULL), "Error Occurred while mounting /dev/pts"); // disabled for unprivileged container
// no_failure(mount("shm", "/dev/shm", "tmpfs", 0, NULL), "Error Occurred while mounting /dev/shm"); // disabled for unprivileged container
no_failure ( mount ( " none " , " /tmp " , " tmpfs " , 0 , NULL ), " Error Occurred while mounting /tmp " ) ;
char * argv [] = { " /bin/sh " , ( char * ) 0 } ;
char * envp [] = {( char * ) 0 } ;
execve ( " /bin/sh " , argv , envp ) ;
return 255 ;
}
int main () {
void * stack = malloc ( 4096 ) + 4096 ;
int child = clone ( child_main , stack , CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWUSER | SIGCHLD , NULL ) ;
no_failure ( child < 0 , " Error Occurred while creating child process " ) ;
char * filename_buffer = malloc ( sizeof ( char ) * 256 ) ;
sprintf ( filename_buffer , " /proc/%d/uid_map " , child ) ;
FILE * fd_uidmap = fopen ( filename_buffer , " w " ) ;
fprintf ( fd_uidmap , " 0 %d 1 " , getuid ()) ;
fclose ( fd_uidmap ) ;
sprintf ( filename_buffer , " /proc/%d/gid_map " , child ) ;
FILE * fd_gidmap = fopen ( filename_buffer , " w " ) ;
fprintf ( fd_gidmap , " 0 %d 1 " , getgid ()) ;
fclose ( fd_gidmap ) ;
free ( filename_buffer ) ;
printf ( " parent process \n " ) ;
no_failure ( waitid ( P_PID , child , NULL , WEXITED | WSTOPPED ), " Error Occurred while waiting for child process " ) ;
return 0 ;
}
## etc...
这里还有 Network Namespace 和 Time Namespace, 但是由于 Network Namespace 涉及过多其他与 Namespace 无关的 API,因此这里暂时不实现(可以参考 Docker,Docker 为了实现容器网络实现了很多功能,包括 bridge macvlan 等驱动和 DNS 相关服务)
到这里,在不考虑 seccomp 等安全措施的前提下,这里已经实现了 Docker 的一小部分核心功能了。( docker run -it --privileged --net host alpine:latest
)
Namespace 技术实际上是修改了进程看待整个计算机的系统资源的一个视图 (View),这个视图被操作系统做了限制,因此进程只能看到或者修改某些指定的内容,而难以修改其他看不到的内容(但其实还是可以修改其他资源的,参考云原生安全的 Docker 容器溢出部分,因为本质上容器还是和宿主机共享一个内核),从而在很大程度上实现了隔离。容器技术相比较于虚拟机, 最大的优势同时也是最大的劣势就是共享内核 ,一方面这显著提升了容器的启动速度,降低了资源开销,另一方面也降低了安全性。