Fork me on GitHub

2023年6月

容器运行时 containerd 学习笔记

2016 年 12 月,Docker 公司宣布将 containerd 项目从 Docker Engine 中分离出来,形成一个独立的开源项目,并捐赠给 CNCF 基金会,旨在打造一个符合工业标准的容器运行时。Docker 公司之所以做出这样的决定,是因为当时在容器编排的市场上 Docker 面临着 Kubernetes 的极大挑战,将 containerd 分离,是为了方便开展 Docker Swarm 项目,不过结果大家都知道,Docker Swarm 在 Kubernetes 面前以惨败收场。

containerd 并不是直接面向最终用户的,而是主要用于集成到更上层的系统里,比如 Docker Swarm、Kubernetes 或 Mesos 等容器编排系统。containerd 通过 unix domain docket 暴露很低层的 gRPC API,上层系统可以通过这些 API 对机器上的容器整个生命周期进行管理,包括镜像的拉取、容器的启动和停止、以及底层存储和网络的管理等。下面是 containerd 官方提供的架构图:

containerd-architecture.png

从上图可以看出,containerd 的核心主要由一堆的 Services 组成,通过 Content Store、Snapshotter 和 Runtime 三大技术底座,实现了 Containers、Content、Images、Leases、Namespaces 和 Snapshots 等的管理。

其中 Runtime 部分和容器的关系最为紧密,可以看到 containerd 通过 containerd-shim 来支持多种不同的 OCI runtime,其中最为常用的 OCI runtime 就是 runc,所以只要是符合 OCI 标准的容器,都可以由 containerd 进行管理,值得一提的是 runc 也是由 Docker 开源的。

OCI 的全称为 Open Container Initiative,也就是开放容器标准,主要致力于创建一套开放的容器格式和运行时行业标准,目前包括了 Runtime、Image 和 Distribution 三大标准。

containerd 与 Docker 和 Kubernetes 的关系

仔细观察 containerd 架构图的上面部分,可以看出 containerd 通过提供 gRPC API 来供上层应用调用,上层应用可以直接集成 containerd client 来访问它的接口,诸如 Docker Engine、BuildKit 以及 containerd 自带的命令行工具 ctr 都是这样实现的;所以从 Docker 1.11 开始,当我们执行 docker run 命令时,整个流程大致如下:

docker-to-containerd.png

Docker Client 和 Docker Engine 是典型的 CS 架构,当用户执行 docker run 命令时,Docker Client 调用 Docker Engine 的接口,但是 Docker Engine 并不负责容器相关的事情,而是调用 containerd 的 gRPC 接口交给 containerd 来处理;不过 containerd 收到请求后,也并不会直接去创建容器,因为我们在上面提到,创建容器实际上已经有一个 OCI 标准了,这个标准有很多实现,其中 runc 是最常用的一个,所以 containerd 也不用再去实现这套标准了,而是直接调用这些现成的 OCI Runtime 即可。

不过创建容器有一点特别需要注意的地方,我们创建的容器进程需要一个父进程来做状态收集、维持 stdin 等工作的,这个父进程如果是 containerd 的话,那么如果 containerd 挂掉的话,整个机器上的所有容器都得退出了,为了解决这个问题,containerd 引入了 containerd-shim 组件;shim 的意思是垫片,正如它的名字所示,它其实是一个代理,充当着容器进程和 containerd 之间的桥梁;每当用户启动容器时,都会先启动一个 containerd-shim 进程,containerd-shim 然后调用 runc 来启动容器,之后 runc 会退出,而 containerd-shim 则会成为容器进程的父进程,负责收集容器进程的状态,上报给 containerd,并在容器中 PID 为 1 的进程退出后接管容器中的子进程进行清理,确保不会出现僵尸进程。

介绍完 containerd 与 Docker 之间的关系,我们再来看看它与 Kuberntes 的关系,从历史上看,Kuberntes 和 Docker 相爱相杀多年,一直是开源社区里热门的讨论话题。

在 Kubernetes 早期的时候,由于 Docker 风头正盛,所以 Kubernetes 选择通过直接调用 Docker API 来管理容器:

kubelet-to-docker.png

后来随着容器技术的发展,出现了很多其他的容器运行时,为了让 Kubernetes 平台支持更多的容器运行时,而不仅仅是和 Docker 绑定,Google 于是联合 Red Hat 一起推出了 CRI 标准。CRI 的全称为 Container Runtime Interface,也就是容器运行时接口,它是 Kubernetes 定义的一组与容器运行时进行交互的接口,只要你实现了这套接口,就可以对接到 Kubernetes 平台上来。不过在那个时候,并没有多少容器运行时会直接去实现 CRI 接口,而是通过 shim 来适配不同的容器运行时,其中 dockershim 就是 Kubernetes 将 Docker 适配到 CRI 接口的一个实现:

kubelet-to-docker-shim.png

很显然,这个链路太长了,好在 Docker 将 containerd 项目独立出来了,那么 Kubernetes 是否可以绕过 Docker 直接与 containerd 通信呢?答案当然是肯定的,从 containerd 1.0 开始,containerd 开发了 CRI-Containerd,可以直接与 containerd 通信,从而取代了 dockershim(从 Kubernetes 1.24 开始,dockershim 已经从 Kubernetes 的代码中删除了,cri-dockerd 目前交由社区维护):

