惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

酷 壳 – CoolShell
酷 壳 – CoolShell
H
Hacker News: Front Page
P
Palo Alto Networks Blog
T
ThreatConnect
Apple Machine Learning Research
Apple Machine Learning Research
博客园_首页
T
True Tiger Recordings
P
Privacy & Cybersecurity Law Blog
B
Blog
IT之家
IT之家
Last Week in AI
Last Week in AI
F
Full Disclosure
Hacker News: Ask HN
Hacker News: Ask HN
C
Comments on: Blog
Microsoft Azure Blog
Microsoft Azure Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Microsoft Security Blog
Microsoft Security Blog
博客园 - 【当耐特】
N
News and Events Feed by Topic
NISL@THU
NISL@THU
腾讯CDC
雷峰网
雷峰网
Security Latest
Security Latest
李成银的技术随笔
M
Microsoft Research Blog - Microsoft Research
L
LangChain Blog
L
Lohrmann on Cybersecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Check Point Blog
Y
Y Combinator Blog
Recent Announcements
Recent Announcements
博客园 - Franky
N
News | PayPal Newsroom
V
V2EX
A
About on SuperTechFans
The Register - Security
The Register - Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Google Online Security Blog
Google Online Security Blog
MyScale Blog
MyScale Blog
Cisco Talos Blog
Cisco Talos Blog
Vercel News
Vercel News
WordPress大学
WordPress大学
C
Cyber Attacks, Cyber Crime and Cyber Security
The Hacker News
The Hacker News
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
爱范儿
爱范儿
A
Arctic Wolf
L
LINUX DO - 最新话题
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More

博客园 - 一个人的合唱

synchronized 泛型学习 反码与补码 解决128位秘钥长度限制的方法 CAP学习 ThreadLocal的学习 读书笔记脑图列表 a=a++和a=++a centos7下安装python3 深入理解jvm虚拟机笔记(二) AtomicInteger的使用 volatile学习 echarts重新绘制的时候数据未更新 深入理解jvm虚拟机笔记(一) elasticsearch学习笔记 使用fiddler模拟post请求 二叉堆 二叉平衡树 二叉查找树
手写简易docker
一个人的合唱 · 2026-04-09 · via 博客园 - 一个人的合唱

在极客时间上学完了 手把手带你写个最精简的 docker 这门课程 学着写了个简易docker。

原理

容器其实是一组隔离的进程。运行一个容器,其实是启动一个进程,然后对它做出各项限制,隔离它与外界以及外界对它的影响。就像给它套了个壳,让它在里面静静的运行。

实现

1.主函数

int main(int argc, char *argv[]) {
    if (strcmp(argv[1], "run") == 0) {
        minidocker_run(&argv[2]);
    } else if (strcmp(argv[1], "exec") == 0) {
        minidocker_exec(&argv[2]);
    } else if (strcmp(argv[1], "ps") == 0) {
        system("tree -L 1 containers");
    } else {
        fprintf(stderr, "Usage: minidocker run|exec|ps\n");
        return EXIT_FAILURE;
    }
    return 0;
}

对输入的指令进行判断 对应于docker的run exec以及ps方法
2.minidocker_run函数

void minidocker_run(char **argv) {
    char image_file[256];
    char container_rootfs[256];
    char timestamp_str[20];
    get_current_timestamp(timestamp_str, sizeof(timestamp_str));
    systemf("mkdir containers/%s", timestamp_str);
    systemf("mkdir runtime/%s", timestamp_str);
    systemf("mount -t overlay overlay -o lowerdir=images/%s,upperdir=containers/%s,workdir=runtime/tmpwork runtime/%s", argv[0], timestamp_str, timestamp_str);
    systemf("mkdir -p containers/%s/etc", timestamp_str);
    systemf("cp /etc/resolv.conf containers/%s/etc/resolv.conf", timestamp_str);
    argv[0] = timestamp_str;
    minidocker_exec(argv);
}

linux里的联合文件系统 (overlay) 可以允许多个文件系统或目录以只读或读写的形式合并。像

