从容器到现代软件分发

z3475,mailto:z3475@foxmail.com,个人博客:https://z3475.work

Intro

About Me

  • 两年 Archlinux 用户
  • 有配置了双启动的 Windows,Arch 装有 steam(proton),运行能玩的游戏;Windows 运行 Linux 根本没法玩的游戏
  • 休闲玩家,平时推推 gal 研究研究钢铁雄心4

然后发现了 Steam Deck...

  1. 市面上最便宜的原生 Linux硬件
  2. Arch+KDE
  3. 输入设备丰富
  4. 超轻薄 PC

How it works?

1、A/B 分区方案

Disk /dev/nvme0n1: 476.94 GiB, 512110190592 bytes, 1000215216 sectors
Disk model: SAMSUNG MZ9LQ512HBLU-00B00

Device            Start        End   Sectors   Size Type
/dev/nvme0n1p1     2048     133119    131072    64M EFI System
/dev/nvme0n1p2   133120     198655     65536    32M Microsoft basic data
/dev/nvme0n1p3   198656     264191     65536    32M Microsoft basic data
/dev/nvme0n1p4   264192   10749951  10485760     5G Linux root (x86-64)
/dev/nvme0n1p5 10749952   21235711  10485760     5G Linux root (x86-64)
/dev/nvme0n1p6 21235712   21759999    524288   256M Linux variable data
/dev/nvme0n1p7 21760000   22284287    524288   256M Linux variable data
/dev/nvme0n1p8 22284288 1000215182 977930895 466.3G Linux home

稳定的,只有/home...和 Flatpak

2、Flatpak

TLDR: 用户级包管理器 with 命名空间限制

flatpak install firefox

And it works!

But it not works,命名空间限制

什么是命名空间(namespace)

TLDR:对内核资源进行分区

每一个进程都有这些 namespace

  • User(user)
  • Mount(mnt)
  • PID(pid)
  • Cgroups(cgroup)
  • Network(net),IPC(ipc),time(time),UTS(uts)

User namespace

  • 和进程树一样形成树的结构
  • uid_map 和 gid_map 管理 UID 和 GUI 映射
  • 进程的 getuid/getgid/stat 等所有涉及 UID/GID 的系统调用会返回映射后的 UID/GID;同理传入的 uid/gid 会映射回去
  • 进程无法更改自己的 User ns,只能更改 pid_for_children
  • 一个 User ns 管理其归属(间接或直接)进程的权能capabilities(7)
  • 其他所有种类的 ns 都有一个 User ns 父亲

User Namespace 功能在哪查看

  • /proc/$PID/[ug]id_map

可以有多行,每一行格式为

uid start length

子命名空间[uid,uid+length-1]<->父命名空间[start,start+length-1]

没有对应映射代表子命名空间的进程系统调用时无法使用子命名空间的 UID,系统调用返回的所有父命名空间 UID 都为 nobody(65534),但是正常的文件访问权限检查不受影响。

直接修改/proc/$PID/[ug]id_map文件即可

用途

  • 让进程以为自己是 root
  • 支撑其他的 namespace
  • 防止容器内程序特权提升攻击(privilege-escalation attacks)

Manuals/Docker Engine/Security/Isolate containers with a user namespace

Mount Namespace

  • 隔离 mount 操作
  • 比 chroot 更强大

查看 Mount Namespace

  • /proc/PID/mounts 展示所有挂载点
  • /proc/PID/mountinfo 顺带展示挂载点命名空间可见性

/proc/PID/mounts = 进程能看见的目录结构 = "/proc/mount"

/proc/PID/mountinfo 展示 mount namespace 挂载点信息

  1. mount ID: 唯一标识一个挂载点的数字;parent ID: 唯一标识父挂载点的数字
  2. major:minor: 设备号
  3. root: 挂载点在设备上的根目录
  4. mount point: 挂载点在文件系统上的位置;mount options: 挂载选项,如 ro, rw 等
  5. optional fields: 可选字段,如 shared, master 等,以-分隔
  6. filesystem type: 文件系统类型,如 ext4, tmpfs 等
  7. mount source: 挂载源,如设备名或标签等
  8. super options: 超级块选项,如 discard, errors=remount-ro 等

How it works?

mount namespace 内可以挂载没有被隔离的资源(如 tmpfs)

每个挂载点可以附四种标记,创建新 mount namespace 时会继承老 mount namespace 的所有挂载点。这些标记影响挂载点内的挂载事件对当前拥有挂载点的 mount namespace 的可见性

  • MS_SHARED(mount --make-shared),可见
  • MS_PRIVATE(mount --make-private),不可见
  • MS_SLAVE(mount --make-slave),子 namespace 才可见
  • MS_UNBINDABLE(--make-unbindable),防止挂载爆炸(mount explosion)问题