kubelet-cri-containerd.png

到了 containerd 1.1 版本,containerd 又进一步将 CRI-Containerd 直接以插件的形式集成到了 containerd 主进程中,也就是说 containerd 已经原生支持 CRI 接口了,这使得调用链路更加简洁:

kubelet-to-containerd.png

这也是目前 Kubernetes 默认的容器运行方案。不过,这条调用链路还可以继续优化下去,在 CNCF 中,还有另一个和 containerd 齐名的容器运行时项目 cri-o,它不仅支持 CRI 接口,而且创建容器的逻辑也更简单,通过 cri-o,kubelet 可以和 OCI 运行时直接对接,减少任何不必要的中间开销:

kubelet-to-crio.png

快速开始

这一节主要学习 containerd 的安装和使用。

安装 containerd

首先从 containerd 的 Release 页面 下载最新版本:

$ curl -LO https://github.com/containerd/containerd/releases/download/v1.7.2/containerd-1.7.2-linux-amd64.tar.gz

然后将其解压到 /usr/local/bin 目录:

$ tar Cxzvf /usr/local containerd-1.7.2-linux-amd64.tar.gz 
bin/
bin/containerd-shim-runc-v1
bin/containerd-shim-runc-v2
bin/containerd-stress
bin/containerd
bin/containerd-shim
bin/ctr

其中,containerd 是服务端,我们可以直接运行:

$ containerd
INFO[2023-06-18T14:28:14.867212652+08:00] starting containerd revision=0cae528dd6cb557f7201036e9f43420650207b58 version=v1.7.2
...
INFO[2023-06-18T14:28:14.922388455+08:00] serving... address=/run/containerd/containerd.sock.ttrpc
INFO[2023-06-18T14:28:14.922477258+08:00] serving... address=/run/containerd/containerd.sock
INFO[2023-06-18T14:28:14.922529910+08:00] Start subscribing containerd event
INFO[2023-06-18T14:28:14.922570820+08:00] Start recovering state
INFO[2023-06-18T14:28:14.922636858+08:00] Start event monitor
INFO[2023-06-18T14:28:14.922653276+08:00] Start snapshots syncer
INFO[2023-06-18T14:28:14.922662467+08:00] Start cni network conf syncer for default
INFO[2023-06-18T14:28:14.922671149+08:00] Start streaming server
INFO[2023-06-18T14:28:14.922689846+08:00] containerd successfully booted in 0.060348s

ctr 是客户端,运行 ctr version 确认 containerd 是否安装成功:

$ ctr version
Client:
  Version:  v1.7.2
  Revision: 0cae528dd6cb557f7201036e9f43420650207b58
  Go version: go1.20.4

Server:
  Version:  v1.7.2
  Revision: 0cae528dd6cb557f7201036e9f43420650207b58
  UUID: 9eb2cbd4-8c1d-4321-839b-a8a4fc498de8

以 systemd 方式启动 containerd

官方已经为我们准备好了 containerd.service 文件,我们只需要将其下载下来,放在 systemd 的配置目录下即可:

$ mkdir -p /usr/local/lib/systemd/system/
$ curl -L https://raw.githubusercontent.com/containerd/containerd/main/containerd.service -o /usr/local/lib/systemd/system/containerd.service

containerd.service 文件内容如下:

[Unit]
Description=containerd container runtime
Documentation=https://containerd.io
After=network.target local-fs.target

[Service]
#uncomment to enable the experimental sbservice (sandboxed) version of containerd/cri integration
#Environment="ENABLE_CRI_SANDBOXES=sandboxed"
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/containerd

Type=notify
Delegate=yes
KillMode=process
Restart=always
RestartSec=5
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNPROC=infinity
LimitCORE=infinity
LimitNOFILE=infinity
# Comment TasksMax if your systemd version does not supports it.
# Only systemd 226 and above support this version.
TasksMax=infinity
OOMScoreAdjust=-999

[Install]
WantedBy=multi-user.target

其中有两个配置很重要,Delegate=yes 表示允许 containerd 管理自己创建容器的 cgroups,否则 systemd 会将进程移到自己的 cgroups 中,导致 containerd 无法正确获取容器的资源使用情况;默认情况下,systemd 在停止或重启服务时会在进程的 cgroup 中查找并杀死所有子进程,KillMode=process 表示让 systemd 只杀死主进程,这样可以确保升级或重启 containerd 时不影响现有的容器。

然后我们使用 systemd 守护进程的方式启动 containerd 服务:

$ systemctl enable --now containerd

这样当系统重启后,containerd 服务也会自动启动了。

安装 runc

安装好 containerd 之后,我们就可以使用 ctr 执行一些基本操作了,比如使用 ctr image pull 下载镜像:

$ ctr image pull docker.io/library/nginx:alpine
docker.io/library/nginx:alpine:                                                   resolved
index-sha256:2d194184b067db3598771b4cf326cfe6ad5051937ba1132b8b7d4b0184e0d0a6:    exists  
manifest-sha256:2d4efe74ef541248b0a70838c557de04509d1115dec6bfc21ad0d66e41574a8a: exists  
layer-sha256:768e67c521a97f2acf0382a9750c4d024fc1e541e22bab2dec1aad36703278f1:    exists  
config-sha256:4937520ae206c8969734d9a659fc1e6594d9b22b9340bf0796defbea0c92dd02:   exists  
layer-sha256:4db1b89c0bd13344176ddce2d093b9da2ae58336823ffed2009a7ea4b62d2a95:    exists  
layer-sha256:bd338968799fef766509223449d72392692f1f56802da9059ae3f0965c2885e2:    exists  
layer-sha256:6a107772494d184e0fddf5d99c877e2fa8d07d1d47b714c17b7d20eba1da01c6:    exists  
layer-sha256:9f05b0cc5f6e8010689a6331bad9ca02c62caa226b7501a64d50dcca0847dcdb:    exists  
layer-sha256:4c5efdb87c4a2350cc1c2781a80a4d3e895447007d9d8eac1e743bf80dd75c84:    exists  
layer-sha256:c8794a7158bff7f518985e76c590029ccc6b4c0f6e66e82952c3476c095225c9:    exists  
layer-sha256:8de2a93581dcb1cc62dd7b6e1620bc8095befe0acb9161d5f053a9719e145678:    exists  
elapsed: 2.8 s                                                                    total:   0.0 B (0.0 B/s)
unpacking linux/amd64 sha256:2d194184b067db3598771b4cf326cfe6ad5051937ba1132b8b7d4b0184e0d0a6...
done: 23.567287ms    

注意这里和 docker pull 的不同,镜像名称需要写全称。

不过这个时候,我们还不能运行镜像,我们不妨用 ctr run 命令运行一下试试:

$ ctr run docker.io/library/nginx:alpine nginx
ctr: failed to create shim task: 
    OCI runtime create failed: 
        unable to retrieve OCI runtime error (open /run/containerd/io.containerd.runtime.v2.task/default/nginx/log.json: no such file or directory):
            exec: "runc": executable file not found in $PATH: unknown

正如前文所述,这是因为 containerd 依赖 OCI runtime 来进行容器管理,containerd 默认的 OCI runtime 是 runc,我们还没有安装它。runc 的安装也非常简单,直接从其项目的 Releases 页面 下载最新版本:

$ curl -LO https://github.com/opencontainers/runc/releases/download/v1.1.7/runc.amd64

并将其安装到 /usr/local/sbin 目录即可:

$ install -m 755 runc.amd64 /usr/local/sbin/runc

使用 ctr container rm 删除刚刚运行失败的容器:

$ ctr container rm nginx

然后再使用 ctr run 重新运行:

$ ctr run docker.io/library/nginx:alpine nginx
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2023/06/18 07:04:52 [notice] 1#1: using the "epoll" event method
2023/06/18 07:04:52 [notice] 1#1: nginx/1.25.1
2023/06/18 07:04:52 [notice] 1#1: built by gcc 12.2.1 20220924 (Alpine 12.2.1_git20220924-r4) 
2023/06/18 07:04:52 [notice] 1#1: OS: Linux 3.10.0-1160.el7.x86_64
2023/06/18 07:04:52 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1024:1024
2023/06/18 07:04:52 [notice] 1#1: start worker processes
2023/06/18 07:04:52 [notice] 1#1: start worker process 30

可以看到此时容器正常启动了,不过目前这个容器还不具备网络能力,所以我们无法从外部访问它,可以使用 ctr task exec 进入容器:

$ ctr task exec -t --exec-id nginx nginx sh

在容器内部验证 nginx 服务是否正常:

/ # curl localhost:80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

安装 CNI 插件

正如上一节所示,默认情况下 containerd 创建的容器只有 lo 网络,无法从容器外部访问,如果希望将容器内的网络端口暴露出来,我们还需要安装 CNI 插件。和 CRI 一样,CNI 也是一套规范接口,全称为 Container Network Interface,即容器网络接口,它提供了一种将容器网络插件化的解决方案。CNI 涉及两个基本概念:容器和网络,它的接口也是围绕着这两个基本概念进行设计的,主要有两个:ADD 负责将容器加入网络,DEL 负责将容器从网络中删除,有兴趣的同学可以阅读 CNI Specification 了解更具体的信息。

实战 Docker 容器网络 这篇笔记中,我们曾经学习过 Docker 的 CNM 网络模型,它和 CNI 相比要复杂一些。CNM 和 CNI 是目前最流行的两种容器网络方案,关于他俩的区别,可以参考 docker的网络-Container network interface(CNI)与Container network model(CNM)

官方提供了很多 CNI 接口的实现,比如 bridgeipvlanmacvlan 等,这些都被称为 CNI 插件,此外,很多开源的容器网络项目,比如 calicoflannelweave 等也实现了 CNI 插件。其实,CNI 插件就是一堆的可执行文件,我们可以从 CNI 插件的 Releases 页面 下载最新版本:

$ curl -LO https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz

然后将其解压到 /opt/cni/bin 目录(这是 CNI 插件的默认目录):

$ mkdir -p /opt/cni/bin
$ tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz 
./
./loopback
./bandwidth
./ptp
./vlan
./host-device
./tuning
./vrf
./sbr
./tap
./dhcp
./static
./firewall
./macvlan
./dummy
./bridge
./ipvlan
./portmap
./host-local

