Container From Scratch

Wed Jun 05 2024

Container From Scratch

## Namespaces Overview

Linux 提供了一套 Namespaces 的机制,用来封装全局系统资源,并使其对于其中的进程来说,像是在完全隔离的全局资源实例上。对全局资源的修改只能被相同 Namespaces 下的进程所观测,而对其他 Namespaces 的进程,这些修改是不可见的。因此,Namespaces 一个最经典的应用实例就是 Containers(容器化),容器是一种用于轻量级虚拟化(以及其他目的)的工具,它为一组进程提供了它们是系统上唯一进程的错觉,Docker、Podman 等工具均基于 Namespaces 实现。

Namespace 有多种类型,这些 Namespaces 分别用来隔离不同的系统资源,如下表所示:

NamespaceFlagPageIsolates
CgroupCLONE_NEWCGROUPcgroup_namespaces(7)Cgroup root directory
IPCCLONE_NEWIPCipc_namespaces(7)System V IPC, POSIX message queues
NetworkCLONE_NEWNETnetwork_namespaces(7)Network devices, stacks, ports, etc.
MountCLONE_NEWNSmount_namespaces(7)Mount points
PIDCLONE_NEWPIDpid_namespaces(7)Process IDs
TimeCLONE_NEWTIMEtime_namespaces(7)Boot and monotonic clocks
UserCLONE_NEWUSERuser_namespaces(7)User and group IDs
UTSCLONE_NEWUTSuts_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;
}

screenshot-uts-namespace

请注意,这里并没有使用 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);

screenshot-pid-namespace

但要注意的是,这里如果使用 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;
}

screenshot-mount-namespace

## 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;
}

screenshot-user-namespace

## 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 容器溢出部分,因为本质上容器还是和宿主机共享一个内核),从而在很大程度上实现了隔离。容器技术相比较于虚拟机,最大的优势同时也是最大的劣势就是共享内核,一方面这显著提升了容器的启动速度,降低了资源开销,另一方面也降低了安全性。