实现细节请翻阅mount_namespaces(7)

PID Namespace

  • 隔离进程
  • PID 映射
  • /proc 文件系统只展示执行进程时的 PID Namespace 可见的进程

查看 PID Namespace

lsns --tree --type pid

How it works?

  • 第一个归属 PID ns 的进程是该 PID ns 的 init 进程,init 进程具有该 PD ns
  • 该 init 进程退出后内核向所有属于该 PID ns 的进程发 SIGKILL 信号
  • 一个进程能见到的所有进程都是其所属的 PID ns 和子 PID ns
  • 一个进程在其 PID ns 和其所有祖先 PID ns 都有一个 PID
  • 孤立进程归属当前 PID ns 的 init 进程

其他 namespace

  • cgroup namespace 隔离 cgroup 设施
  • IPC namespace 隔离 IPC 资源(POSIX 消息队列、SysV IPC 设施)
  • Network namespace 隔离有关网络的资源(网络设备,防火墙设置,IPv4/IPv6 协议栈,Unix 域,路由表等),不同命名空间使用对等点(peer)网卡
  • UTS namespace 隔离 hostname 和 NIS 域名
  • Time namespace 隔离墙上时钟和系统运行时钟,通过偏移量实现

相关系统调用

  • clone(2),fork 升级版,附带更多对子进程继承父进程的内核设施的控制
  • unshare(2),创建一个新 namespace
  • setns(2),让当前进程加入其他进程所属的某个或某些 namespace

example:利用 mount,PID,User 命名空间创建一个简单的容器

本节使用 Alpine linux——一个使用 musl 为标准 c 库的、busybox 为基础设施的以轻量目的构建的 linux 发行版,其 x86_64 标准安装 iso 仅有 153M,最小根文件系统版本解压后只需要 5.6M。非常适合构建容器。

wget https://dl-cdn.alpinelinux.org/alpine/v3.13/releases/x86_64/alpine-minirootfs-3.13.1-x86_64.tar.gz
mkdir fakeroot
tar xvf alpine-minirootfs-3.13.1-x86_64.tar.gz -C fakeroot
unshare --user --map-root-user --mount --pid --fork#创建user/mount/pid namespace
mount --bind `pwd`/fakeroot `pwd`/fakeroot #创建挂载点以便pivot_root
cd fakeroot
mkdir old_root
pivot_root . old_root #更换根目录
umount -l old_root
/bin/ash
export PATH=/bin:/usr/bin:/sbin
mount proc /proc -t proc
top

Next step...

资源分区是分区了,但是某些资源根本不可能用这种方法分区

比如 CPU 和 RAM,给每个程序手动分配 CPU?太麻烦了!

来用另一个内核设施 cgroups 管理资源配额吧。

开始使用 cgroups

cgroups 有 v1,v2 两个版本,只能同时开启一个版本,而且两个版本不兼容。因为 v2 已经在以下发行版默认使用,所以本次主要介绍 v2。

  • Fedora (since 31)
  • Arch Linux (since April 2021)
  • openSUSE Tumbleweed (since c. 2021)
  • Debian GNU/Linux (since 11)
  • Ubuntu (since 21.10)
  • RHEL and RHEL-like distributions (since 9)

cgroups 通过挂载 cgroups 文件系统提供 API

mount -t cgroup2 none $MOUNT_POINT
  • $MOUNT_POINT 在 Archlinux(systemd) 上默认是 /sys/fs/cgroup
  • 其目录树就是实际数据结构,每一个文件夹都是一个控制器,进程分布在所有叶子控制器和根控制器上
  • 文件夹内有控制器设施的属性文件
  • 可以直接操作文件/文件夹来使用 cgroups 设施

文件格式

cgroups 挂载点内所有属性文件都属于这些模式之一

VAL0\n
VAL1\n
VAL0 VAL1 ...\n
KEY0 VAL0\n
KEY1 VAL1\n
...
KEY0 SUB_KEY0=VAL00 SUB_KEY1=VAL01...
KEY1 SUB_KEY0=VAL10 SUB_KEY1=VAL11...
...

后两种一次只能写一个 key 的值,最后一种 SUB KEY 可以任意顺序