可以看到目录中包含了很多插件,这些插件按功能可以分成三大类:Main、IPAM 和 Meta:

  • Main:负责创建网络接口,支持 bridgeipvlanmacvlanptphost-devicevlan 等类型的网络;
  • IPAM:负责 IP 地址的分配,支持 dhcphost-localstatic 三种分配方式;
  • Meta:包含一些其他配置插件,比如 tuning 用于配置网络接口的 sysctl 参数,portmap 用于主机和容器之间的端口映射,bandwidth 用于限流等等。

CNI 插件是通过 JSON 格式的文件进行配置的,我们首先创建 CNI 插件的配置目录 /etc/cni/net.d

$ mkdir -p /etc/cni/net.d

然后在这个目录下新建一个配置文件:

$ vi /etc/cni/net.d/10-mynet.conf
{
    "cniVersion": "0.2.0",
    "name": "mynet",
    "type": "bridge",
    "bridge": "cni0",
    "isGateway": true,
    "ipMasq": true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.22.0.0/16",
        "routes": [
            { "dst": "0.0.0.0/0" }
        ]
    }
}

其中 "name": "mynet" 表示网络的名称,"type": "bridge" 表示创建的是一个网桥网络,"bridge": "cni0" 表示创建网桥的名称,isGateway 表示为网桥分配 IP 地址,ipMasq 表示开启 IP Masquerade 功能,关于 bridge 插件的更多配置,可以参考 bridge plugin 文档

下面的 ipam 部分是 IP 地址分配的相关配置,"type": "host-local" 表示将使用 host-local 插件来分配 IP,这是一种简单的本地 IP 地址分配方式,它会从一个地址范围内来选择分配 IP,关于 host-local 插件的更多配置,可以参考 host-local 文档

除了网桥网络,我们再新建一个 loopback 网络的配置文件:

$ vi /etc/cni/net.d/99-loopback.conf
{
    "cniVersion": "0.2.0",
    "name": "lo",
    "type": "loopback"
}

CNI 项目中内置了一些简单的 Shell 脚本用于测试 CNI 插件的功能:

$ git clone https://github.com/containernetworking/cni.git
$ cd cni/scripts/
$ ls
docker-run.sh  exec-plugins.sh  priv-net-run.sh  release.sh

其中 exec-plugins.sh 脚本用于执行 CNI 插件,创建网络,并将某个容器加入该网络:

$ ./exec-plugins.sh 
Usage: ./exec-plugins.sh add|del CONTAINER-ID NETNS-PATH
  Adds or deletes the container specified by NETNS-PATH to the networks
  specified in $NETCONFPATH directory

该脚本有三个参数,第一个参数为 adddel 表示将容器添加到网络或将容器从网络中删除,第二个参数 CONTAINER-ID 表示容器 ID,一般没什么要求,保证唯一即可,第三个参数 NETNS-PATH 表示这个容器进程的网络命名空间位置,一般位于 /proc/${PID}/ns/net,所以,想要将上面运行的 nginx 容器加入网络中,我们需要知道这个容器进程的 PID,这个可以通过 ctr task list 得到:

$ ctr task ls
TASK     PID      STATUS    
nginx    20350    RUNNING

然后执行下面的命令:

$ CNI_PATH=/opt/cni/bin ./exec-plugins.sh add nginx /proc/20350/ns/net

前面的 CNI_PATH=/opt/cni/bin 是必不可少的,告诉脚本从这里执行 CNI 插件,执行之后,我们可以在主机上执行 ip addr 进行确认:

$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:7f:8e:9a brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global noprefixroute dynamic enp0s3
       valid_lft 72476sec preferred_lft 72476sec
    inet6 fe80::e0ae:69af:54a5:f8d0/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever
3: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 72:87:b6:07:19:07 brd ff:ff:ff:ff:ff:ff
    inet 10.22.0.1/16 brd 10.22.255.255 scope global cni0
       valid_lft forever preferred_lft forever
    inet6 fe80::7087:b6ff:fe07:1907/64 scope link 
       valid_lft forever preferred_lft forever
11: vethc5e583fc@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni0 state UP group default 
    link/ether 92:5c:c3:6a:a0:56 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::905c:c3ff:fe6a:a056/64 scope link 
       valid_lft forever preferred_lft forever

可以看出主机上多了一个名为 cni0 的网桥设备,这个就对应我们创建的网络,执行 ip route 也可以看到主机上多了一条到 cni0 的路由:

$ ip route
default via 10.0.2.2 dev enp0s3 proto dhcp metric 100 
10.0.2.0/24 dev enp0s3 proto kernel scope link src 10.0.2.15 metric 100 
10.22.0.0/16 dev cni0 proto kernel scope link src 10.22.0.1

另外,我们还能看到一个 veth 设备,在 实战 Docker 容器网络 这篇笔记中我们已经学习过 veth 是一种虚拟的以太网隧道,其实就是一根网线,网线一头插在主机的 cni0 网桥上,另一头则插在容器里。我们可以进到容器里面进一步确认:

$ ctr task exec -t --exec-id nginx nginx sh
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
4: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP 
    link/ether 0a:c2:11:63:ea:8c brd ff:ff:ff:ff:ff:ff
    inet 10.22.0.9/16 brd 10.22.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::8c2:11ff:fe63:ea8c/64 scope link 
       valid_lft forever preferred_lft forever

容器除了 lo 网卡之外,多了一张 eth0 网卡,它的 IP 地址是 10.22.0.9,这个正是我们在 10-mynet.conf 配置文件中定义的范围。这时,我们就可以在主机上通过这个 IP 来访问容器内部了:

$ curl 10.22.0.9:80

exec-plugins.sh 脚本会遍历 /etc/cni/net.d/ 目录下的所有配置来创建网络接口,我们也可以使用 cnitool 来创建特定的网络接口。

通过将容器添加到指定网络,可以让容器具备和外界通信的能力,除了这种方式之外,我们也可以直接以主机网络模式启动容器:

$ ctr run --net-host docker.io/library/nginx:alpine nginx

使用 ctr 操作 containerd

经过上面的步骤,containerd 服务已经在我们的系统中安装和配置好了,接下来我们将学习命令行工具 ctr 对 containerd 进行操作,ctr 是 containerd 部署包中内置的命令行工具,功能比较简单,一般用于 containerd 的调试,其实在上面的安装步骤中已经多次使用过它,这一节对 ctr 做个简单的总结。

ctr image 命令

命令含义
ctr image list查看镜像列表
ctr image list -q查看镜像列表,只显示镜像名称
ctr image pull docker.io/library/nginx:alpine拉取镜像,注意镜像名称的前缀不能少
ctr image pull --platform linux/amd64 docker.io/library/nginx:alpine拉取指定平台的镜像
ctr image pull --all-platforms docker.io/library/nginx:alpine拉取所有平台的镜像
ctr image tag docker.io/library/nginx:alpine 192.168.1.109:5000/nginx:alpine给镜像打标签
ctr image push 192.168.1.109:5000/nginx:alpine推送镜像
ctr image push --user username:password 192.168.1.109:5000/nginx:alpine推送镜像到带认证的镜像仓库
ctr image rm 192.168.1.109:5000/nginx:alpine删除镜像
ctr image export nginx.tar docker.io/library/nginx:alpine导出镜像
ctr image import hello.tar导入镜像
ctr image import --platform linux/amd64 hello.tar导入指定平台的镜像,如果导出的镜像文件只包含一个平台,导入时可能会报错 ctr: content digest sha256:xxx: not found,必须带上 --platform 这个参数
ctr image mount docker.io/library/nginx:alpine ./nginx将镜像挂载到主机目录
ctr image unmount ./nginx将镜像从主机目录卸载

ctr container 命令

命令含义
ctr container list查看容器列表
ctr container list -q查看容器列表,只显示容器名称
ctr container create docker.io/library/nginx:alpine nginx创建容器
ctr container info nginx查看容器详情,类似于 docker inspect
ctr container rm nginx删除容器

ctr task 命令

命令含义
ctr task list查看任务列表,使用 ctr container create 创建容器时并没有运行,它只是一个静态的容器,包含了容器运行所需的资源和配置数据
ctr task start nginx启动容器
ctr task exec -t --exec-id nginx nginx sh进入容器进行操作,注意 --exec-id 参数随便写,只要唯一就行
ctr task metrics nginx查看容器的 CPU 和内存使用情况
ctr task ps nginx查看容器中的进程对应宿主机中的 PID
ctr task pause nginx暂停容器,暂停后容器状态变成 PAUSED
ctr task resume nginx恢复容器继续运行
ctr task kill nginx停止容器,停止后容器状态变成 STOPPED
ctr task rm nginx删除任务

ctr run 命令

命令含义
ctr run docker.io/library/nginx:alpine nginx创建容器并运行,相当于 ctr container create + ctr task start
ctr run --rm docker.io/library/nginx:alpine nginx退出容器时自动删除容器
ctr run -d docker.io/library/nginx:alpine nginx运行容器,运行之后从终端退出(detach)但容器不停止
ctr run --mount type=bind,src=/root/test,dst=/test,options=rbind:rw docker.io/library/nginx:alpine nginx挂载本地目录或文件到容器
ctr run --env USER=root docker.io/library/nginx:alpine nginx为容器设置环境变量
ctr run --null-io docker.io/library/nginx:alpine nginx运行容器,并将控制台输出重定向到 /dev/null
ctr run --log-uri file:///var/log/nginx.log docker.io/library/nginx:alpine nginx运行容器,并将控制台输出写到文件中
ctr run --net-host docker.io/library/nginx:alpine nginx使用主机网络运行容器
ctr run --with-ns=network:/var/run/netns/nginx docker.io/library/nginx:alpine nginx使用指定命名空间文件运行容器

命名空间

命令含义
ctr ns list查看命名空间列表
ctr ns create test创建命名空间
ctr ns rm test删除命名空间
ctr -n test image list查看特定命名空间下的镜像列表
ctr -n test container list查看特定命名空间下的容器列表

containerd 通过命名空间进行资源隔离,当没有指定命名空间时,默认使用 default 命名空间,Docker 和 Kubernetes 都可以基于 containerd 来管理容器,Docker 使用的是 moby 命名空间,Kubernetes 使用的是 k8s.io 命名空间,所以如果想查看 Kubernetes 运行的容器,可以通过 ctr -n k8s.io container list 查看。

除了上面的一些常用命令,还有一些不常用的命令,比如 pluginscontentleasessnapshotsleasesshim 等,这里就不一一介绍了,感兴趣的同学可以使用 ctrctr help 获取更多的帮助信息。