systemf("mount -t overlay overlay -o lowerdir=images/%s,upperdir=containers/%s,workdir=runtime/tmpwork runtime/%s", argv[0], timestamp_str, timestamp_str);

里指定下层目录为images里的镜像,上层目录为containers里的容器目录,runtime/tmpwork为合并后的目录。在合并目录里可以看到镜像目录和容器目录的文件,如果有相同的,那么取上层目录里的,也就是容器目录的,因此合并目录其实只是个虚拟目录,像个视图。下层目录是只读的,因此在合并目录里增加文件其实是在上层的容器目录里增加,而如果想修改的文件来自下层,那么将该文件复制到上层的容器目录里 ,然后修改。因为优先看到上层的文件,所以下层的该文件看不到了,这就是写时复制。这样,镜像文件是不可变的,可以由所有需要的容器共享,而上层的容器可以通过写时复制进行了自己想要的修改,达成目标的同时极大减省了资源。
3.minidocker_exec函数

    pipe(pipefd);
    // 创建新进程的命名空间标识
    int flags = CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWIPC;
    // 创建新进程,新进程在上述独立的命名空间下
    char container_rootfs[256];
    snprintf(container_rootfs, sizeof(container_rootfs), "runtime/%s", argv[0]);
    argv[0] = container_rootfs;
    int pid = clone(child, malloc(4096) + 4096, flags | SIGCHLD, argv);
    printf("父进程创建子进程完毕,子进程 pid = %d\n", pid);
    // 设置 cgroup
    cfcgroup(pid);
    printf("父进程设置 cgroup 完毕\n");
    // 设置网络
    cfnet(pid);
    close(pipefd[1]);
    // 等待子进程结束的信号
    waitpid(pid, NULL, 0);
    umount(container_rootfs);
    systemf("rm -rf %s", container_rootfs);

到了最重要的核心。该函数进行大部分工作:

  1. 在宿主机创建子进程 由子进程执行想要在容器里执行的任务 并且创建的子进程使用对各种资源使用自己的命名空间,如pid、网络、uts、IPC,从而和宿主机以及其他容器隔离开来。
  2. 在宿主机设置 cgroup,限制子进程使用的资源量。
  3. 在宿主机设置网络

创建子进程

    int pid = clone(child, malloc(4096) + 4096, flags | SIGCHLD, argv);

clone用于创建子进程,其中参数child是在子进程中执行的函数,flags是创建的子进程的命名空间参数,CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWIPC表示该子进程的ufs、pid、ns、net以及IPC等资源使用全新的命名空间,而不从父进程中继承,就像java里的在不同包下。因此子进程像套了个密不透风的壳,跟外界的联系断得干干净净。而child函数的代码如下:

int child(void *arg) {
    char **argv = (char **)arg;
    char ch;
    close(pipefd[1]);
    read(pipefd[0], &ch, 1);
    // 把 mount 挂载的目录从共享状态变成私有状态,这样子进程改变 mount 不影响父进程
    mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL);
    // 设置根目录
    chroot(argv[0]);
    // 把当前目录设置为根目录
    chdir("/");
    // 挂载 proc 文件系统
    mount("proc", "/proc", "proc", MS_NOEXEC | MS_NOSUID | MS_NODEV, "");
    // 替换对应的程序,比如调用者传来的 /bin/bash
    setenv("PATH", "/bin", 1);
    // 设置容器的网络
    child_cfnet();
    // 替换并执行指定的程序
    execvp(argv[1], argv + 1);
    perror(argv[1]);
    return 1;
}

进入子进程后后执行该函数。该函数是为容器启动后做一些初始化工作。

    chroot(argv[0]);
    chdir("/");

首先要设置子进程的根目录,对应于前面containers/容器ID 目录。设置之后 子进程的 / 目录其实是宿主机的containers/容器ID 目录。因为该目录只有该容器操作,所以容器像拥有了只有自己能操作的整套目录。

 mount("proc", "/proc", "proc", MS_NOEXEC | MS_NOSUID | MS_NODEV, "");