资源分配模式

  • Weights(权重),维护权重和并计算出孩子所占的比例,按周期给予资源(如cpu.weight
  • Limits(限制),限制孩子最多配置的资源量(如io.max
  • Protections(保护),保护孩子最少获得的资源量(如memory.low
  • Allocations(分配),孩子独占的资源量(如cpu.rt.max

常见资源分配

CPU

单位均为 微秒。

参数名 含义 默认值 格式
cpu.weight CPU 权重 100
cpu.max CPU 占用时间最大值 max 100000 $MAX $PERIOD
cpu.max.burst CPU 占用时间最大值(突发) 0

内存

单位均为 Byte,并向上舍入到与页大小对齐。

参数名 含义 默认值 格式
memory.current 该 cg 及其后代占用内存总量 $MEMORY_CURRENT
memory.min 内存最小值 0
memory.low 内存较小值 0
memory.high 内存较大值 max
memory.max 内存最大值 max

按一般理性来讲 。当前内存在不同区域有五种情况。

情况 内核行为 当无可用内存时
硬内存保护 OOM killer
尽最大努力保护内存
常规状态
尽可能维持更小内存
尽可能回收自身 OOM killer

内存回收压力(memory reclaim pressure) 指操作系统在内存不足时,通过回收内存来释放更多的空闲内存页。这个过程中,数据会从 RAM 中删除(如果可以重新获取)或复制到交换文件中(以便数据可以重新获取)。

其他资源控制

  • pids.xxx 线程数限制
  • io.xxx 硬盘读写限制
  • cpuset.xxx NUMA 节点限制
  • rdma.xxx RDMA 资源限制
  • ...

Example-简单的限制死循环和大内存应用程序

infalloc.cpp

#include <bits/stdc++.h>

using namespace std;
int total;
int main(){
  while (char* f=(char*)malloc(1024*1024)){
    for (int i=0;i<1024*1024;i++)
      f[i]=rand();
    total++;
    cout<<total<<"mb in total"<<endl;
  }
  cout<<strerror(errno)<<endl;
}

loop.cpp

#include <bits/stdc++.h>

using namespace std;

void loop(){
  while (1);
}

int main(){
  vector<jthread> threads;
  for (int i=0;i<32;i++)
    threads.emplace_back(loop);
}
g++ infalloc.cpp -o infalloc
g++ loop.cpp -o loop
echo $$ #查看当前终端进程PID
cd /sys/fs/cgroup
mkdir test #创建cgroups
cd test
sudo bash -c "echo $PID >> cgroup.procs" #分配进程至/test
vim cpu.max
vim memory.max

使用 top 等工具查看资源占用吧

与 namespace 合作

cgroup namespace隔离cgroup挂载和设施,在创建cgroup ns时会将当前进程的cgroup控制器设为新cgroup ns的父亲。/proc/self/cgroup变成0::/以隔离cgroup设施。但是原先的cgroup控制器也对新的cgroup ns的所有进程生效。

容器的最后一脚:overlayfs

namespace 解决了资源隔离问题;
cgroups 解决了资源配额问题;
但是这仅仅解决了容器如何运行起来的问题,既然容器还是一个“软件”,我们还需要为容器添加版本控制。
文件系统进行版本控制,怎么实现?

  1. 压缩容器的根目录
  2. 依赖采取解压覆盖的方式

overlayfs

一图胜千言

Example-挂载一个overlayfs

mount -t overlay overlay -o lowerdir=/lower1:/lower2,upperdir=/upper,workdir=/work /merged
/merge=
/upper
/lower2
/lower1

lower层只读,对/merge的修改集中在upper上,work为临时目录。

秒级启动

Next Dream...

运行一个软件=目录结构+共享库文件+可执行文件+配置文件

传统 Linux 发行版

  • 包管理器:假设目录结构不变,为不同版本共享库/可执行文件+配置文件划分不同版本不同名字的软件包,附加之间的依赖关系
  • 用户自己改/etc 内配置文件,然后运行

改配置文件麻烦,软件包版本管理更麻烦...

万一哪一天出错了还不能回滚配置文件...

How to improve?

Flatpak

用户级包管理器,内部使用namespace+cgroup维护软件沙箱环境,达成跨不同发行版运行软件。

Alt text

NixOS

将这一切文本化,并辅以可选的版本控制configuration.nix

{ config, ...}: {
services.nginx = {
  enable = true;
  virtualHosts."blog.example.com" = {
    enableACME = true;
    forceSSL = true;
    root = "/var/www/blog";
    locations."~ \.php$".extraConfig = ''
      fastcgi_pass  unix:${config.services.phpfpm.pools.mypool.socket};
      fastcgi_index index.php;
    '';
  };
};
services.mysql = {
  enable = true;
  package = pkgs.mariadb;
};
services.phpfpm.pools.mypool = {
  user = "nobody";
  settings = {
    pm = "dynamic";
    "listen.owner" = config.services.nginx.user;
    "pm.max_children" = 5;
    "pm.start_servers" = 2;
    "pm.min_spare_servers" = 1;
    "pm.max_spare_servers" = 3;
    "pm.max_requests" = 500;
  };
};

Docker

cgroups+namespace+overlayfs

Alt text

常见docker概念

  • 镜像(image)=overlayfs的压缩包(带版本控制文件)
  • 容器(container)=镜像实例化=可运行的容器
  • Dockerfile=镜像的makefile
  • Docker-Compose=容器的makefile

常用docker命令

docker pull <Image>           #下载一个镜像
docker run --it --rm <Image>  #运行一个容器并分配终端tty设备,结束后就销毁
docker run -d <Image>         #后台运行一个容器
docker ps  -a                 #查看所有容器
docker start/stop <Container> #启动一个容器
docker exec <Conatiner>       #在容器中运行命令(Ctrl+C退出;Ctrl+P Ctrl+Q分离)

其中Image格式为NAME[:TAG|@DIGEST],tag为版本号。常见如debian:latest表示最新版本的debian镜像。

dockerfile格式

FROM python:3

WORKDIR /usr/src/app

COPY requirements.txt ./
ENV http_proxy=http://127.0.0.1:7890
ENV https_proxy=http://127.0.0.1:7890
RUN pip install --no-cache-dir \
        -r requirements.txt \
        -i https://pypi.tuna.tsinghua.edu.cn/simple
#shell-like命令行
COPY . .

CMD [ "uvicorn" , "webui:app" , "--reload" , "--host" , "0.0.0.0" ]
docker build 'https://github.com/docker/rootfs.git#branch:docker'

SQL-like语法,带缓存

docker-compose.yml格式

version: '3'
services:
    rsshub: #实际容器名为动态分配,用户无需管理
        image: diygod/rsshub
        restart: always
        ports: #端口映射,主机:容器
            - '1200:1200'
        environment: #环境变量
            NODE_ENV: production
            CACHE_TYPE: redis
            REDIS_URL: 'redis://redis:6379/'
            PUPPETEER_WS_ENDPOINT: 'ws://browserless:3000'  
        depends_on: #依赖其他容器
            - redis
            - browserless  
    browserless:
        image: browserless/chrome  
        restart: always  
        ulimits:  
          core:  
            hard: 0  
            soft: 0  
    redis:
        image: redis:alpine
        restart: always
        volumes: #映射存储
            - redis-data:/data
volumes:
    redis-data:
docker volume create redis-data #创建redis-data介质
docker-compose up -d #使用当前目录的docker-compose.yml创建镜像
docker-compose down -d

k8s

搭建容器集群以实现分布式系统。

  1. 服务发现和负载均衡
  2. 服务自动调度
  3. 容器存储编排
  4. 容器故障恢复
  5. 自动更新和回滚
  6. 配置和密钥存储
  7. 服务水平伸缩
  8. 批量执行以及守护进程任务
  9. 服务存活探针

  • Pod是实现业务的最小单元(就像docker-compose)
    • Deployment实现保障,滚动更新功能
      • Service实现对外提供服务,可提供上述的自动调度、负载均衡、高层保障功能
  • Job实现一次运行多个Pod
  • CronJob实现定时Job

还有更多的地方用到了容器技术...

  • Chrome使用pid namespace实现沙箱
  • steam&proton使用pid namespace实现沙箱
  • systemd自带cgroup支持控制linux服务资源配额
  • appImages/electron也使用namespace实现沙箱
  • openwrt使用overlayfs控制系统版本更新...

namespace/cgroup/overlayfs is everywhere!

Unix 哲学达成了 linux 容器的成功

  1. Small is beautiful.(小即是美)
  2. Make each program do one thing well.(让程序只做好一件事)
  3. Build a prototype as soon as possible.(尽可能早地创建原型)
  4. Choose portability over efficiency.(可移植性比效率更重要)
  5. Store data in flat text files.(数据应该保存为文本文件)
  6. Use software leverage to your advantage.(尽可能地榨取软件的全部价值)
  7. Use shell scripts to increase leverage and portability.(使用 shell 脚本来提高效率和可移植性)
  8. Avoid captive user interfaces.(避免使用可定制性低下的用户界面)
  9. Make every program a filter.(所有程序都是数据的过滤器)

有多少工作能程序化?

Reference

namespace

cgroups

overlayfs