虽然使用 ctr 可以进行大部分 containerd 的日常操作,但是这些操作偏底层,对用户很不友好,比如不支持镜像构建,网络配置非常繁琐,所以 ctr 一般是供开发人员测试 containerd 用的;如果希望找一款更简单的命令行工具,可以使用 nerdctl,它的操作和 Docker 非常类似,对 Docker 用户来说会感觉非常亲近,nerdctl 相对于 ctr 来说,有着以下几点区别:

  • nerdctl 支持使用 Dockerfile 构建镜像;
  • nerdctl 支持使用 docker-compose.yaml 定义和管理多个容器;
  • nerdctl 支持在容器内运行 systemd;
  • nerdctl 支持使用 CNI 插件来配置容器网络;

除了 ctr 和 nerdctl,我们还可以使用 crictl 来操作 containerd,crictl 是 Kubernetes 提供的 CRI 客户端工具,由于 containerd 实现了 CRI 接口,所以 crictl 也可以充当 containerd 的客户端。此外,官方还提供了一份教程可以让我们 实现自己的 containerd 客户端

参考

更多

使用 ctr run --with-ns 让容器在启动时加入已存在命名空间

上面我们是通过往容器进程的网络命名空间中增加网络接口来实现的,我们也可以先创建网络命名空间:

$ ip netns add nginx

这个网络命名空间的文件位置位于 /var/run/netns/nginx

$ ls /var/run/netns
nginx

然后在这个网络命名空间中配置网络接口,可以执行 exec-plugins.sh 脚本:

$ CNI_PATH=/opt/cni/bin ./exec-plugins.sh add nginx /var/run/netns/nginx

或执行 cnitool 命令:

$ CNI_PATH=/opt/cni/bin cnitool add mynet /var/run/netns/nginx

ctr run 命令在启动容器的时候可以使用 --with-ns 参数让容器在启动时候加入到一个已存在的命名空间,所以可以通过这个参数加入到上面配置好的网络命名空间中:

$ ctr run --with-ns=network:/var/run/netns/nginx docker.io/library/nginx:alpine nginx

containerd 的配置文件

使用 containerd config default 命令生成默认配置文件:

$ mkdir -p /etc/containerd
$ containerd config default > /etc/containerd/config.toml
扫描二维码,在手机上阅读!

基于 ChatGPT 实现一个划词翻译 Chrome 插件

去年 11 月,美国的 OpenAI 公司推出了 ChatGPT 产品,它在发布后的 5 天内用户数就突破了 100 万,两个月后月活用户突破了 1 个亿,成为至今为止人类历史上用户数增长最快的消费级应用。ChatGPT 之所以能在全球范围内火出天际,不仅是因为它能以逼近自然语言的能力和人类对话,而且可以根据不同的提示语解决各种不同场景下的问题,它的推理能力、归纳能力、以及多轮对话能力都让世人惊叹不已,让实现通用人工智能(AGI,Artificial General Intelligence)变成为了现实,也意味着一种新型的人机交互接口由此诞生,这为更智能的 AI 产品提供了无限可能。

很快,OpenAI 推出了相应的 API 接口,所有人都可以基于这套 API 快速实现一个类似 ChatGPT 这样的产品,当然,聊天对话只是这套 API 的基本能力,OpenAI 官方网站有一个 Examples 页面,展示了结合不同的提示语 OpenAI API 在更多场景下的应用:

chatgpt-examples.png

OpenAI API 快速入门

OpenAI 提供了很多和 AI 相关的接口,如下:

  • Models - 用于列出所有可用的模型;
  • Completions - 给定一个提示语,让 AI 生成后续内容;
  • Chat - 给定一系列对话内容,让 AI 生成对应的回复,使用这个接口就可以实现类似 ChatGPT 的功能;
  • Edits - 给定一个提示语和一条指令,AI 将对提示语进行相应的修改,比如常见的语法纠错场景;
  • Images - 用于根据提示语生成图片,或对图片进行编辑,可以实现类似于 Stable DiffusionMidjourney 这样的 AI 绘画应用,这个接口使用的是 OpenAI 的图片生成模型 DALL·E
  • Embeddings - 用于获取一个给定文本的向量表示,我们可以将结果保存到一个向量数据库中,一般用于搜索、推荐、分类、聚类等任务;
  • Audio - 提供了语音转文本的功能,使用了 OpenAI 的 Whisper 模型;
  • Files - 文件管理类接口,便于用户上传自己的文件进行 Fine-tuning;
  • Fine-tunes - 用于管理你的 Fine-tuning 任务,详细内容可参考 Fine-tuning 教程
  • Moderations - 用于判断给定的提示语是否违反 OpenAI 的内容政策;

关于 API 的详细内容可以参考官方的 API referenceDocumentation

其中,CompletionsChatEdits 这三个接口都可以用于对话任务,Completions 主要解决的是补全问题,也就是说用户给出一段话,模型可以按照提示语续写后面的内容;Chat 用于处理聊天任务,它显式的定义了 systemuserassistant 三个角色,方便维护对话的语境信息和多轮对话的历史记录;Edit 主要用于对用户的输入进行修改和纠正。

要调用 OpenAI 的 API 接口,必须先创建你的 API Keys,然后请求时像下面这样带上 Authorization 头即可:

Authorization: Bearer OPENAI_API_KEY

下面是直接使用 curl 调用 Chat 接口的示例:

$ curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
     "model": "gpt-3.5-turbo",
     "messages": [{"role": "user", "content": "你好!"}],
     "temperature": 0.7
   }'

我们可以得到类似下面的回复:

{
  "id": "chatcmpl-7LgiOhYPcGGwoBcEPQmQ2LaO2pObn",
  "object": "chat.completion",
  "created": 1685403440,
  "model": "gpt-3.5-turbo-0301",
  "usage": {
    "prompt_tokens": 11,
    "completion_tokens": 18,
    "total_tokens": 29
  },
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "你好!有什么我可以为您效劳的吗?"
      },
      "finish_reason": "stop",
      "index": 0
    }
  ]
}

如果你无法访问 OpenAI 的接口,或者没有 OpenAI 的 API Keys,网上也有很多免费的方法,比如 chatanywhere/GPT_API_free

OpenAI 官方提供了 Python 和 Node.js 的 SDK 方便我们在代码中调用 OpenAI 接口,下面是使用 Node.js 调用 Completions 的示例:

import { Configuration, OpenAIApi } from "openai";

const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

const response = await openai.createCompletion({
    "model": "text-davinci-003",
    "prompt": "你好!",
    "max_tokens": 100,
    "temperature": 0
});
console.log(response.data);

由于 SDK 底层使用了 axios 库发请求,所以我们还可以对 axios 进行配置,比如像下面这样设置代理:

const response = await openai.createCompletion({
    "model": "text-davinci-003",
    "prompt": "你好!",
    "max_tokens": 100,
    "temperature": 0
}, {
    proxy: false,
    httpAgent: new HttpsProxyAgent(process.env.HTTP_PROXY),
    httpsAgent: new HttpsProxyAgent(process.env.HTTP_PROXY)
});

使用 OpenAI API 实现翻译功能

从上面的例子可以看出,OpenAI 提供的 CompletionsChat 只是一套用于对话任务的接口,并没有提供翻译接口,但由于它的对话已经初步具备 AGI 的能力,所以我们可以通过特定的提示语让它实现我们想要的功能。官方的 Examples 页面有一个 English to other languages 的例子,展示了如何通过提示语技术将英语翻译成法语、西班牙语和日语,我们只需要稍微修改下提示语,就可以实现英译中的功能:

async function translate(text) {

    const prompt = `Translate this into Simplified Chinese:\n\n${text}\n\n`
    
    const openai = createOpenAiClient();
    const response = await openai.createCompletion({
        "model": "text-davinci-003",
        "prompt": prompt,
        "max_tokens": 100,
        "temperature": 0
    }, createAxiosOptions());
    return response.data.choices[0].text
}

上面我们使用了 Translate this into Simplified Chinese: 这样的提示语,这个提示语既简单又直白,但是翻译效果却非常的不错,我们随便将一段官方文档丢给它:

console.log(await translate("The OpenAI API can be applied to virtually any task that involves understanding or generating natural language, code, or images."));

OpenAI API 可以应用于几乎任何涉及理解或生成自然语言、代码或图像的任务。

看上去,翻译的效果不亚于 Google 翻译,而且更神奇的是,由于这里的提示语并没有明确输入的文本是什么,也就意味着,我们可以将其他任何语言丢给它:

console.log(await translate("どの部屋が利用可能ですか?"));

这些房间可以用吗?

这样我们就得到了一个通用中文翻译接口。

Chrome 插件快速入门

我在很久以前写过一篇关于 Chrome 插件的博客,我的第一个 Chrome 扩展:Search-faster,不过当时 Chrome 扩展还是 V2 版本,现在 Chrome 扩展已经发展到 V3 版本了,并且 V2 版本不再支持,于是我决定将 Chrome 扩展的开发文档 重温一遍。

一个简单的例子

每个 Chrome 插件都需要有一个 manifest.json 清单文件,我们创建一个空目录,并在该目录下创建一个最简单的 manifest.json 文件:

{
  "name": "Chrome Extension Sample",
  "version": "1.0.0",
  "manifest_version": 3,
  "description": "Chrome Extension Sample"
}

这时,一个最简单的 Chrome 插件其实就已经准备好了。我们打开 Chrome 的 管理扩展程序 页面 chrome://extensions/,启用开发者模式,然后点击 “加载已解压的扩展程序”,选择刚刚创建的那个目录就可以加载我们编写的插件了:

chrome-extension-sample.png

只不过这个插件还没什么用,如果要添加实用的功能,还得添加这些比较重要的字段:

  • background:背景页通常是 Javascript 脚本,在扩展进程中一直保持运行,它有时也被称为 后台脚本,它是一个集中式的事件处理器,用于处理各种扩展事件,它不能访问页面上的 DOM,但是可以和 content_scriptsaction 之间进行通信;在 V2 版本中,background 可以定义为 scriptspage,但是在 V3 版本中已经废弃,V3 版本中统一定义为 service_worker
  • content_scripts:内容脚本可以让我们在 Web 页面上运行我们自定义的 Javascript 脚本,通过它我们可以访问或操作 Web 页面上的 DOM 元素,从而实现和 Web 页面的交互;内容脚本运行在一个独立的上下文环境中,类似于沙盒技术,这样不仅可以确保安全性,而且不会导致页面上的脚本冲突;
  • action:在 V2 版本中,Chrome 扩展有 browser_actionpage_action 两种表现形式,但是在 V3 版本中,它们被统一合并到 action 字段中了;用于当用户点击浏览器右上角的扩展图标时弹出一个 popup 页面或触发某些动作;
  • options_page:当你的扩展参数比较多时,可以制作一个单独的选项页面对你的扩展进行配置;