接着要挂载proc文件系统。linux里万物皆文件,因此进程信息放在proc目录下。就行互联网通过url访问文件一样,proc目录其实是虚拟目录,去访问该目录的内容其实是内核将相应的进程信息返回。但前面子进程已经跟宿主机断得干干净净了,所以只能重新搭上关系,将宿主机的proc目录挂载进子进程里,让子进程通过proc目录也能去访问进程信息。另外为了子进程的操作不影响宿主机,挂载后将相应的目录从共享改为私有,子进程的后续改动只对自己起效。

void cfnet(pid_t container_pid) {
    systemf("ip link add veth-host-%d type veth peer name veth-container", container_pid);
    systemf("ip link set veth-container netns %d", container_pid);
    systemf("ip link set veth-host-%d up", container_pid);
    systemf("ip link set veth-host-%d master docker0", container_pid);
}
 void child_cfnet() {
    srand(time(NULL));
    int random_num = rand() % (254 - 2 + 1) + 2;
    system("ip link set lo up");
    system("ip link set veth-container up");
    systemf("ip addr add 172.17.0.%d/16 dev veth-container", random_num);
    system("ip route add default via 172.17.0.1");
}

容器还是需要访问网络的,上面两个函数,cfnet函数在minidocker_exec函数里由宿主机执行,child_cfnet在child函数里由子进程执行。因为子进程跟父进程使用不同命名空间断开了网络,因此要将他们的网络连接起来。在linux里,一对veth设备像一根网线 其中一个设备发送的数据 另一个设备都会接收到。因为

systemf("ip link add veth-host-%d type veth peer name veth-container", container_pid);
systemf("ip link set veth-container netns %d", container_pid);

创建了一对veth设备,veth-host在宿主机上,但随后veth-container被移到容器的网络命名空间里。像一根网线连通了宿主机和容器,容器里发的消息通过该设备流到宿主机上。

    systemf("ip link set veth-host-%d master docker0", container_pid);

但如果这样做的话 宿主机上太多veth-host设备了,都要给它分配IP的话太多了,因此将所有veth-host都插入docker0网桥,并且在宿主机上给网桥分配IP,这样容器发送数据都到了网桥,然后发到其他容器或者真正的网络上。

    system("ip link set lo up");
    system("ip link set veth-container up");
    systemf("ip addr add 172.17.0.%d/16 dev veth-container", random_num);
    system("ip route add default via 172.17.0.1");

第一条启用容器内部网络的本地回环接口,第二条在容器内部启动移进来的veth-container设备 并且给分配内部IP地址,最后是设置默认网关。默认网关即是宿主机的docker0网桥。因为给veth-container分配ip与默认网关子网掩码一样,处于同一子网,所以容器内部发出的包,docker0都收的到。这样容器实现了和宿主机以及其他容器的联网。

    execvp(argv[1], argv + 1);

回到child函数,这行代码设置了容器真正要执行的操作,一般是/bin/sh。接着child函数执行完 回到minidocker_exec函数,下一步是设置资源限制:

void cfcgroup(pid_t pid) {
    system("mkdir -p /sys/fs/cgroup/cpu/minidocker");
    systemf("mkdir -p /sys/fs/cgroup/cpu/minidocker/%d", pid);
    systemf("echo %d >> /sys/fs/cgroup/cpu/minidocker/%d/tasks", pid, pid);
    systemf("echo 20000 > /sys/fs/cgroup/cpu/minidocker/%d/cpu.cfs_quota_us", pid);
}

linux里万物皆文件,进程信息可以通过读取proc目录得到,其对进程的资源限制也是写入目录里,像上面就是把要限制的进程号写入 再写入对该进程使用CPU的限制。
minidocker_exec函数下一步是设置网络 ,这个上面讲了,而之后就是等待子进程结束然后执行清理工作了

    waitpid(pid, NULL, 0);
    umount(container_rootfs);
    systemf("rm -rf %s", container_rootfs);

解除与容器根目录的挂载 ,最后删除容器文件。

总结

容器其实是隔离的进程,之所以实现了隔离,不是造了个壳再往里面塞东西,而是先启动一个子进程 ,然后进行各种限制,再根据需要开放部分限制。这就像面向对象的封装,先将全部封起来,然后根据需要开放部分限制,尽可能避免其内部受到影响。