接下来,我们在 manifest.json 文件中加上 action 字段:

  "action": {
    "default_popup": "popup.html"
  }

然后,编写一个简单的 popup.html 页面,比如直接使用 iframe 嵌入我的博客:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <iframe src="https://www.aneasystone.com" frameborder="0" style="width: 400px;height:580px;"></iframe>
    </body>
</html>

修改完成后,点击扩展上的刷新按钮,将会重新加载扩展:

chrome-extension-sample-reload.png

这样当我们点击扩展图标时,就能弹出我的博客页面了:

chrome-extension-sample-popup.png

如果我们把页面换成 ChatGPT 的页面,那么一个 ChatGPT 的 Chrome 插件就做好了:

chrome-extension-chatgpt.png

在嵌入 ChatGPT 页面时发现,每次打开扩展都会跳转到登录页面,后来参考 kazuki-sf/ChatGPT_Extension 这里的做法解决了:在 manifest.json 中添加 content_scripts 字段,内容脚本非常简单,只需要一句 "use strict"; 即可。

注意并不是所有的页面都可以通过 iframe 嵌入,比如当我们嵌入 Google 时就会报错:www.google.com 拒绝了我们的连接请求,这是因为 Google 在响应头中添加了 X-Frame-Options: SAMEORIGIN 这样的选项,不允许被嵌入在非同源的 iframe 中。

实现划词翻译功能

我们现在已经学习了如何使用 OpenAI 接口实现翻译功能,也学习了 Chrome 扩展的基本知识,接下来就可以实现划词翻译功能了。

首先我们需要监听用户在页面上的划词动作以及所划的词是什么,这可以通过监听鼠标的 onmouseup 事件来实现。根据前面一节的学习我们知道,内容脚本可以让我们在 Web 页面上运行我们自定义的 Javascript 脚本,从而实现和 Web 页面的交互,所以我们在 manifest.json 中添加 content_scripts 字段:

  "content_scripts": [
    {
      "matches": ["*://*/*"],
      "js": ["content_script.js"]
    }
  ]

content_script.js 文件的内容很简单:

window.onmouseup = function (e) {

    // 非左键,不处理
    if (e.button != 0) {
        return;
    }
    
    // 未选中文本,不处理
    let text = window.getSelection().toString().trim()
    if (!text) {
        return;
    }

    // 翻译选中文本
    let translateText = translate(text)

    // 在鼠标位置显示翻译结果
    show(e.pageX, e.pageY, text, translateText)
}

可以看到实现划词翻译的整体脉络已经非常清晰了,后续的工作就是调用 OpenAI 的接口翻译文本,以及在鼠标位置将翻译结果显示出来。先看看如何实现翻译文本:

async function translate(text) {
    const prompt = `Translate this into Simplified Chinese:\n\n${text}\n\n`
    const body = {
        "model": "text-davinci-003",
        "prompt": prompt,
        "max_tokens": 100,
        "temperature": 0
    }
    const options = {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + OPENAI_API_KEY,
        },
        body: JSON.stringify(body),
    }
    const response = await fetch('https://api.openai.com/v1/completions', options)
    const json = await response.json()
    return json.choices[0].text
}

这里直接使用我们之前所用的提示语,只不过将发请求的 axios 换成了 fetchfetch 是浏览器自带的发请求的 API,但是不能在 Node.js 环境中使用。

接下来我们需要将翻译后的文本显示出来:

function show(x, y, text, translateText) {
    let container = document.createElement('div')
    container.innerHTML = `
    <header>翻译<span class="close">X</span></header>
    <main>
      <div class="source">
        <div class="title">原文</div>
        <div class="content">${text}</div>
      </div>
      <div class="dest">
        <div class="title">简体中文</div>
        <div class="content">${translateText}</div>
      </div>
    </main>
    `
    container.classList.add('translate-panel')
    container.classList.add('show')
    container.style.left = x + 'px'
    container.style.top = y + 'px'
    document.body.appendChild(container)

    let close = container.querySelector('.close')
    close.onclick = () => {
        container.classList.remove('show')
    }
}

我们先通过 document.createElement() 创建一个 div 元素,然后将其 innerHTML 赋值为提前准备好的一段 HTML 模版,并将原文和翻译后的中文放在里面,接着使用 container.classList.add()container.style 设置它的样式以及显示位置,最后通过 document.body.appendChild() 将这个 div 元素添加到当前页面中。实现之后的效果如下图所示:

chrome-extension-translate.png

至此,一个简单的划词翻译 Chrome 插件就开发好了,开发过程中参考了 CaTmmao/chrome-extension-translate 的部分实现,在此表示感谢。

当然这个扩展还有很多需要优化的地方,比如 OpenAI 的 API Keys 是写死在代码里的,可以做一个选项页对其进行配置;另外在选择文本时要等待一段时间才显示出翻译的文本,中间没有任何提示,这里的交互也可以优化一下;还可以为扩展添加右键菜单,进行一些其他操作;有兴趣的朋友可以自己继续尝试改进。

本文所有代码 在这里

参考

扫描二维码,在手机上阅读!