S

Shall We Code?

分享后端工作中的所思所得,涉及的领域包括但不限于:Python, Go, Linux, Kubernetes, Container, Git, Github 等

Kubernetes 故障排查指南

基础流程和方法 查询 pod 的状态,适用于 pod Pending 的场景: 1 kubectl describe <pod-name> -n <namespace> 获取集群中的异常事件,作为排查 pod Pending 原因的补充: 1 kubectl get events -n <namespace> --sort-by .lastTimestamp [-w] 获取 pod 的日志,适用于 pod Error 或者 CrashLoopBack 的场景: 1 kubectl logs <pod-name> -n <namespace> [name-of-container, if multiple] [-f] 如果 pod 已经处于 Running 状态,并且现有的日志未能直接指出问题,则需要进入 pod 容器进一步测试,例如验证一个正在运行的进程的状态、配置,或者检查容器的网络连接。 如何访问容器 通过 exec 命令:kubectl exec -it <podName> sh。 通过 ephemeral container(需要 Kubernetes v1.23 以上版本)。 在镜像中缺乏程序二进制的前提下,执行常用排查工具和命令: 获取容器在宿主机对应的 PID:docker ps | grep k8s_<containerName>_<podName> | awk '{print $1}' | xargs docker inspect --format '{{ .State.Pid }}' 在容器的网络命名空间中执行安装于宿主机的工具:nsenter -t <PID> -n bash 退出容器的命名空间(即退出运行于该命名空间中的 shell):exit 如何访问节点 ssh 到节点。 journalctl -xeu kubelet 查看 kubelet 日志,适用于节点 NotReady 的场景。 常见问题及排查方法 kubectl 执行结果异常 表现: 执行任意 kubectl 命令输出以下结果: Error from server (InternalError): an error on the server (“”) has prevented the request from succeeding etcdserver: leader changed 原因: kubectl 和 apiserver 认证失败 apiserver 异常 通常由于 etcd 导致 apiserver 异常 etcd 异常 选举节点拓扑不满足奇数导致频繁切主 磁盘性能太差导致延迟太高甚至频繁切主 排查方法: 检查 kubectl 当前所使用的配置文件是否正确:kubectl config view 当前所使用的配置文件 ~/.kube/config 是否由该集群生成。 检查配置文件中的 server 地址和访问协议是否正确。 apiserver 和 etcd 均属于静态 pod,如果无法使用 kubectl 命令,可直接用容器运行时如 docker 检查容器的日志: 1 2 3 4 # 获取 apiserver 和 etcd 对应的 container_id docker ps -a | grep -e k8s_kube-apiserver -e k8s_etcd # 获取日志 docker logs <container_id> [-f] 测试 etcd 所使用的文件系统性能: 1 fio --rw=write --ioengine=sync --fdatasync=1 --filename=<etcd-data-dir>/iotest --size=22m --bs=2300 --name=etcdio-bench 该测试重点关注 etcd 所使用文件系统的 fsync 性能: fsync/fdatasync/sync_file_range: sync (usec): min=534, max=15766, avg=1273.08, stdev=1084.70 sync percentiles (usec): | 1.00th=[ 553], 5.00th=[ 578], 10.00th=[ 594], 20.00th=[ 627], | 30.00th=[ 709], 40.00th=[ 750], 50.00th=[ 783], 60.00th=[ 1549], | 70.00th=[ 1729], 80.00th=[ 1991], 90.00th=[ 2180], 95.00th=[ 2278], | 99.00th=[ 2376], 99.50th=[ 9634], 99.90th=[15795], 99.95th=[15795], | 99.99th=[15795] 测试结果中,如上述 fsync 99.00th 的值大于 10000 usec(即 P99 > 10ms),通常认为不满足使用条件,建议更换磁盘硬件。 DNS 解析异常 表现: 应用无法连接到 kubernetes apiserver 或其他 DB、proxy 等依赖服务,错误信息里通常包含 53 端口访问超时、can’t resolve host 等。 原因与排查方法: 无法访问 kube-dns service 对应的后端 pod 应用容器内 dns 配置(/etc/resolv.conf)出错导致无法发出正常的解析请求(部分旧版本发行版和 libc 库存在该问题) 检查容器内的 /etc/resolv.conf 文件是否包含正确的 nameserver 和 search path,如: 1 2 3 4 $ cat /etc/resolv.conf nameserver 11.96.0.10 search qfusion-admin.svc.cluster.local svc.cluster.local cluster.local options ndots:5 service 的负载均衡功能异常,出站请求无法 DNAT 到实际的 coreDNS 后端 pod 检查 kube-proxy pod 的运行状态。 检查应用容器所在节点是否能正常匹配 service 并完成 DNAT。 检查节点路由表是否有默认路由或 service 的路由规则,否则数据包不会被 DNAT。 检查是否存在 service 对应的 DNAT 规则,以 iptables 为例: 1 iptables-save | grep <serviceName> 对于每个 service 的每个端口,在 KUBE-SERVICES 中应该有1条规则和一个 KUBE-SVC-<hash> 链。 对于每个 Pod 端点,在该 KUBE-SVC-<hash> 中应该有少量的规则,以及一个KUBE-SEP-<hash> 链,其中有少量的规则。确切的规则将根据你的具体配置(包括节点端口和负载平衡器)而变化。 查询当前 NAT 记录: 1 2 3 conntrack -L | grep <dns-ip> # destination conntrack -L -d 10.32.0.1 详见 https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/ 跨节点容器网络异常,请求无法到达 DNS pod 所在节点 检查 CNI 插件相关组件的运行状态,执行 CNI 插件自带的健康检查或状态检查命令,以 cilium 为例: 1 2 cilium status [--verbose] kubectl -n kube-system exec -it cilium-xrd4d -- cilium-health status 检查应用容器和 coredns 之间节点和 pod 的连通性 使用 nc 测试两个节点及 pod 之间的四层连通性: 1 2 3 4 # host1 nc -l 9999 # host2 nc -vz <host1> 9999 如果容器网络(大部分情况下)采用的是 vxlan 方案,测试 vxlan 默认使用的宿主机 8472 端口是否被阻断。 使用 netstat -lnup | grep 8472 或者 nc -lu 8472 检查 8472 UDP 端口是否已被其他程序占用。 使用 tcpdump 对 8472 端口进行抓包,判断数据包能否经由网络到达。例如: 1 tcpdump -i p4p1 dst port 8472 -c 1 -Xvv -nn 测试 8472 UDP 是否可使用: 1 2 3 4 # on the server side iperf -s -p 8472 -u # on the client side iperf -c 172.28.128.103 -u -p 8472 -b 1K MTU 问题,网络拓扑中的某一中间设备设置了比发送端网卡更小的 MTU,导致部分大小超过的包被 drop。具体表现为能 ping 通,同一协议和端口有些请求能通,有些请求不通。 获取发送端网卡的 MTU: 1 2 3 ifconfig # or ip -4 -s address 使用 ping 命令对目标主机的 MTU 进行测试: 1 ping -M do -s 1472 [dest IP] ICMP头部使用 8 个字节,以太网头部使用 20 个字节,ping 命令再额外发送了 1472 个字节,测试 MTU 1500 字节能否到达目标主机。 修改发送端网卡的 MTU 1 2 echo MTU=1450 >> /etc/sysconfig/network-scripts/ifcfg-eth0 # 文件名修改为网卡名称 systemctl restart network kube-dns 对应的后端 pod 异常导致无法响应解析请求或者未包含 dns 记录 kube-dns service 不存在 检查 kube-system 命名空间下 service 以及 endpoint 的状态: 1 2 kubectl -n kube-system get svc | grep dns kubectl -n kube-system get ep | grep dns coreDNS pod 状态异常 使用上文介绍的 kubectl describe 和 kubectl logs 命令获取 coreDNS 的 pod 状态及日志。 coreDNS 无法响应解析或未包含指定的 dns 记录 进入应用容器以及另一任意容器,测试 coreDNS 能否完成其他容器、节点及域名的解析 请求 DNS server 的 53 端口,判断能够访问 DNS server 及其是否正在监听:nc -vz <ip-of-dns> 53 测试域名解析:dig example.com @<ip-of-dns> +trace (+trace 输出追踪信息以区分是否使用缓存) 其他域名解析工具:nslookup, host 更多排查 dns 解析有关的资料详见 https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/ TLS 证书异常 tls 证书的关键概念是多个证书形成一个有效的信任链,从主机上的服务器/叶子证书到多个中间证书,最后到根/CA证书。正是通过形成的信任链,客户端和服务器之间才能开始加密通信会话。 表现:因为 ssl/tls 原因无法完成预期的请求,错误信息通常包含 ssl/tls handshake error 原因: 未将根/CA 证书添加到可信任证书中 服务端/CA 证书已过期或未到开始生效时间(通常是因为系统时间不一致导致生成了还未到生效时间的证书) 排查方法: 使用 openssl 获取指定服务的证书信息: 1 openssl s_client -connect <fqdn/ip>:<port> 使用 openssl 获取指定服务(以 [localhost:6443](http://localhost:6443) 为例)的服务端证书并打印成可读格式: 1 openssl s_client -showcerts -connect localhost:6443 </dev/null 2>/dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text 通常可检查输出中的 Issuer(签发者)、Validity(生效时间)和 Subject(CN)。 如果遇到 x509: certificate signed by unknown authority(如 docker CLI 在访问内部的 https 镜像仓库时会抛出以上错误),说明目标服务器使用了不受信任的签发证书(如自签发证书),需要按照应用程序文档安装 CA 证书或变更与信任证书有关的配置。 路由和内核参数配置错误 表现: 无法完成对集群内部或外部某些状态正常服务的访问,报错信息中通常包含 Unreachable 或者 Connection timeout。 原因: 应用所在的 pod 或主机的路由表中无访问地址对应的路由项,且没有配置默认路由。 与路由有关的内核参数未正确配置或在安装 kubernetes 时开启之后被其他服务重置。 排查方法: 使用 netcat 或者 curl 测试服务端是否可通过四层网络访问: 1 2 nc -vz <ip/fqdn> <port> curl -kL telnet://<ip/fqdn>:<port> -vvv 使用 curl 简单测试网络延迟: 1 curl -s -w 'Total time: %{time_total}s\n' http://example.com 打印系统路由表,注意检查是否存在默认路由: 1 2 3 route -n # or ip route show 查询指定 IP 的路由规则: 1 ip route get 10.101.203.141 跟踪请求的路由路径: 1 traceroute 10.101.203.141 traceroute 默认使用 ICMP 协议的 ECHO 包,部分设备可能不会响应。 net.ipv4.ip_forward:允许 Linux 在不同网络接口间转发流量的核心参数,绝大部分 CNI 要求启用此参数才能实现 pod 间访问。 1 2 3 4 5 sysctl net.ipv4.ip_forward # this will turn things back on a live server sysctl -w net.ipv4.ip_forward=1 # on Centos this will make the setting apply after reboot echo net.ipv4.ip_forward=1 >> /etc/sysconf.d/10-ipv4-forwarding-on.conf net.bridge.bridge-nf-call-iptables:允许对 Linux Bridge 设备使用 iptables 规则,部分基于 Linux Bridge 的 CNI 需要启用此参数实现容器请求外部网络的 NAT。 1 2 3 4 5 sysctl net.bridge.bridge-nf-call-iptables modprobe br_netfilter # turn the iptables setting on sysctl -w net.bridge.bridge-nf-call-iptables=1 echo net.bridge.bridge-nf-call-iptables=1 >> /etc/sysconf.d/10-bridge-nf-call-iptables.conf net.ipv4.conf.all.rp_filter:部分 CNI (如 Cilium)要求启用此参数实现 pod 间访问,但一些发行版版本的 systemd 可能会覆盖此参数。 1 2 3 4 5 6 cat <<EOF > /etc/sysctl.d/99-override_cilium_rp_filter.conf net.ipv4.conf.all.rp_filter = 0 net.ipv4.conf.default.rp_filter = 0 net.ipv4.conf.lxc*.rp_filter = 0 EOF systemctl restart systemd-sysctl 防火墙 表现: 请求被某些 4 层或者 7 层防火墙规则拦截,通常表现为可以访问节点但无法访问部分端口;部分请求无法完成,报错信息中通常包含 Connection Reset。 原因: 4 层防火墙禁止访问某些端口或者特定协议。 7 层防火墙审计请求的目标路径和请求体内容。 排查方法: nc -vz 可测试四层连通性,curl -kL 可测试七层连通性,如果一个服务四层通但是七层不通,表明可能有七层防火墙。 使用 tcpdump 进行抓包分析,重点关注 ACK 为 RST 或者 Reject 的 TCP 包。 检查 iptables 是否有 reject 规则,-reject-with icmp-host-unreachable 也可能表现为有路由规则但是请求返回 no route to host。 1 iptables -nvL | grep -i reject 配置错误和程序 BUG 表现: 部分应用无法正常启动或按照预定配置运行,错误信息通常包含 syntax error 原因: 空格或者 tab 导致配置文件格式出现错误 如果以上排查步骤均未发现问题,且在应用容器的日志中发现了可疑的信息,可直接向开发者报告或讨论该问题,但注意不要急于给出结论。 Further reading https://tanzu.vmware.com/developer/learningpaths/effective-efficient-kubernetes-debugging/ 通用的 kubernetes 问题排查流程和方法 https://kubernetes.io/docs/tasks/debug/debug-application/ Debugging Pod、Service、StatefulSet https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/ Debugging DNS Resolution https://goteleport.com/blog/troubleshooting-kubernetes-networking/

2023/3/19
articleCard.readMore

Unix 终端系统(TTY)是如何工作的

长久以来,我们把终端和 shell 视作开发者理所当然的基本工具,但其底层涉及的组件和机制却并不简单。作为一名后端工程师,每天使用终端的时长甚至会超过代码编辑器,深入了解其背后的实现是有必要的。本文将尝试尽量清晰全面的介绍 Unix 所使用的终端系统。 本文内容主要以 Linux 系统的具体实现为基础,但因为其遵循 SUSv3(Single UNIX Specification Version 3)规范,因此也适用于其他 Unix 系统(如 macOS)。 tty 的由来 所有介绍 tty 的文章都会介绍终端设备的发展史,过去几十年硬件设备的发展日新月异,但直到今天,Unix 终端在软件部分仍在使用从上世纪 70 年代沿袭至今的工作机制。 电传打字机 电传打字机是早期的计算机输入输出设备,其英文 teletype 的缩写也是 tty 这一名称的由来。终端和 tty 实际上是可相互替代的同义术语,下文将结合语境使用。 电传打字机的发明远远早于计算机,一开始计算机的计算能力无法支持实时的交互,只能使用打孔卡片进行批处理计算,然后等待一晚上之后得到结果。随着算力的提升,计算机操作系统从批处理系统迈向分时处理系统,聪明的计算机先驱们发现稍作改造,就能使用现成的电传打字机作为输入输出设备。 电传打字机通过两条线缆连接到计算机的 UART(Universal Asynchronous Receiver and Transmitter)接口,一条线缆传输从电传打字机按下键盘触发的输入信号到计算机,一条线缆传输从计算机回到电传打字机的输出信号。计算机操作系统提供 UART 驱动程序来管理字节的物理传输,包括奇偶校验和流量控制。 整个终端系统的示意图如下: 引用自 http://www.linusakesson.net/programming/tty/ 引用自 https://www.yabage.me/2016/07/08/tty-under-the-hood/ 特定的 UART 驱动、line descipline 实例和 TTY 驱动三者组成了一个 TTY 设备,有时也被直接称为 TTY。这只是一个抽象表示,如果你对它们之间的交互细节还有疑惑,后面的章节会深入其内部实现。 下面通过一段执行 cat 命令的输入序列和输出,来描述电传打字机终端的工作过程。输入序列如下: cat^M hello^M ^Z ^M 表示按下回车键,按惯例下文均使用 ^ 表示 ASCII 控制字符,如 ^Z 实际表示按下 Ctrl + Z 键。 电传打字机应当打印以下结果: 1 2 3 4 5 # cat hello hello ^Z [1]+ 已停止 cat 在打字机敲下 cat 命令时,电信号经过 UART 驱动转换为 ASCII 字符并传递给 line discipline 组件的字符缓冲区,此时可以通过退格键或者 ^W 键对缓冲区中的内容进行编辑,只有当我们按下回车键后,line discipline 才会将整行字符串发送给 tty driver,之后用户空间的 shell 进程可以通过 read() 调用读取到 cat 命令并执行。 默认配置下 line discipline 组件会将字符缓冲区的字符立刻复制到输出缓冲区,随即经过 UART 驱动转换成电信号通过输出线缆传输给电传打字机,因此电传打字机会立刻打印输入的每一个字符,这一机制被称为 echo (回声,这也许是 echo 命令的最初由来)。 cat 命令执行时,其标准输入、标准输出和标准错误都指向当前的 tty driver。再次从电传打字机输入 hello 并回车时,电传打字机首先 echo 输入的字符,在换行后,cat 命令从标准输入读取到一行 hello 并立刻写入到标准输出也就是 tty driver,再经过 line discipline 最终输出到电传打字机,也就是我们看到的第二行 hello。 最后按下 ^z 按钮,这两个按键对应 26 这个 ASCII 码点,在到达 line discipline 组件时,line discipline 并不会将该字符继续发给 tty driver,而是在 echo 给电传打字机后发送一个 SIGSTP 信号给当前正在运行的 cat 进程,使其进入 STOPPED 状态。 控制权回到 shell 进程后,由于它是 cat 进程的父进程,可以通过 wait() 调用获取子进程的运行状态,shell 进程会打印一行 [1]+ 已停止 cat 到 tty driver。 半个世纪后的今天,终端的使用方式与上述过程并没有太大差别。 视频终端 DEC VT-100 视频终端 随着显示屏技术的进步,很快出现了基于数字图形的视频终端,1978 年发布的 VT100 是其中最具代表性的终端产品。 除了显示效果大幅提升,视频终端还能根据特殊的转义码(escape code)执行光标移动、换行等操作,其使用效果已经接近我们今天所使用的终端了。虽然视频终端长的像家用计算机,但实际上没有任何计算能力,是纯粹的输入输出设备。 虽然外接设备实现了全面升级,不过这一阶段终端系统的软件架构并没有太大变化,可以认为和电传打字机基本相同。 终端模拟器 现在只有在博物馆才能见到以上两种专用终端设备了,取而代之的,是完全用软件实现的的终端模拟器,以及显示器和键盘等通用外接设备。UART 和物理终端不复存在,操作系统在内核完全通过软件模拟出一个视频终端,并直接渲染到显示器设备: 这一系统已经相当接近我们日常使用的终端,你可以在 Ubuntu 等发行版的图形界面直接通过 Ctrl+Alt+F1 呼出一个模拟终端。这些虚拟设备在操作系统的 /dev 目录下作为字符设备存在: 1 2 3 4 5 $ ll /dev/tty* crw-rw-rw- 1 root tty 5, 0 3月 31 2022 /dev/tty crw--w---- 1 root tty 4, 0 3月 31 2022 /dev/tty0 crw--w---- 1 root tty 4, 1 8月 11 22:37 /dev/tty1 crw--w---- 1 root tty 4, 10 3月 31 2022 /dev/tty10 内核空间实现的终端模拟器不够灵活,一些场景下,如通过 ssh 连接到远程主机或者使用 iterm2 等自定义终端程序,使用的是基于 tty 系统扩展的伪终端(pseudoterminal, pty),在 shell 中执行 tty 命令可以直接获取当前使用的终端: 1 2 $ tty /dev/pts/0 在终端中执行的命令会打开所当前所使用的终端作为其标准输入、标准输出和标准错误: 1 2 3 4 5 6 7 8 9 10 $ cat ^Z [1]+ 已停止 cat $ jobs -l [1]+ 8775 停止 cat $ ll /proc/8775/fd 总用量 0 lrwx------ 1 root root 64 10月 6 15:24 0 -> /dev/pts/0 lrwx------ 1 root root 64 10月 6 15:24 1 -> /dev/pts/0 lrwx------ 1 root root 64 10月 6 15:24 2 -> /dev/pts/0 由于核心机制相同,伪终端会在稍后一些的章节中介绍,我们先来了解一下 job controll 机制。 job controll 作为计算机早期的主要交互设备,终端从一开始就承载了 job controll(作业控制)的职能,使用终端可以方便地在系统中同时运行多个进程:输入 ^C 或 ^Z 杀死或挂起进程,通过 fg 或者 bg 让进程切换到前台或后台运行,关闭终端时终止当前终端中的后台进程。 job controll 是由 tty 系统配合 shell 共同实现的,支持 job controll 功能的 shell 称为 job controll shell,主要的交互式 shell 都属于 job controll shell。 process group 和 job 在 shell 中执行命令时,shell 会 fork 出一个新的进程去执行命令,在简单地单个命令场景下,会创建一个仅包含单个进程的进程组;在执行多个命令组成的管道时,一个管道中的所有进程都属于一个新的进程组。 进程组中的每个进程都有相同的进程组 ID,这个 ID 与其中一个进程的 PID 相同,该进程被称为进程组 leader。一个进程组也被称为一个 job,下文将使用 job 来称呼进程组。 内核允许对一个进程组也就是一个 job 的所有成员同时进行各种操作,特别是发送信号。这一特性是 job controll 的基础机制:shell 允许用户暂停或恢复一个管道中的所有进程,tty 可以对通过 shell 执行的命令以 job 为单位进行管理。 除了终端输入,我们还可以通过 jobs 和 fg、bg、kill 等命令管理 job: 1 2 3 4 5 6 7 8 9 10 11 $ bat ^Z [1] + 16849 suspended bat $ jobs -l [1] + 16849 suspended bat $ bg bat [1] + 16849 continued bat [1] + 16849 suspended (tty input) bat $ fg bat [1] + 16849 continued bat ^C session 和控制终端 用户通过 login 命令登录主机时,在完成身份凭证的校验后,login 会使用 /etc/password 文件中指定的 shell 程序为用户创建一个初始 session。一个 session 是多个 job 的集合,一个 session 中创建的所有 job 都有相同的 session ID。 session leader 是创建 session 的进程,它的 PID 成为 session ID。使用 job controll shell 创建一个 session 时,session leader 即为该 shell 进程。默认情况下,fork(2) 创建的子进程与父进程属于同一进程组,因此从键盘发出的 ^C 将同时影响父进程和子进程。但作为 session leader 的职责之一,shell 每次执行命令或启动管道时都会创建一个新的进程组。 执行 ps -efj 命令,可以查看进程对应的进程组 ID(PGID)和 session ID(SID)(命令输出经过筛选): 1 2 3 4 5 6 7 8 9 10 11 $ ps -efj UID PID PPID PGID SID C STIME TTY TIME CMD root 6541 1813 6541 6541 0 11:14 ? 00:00:00 sshd: root@pts/2 root 7186 6541 7186 7186 0 11:15 pts/2 00:00:00 -bash root 8577 1813 8577 8577 0 11:06 ? 00:00:00 sshd: root@pts/1 root 8579 8577 8579 8579 0 11:06 pts/1 00:00:00 -bash root 17834 8579 17834 8579 0 11:17 pts/1 00:00:00 sleep 100000 root 19131 8579 19131 8579 0 11:18 pts/1 00:00:00 ps -efj root 19132 8579 19131 8579 0 11:18 pts/1 00:00:00 grep --color=auto pts root 22526 1813 22526 22526 0 10:28 ? 00:00:00 sshd: root@pts/0 root 22643 22526 22643 22643 0 10:28 pts/0 00:00:00 -bash session 通常会有一个控制终端。控制终端是在 session leader 进程第一次打开 tty 设备时建立的(有些 Unix 系统需要执行特定的调用才能将一个 tty 设置为控制终端)。对于一个由交互式 shell 创建的会话,控制终端就是用户登录所使用的终端。一个终端最多可以成为一个 session 的控制终端。 打开控制终端后,session leader 将成为该终端的控制进程。如果终端断开连接(例如终端窗口被关闭),控制进程会收到一个 SIGHUP 信号,如果控制进程是 shell,它会在退出前先向当前 session 中已暂停的 job 发送 SIGCONT 信号,并在这些 job 恢复运行后向所有 job 发送 SIGHUP 信号。这就是关闭终端窗口时,终端中的后台执行命令或已暂停命令也会被终止的原因。 在任意时刻一个 session 中有且仅有一个 job 位于前台,没有执行任何命令时,前台 job 就是交互式 shell 本身。一个 session 中只有前台 job 可以从控制终端读取输入并向其发送输出。如果用户通过控制终端输入中断字符(通常是 ^C)或暂停字符(通常是 ^Z),终端驱动程序就会发送一个信号,杀死或暂停(即停止)前台 job。 执行 ps l 命令时,如果 STAT 一栏包含小写的 s,该进程是其所在 session 的 session leader;如果包含 + 号,说明该进程属于其所在 session 的前台 job: 1 2 3 4 5 6 7 $ ps l F UID PID PPID PRI NI VSZ RSS WCHAN STAT TTY TIME COMMAND 4 0 8729 8727 20 0 115764 3776 wait_w Ss+ pts/0 0:00 -bash 0 0 8775 8729 20 0 108088 688 do_sig T pts/0 0:00 cat 4 0 8811 8809 20 0 115560 3552 do_wai Ss pts/1 0:00 -bash 0 0 8855 8811 20 0 153364 3716 - R+ pts/1 0:00 ps l 4 0 25232 1 20 0 110220 1700 wait_w Ss+ tty1 0:00 /sbin/agetty --noclear tty1 linux 一个 session 可以有任意数量的后台 job,这些作业是通过用 & 字符来作为命令后缀而创建的。如果一个后台 job 尝试去读取或写入终端,终端驱动会向其发送 SIGTTIN 或 SIGTTOU 信号,上文示例中执行 bg bat 时(bat 是一个现代化的 cat 程序),由于 bat 会尝试从标准输入即当前 tty 中读取,因此 job 在恢复运行后立刻收到了 tty driver 的 SIGTTIN 信号而又被挂起。 SIGTTIN 信号的默认行为是 suspend,但即使程序故意忽略了 SIGTTIN 信号,也无法从终端中成功读取到数据,SIGTTOU 也是同理。 shell 与 tty 的协作 为了实现 job controll,tty 还需要记录并且实时更新当前 session 的前台 job,这一任务实际上是由作为 session leader 的交互式 shell 来完成的,tty 只是被动地读取前台 job 信息。 在创建一个新的 job 并运行于前台后,shell 将 tty 当前的前台 job 设置为该 job;之后 shell 会通过 wait() 系统调用监控子进程也就是所有 job 的运行状态。如果当前的前台 job 结束运行或者被用户通过 ^Z 暂停,终端的控制权将重新归还给 shell,此时 shell 要做的第一件事是将 tty 当前的前台 job 重置为自己。否则,它自己对 tty 的 IO 也会失败(shell 需要打印提示符,循环读取输入以及打印刚刚被停止的进程)。 如果在当前的前台 job 中有多个进程,它们全都可以自由地向 TTY 输出,或者同时试图从 TTY 读取。job controll 只是保护你免受不同 job 的干扰,这些进程通常位于一个管道或者 shell 脚本中,所以一般来说不会出什么问题。 tty 的内部实现 linux 的 tty 实现 上文使用的 tty 示意图中,line discipline 被抽象为单独的组件,并且通过 tty driver 与 session 中的 job 进行交互,而操作系统的具体实现可能不同。接下来我们将以 Linux kernel 2.6 源码为基础,深入 tty 的内部实现。 终端系统的具体实现由三层驱动组成,示意图如下: 最上层提供通用的字符设备接口(如 read()、write() 和 ioctl())给用户进程访问,图中省略了用户进程访问字符设备所经过的 VFS 等中间层。 用户进程通过字符设备接口写入要发送到 tty 设备的数据,然后数据通过 line discipline 传递给 tty 设备驱动的 write() 方法。 用户进程对字符设备执行 read() 时,会调用 line discipline 的 read() 方法,最终从输入缓冲区中获取数据。 中间的 line discipline 包装了终端设备层的访问行为,对经过的字符进行处理后放入专门的输入和输出缓冲区。此外 line discipline 还有保存未回车提交的字符缓冲区,开启 tty 的 echo 配置时,写入到字符缓冲区的字符会自动被复制到输出缓冲区,最后写入到 tty 设备驱动。 下层硬件驱动与硬件或虚拟设备通信,并负责将接收到的数据发送给 line discipline。 有些情况下用户进程可配置 line discipline 不对经过的数据进行处理,即数据将未经处理直接发送给用户进程,下文将详细介绍如何配置 line discipline 的具体行为。 line discipline line discipline 是 tty 系统三层实现中最复杂的部分,顶层和底层的驱动只负责单一的数据输入输出功能,line discipline 层承担了数据处理和控台职能: line buffer:对输入的单个字符进行缓冲,按下回车键时缓冲的整行字符才会发送到输入缓冲区,能够被 TTY 的前台用户进程所读取。 今天我们很难理解 tty 的行缓冲设计,因为绝大部分命令行程序都能够接受单个字符的输入,由用户程序来处理删除换行等逻辑(如 vi 等编辑器)。但追溯到计算机发展的初期,CPU 的计算能力和内存大小都非常有限,如果由用户程序处理单个字符的输入,每一次输入都需要切换 CPU 上下文甚至从磁盘换入换出内存,频次较高的终端输入就会成为计算机的性能瓶颈,因此在内核建立中间层统一处理字符的输入是非常高效的选择。 line edit:在输入出错的情况下,用户可以输入特殊字符对尚未通过回车提交的字符进行编辑,line discipline 负责将特殊字符转换为缓冲字符的编辑操作,如 ^H 退格、^W 擦除字、^U 清空行、^D EOF。 echo:将输入的内容回传给终端,以便用户可以看到输入的内容,具体实现是由 line discipline 将输入的每个字符复制到输出缓冲区。 job controll:用户可以直接从终端输入特殊字符暂停或终止正在运行的进程,line discipline 负责将特殊字符转换为信号并发送给前台 job,如 ^C 发送 SIGINT、^Z 发送 SIGTSTP 等。 line discipline 使得终端提供的功能更加完整,但我们并不总是需要上述的所有特性: 计算机性能大幅提升的现在,line buffer 已经不再必要。 有时候我们需要自定义用于 line edit 和 job controll 的自定义按键。 打开 vi 编辑器的插入模式时,需要禁用 job controll,切换回命令模式后需要重新开启,且禁用 echo 功能。 通过 ssh 连接到远程主机时,需要禁用本地终端的 job controll,否则在本地输入 ^C 会直接发送 SIGINT 信号给本地的前台 job 即 ssh 客户端进程,即使 ssh 进程忽略了该信号,也无法通过输入将字符发送到远程终端。 因此 line discipline 提供的这些特性都是可以配置或者开关的。 配置 tty 的行为 tty 的各种行为具有极高的可配置性,通过 stty -a 命令可以列出当前 tty 的所有配置(-F 可以指定其他 tty 设备): 1 2 3 4 5 6 7 8 $ stty -a speed 9600 baud; rows 41; columns 143; line = 0; intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0; -parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts -ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc ixany imaxbel iutf8 opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0 isig icanon iexten echo echoe -echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke 下面按行数对上面输出的配置项进行简要介绍(所有配置项的详细描述可通过 man stty 查阅): 输出速率(无终端硬件时无意义);rows 和 columns 代表以字符为单位的终端渲染窗口大小;line = 0 代表 line discipline 使用默认的 N_TTY 类型驱动(其他类型基本用不上)。 当前 line edit 和 job controll 功能所绑定的特殊按键。 输出的第三行起都是控制 line discipline 和 tty driver 行为的开关 flag,以 - 开头表示该 flag 处于关闭状态。我们只介绍几个关键的 flag: echo:控制 line discipline 是否 echo 终端的输入。 icanon:控制 line buffer 和 line edit 行为,关闭后输入字符会立刻发送前台 job,且停止对特殊字符的转义。 isig:控制是否开启对 INTR,QUIT 和 SUSP 信号对应的特殊符号的检查,如果关闭,输入 ^C 等特殊符号不会触发这些信号。 raw:关闭绝大部分开关 flag(包括 icanon),终端基本不会对输入输出字符做任何处理。 这些配置项都可以通过 stty 命令更改,尝试执行如下命令禁用 icanon 和 echo,会发现终端的输入行为变得相当奇怪: 1 stty -echo -icanon; cat 执行 stty sane 可以将当前 tty 恢复到默认配置。 stty 使用 SUSv3 定义的 termios api 操作 tty 的配置,在所有 Unix 系统都可以通用,此外进程还可以使用 ioctl 或者 libc 提供的库函数读取或修改已打开的 TTY 设备的配置。 伪终端 为什么需要伪终端 虽然操作系统通过内核的终端模拟器实现了对终端硬件设备的模拟,但这种 tty 系统无法适应一些场景下的需求: 用户希望使用由用户实现的终端模拟程序以扩展终端的功能,需要某种机制接入当前的 tty 系统并替代内核的终端模拟器。 用户希望实现通过网络访问远程主机上的面向终端程序(比如 vi),面向终端程序期望通过终端打开,以便执行一些面向终端的操作如通过 termios 接口设置终端 icanon 和 echo 开关 flag,以及实现终端对前台 job 的控制。这些操作要求远程主机上必须打开一个终端,否则对应的系统调用会执行失败,但我们无法通过 socket 等数据传输机制来连接本地程序到远程主机的终端模拟器。 tty 系统的特点就是在不断扩展的同时,仍然保持对历史设计的兼容性。为了实现以上需求,Unix 在原有 tty 系统的基础上发展出了伪终端,今天我们所使用的终端大部分都是伪终端。 什么是伪终端 伪终端(pseudoterminal)是一组成对的虚拟设备:一个 pty master 和一个 pty slave,有时也被称为 pty pair。一对伪终端设备提供了像双向管道一样的 IPC 通道—两个进程可以分别打开 master 和 slave,然后通过伪终端向任一方向传输数据。熟悉 Linux 网络的话你会发现它们特别像 veth pair。 如下图所示,理解伪终端有以下两个关键点: slave 设备就像一个标准的 tty 设备一样工作,实际上它们甚至在内核中使用同一套驱动代码,用同一种对象表示,只是很小部分属性和方法有差异,所有可以应用于标准终端设备的操作也可以应用于 slave 设备,只有个别配置对伪终端没有意义会被忽略(例如设置终端的 speed)。 Once both the pseudoterminal master and slave are open, the slave provides processes with an interface that is identical to that of a real terminal. 在使用时,slave 设备代替原来的标准 tty 打开面向终端的程序,作为控制终端。 许多介绍 tty 的文章会把 pty master、pty slave 和 tty 视作三个独立个体进行交互,却忽视了 pty slave 本身就是 type=pty 的 tty 设备(type=console 为标准 tty 设备即终端模拟器)。 master 设备可以作为文件打开,打开后的文件就像通过输入输出线缆连接到了 slave 设备的 tty 驱动,写入文件的数据会输入到 slave 设备,slave 设备的输出数据能够从这个文件中读取,关闭 master 设备对 slave 设备意味着终端设备断开了连接。 在使用时,master 设备就像一个中继代理,对应的 tty 设备(pty slave)将其视作用户正在使用的终端硬件接收其输入的数据,并向其返回输出数据;用户程序以直接读写 master 设备文件的方式向 tty 设备输入数据并读取其输出,然后自行处理键盘输入和视频输出等逻辑,甚至不处理直接通过网络 socket 收发数据。 伪终端是如何工作的 与标准终端直接打开 /dev/ttyN 相比,伪终端的使用方式有些特别。在伪终端的使用场景里,通常会有一个和用户直接或通过网络交互的用户程序,以及一个用户希望使用的面向终端程序,典型的工作流程如下: 用户程序打开 /dev/ptmx (pseudo terminal master multiplexer)文件获取一个可用的 pty master 设备,该设备将被打开并返回给用户程序一个对应的文件描述符。 为了避免竞争条件,进程必须通过 /dev/ptmx 分配一个 pty master,对应的 pty slave 设备文件(/dev/pts/N)也会同时被创建出来,但必须对 pty master 的文件描述符执行特定调用后才能打开使用。虽然 pty master 实际上是和 pty slave 同一类型的 tty 设备,但它不会出现在 /dev 文件系统中,用户程序只能通过这个文件描述符和它交互(SUSv3 是这样定义的,实际上 macOS 会预先创建好许多对伪终端设备,master 命名为 /dev/pty[p-za-e][0-9a-f])。 用户程序调用 fork() 来创建一个子进程。该子进程执行以下步骤。 调用 setsid() 来启动一个新的 session,子进程是该 session 的 session leader。这个步骤也导致子进程失去其控制终端。 打开 pty master 对应的 pty slave 设备,打开前需要依次执行grantpt、unlockpt、ptsname 等调用以获取 slave 设备的文件名和使用权限。因为子进程是一个 session leader 且没有控制终端,所以 pty slave 会自动成为子进程的控制终端。 调用 dup() 复制 pty slave 设备的文件描述符并设置为标准输入、输出和错误。 调用 exec() 来启动将被连接到 pty slave 设备的面向终端程序。 伪终端支持任意两个进程之间通信,并不要求它们之间有父子关系,只需要另一个进程能够获取到 pty master 对应的 pty slave 设备的名称并打开它。 这两个程序现在可以通过伪终端进行通信。用户程序写入 pty master 的任何数据都会变成连接到 pty slave 的面向终端程序的输入,而面向终端程序写入 pty slave 的任何数据都可以被打开 pty master 的用户程序读取。 UNIX 98 伪终端的实现 早期的 Unix 衍生系统各自实现了不同的接口来初始化伪终端,Linux 和 macOS 对伪终端的命名规则也完全不同,因为它们分别继承了 system V 和 BSD 的实现,SUSv3 基于 System V 规范化了伪终端的实现,按该规范实现的伪终端被称为 UNIX 98 Pseudoterminals,规范中定义了以下库函数: posix_openpt():打开一个未被使用的 pty master 设备,并返回其文件描述符。在 Linux 的实现中,该函数被实现成直接返回 open("/dev/ptmx", flags)。 grantpt():传入 pty master 的文件描述符,更改对应 pty slave 的所有权和访问权限。在 Linux 系统的实现中 pty slave 在创建时就已经设置好了权限,因此可省略该调用。 unlockpt():传入 pty master 的文件描述符,解锁对应 pty slave,之后该设备才可以被打开。上锁的目的是避免 slave 设备在完成初始化(例如调用 grantpt())之前,就被另一个进程打开。 ptsname():传入 pty master 的文件描述符,返回对应 pty slave 的设备名称,将名称传入 open() 系统调用可以打开该设备。在 Linux 系统中,slave 设备的命名格式是 /dev/pts/N,N 代表一个递增的整数,而 macOS 的命名格式是 /dev/ttysN。 上述库函数之间有依赖关系,需要依次执行才能成功获取一对伪终端。这些基本库函数的调用基本上是不变的,系统编程时可以直接使用 libc 中由 BSD 封装好的 openpty() 和 forkpty() 函数: 1 2 3 4 5 6 7 #include <pty.h> int openpty(int *amaster, int *aslave, char *name, const struct termios *termp, const struct winsize *winp); pid_t forkpty(int *amaster, char *name, const struct termios *termp, const struct winsize *winp); openpty() 函数封装了上述初始化库函数,直接返回一对完成初始化的伪终端设备,返回 pty master 的文件描述符到 amaster 参数中,返回 pty slave 的文件名到 name 参数中。传入的 termp 和 winp 参数如果不为 NULL,将决定 pty slave 的终端配置和窗口大小。 forkpty() 函数封装了 openpty()、fork(2) 和 login_tty() 等函数来获取一对伪终端设备,并创建一个在伪终端中运行的子进程。子进程被创建后会创建一个新的会话,并打开 pty slave 使其成为子进程的控制终端,复制 pty slave 的文件描述符并设置为子进程的标准输入、输出和错误,最后关闭 pty slave。像 fork 一样该函数会分别在父子进程中返回,通过判断返回的子进程 ID 是否为 0,我们可以在子进程逻辑中通过 exec 执行最终需要在终端中运行的程序。 远程终端示例 在实践中,伪终端被用来实现终端模拟程序如 iterm2,应用程序从 pty master 读取数据并像终端模拟器一样将数据渲染到显示器;还被用来实现远程登录程序如 sshd,从 pty master 读取的数据通过网络发送到另一台主机上连接到终端或终端模拟器的 ssh 客户端程序。 下面我们结合终端模拟程序和 ssh 来看看一个远程终端的示例: 假设 iterm2 已经用上文描述过的流程打开了一对伪终端,并创建了一个 bash 进程连接到 pty slave。用户在 iterm2 中输入 ssh 命令并回车,写入字符到 pty master 然后输入到 pty slave。bash 从标准输入 pty slave 中读取字符序列并解释执行,启动 ssh 客户端,ssh 客户端启动后首先通过 ioctl 为当前终端设置 opost、-isig、-icanon、-echo 等 flag,然后请求和远程主机建立 TCP 连接。 远程主机的 sshd 接收客户端的 TCP 连接请求,执行 openpty() 获得一对已初始化的伪终端设备。接着 sshd fork 出一个子进程执行 login 完成用户认证,打开 pty slave 并设置为标准输入、标准输出和标准错误。最后 login 创建一个新的 session 并执行 bash,bash 成为新 session 的 session leader,bash 的标准输入、标准输出和标准错误都设置为了 pty slave,pty slave 成为新 session 的控制终端。 当用户在本地主机的 iterm2 中输入命令 ls 和回车键,由于本地主机 bash 所连接的终端 pty slave 的大部分 line discipline 规则已经被 ssh 客户端禁用,输入的每一个字符包括回车都会不经处理直接写入到 pty slave,echo 配置也已经被关闭,此时的输入不会在本地终端回显。ssh 客户端从标准输入读取字符序列,并通过网络将 ls^M 发送给远程主机的 sshd。 sshd 将从 TCP 连接上接收到的字符序列写入 pty master,输入到 pty slave,pty slave 中的 line discipline 会缓冲收到的单个字符。由于远程主机 pty slave 的 line discipline 没有禁用 echo 规则,所以 pty slave 会将收到的字符写入到输出缓冲区,发送给 pty master,sshd 从 pty master 中读取到字符并通过 TCP 连接发回客户端。客户端收到字符后写入标准输出,最终在 iterm2 终端应用中展示。在 ssh 连接中,本地输入最终由远程的 pty slave echo 到本地终端。 远程主机的 pty slave 接收到 ^M 特殊字符后将缓冲的字符发送到输入缓冲区,然后 bash 从标准输入读取字符、解释并执行命令 ls。bash fork 出 ls 子进程,该子进程的标准输入、标准输出和标准错误同样设置为了 pty slave。ls 命令的执行结果写入标准输出 pty slave,然后输出到 pty master,再由 sshd 读取后通过 TCP 连接发送给本地主机的 ssh 客户端,最终在 iterm2 终端应用中展示。 如果想通过实际代码加强理解,可查看该 GitHub 仓库,仓库作者用 golang 实现一个没有数据加密能力的 ssh。 总结 本文的篇幅很长,最后再来回顾一下主要内容: tty 得名于电传打字机,和终端是同义词,最早的 tty 系统软件设计基本延续到了今天。 tty 一开始就承载了 job controll 职能,需要了解 process group、job、session 和控制终端等概念,以及下述规则: 内核允许对一个 job 的所有成员同时发送信号。 控制终端只会对前台 job 发送输入特殊字符触发的信号,只有前台 job 可以从控制终端读取输入并向其发送输出。 shell 作为 session leader 会持续监控所有 job 的运行状态,并根据状态的变化及时更新控制终端的前台 job。 linux 的 tty 实现采取分层设计,每一层都封装了对更下一层的访问,顶层和底层的设备驱动只有单一的数据传输职责,更复杂的功能都在中间的 line discipline 中实现。 line discipline 实现了 line buffer、line edit、echo、job controll 等功能,这些功能可以通过 stty 命令和 ioctl() 调用进行配置。 伪终端的 slave 设备就像一个标准的 tty 设备一样工作,连接到面向终端程序作为控制终端;master 设备提供了一个文件描述符给用户程序,用户程序可以读写该文件向 slave 设备输入数据并读取 slave 设备的输出。 常见的终端使用场景,如用户终端模拟程序及 ssh 连接远程主机,使用的都是伪终端。 参考链接 The TTY demystified 全面介绍了 tty、job controll 和信号等概念,本文引用了其中 2 张图片 TTY: under the hood 作者制作的示意图非常棒,本文引用了其中 3 张图片 Linux terminals, tty, pty and shell The Linux programming interface - 64: Pseudoterminals Linux Device Drivers, 3rd Edition TTY 到底是什么? | 卡瓦邦噶! 理解 Linux 终端、终端模拟器和伪终端_Linux_swordholder_InfoQ 写作社区 Chris's Wiki :: blog/unix/JobControlAndTTYs pty(7) - Linux manual page pts(4) - Linux manual page termios(3) - Linux manual page What are the responsibilities of each Pseudo-Terminal (PTY) component (software, master side, slave side)?

2022/10/7
articleCard.readMore

读《Building a Second Brain》

这本书出版于六月份,花了些时间看了大半。笔记中掺杂了一些个人的总结和看法,一般会放在原文内容归纳的后面。 为何要构建外脑 We spend hours every day interacting with social media updates that will be forgotten in minutes. We bookmark articles to read later, but rarely find the time to revisit them again 信息技术的进步极大地丰富了人们获取信息的手段,但并未带来显著的学习效率提升,因为人类的大脑🧠结构和几万年之前没有区别,只能处理有限的信息,而且很快就会遗忘。人类的精力和专注力也是有限的,每天获取信息的容量已经严重过载,大量的无效信息反而会消耗人的专注力和精力导致效率降低。 如果你每天不断被各种事情打断,不断在多个任务之间切换,花费过多时间去整理检索信息,可能会有下面的体验: 为了实现自我成长或达成某个目标,你会为自己设定一些耗时长且有难度的任务事项,这些任务往往不与当下的工作或职责直接关联,但对你个人来说比重复的日常工作更有意义。 过多的临时任务和琐碎事务使你不得不调整优先级,将对你来说真正重要的任务不断延后。 等到你终于有时间处理自己的任务,会发现此时已经没有足够的专注力和精力。 作者提出的一种解决方案是建立外脑,在人脑外部建立数字空间集中存放、处理输入的信息。其本质是建立一种有效应对过载信息的手段,使得我们有足够的时间和精力,来处理真正重要的信息并进行有效产出。外脑的作用不仅仅只是知识管理,还兼有时间管理、精力管理等自我管理功能。 外脑带来的好处 使得想法和理念具象化,大脑更擅长处理具象而不是抽象,获得诺贝尔奖的科学家也曾经需要借助物理模型来构建抽象理论,具象化能够增强大脑处理信息的能力。 发现已有事物间的新关联。将不同种类的材料组织在一起,我们会自然地去发现它们之间的相同点和关联,并发散式地去建立新的关联。 在更长的时间跨度内积累孵化我们的想法。大脑在短时间内只能基于已有材料生产有限的信息,借助外脑可以在一个想法或主题之上不断积累和发展,随着时间的推移就有可能产生更高价值的结果。 强化我们的独特视角。 个人知识管理的三个阶段 记录 连接 创造 CODE 四步骤方法 构建外脑的主要方法就是多记笔记,作者在此基础上扩展了一套 CODE 四步骤作为方法论。 Capture 什么是可以记录的信息? 来自外部:Highlights,Quotes,Bookmarks and favorites,Voice memos,Meeting notes,Images,Takeaways 来自内部:Stories,Insights,Memories,Reflections,Musings 什么信息不适合记录? 私人的 特定格式的 大文件 协作编辑的 记录哪些信息? 如何确定哪些信息需要记录下来,核心在于不要被动和随意地去接收信息,而是建立自己的关注领域,在关注领域内提出最多 12 个待解决的问题或疑惑,然后主动寻找或检查被动接收的信息,是否有助于解决自己提出的问题。 对于开发者来讲,则是建立一个有限的技术问题列表,在一个时间段尽量只关注和记录和解决这些问题有关的信息。比方说我个人近期(至少一年内)关注以下问题: golang 的调度器如何工作,内存模型以及垃圾回收机制是怎样的 如何熟练掌握 client-go,controller-runtime 等 k8s 库的使用方法 容器网络的工作机制 linux 网络的内核实现,如何快速定位网络问题和排除故障 数据包在 cilium 和 ebpf 构建的容器网络中是如何流动的 k8s 的调度器如何工作的,如何自定义调度策略 postgresql 的备份恢复机制,主从同步机制 linux 文件系统的内核实现,如何监控磁盘io 分布式领域需要关注的技术问题与解决方案,raft 共识算法的原理和实现 如何提高日常工作的效率,终端和 vim 相关的技巧 如何提升自己的技术写作水平和效率,扩大自己的技术影响力 如何提高自己的 leetcode 水平 如何避免记录太多或太少信息? 信息内容中的价值并不是均匀分布的,小部分内容蕴含了最具帮助和有趣的价值(这里也许可以套二八法则?),因此我们只需要从输入的信息中选取一小部分记录下来(最多不要超过10%,保留回到原文的链接)。 选取的条件: 在情感上有鼓舞、驱动作用 有实际作用的 个人化的 能改变现有认知,令人惊讶的 最重要也是唯一的判断条件:Capture What Resonates。记录在直觉上能够引起你共鸣的信息。 为什么靠直觉?我们不应该依赖死板的标准判断信息是否值得记录,而应该练习使用直觉去辨别,这样一是降低了辨别信息的难度和工作量,二是发展了自己快速识别有价值信息的能力。 “it’s often helpful to capture chapter titles, headings, and bullet-point lists, since they add structure to your notes and represent distillation already performed by the author on your behalf.” 记录时尽量保留原文的多级标题和列表等已有的组织结构。 选择合适的记录工具 “don’t leave all the knowledge they contain scattered across dozens of places you’ll never think to look. Make sure your best findings get routed back to your notes app where you can put them all together and act on them.” 任何趁手的记录工具都可以,只要能将其收集的信息汇总到一处。 外部化思维过程 研究者发现,与阅读一段话相比,口述或写出同一段话时大脑的活跃区域更广。 外部化思维过程能够显著增强我们的记忆和关联发散能力,甚至有益身心健康。 “Don’t worry about whether you’re capturing “correctly.” There’s no right way to do this, and therefore, no wrong way.” Organize 整理信息的重要性 “大教堂效应”(Cathedral Effect)描述了建筑屋顶的高度对人类思考行为的心理学影响。高屋顶促进抽象思维(激发创造力),低屋顶促进具象思维(聚焦细节)。 外脑作为我们工作和创作的数字环境,也需要得到良好的整理和组织。 组织信息的最大诱惑是完美主义,将组织过程本身视为目的。许多人希望能找到一套像图书馆分类书籍一样完美的分类方法对信息进行整理,然而这种完美的方法是不存在的。 PARA 系统 PARA 系统和传统分类方法的区别在于,其目的不是为了设计一套大而全的分类系统将信息放到正确的位置,而是尽量减少组织信息的负担同时不影响信息的功用。 PARA 系统将所有的信息分为四类: Projects:围绕当前正在进行的,短期的工作或生活项目,有明确的起止时间或产出结果。设定 Projects 也是一项设定目标、分配时间和优先级的计划过程。 Areas:长期关注或从事的领域。不需要很具体的目标,但应该设定预期到达的掌握程度或技能水平。 Resources:在未来可能用得上的主题。与个人的兴趣、研究有关,并且在未来能够被引用或参考。 Archives:以上三个分类中不在活跃的项目。已完成的 projects 或者不再关注的 areas 等。 PARA 的核心思想是按笔记被使用的紧急程度和频率进行组织,它的依据不是信息从哪里来,而是信息将到哪里去。 知识是否有价值的判断条件不是它是否井井有条整洁有序,而是它能否对个人或个人关注的目标产生影响。使用 PARA 系统的过程不仅仅只是对记录的信息进行整理分类,更是对个人的工作和生活进行组织和管理。 看完这章,发现自己基本上已经在 Notion 中使用这套所谓的「系统」了,于是在 Dashboard 页面中重新整理归类了原有的页面(套了个 PARA 的标题): Projects:每周的任务安排,Todo Inbox,个人OKR,博客写作看板和阅读清单,以及工作中用到的日志等时效性内容。 Areas:基本上就是技术 wiki 了。 Resources:纯粹用来整理通过 Save to Notion 收藏的页面。 Archives:对 Projects 里面往周往年的项目进行归档。 整理的时机 记录信息的时刻并不是对其整理的最佳时刻,可以尝试在笔记中建立一个 Inbox 分类存放所有的未处理信息,并定期比如每周对其进行整理。 Distill 看到这里,感觉作者有点为了写书而写书了。 这一章讲的是,如何提取信息中的关键点,使得记录和组织信息一段时间之后能够快速找到并投入使用,即增强信息的可发现性(Discoverability)。 没有人能够过目不忘,在第一次阅读一篇文章时,我们会深入其中的细节;但过段时间想用到这篇文章时,你可能需要再从头到尾阅读一遍,才能从中找到对自己有价值的那部分信息。 解决方法是在记录和组织之后增加一个提取环节,在提取环节中,提前设想哪些部分的信息会在未来对自己有帮助,并标记出来。 渐进式概要 作者在书中花了十几页介绍了一套名为「渐进式概要」的分层提取方法,其实几句话就能讲清楚: 第一个层级,是直接摘取原文中的关键段落,并保留原文链接。 在上一层基础上,对关键信息加粗。 高亮更关键的信息。 用自己的话总结关键点。 需要避免的三个误区 每一节都是重点,就等于没有重点。重点只是起到原文索引的作用,不要超过上一层内容的 10-20%。 在准备用到信息的时候再去做提取。即根据哪些信息在未来最有可能用到,来辨别信息中的关键点。 不要花太多时间和精力来提取信息,运用你的直觉。 这章讲的其实就是在保存信息后,如何对信息进行简单处理,使得在需要时能够快速找到信息。 解决这种问题还有比数据库更在行的吗?作者介绍的方法其实就是为信息建立索引,为了提高效率我们会为最常用到的数据列建立索引。一层索引不够就再加一层 redis 作为缓存,缓存还不够就把最常用的数据 hardcode 在代码中作为常量…… Express 这章的大部分内容已经是在充字数了。 CODE 的最后阶段是 Express 即表达,是指不应该彻底掌握了一项知识再对外分享,而是应该更早、更频繁、更小规模地表达你的想法,以验证是否有效,并从他人那里收集反馈。这些反馈有助于开始下一轮的表达。 具体来说应该将一个有挑战性的大的计划,分解成难度更小需要资源更少的子目标。作者又引入了一个新的概念 Intermediate Packet (IP?),即完成目标过程中所记录的信息或者中间产物,这些对实现新的目标会很有用。 可能是我英语水平不到位,这章的大部分段落读起来云里雾里,干脆尝试根据标题归纳下每节内容: How to Protect Your Most Precious Resource。 注意力是知识工作者最稀缺的资源,因为注意力很宝贵,所以应该尽量复用知识工作的中间产物。(主题、内容以及最后得出结论之间的逻辑联系很牵强) Intermediate Packets: The Power of Thinking Small。 引入了 Intermediate Packet 这个概念,意思就是完成大的项目或目标过程中所产生的笔记、文档、PPT 等中间产物。 Assembling Building Blocks: The Secret to Frictionless Output。 这段的大概是讲复用上面的中间产物组合起来就能实现快速产出新的成果。 How to Resurface and Reuse Your Past Work。 大意是讲通过搜索、浏览、打标签和碰运气等方式更好地从已完成的工作中,发掘对当前目标有用的信息。 Three Stages of Expressing: What Does It Look Like to Show Our Work? 重新回顾知识管理的三个阶段:记忆、连接和创作。有了外脑实现这三者更轻松和高效了。 Creativity Is Inherently Collaborative。 创造力本质上是协作的,所以要经常向他人展示阶段性的成果寻求反馈。 Everything Is a Remix。 任何新事物都建立在已有事物的基础上。 这章感觉作者在写长篇大论且正确的废话了,每一句话都写的很对,但看完只能体会到时间白白流逝。洋洋洒洒几十页并没有提出什么实际的观点和方法,一直在强调通过外脑保留知识创作的中间产物,能够加速新项目的成功。但其实根本不需要所谓的外脑就天然如此。 根据输入-内化-输出这样一个笼统的学习流程,个人理解最后一个阶段是要将知识尽量地学以致用,「用」的方式可以是在工作或个人项目中实践,通过写作或其他媒介教授给他人,以及直接参与有关该知识的讨论,总之要将吸收归纳的知识表达到外部环境获得反馈。 后续章节 后面的章节从标题来看十分抽象,没有继续看下去的欲望了。 Chapter 8:The Art of Creative Execution Chapter 9:The Essential Habits of Digital Organizers Chapter 10:The Path of Self-Expression 书评 不足之处 书中每一章都会以名人轶事作为开头,来佐证本节所抛出的方案或论点的必要性和有效性,但逻辑十分牵强,个人建议直接跳过这部分。 后半部分的章节有充字数的嫌疑,充斥着对外脑概念的刻意强化和转化为效益的承诺,实际上言之无物。小节之间缺乏逻辑连贯性,从名称来看很多章节都是独立的,更像是博客文章的堆砌组合。 这类自我成长书籍的共性:为解决某一问题,作者会尝试创造一套适合所有读者的体系或方法,但当你发现这些方法不适合自己,或者已经在实践类似的或更有效的方法时,就会失去继续读下去的动力。 这本书值得一读吗 说说我的个人体验,开始读这本书的时候期待很高,因为检视目录时发现作者提出了许多新的概念和方法论,之前也被推荐过作者的 Youtube 频道和个人网站。但读到一半发现如食鸡肋:想就此放弃,但已经花了时间读了一半多了;继续读下去,后面的章节注水严重,也嚼不出什么味来。最后实在不想浪费时间半途而废了。 书中有用的内容还是有的,作者提倡的多记笔记以及提炼的 CODE 四步骤和 PARA 系统都有可取之处。对我来说,最大的收获是强化了知识管理系统的重要性,推动了自己对现有知识管理系统的思考和优化。 如果你平时已经在实践终身学习理念,对 Notion,Obsidian,Logseq,Roam Research 等笔记应用如数家珍,有使用 Pocket、InstaPaper 以及 RSS 阅读器的习惯,对双向链接、滑箱卡片笔记、卢曼等名词都不陌生(尤其是少数派网站读者),这本书里对你有用的信息掐掉水分顶多只有一篇博客,我不建议你浪费时间去读英文原文。 如果你不满足上述条件,但想提升自己基于数字媒体的学习能力,我推荐你看看本书的前两部分。

2022/7/28
articleCard.readMore

如何优化 docker 镜像体积

最近在接手一个新项目后,将原本 1.6GB 的镜像精简到了 600 多MB,直接进入了贤者时间,特地记录下优化过程中总结的一些经验。 理论依据 镜像的本质是镜像层和运行配置文件组成的压缩包,构建镜像是通过运行 Dockerfile 中的 RUN 、COPY 和 ADD 等指令生成镜像层和配置文件的过程。 在我去年写的博客 容器技术原理(一):从根本上认识容器镜像 | Shall We Code? 中,详细解释了镜像组成、联合文件系统及其工作方式等理论,本文不再赘述,只从中提取和镜像体积有关的关键点: RUN、COPY 和 ADD 指令会在已有镜像层的基础上创建一个新的镜像层,执行指令产生的所有文件系统变更会在指令结束后作为一个镜像层整体提交。 镜像层具有 copy-on-write 的特性,如果去更新其他镜像层中已存在的文件,会先将其复制到新的镜像层中再修改,造成双倍的文件空间占用。 如果去删除其他镜像层的一个文件,只会在当前镜像层生成一个该文件的删除标记,并不会减少整个镜像的实际体积。 上述理论可以通过如下 Dockerfile 来验证: 1 2 3 4 5 FROM alpine:latest COPY resource.tar / RUN touch /resource.tar RUN rm -f /resource.tar ENTRYPOINT ["/bin/ash"] 我们在 Dockerfile 中简单地添加、修改和删除某个资源文件,然后构建镜像查看其镜像层信息: 1 2 3 4 5 6 7 8 9 $ docker build -t test-image -f Dockerfile . $ docker history test-image:latest IMAGE CREATED CREATED BY SIZE COMMENT 95f1695b2904 About a minute ago /bin/sh -c #(nop) ENTRYPOINT ["/bin/ash"] 0B 1780448c656f About a minute ago /bin/sh -c rm -f /resource.tar 0B a85d29bf7738 About a minute ago /bin/sh -c touch /resource.tar 135MB 6dac335fa653 4 minutes ago /bin/sh -c #(nop) COPY file:66065d6e23e0bc52… 135MB e66264b98777 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B <missing> 7 weeks ago /bin/sh -c #(nop) ADD file:8e81116368669ed3d… 5.53MB 在 docker history 的输出结果中可以看到: RUN touch /resource.tar 指令只是修改了文件的元信息,但依然将整个文件拷贝到了新的镜像层中。 RUN rm -f /resource.tar 指令虽然删除了文件,并且该文件在运行容器时不可见,但依然在前两个镜像层中以及最终的镜像中存在。 分析工具 给代码做性能调优时,首先要借助 Profiling 工具找到代码的性能瓶颈,对于优化镜像体积也是如此。下面介绍两个可以分析镜像体积的工具: docker history docker 自带的 docker history 命令,该命令可以展示所有镜像层的创建时间、指令以及体积等较为基础的信息,但对于复杂的镜像则有些乏力。使用方式见上方的示例。 dive 第三方的 dive 工具,该工具可以分析镜像层组成,并列出每个镜像层所包含的文件列表,可以很方便地定位到影响镜像体积的构建指令以及具体文件。 以 golang:1.16 镜像为例,首先安装 dive,然后执行 dive golang:1.16,输出如下: 如上图所示,在左侧选中镜像层后,在右侧的文件树视图中可以清晰地看到该层的具体文件,并能够筛选相比上一层新增、更新或删除的文件。在选中的镜像层中,由于执行了 apt-get 安装编译依赖,因此在 /usr/lib 目录下新增了 150MB 依赖库文件。 优化技巧 下面介绍一些优化效果比较显著的优化技巧。 分阶段构建与从零构建 分阶段构建(multi-stage builds)和从零构建(build from scratch)是优化镜像体积的基本手段和必备技巧。该技巧将镜像构建过程区分为构建和运行环境,在构建环境安装编译器等依赖并编译所需的二进制包,然后将其复制到仅包含必要运行依赖的运行环境中。 对 golang 这类能够编译静态二进制文件的语言来说分阶段构建的效果尤为明显,我们可以将编译产生的二进制文件放到 scratch 镜像中运行(scratch 是一个特殊的空镜像): 1 2 3 4 5 6 7 8 FROM golang COPY hell0.go . ENV CGO_ENABLED=0 RUN go build hello.go FROM scratch COPY --from=0 /go/hello . CMD ["./hello"] 如果直接使用 golang 镜像作为运行环境,其镜像体积通常接近 1 个 G,其中大部分文件都不是在运行容器时所必要的。 将编译结果拷贝到运行环境后,体积只有几十 kb~mb 不等,如果需要在运行容器中保留基本的系统工具,可以考虑使用 alpine 镜像作为运行环境。 关于分阶段构建和从零构建的更多细节可参考 Docker 官方文档中的 Use multi-stage builds 和 Create a simple parent image using scratch。 避免产生无用的文档或缓存 docker 镜像不应该包含文档、缓存等对运行容器没有作用的内容。 避免在本地保留安装缓存。 大部分包管理器会在安装时缓存下载的资源以备之后使用,以 pip 为例,会将下载的响应和构建的中间文件保存在 ~/.cache/pip 目录,应使用 --no-cache-dir 选项禁用默认的缓存行为。 避免安装文档。 部分包管理器提供了选项可以不安装附带的文档,如 dnf 可使用 --nodocs 选项。 避免缓存包索引。 部分包管理器在执行安装之前,会尝试查询所有已启用仓库的包列表、版本等元信息缓存在本地作为索引。个别仓库的索引缓存可达到 150 M 以上。 我们应该仅在安装包时查询索引,并在安装完成后清理,不应该在单独的指令中执行 yum makecache 这类缓存索引的命令。 及时清理不需要的文件 运行容器时不需要的文件,一定要在创建的同一层清理,否则依然会保留在最终的镜像中。 通过包管理安装包,通常会产生大量的缓存文件,一定要在同一 RUN 指令的结尾处立刻清理。在安装依赖数量较多时,可以节省大量的缓存空间。 以 dnf 为例: 1 2 3 RUN dnf install -y --nodocs <PACKAGES> \ && dnf clean all \ && rm -rf /var/cache/dnf 以 apt 为例: 1 2 3 4 RUN apt-get update \ && apt-get install -y <PACKAGES> \ && rm -rf /var/lib/apt/lists/* # 官方的 ubuntu/debian 镜像 apt-get 会在安装后自动执行 clean 命令 合并多个镜像层 上文解释过,应该避免在不同镜像层中更新文件而造成额外的体积占用。当构建的层数很多且执行指令较复杂时,很难避免在不同的镜像层中更新文件,可通过以下手段精简这部分额外体积: 在最终生成镜像时将所有镜像层合并成一层,在 docker build 命令中使用 —squash 即可实现(需要开启 docker daemon 的实验性功能)。以本文开头的 Dockerfile 为例: 1 2 3 4 5 6 7 8 9 10 $ docker build -t squash-image --squash -f Dockerfile . $ docker history squash-image IMAGE CREATED CREATED BY SIZE COMMENT 55ded8881d63 9 hours ago 0B merge sha256:95f1695b29044522250de1b0c1904aaf8670b991ec1064d086c0c15865051d5d to sha256:e66264b98777e12192600bf9b4d663655c98a090072e1bab49e233d7531d1294 <missing> 11 hours ago /bin/sh -c #(nop) ENTRYPOINT ["/bin/ash"] 0B <missing> 11 hours ago /bin/sh -c rm -f /resource.tar 0B <missing> 11 hours ago /bin/sh -c touch /resource.tar 0B <missing> 11 hours ago /bin/sh -c #(nop) COPY file:66065d6e23e0bc52… 0B <missing> 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B <missing> 7 weeks ago /bin/sh -c #(nop) ADD file:8e81116368669ed3d… 5.53MB 最终生成的镜像只有一个镜像层,包含最后实际存在的文件系统,在合并所有镜像层的过程中,相当于禁用了 copy-on-write 特性。 这种做法的坏处在于,镜像在保存和分发时是可以复用镜像层的,推送镜像时会跳过镜像仓库已存在的镜像层,拉取镜像时会跳过本地已拉取过的镜像层,而合并成一层后则失去了这种优势。 对于可能和其他共用镜像层的场景,可以采取下面一种方式。 分阶段构建,将部分中间镜像层压缩成一层作为基础镜像。 在开发团队内部,我们往往会在官方镜像的基础上添加或更新部分依赖,然后作为团队内部统一使用的基础镜像,这种复用方式可以大大减少实际占用的镜像体积。 更进一步,我们可以将这类基础镜像压缩成一层。下面以 golang 官方镜像为例: 1 2 3 4 5 FROM golang:1.16 as base FROM scratch COPY --from=base / / ENTRYPOINT ["/bin/bash"] 压缩成一层后,golang:1.16 的镜像体积从 919MB 变成 913MB,官方镜像已经做了很多优化所以节省空间十分有限,但对于开发团队内部制作的基础镜像,这种优化往往会带来意外惊喜。 复制文件的同时修改元信息 先将文件添加到镜像内,然后再修改文件的执行权限和所属用户,这类 COPY-RUN 指令在 Dockerfile 中十分常见: 1 2 COPY output/hello /usr/bin/hello RUN chmod +x /usr/bin/hello && chown normal:normal /usr/bin/hello 但修改文件元信息也会将文件复制到新的镜像层,以上指令会产生两份相同的文件。在文件体积较大时,会显著增加整个镜像的体积。 事实上,我们可以在复制文件的同时完成对文件元信息的修改,COPY 和 ADD 指令都提供了修改元信息的 --chmod 和 --chown 选项: 1 COPY --chmod=755 --chown=normal:normal output/hello /usr/bin/hello --chmod 特性目前还未添加到官方文档,使用前需要开启 docker 的 buildkit 特性(在 docker build 命令前添加 DOCKER_BUILDKIT=1 即可),目前只支持 --chmod=755 和 --chmod=0755 这种设置方法,不支持 --chmod=+x。 注:经测试,当使用 ADD 指令且源文件为下载链接时 --chmod 选项不起作用,不清楚这是 docker 的 bug 还是 feature。解决方案是直接使用 RUN 指令 wget + chmod 来替代 ADD。 参考链接 Best practices for writing Dockerfiles | Docker Documentation How to build tiny container images | Enable Sysadmin COPY --chmod reduced the size of my container image by 35% Docker Images : Part I - Reducing Image Size

2022/7/17
articleCard.readMore

如何使用 PostgreSQL 14 的持续归档备份和基于时间点恢复

持续归档备份 pg 有三种基本的备份方式: 利用 pg_dump 进行 sql dump,属于逻辑备份无法恢复到指定状态。 基于文件系统备份,需要文件系统提供快照功能保证一致性,否则必须停机备份。 持续归档,首选的高可靠性备份技术。 WAL 日志持续归档是实现归档备份的关键,把一个文件系统级别的备份和归档的 WAL 文件结合起来,当需要恢复时,先恢复文件系统备份,然后重放归档的 WAL 文件把系统恢复到当前(或指定的时间点)状态。 持续归档的优点: 不需要完全一致的文件系统备份,通过从重做点重放 WAL 到达一致状态,因此可使用简单工具在不停机状态下制作基础备份。 简单地持续归档 WAL 文件即可在无法频繁完全备份的情况下实现连续备份。 利用基础备份和 WAL 存档重放到指定时间点可以将数据库恢复到基础备份之后的任意时间点。 连续地传输 WAL 归档文件到另一台已应用相同基础备份的机器,即可实现主备复制系统。 缺点: 只能备份恢复整个数据库集簇,不支持更细粒度。 基础备份和持续归档文件会占用大量空间。 开启 WAL 归档 pg 默认在 pg_wal 目录持续生成 16M 大小的 WAL 段文件,没有开启归档模式时 pg 会对 WAL 段文件进行清理和回收。 归档过程是将检查点之前的 WAL 段文件从 pg_wal 目录传输到指定位置(如复制到用户自定义的目录),传输成功后原有的 WAL 段文件将被清除或回收,未成功将保留文件并不断重试归档命令。 pg 归档过程具有极高可扩展性,传输过程即执行用户提供的一条 shell 命令,传输的具体方式和目标位置完全由用户自定义,仅根据命令返回码判断归档是否成功。 开启 WAL 归档的配置示例: 1 2 3 wal_level = replica # 或更高级别 archive_mode = on archive_command = 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' 关于归档命令有以下注意点: 归档命令千万不要直接覆盖相同名称的归档文件,因为不同集簇可能产生相同名称的 WAL 段文件,归档命令应该在归档位置已存在归档文件时以非零状态码返回。 归档命令应当保留 WAL 段文件的原始文件名。 设计归档命令时应考虑如何解决或处理潜在的异常情况,否则会导致 pg_wal 目录不断增长从而导致 pg 因空间不足关闭。 有必要开启 logging_collector 参数,之后归档命令的标准错误输出会被收集到数据库日志中,方便对归档过程调试监控。 归档命令只会调用于已写完毕的 WAL 段文件,对于工作负载很小的数据库一个 WAL 段文件可能很长时间都不会切换,设置较小的 archive_timeout 可以缩短事务执行完毕到被可靠地归档之间的时间间隔,也可通过执行 SELECT pg_switch_wal(); 手动切换 WAL 段文件,这也会触发对切换前使用的 WAL 段文件进行归档,该命令只能在主库执行。 如果命令被 SIGTERM 以外的信号或者 shell 的错误(如未找到命令)终止,归档将被中止且服务器将会关闭。 基础备份 基础备份是由多个顺序命令组成的阶段性操作: 执行 pg_start_backup 开始备份,数据库会执行一次检查点,在备份开始时刻显式创建一个重做点,尽量将此时间点之前的事务写入磁盘,同时记录下该检查点的 LSN 位置。 为了不影响在线运行,该命令默认通过分散 IO 的方式执行检查点,因此可能持续较长时间才返回。 pg_start_backup 还会强制开启整页写入模式,保证在基础备份期间对数据库的写入可以完全回放。 pg_start_backup 执行成功后,用户需要自行使用文件系统工具对数据目录进行归档,获得一份文件系统备份,注意不同文件可能在不同的时间点被复制,因此文件系统备份中数据库的状态可能不一致。pg 预期这种情况的发生,并通过重放在整个基础备份期间写入的 WAL 段文件使其到达一致状态。 执行 pg_stop_backup,数据库会强制切换正在使用的 WAL 段文件,并记录切换前的最后一条 LSN,结束 LSN 即为本次基础备份实际所备份的一致状态。基础备份期间写入的 WAL 段文件将被归档,结束操作还会发送一份记录了这些 WAL 段文件名称的备份历史文件到归档位置,该文件的文件名记录了使用该基础备份恢复时所需要的第一个 WAL 段文件,文件内容记录了本次备份的起止时间戳和所需要的起止 WAL 段文件。在文件系统备份的基础上应用这些已归档的 WAL 段文件,即可恢复到结束 LSN 所代表的一致状态。 结束备份后需要在文件系统备份的根目录中手动创建 backup_label 文件,内容为 pg_stop_backup 操作所返回的输出,同时最好单独保存备份历史文件中所记录的 WAL 段文件,文件系统备份加上备份期间写入的 WAL 段文件才是一份有效的基础备份,能够成功恢复到数据库在结束备份时所处的一致状态。 pg_start_backup 和 pg_stop_backup 命令的具体细节可参考: what-does-pg_start_backup-do/ FUNCTIONS-ADMIN-BACKUP 在安全地归档文件系统备份和备份期间使用的 WAL 段文件后(在备份历史文件中指定),文件名称中数字序列小于备份历史文件名称的 WAL 段文件可以被删除。 恢复需要一个基础备份加上该基础备份之后产生的持续 WAL 归档文件,而重放大量 WAL 归档文件较为耗时,推荐的做法是定期做一次基础备份,同时清理基础备份之前的较早的 WAL 归档文件(清理后无法再恢复到该时间点)。 使用底层 API 制作基础备份 确保归档功能已开启并工作正常。 使用超级用户连接到数据库并执行以下命令(备份期间需要保持该连接): 1 SELECT pg_start_backup('label', false, false); 第一个参数是一个自定义的描述性的标签。 第二个参数代表是否开启快速检查点,开启后会执行快速检查点立刻发起大量 IO,可能会影响备份期间的数据库性能。关闭后会分散 IO 降低对数据库影响,但需要执行较长时间。 第三个参数代表是否开启独占备份,新版本已不建议开启。 使用任意的文件系统备份工具将数据目录进行归档。 如果备份工具因为复制期间文件被更改而以非零状态码返回,该错误可以被忽略。 数据目录下的部分临时子目录、文件或子目录中的文件(如 pg_wal)可以在归档时忽略。 在同一连接中继续执行如下命令: 1 SELECT * FROM pg_stop_backup(false, true); 该命令将终止备份模式,并自动切换 WAL 段文件使得备份期间写入的 WAL 段文件能够被归档,默认情况下这些 WAL 端文件归档成功后该命令才会返回,返回的第二个字段输出需要写入到文件系统备份根目录下的 backup_label 文件中,该文件在非独占备份模式需要手动创建。 结束命令成功返回后,说明备份期间写入的 WAL 段文件已经被归档,此时备份结束。可以将文件系统备份和这些 WAL 段文件保存在一起,它们组成了一份完整的基础备份。 使用 pg_basebackup 制作基础备份 pg_basebackup 封装了底层的 pg_start_backup 和 pg_stop_backup 等命令,并提供了一些非常方便的特性: 支持对数据目录的文件系统备份进行存档压缩,自动忽略不需要的文件,自动写入 backup_label 文件。 自动记录备份文件的校验和,防止备份被更改。 支持自动获取备份期间生成的所有 WAL 段归档文件,并存放到 pg_wal 或其他目录中。 使用示例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ pg_basebackup -D /backup/demo -Ft -z -Xs -c fast -P -v pg_basebackup: initiating base backup, waiting for checkpoint to complete pg_basebackup: checkpoint completed pg_basebackup: write-ahead log start point: 0/75ED8820 on timeline 1 pg_basebackup: starting background WAL receiver pg_basebackup: created temporary replication slot "pg_basebackup_15233" 244438/244438 kB (100%), 1/1 tablespace pg_basebackup: write-ahead log end point: 0/859200C8 pg_basebackup: waiting for background process to finish streaming ... pg_basebackup: syncing data to disk ... pg_basebackup: renaming backup_manifest.tmp to backup_manifest pg_basebackup: base backup completed $ ls /backup/demo backup_manifest base.tar.gz pg_wal.tar.gz 分别将 base.tar.gz 和 pg_wal.tar.gz 解压,得到文件系统备份和备份期间写入的 WAL 段归档: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 $ cat /backup/demo/backup_label START WAL LOCATION: 0/75ED8820 (file 000000010000000000000075) CHECKPOINT LOCATION: 0/763C57B0 BACKUP METHOD: streamed BACKUP FROM: primary START TIME: 2022-07-09 15:13:35 UTC LABEL: pg_basebackup base backup START TIMELINE: 1 $ ls -l /backup/demo/pg_wal total 278532 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000075 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000076 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000077 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000078 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000079 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 00000001000000000000007A -rw------- 1 postgres postgres 16777216 Jul 9 15:13 00000001000000000000007B -rw------- 1 postgres postgres 16777216 Jul 9 15:13 00000001000000000000007C -rw------- 1 postgres postgres 16777216 Jul 9 15:13 00000001000000000000007D -rw------- 1 postgres postgres 16777216 Jul 9 15:13 00000001000000000000007E -rw------- 1 postgres postgres 16777216 Jul 9 15:13 00000001000000000000007F -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000080 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000081 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000082 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000083 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000084 -rw------- 1 postgres postgres 16777216 Jul 9 15:13 000000010000000000000085 drwx------ 2 postgres postgres 4096 Jul 9 15:20 archive_status 数据目录中的 backup_label 文件并没有记录基础备份结束的 LSN 位置,但可以在 WAL 归档目录下对应的备份历史文件中查看: 1 2 3 4 5 6 7 8 9 10 11 $ cat 000000010000000000000075.00ED8820.backup START WAL LOCATION: 0/75ED8820 (file 000000010000000000000075) STOP WAL LOCATION: 0/859200C8 (file 000000010000000000000085) CHECKPOINT LOCATION: 0/763C57B0 BACKUP METHOD: streamed BACKUP FROM: primary START TIME: 2022-07-09 15:13:35 UTC LABEL: pg_basebackup base backup START TIMELINE: 1 STOP TIME: 2022-07-09 15:13:42 UTC STOP TIMELINE: 1 在使用该基础备份恢复时,恢复进程在重放了 000000010000000000000085 WAL 段文件后,数据库才会进入一致状态,之后数据库才能够接受连接执行查询操作,如下方的恢复日志所示: 1 2 3 4 5 6 2022-07-10 08:05:28.780 UTC [21181] LOG: restored log file "000000010000000000000084" from archive 2022-07-10 08:05:29.024 UTC [21181] LOG: restored log file "000000010000000000000085" from archive 2022-07-10 08:05:29.147 UTC [21181] LOG: consistent recovery state reached at 0/859200C8 2022-07-10 08:05:29.148 UTC [21179] LOG: database system is ready to accept read-only connections 2022-07-10 08:05:29.232 UTC [21181] LOG: restored log file "000000040000000000000086" from archive 2022-07-10 08:05:29.480 UTC [21181] LOG: restored log file "000000040000000000000087" from archive 从归档备份中恢复 恢复的操作步骤如下: 停止 pg 服务器进程。 备份当前数据目录,如果空间不够,至少要备份 pg_wal 目录下还未归档的 WAL 段文件。 移除数据目录下的所有文件和子目录。 将文件系统备份恢复到数据目录,确保恢复后的文件和目录有正确的权限。 如果做文件系统备份时没有忽略 pg_wal 目录,现在需要清空恢复后的 pg_wal 目录,将未归档的 WAL 段文件复制到该目录。 在数据目录下创建一个 recovery.signal 文件,该文件指示 pg 在启动时进入恢复模式,并将在恢复成功后自动删除。 在配置文件 postgresql.conf 中设置和恢复相关的参数,如 restore_command,下文会进行详细介绍。 启动 pg 服务器。服务器将进入恢复模式,并开始获取和处理恢复所需的 WAL 段文件。如果恢复过程因外部错误(如主机断电)被终止,可以简单地重启服务器让它继续恢复。恢复完成后,服务器将删除 recovery.signal 文件以防止重新进入恢复模式,然后开始正常运行。 恢复的操作流程并不复杂,其关键点在于通过配置文件设置: 恢复时获取已归档 WAL 段文件的方式。 恢复需要到达的目标状态。 恢复命令 恢复时必须设置 restore_command (下文称之为恢复命令)来告诉 pg 如何获取已归档的 WAL 段文件,与存档命令类似,该命令定义如何将 pg 需要的特定 WAL 段文件(也包含其他类型文件)通过用户自定义的方式从归档位置传输到数据目录下的临时位置,然后 pg 会读取该临时文件执行重做。在该命令中可以通过执行脚本的方式,自定义更加复杂的行为。 如果已归档的 WAL 段文件存放于 /backup/demo/pg_wal 目录,则示例的恢复命令为: 1 restore_command = 'cp /backup/demo/pg_wal/%f %p' 设计恢复命令时有以下注意点: 恢复命令一定要在传输失败或传输文件不存在时以非零状态码返回。pg 会尝试通过恢复命令获取一些并不在归档位置中的文件,因此恢复不会因为恢复命令失败而直接中止: 1 2 3 4 5 6 2022-07-10 08:05:24.762 UTC [21181] LOG: restored log file "00000003.history" from archive 2022-07-10 08:05:24.766 UTC [21181] LOG: restored log file "00000004.history" from archive cp: cannot stat '/var/lib/postgresql/archive/14/demo/00000005.history': No such file or directory 2022-07-10 08:05:24.771 UTC [21181] LOG: starting point-in-time recovery to 2022-07-10 08:03:23.157122+00 2022-07-10 08:05:24.776 UTC [21181] LOG: restored log file "00000004.history" from archive 2022-07-10 08:05:24.862 UTC [21181] LOG: restored log file "000000010000000000000076" from archive 如果 pg 无法通过恢复命令获取某个文件,之后会尝试从数据目录下的 pg_wal 目录中寻找。 如果命令被 SIGTERM 以外的信号或者 shell 的错误(如未找到命令)终止,恢复将被中止且服务器将会关闭。 恢复模式 恢复实际上有两种工作模式: 在数据目录下创建一个名为 standby.signal 的文件,服务器启动后将进入 standby 模式。服务器进入恢复状态,并且在达到归档的 WAL 的末端时不会停止恢复,而是通过连接到由primary_conninfo 设置指定的主服务器或通过使用 restore_command 获取新的 WAL 段,继续尝试恢复。 在数据目录中创建一个叫做 recovery.signal 的文件,服务器启动后将进入目标恢复模式。目标恢复模式在归档的 WAL 段文件被完全重放或达到 recovery_target 时结束。 通常情况下,standby 模式被用来提供高可用和扩展只读库,而目标恢复则被用来恢复丢失的数据或克隆新的服务器。如果同时创建了standby.signal 和 recovery.signal 文件,则以 standby 模式为优先。 恢复目标 默认情况下,恢复模式会处理完所有可用的 WAL 段文件,即通过递增 WAL 段命名中的序列号不断获取下一个 WAL 段文件,直到获取失败为止,最后会将数据库恢复到「相对最新」的状态。因此在恢复过程中出现的类似「文件未找到」的错误通常是正常的,尤其是在恢复结束时。 如果你只需要恢复到基础备份结束到当前时刻之间的某一个时间点,则需要指定停止点,即恢复目标。恢复目标可以是时间戳、命名的恢复点和事务 ID,在实践中使用基于时间点恢复居多。 我们可以且仅可从以下参数中选择一个来指定恢复目标: recovery_target:唯一可用的值是 immediate,到达基础备份结束时的一致状态即停止恢复。 recovery_target_lsn:设置恢复目标为指定的 LSN。 recovery_target_name:设置恢复目标为指定的命名恢复点(通过pg_create_restore_point() 创建命名恢复点)。 recovery_target_time:设置恢复目标为指定的时间戳。 recovery_target_xid:设置恢复目标为指定的事务 ID。 通过以下选项进一步设定是否包括恢复目标以及到达后的行为: recovery_target_inclusive:指定恢复时是否包含恢复目标,即是否重放包含了目标时间戳、LSN 或事务 ID 的 WAL 日志条目,否则将在该恢复目标前停止。默认值为 true。 recovery_target_timeline:指定恢复时所到达的时间线,通常使用默认值 latest,即归档日志中最后生成的时间线。 recovery_target_action:指定到达恢复目标后服务器将执行的动作,仅在设定了恢复目标时生效。可执行以下三种动作: pause:暂停恢复进程。暂停时用户可根据从数据库查询到的当前状态执行下一步操作。可以选择 resume 被暂停的恢复进程,这会导致恢复模式结束;也可以选择更改恢复目标配置后重启数据库服务器,重新恢复到另一目标。如果服务器因 hot_standby=off 配置而无法接受查询,该选项等同于 shutdown。 promot:结束恢复进程并创建一条新的时间线,开始接受数据库连接。 shutdown:直接关闭服务器。此时不会删除 recovery.signal 文件,如果服务器重新启动将立刻再次关闭;如果更改恢复目标配置后重启,将进入恢复模式从上一个恢复目标开始继续重放到新的恢复目标。 恢复终止 在任何情况下,如果恢复进程在到达已配置的恢复目标之前,因处理完了可用的 WAL 段文件而结束,恢复进程将以 FATAL 错误退出导致服务器关闭: 1 2 3 4 5 6 7 8 9 10 2022-07-10 08:05:50.673 UTC [21181] LOG: restored log file "0000000400000000000000D1" from archive 2022-07-10 08:05:50.865 UTC [21181] LOG: restored log file "0000000400000000000000D2" from archive cp: cannot stat '/var/lib/postgresql/archive/14/demo/0000000400000000000000D3': No such file or directory 2022-07-10 08:05:51.003 UTC [21181] LOG: redo done at 0/D2CA8530 system usage: CPU: user: 5.91 s, system: 9.59 s, elapsed: 25.94 s 2022-07-10 08:05:51.003 UTC [21181] LOG: last completed transaction was at log time 2022-07-10 08:02:30.614229+00 2022-07-10 08:05:51.003 UTC [21181] FATAL: recovery ended before configured recovery target was reached 2022-07-10 08:05:51.015 UTC [21179] LOG: startup process (PID 21181) exited with exit code 1 2022-07-10 08:05:51.015 UTC [21179] LOG: terminating any other active server processes 2022-07-10 08:05:51.026 UTC [21179] LOG: shutting down due to startup process failure 2022-07-10 08:05:51.047 UTC [21179] LOG: database system is shut down 如果恢复进程发现了损坏的 WAL 数据,恢复将立刻停止且服务器无法正常启动。这种情况下可以在损坏点之前指定一个新的恢复目标,然后重新运行恢复过程。 如果恢复由于外部原因而失败,比如系统崩溃或 WAL 归档无法访问,可以简单地重新启动服务器继续恢复,恢复进程会从失败的地方重新开始。恢复重启的工作方式很像普通的检查点:服务器定期将其所有状态刷新到磁盘,然后更新 pg_control 文件记录处已理完成的 WAL 数据,在重启时不再需要重新处理这些数据。 时间线 pg 中引入了时间线的概念,每一次恢复成功后都会基于恢复的时间线创建一条新的时间线,时间线主要体现在 WAL 段文件的命名中: 1 2 3 4 5 6 7 8 9 10 postgres=# SELECT timeline_id FROM pg_control_checkpoint(); timeline_id ------------- 2 (1 row) postgres=# SELECT pg_walfile_name(pg_current_wal_lsn()); pg_walfile_name -------------------------- 000000020000000000000086 (1 row) 每次创建一个新的时间线 pg 还会创建一个时间线历史文件 <timeline-ID>.history,记录了该时间线的父时间线。当从包含多个时间线的存档中恢复时,pg 需要时间线历史文件来不断往前回溯,选择正确的 WAL 段文件序列。因此时间线历史文件也会归档到 WAL 归档位置。 引入时间线的好处是,用户可以基于同一备份恢复之后再备份,通过时间线模拟出一条树状的备份历史,然后方便地在不同分叉之间定位和切换,不需要担心它们之间因命名冲突等原因造成混乱。在某些场景下时间线非常有用,通常我们会直接使用 WAL 归档中最近的时间线。 总结 持续归档的基础备份是一个有开始和结束动作的阶段性过程: 开始时 pg 会执行检查点并强制开启 full_page_writes,在开始到结束期间任何时间点的文件系统备份都可以回退到开始时间点进行重放。 结束时 pg 会强制切换一次 WAL 段日志,文件系统备份重放到结束时被归档的最后一条 WAL 段日志就会使数据库到达一致状态。 因此一份完整有效的备份,必须包含文件系统备份和特定的 WAL 段日志。 作为久负盛名的开源数据库,pg 的持续归档可以评价为「大道至简」:整个过程由简单、基础的数据库指令和 OS 工具协作完成,完美契合 Unix 哲学;归档和恢复的关键过程(archive_command 和 restore_command)完全交给用户,具备了极高的可扩展性,但也对管理员的技术水平有一定的要求。 参考链接 PostgreSQL: Documentation: 14: 26.3. Continuous Archiving and Point-in-Time Recovery (PITR)

2022/7/10
articleCard.readMore

浅析 Linux 如何接收网络帧

本文首发于微信订阅号「沃趣技术」,转载时请注明来源。 本文将从初学者角度,介绍 Linux 内核如何接收网络帧:从网卡设备完成数据帧的接收开始,到数据帧被传递到网络栈中的第三层结束。重点介绍内核的工作机制,不会深入过多代码层面的细节,示例代码来自 Linux 2.6。 设备的通知手段 从计算机硬件的角度,一个数据帧从进入网卡到最后被内核网络栈处理的整体示意图如下: 当网卡设备完成一个数据帧的接收后,可能将数据帧暂存于设备内存,也可能通过 DMA(Direct memory access) 直接写入到主内存的接收环(rx ring),接下来必须通知操作系统内核对已接收的数据进行处理。 下面将讨论几种可能的通知手段。 轮询 轮询(Polling)指的是由内核主动地去检查设备,比如定期读取设备的内存寄存器,判断是否有新的接收帧需要处理。这种方式在设备负载较高时响应效率低,在设备负载低时又占用系统资源,操作系统很少单独采用,结合其他机制后才能实现较理想的效果。 硬件中断 当接收到新的数据帧等事件发生时,设备将生成一个硬件中断信号。该信号通常由设备发送给中断控制器,由中断控制器分发给 CPU。CPU 接受信号后将从当前执行的任务中被打断,转而执行由设备驱动注册的中断处理程序来处理设备事件。中断处理程序会将数据帧拷贝到内核的输入队列中,并通知内核做进一步处理。这种技术在低负载时表现良好,因为每一个数据帧都会得到及时响应,但在负载较高时,CPU 会被频繁的中断从而影响到其他任务的执行。 对接收帧的处理通常分为两个部分:首先驱动注册的中断处理程序将帧复制到内核可访问的输入队列中,然后内核将其传递给相关协议的处理程序直到最后被应用程序消费。第一部分的中断处理程序是在中断上下文中执行的,可以抢占第二部分的执行,这意味着复制接收帧到输入队列的程序比消费数据帧的协议栈程序有更高的优先级。 在高流量负载下,中断处理程序会不断抢占 CPU。后果显而易见:输入队列最终将被填满,但应该去出队并处理这些帧的程序处于较低优先级没有机会执行。结果新的接收帧因为输入队列已满无法加入队列,而旧的帧因为没有可用的 CPU 资源不会被处理。这种情况被称为接收活锁(receive-livelock)。 硬件中断的优点是帧的接收和处理之间的延迟非常低,但在高负载下会严重影响其他内核或用户程序的执行。大多数网络驱动会使用硬件中断的某种优化版本。 一次处理多个帧 一些设备驱动会采用一种改良方式,当中断处理程序被执行时,会在指定的窗口时间或帧数量上限内持续地入队数据帧。由于中断处理程序执行时其他中断将被禁用,因此必须设置合理的执行策略来和其他任务共享 CPU 资源。 该方式还可进一步优化,设备仅通过硬件中断来通知内核有待处理的接收帧,将入队并处理接收帧的工作交给内核的其他处理程序来执行。这也是 Linux 的新接口 NAPI 的工作方式。 计时中断 除了根据事件立刻生成中断,设备也可在有接收帧时,以固定的间隔发送中断,中断处理程序将检查这段间隔时间内是否有新的帧,并一次性处理它们。如果所有接收帧已经处理完毕并且没有新的帧,设备会停止发送中断。 这种方式要求设备在硬件层面实现计时功能,而且根据计时间隔长短会带来固定的处理延迟,但在高负载时可以有效地减少 CPU 占用并且避免接收活锁。 在实践中的组合 不同的通知机制有其适合的工作场景:低负载下纯中断模型保证了极低延迟,但在高负载下表现糟糕;计时中断在低负载下可能会引入过高延迟并浪费 CPU 时间,但在高负载下对减少 CPU 占用和解决接收活锁有很大帮助。在实践中,网络设备往往不依赖某种单一模型,而是采取组合方案。 以 Linux 2.6 Vortex 设备所注册的中断处理函数 vortex_interrupt (位于 /drivers/net/3c59x.c)为例: 设备会将多个事件归类为一种中断类型(甚至还可以在发送中断信号前等待一段时间,将多个中断聚合成一个信号发送)。中断触发 vortex_interrupt 的执行并禁用该 CPU 上的中断。 如果中断是由接收帧事件 RxComplete 引发,处理程序调用其他代码处理设备接收的帧。 vortex_interrupt 在执行期间持续读取设备寄存器,检查设备是否有新的中断信号发出。如果有且中断事件为 RxComplete,处理程序将继续处理接收帧,直到已处理帧的数量达到预设的 work_done值才结束。而其他类型的中断将被处理程序忽略。 软中断处理机制 当硬件中断信号到达 CPU 后,需要通过合理的任务调度机制,才能以较低延迟处理接收帧,又避免接收活锁和饥饿等资源抢占问题。 一个中断通常会触发以下事件: 设备产生一个中断并通过硬件通知内核。 如果内核没有正在处理另一个中断(即中断没有被禁用),它将收到这个通知。 内核禁用本地 CPU 的中断,并执行与收到的中断类型相关联的处理程序。 内核退出中断处理程序,重新启用本地 CPU 的中断。 CPU 在执行中断号对应处理程序的期间处于中断上下文,中断会被禁用。这意味着 CPU 在处理某个中断期间,它既不会处理其他中断,也不能被其他进程抢占,CPU 资源由该中断处理程序独占。这种设计决定减少了竞争条件的可能性,但也带来了潜在的性能影响。 显然,中断处理程序应当尽可能快地完成工作。不同的中断事件所需要的处理工作量并不相同,比如当键盘的按键被按下时,触发的中断处理函数只需要将该按键的编码记录下来,而且这种事件的发生频率不会很高;而处理网络设备收到的新数据帧时,需要为 skb 分配内存空间,拷贝接收到的数据,同时完成一些初始化工作比如判断数据所属的网络协议等。 为了尽量减少 CPU 处于中断上下文的时间,操作系统为中断处理程序引入了上、下半部的概念。 下半部处理程序 即使由中断触发的处理动作需要大量的 CPU 时间,大部分动作通常是可以等待的。中断可以第一时间抢占 CPU 执行,因为如果操作系统让硬件等待太长时间,硬件可能会丢失数据。这既适用于实时的数据,也适用于在固定大小缓冲区中存储的数据。如果硬件丢失了数据,一般没有办法再恢复(不考虑发送方重传的情况)。 另一方面,内核或用户空间的进程被推迟执行或抢占时,一般不会有什么损失(对实时性有极高要求的系统除外,它需要用完全不同的方式来处理进程和中断)。 鉴于这些考虑,现代中断处理程序被分为上半部和下半部。上半部分执行在释放 CPU 资源之前必须完成的工作,如保存接收的数据;下半部分则执行可以在推迟到空闲时完成的工作,如完成接收数据的进一步处理。 你可以认为下半部是一个可以异步执行的特定函数。当一个中断触发时,有些工作并不要求马上完成,我们可以把这部分工作包装为下半部处理程序延后执行。 上、下半部工作模型可以有效缩短 CPU 处于中断上下文(即禁用中断)的时间: 设备向 CPU 发出中断信号,通知它有特定事件发生。 CPU 执行中断相关的上半部处理函数,禁用之后的中断通知,直到处理程序完成工作: a. 将一些数据保存在内存中,用于内核在之后进一步处理中断事件。 b. 设置一个标志位,以确保内核知道有待处理的中断。 c. 在终止之前重新启用本地 CPU 的中断通知。 在之后的某个时间点,当内核没有更紧迫的任务处理时,会检查上半部处理程序设置的标志位,并调用关联的下半部分处理程序。调用之后它会重置这个标志位,进入下一轮处理。 Linux 为下半部处理实现了多种不同的机制:软中断、微任务和工作队列,这些机制同样适用于操作系统中的延时任务。下半部处理机制通常都有以下共同特性: 定义不同的类型,并在类型和具体的处理任务之间建立关联。 调度处理任务的执行。 通知内核有已调度的任务需要执行。 接下来着重介绍处理网络数据帧用到的软中断机制。 软中断 软中断有以下几种常用类型: 1 2 3 4 5 6 7 8 9 10 enum { HI_SOFTIRQ=0, TIMER_SOFTIRQ, NET_TX_SOFTIRQ, NET_RX_SOFTIRQ, BLOCK_SOFTIRQ, IRQ_POLL_SOFTIRQ, TASKLET_SOFTIRQ, }; 其中 NET_TX_SOFTIRQ 和 NET_RX_SOFTIRQ 用于处理网络数据的接收和发送。 调度与执行时机 每当网络设备接收一个帧后,会发送硬件中断通知内核调用中断处理程序,处理程序通过以下函数在本地 CPU 上触发软中断的调度: __raise_softirq_irqoff:在一个专门的 bitmap (位图)结构中设置与软中断类型对应的比特位,当后续对该比特位的检查结果为真时,调用与软中断关联的处理程序。每个 CPU 使用一个单独的 bitmap。 raise_softirq_irqoff:内部包装了 __raise_softirq_irqoff 函数。如果此函数不是从中断上下文中调用,且抢占未被禁用,将会额外调度一个 ksoftirqd 线程。 raise_softirq: 内部包装了 raise_softirq_irqoff,但执行时会禁用 CPU 中断。 在特定的时机,内核会检查每个 CPU 独有的 bitmap 判断是否有已调度的软中断等待执行,如果有将会调用 do_softirq 处理软中断。内核处理软中断的时机如下: do_IRQ 每当内核收到一个硬件中断的 IRQ 通知时,会调用 do_IRQ 来执行中断对应的处理程序。中断处理程序中可能会调度新的软中断,因此在 do_IRQ 结束时处理软中断是一个很自然的设计,也可以有效的降低延迟。此外,内核的时钟中断还保证了两次软中断处理时机之间的最大时间间隔。 大部分架构的内核会在退出中断上下文步骤 irq_exit() 中调用 do_softirq: 1 2 3 4 5 6 7 8 9 10 11 12 13 unsigned int __irq_entry do_IRQ(struct pt_regs *regs) { ...... exit_idle(); irq_enter(); // handle irq with registered handler irq_exit(); set_irq_regs(old_regs); return 1; } 在 irq_exit() 中,如果内核已经退出中断上下文且有待执行的软中断,将调用 invoke_softirq(): 1 2 3 4 5 6 7 8 9 10 11 12 void irq_exit(void) { account_system_vtime(current); trace_hardirq_exit(); sub_preempt_count(IRQ_EXIT_OFFSET); if (!in_interrupt() && local_softirq_pending()) invoke_softirq(); rcu_irq_exit(); preempt_enable_no_resched(); } invoke_softirq 是对 do_softirq 的简单封装: 1 2 3 4 5 6 7 static inline void invoke_softirq(void) { if (!force_irqthreads) do_softirq(); else wakeup_softirqd(); } 从中断和异常事件(包括系统调用)返回时,这部分处理逻辑直接写入了汇编代码。 调用 local_bh_enable 开启软中断时,将执行待处理的软中断。 每个处理器有一个软中断线程 ksoftirqd_CPUn,该线程执行时也会处理软中断。 软中断执行时 CPU 中断是开启的,软中断可以被新的中断挂起。但如果软中断的一个实例已经在一个 CPU 上运行或挂起,内核将禁止该软中断类型的新请求在 CPU 上运行,这样可以大幅减少软中断所需的并发锁。 软中断的处理 当执行软中断的时机达成,内核会执行 do_softirq 函数。 do_softirq 首先会将待执行的软中断保存一份副本。在 do_softirq 运行时,同一个软中断类型有可能被调度多次:运行软中断处理程序时可以被硬件中断抢占,处理中断时期间可以重新设置 cpu 的待处理软中断 bitmap,也就是说,在执行一个待处理的软中断期间,这个软中断可能会被重新调度。出于这个原因,do_softirq 会首先禁用中断,将待处理软中断的 bitmap 保存一份副本到局部变量 pending 中,然后将本地 CPU 的软中断 bitmap 中对应的位重置为 0,随后重新开启中断。最后,基于副本 pending 依次检查每一位是否为 1,如果是则根据软中断类型调用对应的处理程序: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 do { if (pending & 1) { unsigned int vec_nr = h - softirq_vec; int prev_count = preempt_count(); kstat_incr_softirqs_this_cpu(vec_nr); trace_softirq_entry(vec_nr); h->action(h); trace_softirq_exit(vec_nr); if (unlikely(prev_count != preempt_count())) { printk(KERN_ERR "huh, entered softirq %u %s %p" "with preempt_count %08x," " exited with %08x?\n", vec_nr, softirq_to_name[vec_nr], h->action, prev_count, preempt_count()); preempt_count() = prev_count; } rcu_bh_qs(cpu); } h++; pending >>= 1; } while (pending); 等待中的软中断调用次序取决于位图中标志位的位置以及扫描这些标志的方向(由低位到高位),并不是以先进先出的方式执行的。 当所有的处理程序执行完毕后,do_softirq 再次禁用中断,并重新检查 CPU 的待处理中断 bitmap,如果发现又有新的待处理软中断,则再次创建一份副本重新执行上述流程。这种处理流程最多会重复执行 MAX_SOFTIRQ_RESTART 次(通常值为 10),以避免无限抢占 CPU 资源。 当处理轮次到达 MAX_SOFTIRQ_RESTART 阈值时,do_ softirq 必须结束执行,如果此时依然有未执行的软中断,将唤醒 ksoftirqd 线程来处理。但是 do_softirq 在内核中的调用频率很高,实际上后续调用的 do_softirq 可能会在 ksoftirqd 线程被调度之前就处理完了这些软中断。 ksoftirqd 内核线程 每个 CPU 都有一个内核线程 ksoftirqd(通常根据 CPU 序号命名为 ksoftirqd_CPUn),当上文描述的机制无法处理完所有的软中断时,该 CPU 位于后台的 ksoftirqd 线程被唤醒,并承担起在获得调度后尽可能多的处理待执行软中断的职责。 ksoftirqd 关联的任务函数 run_ksoftirqd 如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 static int run_ksoftirqd(void * __bind_cpu) { set_current_state(TASK_INTERRUPTIBLE); while (!kthread_should_stop()) { preempt_disable(); if (!local_softirq_pending()) { preempt_enable_no_resched(); schedule(); preempt_disable(); } __set_current_state(TASK_RUNNING); while (local_softirq_pending()) { /* Preempt disable stops cpu going offline. If already offline, we'll be on wrong CPU: don't process */ if (cpu_is_offline((long)__bind_cpu)) goto wait_to_die; local_irq_disable(); if (local_softirq_pending()) __do_softirq(); local_irq_enable(); preempt_enable_no_resched(); cond_resched(); preempt_disable(); rcu_note_context_switch((long)__bind_cpu); } preempt_enable(); set_current_state(TASK_INTERRUPTIBLE); } __set_current_state(TASK_RUNNING); return 0; wait_to_die: preempt_enable(); /* Wait for kthread_stop */ set_current_state(TASK_INTERRUPTIBLE); while (!kthread_should_stop()) { schedule(); set_current_state(TASK_INTERRUPTIBLE); } __set_current_state(TASK_RUNNING); return 0; } ksoftirqd 做的事情和 do_softirq 基本相同,其主要逻辑是通过 while 循环不断的调用 __do_softirq (该函数也是 do_softirq 的核心逻辑),只有达到以下两种条件时才会停止: 没有待处理的软中断时,此时 ksoftirqd 会调用 schedule() 触发调度主动让出 CPU 资源。 该线程执行完毕被分配的时间分片,被要求让出 CPU 资源等待下一次调度。 ksoftirqd 线程设置的调度优先级很低,同样可以避免软中断较多时抢占过多的 CPU 资源。 网络帧的接收 在 do_softirq 中,内核通过执行 h->action(h); 调用该软中断类型所注册的处理程序,本文仅关注与接收网络帧相关的软中断处理程序。 Linux 的网络系统主要使用以下两种软中断类型: NET_RX_SOFTIRQ 用于处理接收(入站)网络数据 NET_TX_SOFTIRQ 用于处理发送(出栈)网络数据 NET_RX_SOFTIRQ 软中断处理程序接收网络帧的整体流程示意如下: 在了解具体的软中断处理程序之前,我们还需要结合 Linux 的具体实现重新回顾上文介绍过的通知处理机制。 Linux New API (NAPI) 网卡设备每接收到一个二层的网络帧后,使用硬件中断来向 CPU 发出信号,通知其有新的帧需要处理。收到中断的CPU会执行 do_IRQ 函数,调用与硬件中断号关联的处理程序。处理程序通常是由设备驱动程序在初始化时注册的一个函数,这个中断处理程序将在禁用中断模式下执行,使得 CPU 暂时停止接收中断信号。 中断处理程序会执行一些必要的即时任务,并将其他任务调度到下半部中延迟执行。在 Linux 中,具体来说中断处理程序会做这些事情: 将网络帧复制到 sk_buff 数据结构中。 初始化一些 sk_buff 的参数,供上层的网络栈使用。特别是 skb->protocol,它标识了上层的协议处理程序。 更新其他的设备专用参数。 通过调度软中断 NET_RX_SOFTIRQ 来通知内核进一步处理接收帧。 我们上文介绍过轮询和中断通知机制(包括几种改良版本),它们有不同的优缺点,适用不同的工作场景。Linux 在 Linux v2.6 引入了一种混合了轮询和中断的 NAPI 机制来通知并处理新的接收帧。NAPI 在高负载场景下有良好表现,还能显著的节省 CPU 资源。本文将重点介绍 NAPI 机制。 当设备驱动支持 NAPI 时,设备在接收到网络帧后依然使用中断通知内核,但内核在开始处理中断后将禁用来自该设备的中断,并持续地通过轮询方式从设备的输入缓冲区提取接收帧进行处理,直到缓冲区为空时,结束处理程序的执行并重新启用该设备的中断通知。NAPI 结合了轮询和中断的优点: 空闲状态下,内核既不需要浪费资源去做轮询,也能在设备接收到新的网络帧后立刻得到通知。 内核被通知在设备缓冲区有待处理的数据之后,不需要再浪费资源去处理中断,简单通过轮询去处理这些数据即可。 对内核来说,NAPI 有效减少了高负载下需要处理的中断数量,因此降低了 CPU 占用,此外通过轮询地方式去访问设备,也能够减少设备之间的争抢。 内核通过以下数据结构来实现 NAPI: poll:用于从设备的入站队列中出队网络帧的虚拟函数,每个设备都会有一个单独的入站队列。 poll_list: 一个维护处于轮询中状态设备的链表。多个设备可以共用同一个中断信号,因此内核需要轮询多个设备。加入到列表之后来自该设备的中断将被禁用。 quota 和 weight:内核通过这两个值来控制每次从设备中出队数据的数量,quota 数量越小意味不同设备的数据帧更有机会得到公平的处理机会,但内核会花费更多的时间在设备之前切换,反之依然。 当设备发送中断信号且被接收之后,内核执行该设备驱动注册的中断处理程序。中断处理程序将调用 napi_schedule 来调度轮询程序的执行。在 napi_schedule 中,如果发送中断的设备未在 CPU 的 poll_list 中,内核将其加入到 poll_list,并通过 __raise_softirq_irqoff 触发 NET_RX_SOFTIRQ 软中断的调度。其主要逻辑位于 ____napi_schedule 中: 1 2 3 4 5 6 7 /* Called with irq disabled */ static inline void ____napi_schedule(struct softnet_data *sd, struct napi_struct *napi) { list_add_tail(&napi->poll_list, &sd->poll_list); __raise_softirq_irqoff(NET_RX_SOFTIRQ); } 输入队列 每个 CPU 都有一个存放接收网络帧的输入队列 input_pkt_queue,这个队列位于 softnet_data 结构中: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 struct softnet_data { struct Qdisc *output_queue; struct Qdisc **output_queue_tailp; struct list_head poll_list; struct sk_buff *completion_queue; struct sk_buff_head process_queue; /* stats */ unsigned int processed; unsigned int time_squeeze; unsigned int cpu_collision; unsigned int received_rps; unsigned dropped; struct sk_buff_head input_pkt_queue; struct napi_struct backlog; }; 但并不是所有的网卡设备驱动都会使用这个输入队列,对于使用 NAPI 机制的网卡,每个设备都有一个单独的输入队列。这个输入队列可能位于设备内存,也可能是主内存中的接收环。 接收帧软中断处理程序 NET_RX_SOFTIRQ 的处理程序是 net_rx_action。其部分代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 static void net_rx_action(struct softirq_action *h) { struct softnet_data *sd = &__get_cpu_var(softnet_data); unsigned long time_limit = jiffies + 2; int budget = netdev_budget; local_irq_disable(); while (!list_empty(&sd->poll_list)) { struct napi_struct *n; int work, weight; // If softirq window is exhuasted or has run for 2 jiffies then exit if (unlikely(budget <= 0 || time_after(jiffies, time_limit))) goto softnet_break; // Acces current first entry in poll_list n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list); weight = n->weight; work = 0; if (test_bit(NAPI_STATE_SCHED, &n->state)) { work = n->poll(n, weight); trace_napi_poll(n); } budget -= work; // remove device or move device to tail if (unlikely(work == weight)) { if (unlikely(napi_disable_pending(n))) { local_irq_enable(); napi_complete(n); local_irq_disable(); } else list_move_tail(&n->poll_list, &sd->poll_list); } } out: net_rps_action_and_irq_enable(sd); return; // schedule again before exit softnet_break: sd->time_squeeze++; __raise_softirq_irqoff(NET_RX_SOFTIRQ); goto out; } 当 net_rx_action 被调度执行后: 从头开始遍历 poll_list 链表中的设备,调用设备的 poll 虚拟函数处理入站队列中的数据帧。我们将在下一节介绍该虚拟函数。 poll 被调用时所处理的数据帧数量到达最大阈值后,即使该设备的入站队列还未被清空,也会将该设备移动到 poll_list 的尾部,转而去处理 poll_list 中的下一个设备。 如果设备的入站队列被清空,调用 napi_complete 将设备移出 poll_list 并开启该设备的中断通知。 一直执行该流程直到 poll_list 被清空,或者 net_rx_action 执行完了足够的时间片(为了不过多占用 CPU 资源),这种情况退出前 net_rx_action 会重新调度自己的下一次执行。 Poll 虚拟函数 在设备驱动的初始化过程中,设备会将 dev->poll 指向由驱动提供的自定义函数,因此不同驱动会使用不同的 poll 函数。我们将介绍由 Linux 提供的默认 poll 函数 process_backlog,它的工作方式与大多数驱动的 poll 函数相似,其主要的区别在于,process_backlog 工作时不会禁用中断,由于非 NAPI 设备使用一个共享的输入队列,因此从输入队列中出栈数据帧时需要临时禁用中断以实现加锁;而 NAPI 设备使用单独的入站队列,且加入 poll_list 的设备会被单独禁用中断,因此在 poll 时不需要考虑加锁的问题。 process_backlog 执行时,首先计算出该设备的 quota。然后进入下面的循环流程: 禁用中断,从该 CPU 关联的输入队列中出栈数据帧,然后重新启用中断。 如果出栈时发现输入队列已空,则将该设备移出 poll_list,并结束执行。 如果输入队列不为空,调用 netif_receive_skb(skb) 处理被出栈的数据帧,我们将在下一节介绍该函数。 检查以下条件,如果未满足条件则跳转到步骤 1 继续循环: 如果已出栈的数据帧数量达到该设备的 quota 值,结束执行。 如果已执行完了足够的 CPU 时间片,结束执行。 处理接收帧 netif_receive_skb 是 poll 虚拟函数用于处理接收帧的工具函数,它主要调用了 __netif_receive_skb(skb); 对数据帧依次进行一系列处理工作: 处理数据帧的 bond 功能。Linux 能够将一组设备聚合成一个 bond 设备,数据帧在进入三层处理之前,会在此将其接收设备 skb->dev 更改为 bond 中的主设备。 传递一份数据帧副本给已注册的各个协议的嗅探程序。 处理一些需要在二层完成的功能,包括桥接。如果数据帧不需要桥接,继续向下执行。 传递一份数据帧副本给 skb->protocol 对应的且已注册的三层协议处理程序。至此数据帧进入内核网络栈的更上层。 如果没有找到对应的协议处理程序或者未被桥接等功能消费,数据帧将被内核丢弃。 通常来说,三层协议处理程序会对数据帧作如下处理: 将它们传递给网络协议栈中更上层的协议如 TCP, UDP, ICMP,最后传递给应用进程。 在 netfilter 等数据帧处理框架中被丢弃。 如果数据帧的目的地不是本地主机,将被转发到其他机器。 对 Linux 如何接收网络帧的讨论到此结束,如果对数据帧在三层网络栈的处理流程感兴趣,可查看作者的另一篇文章 深入理解 netfilter 和 iptables。 参考链接 Understanding Linux network internals-Christian Benvenuti -O'Reilly Media Linux 内核深度解析 - 余华兵

2022/4/25
articleCard.readMore

为 Kubernetes 集群启用 Pod 安全策略

最近有客户反馈在开启了安全策略的集群中部署产品失败,因此研究了一下 Kubernetes 提供的 pod 安全策略。 文中的演示和示例均在 v1.18.17 集群中通过验证。 Pod Security Policies Pod Security Policies (下文简称 psp 或 pod 安全策略)是一种集群级别的全局资源,能够对 pod 的创建和更新进行细粒度的授权控制。具体来说,一个 psp 对象定义了一组安全性条件,一个 pod 的 spec 字段必须满足这些条件以及适用相关字段的默认值,其创建或更新请求才会被 apiserver 所接受。 具体的 pod 字段和安全条件可见文档 what-is-a-pod-security-policy 。 启用 Pod Security Policies Kubernetes 默认不开启 pod 安全策略功能,在集群中启用 pod 安全策略的步骤大体上分为三步: 在集群中创建指定的安全策略资源。 通过 RBAC 机制授予创建 pod 的 user 或者被创建 pod 的 service account 使用安全策略资源的权限,通常会将使用权限授予一组 users 或 service accounts。 启用 apiserver 的 admission-controller 插件。 注意步骤 1、2 可以单独执行,因为它们不会对集群产生实际影响,但需要确保步骤 3 在前两步之后执行。 因为一旦启用 admission-controller 插件,apiserver 会对所有的 pod 创建/更新请求强制执行安全策略检查,如果集群中没有可用的 pod 安全策略资源或者未对安全策略资源预先授权,所有的 pod 创建/更新请求都会被拒绝。包括 kube-system 命名空间下的系统管理组件如 apiserver 本身(由于 apiserver 是受 kubelet 管理的静态 pod,实际上容器依然会运行)。 启用的整体流程如下示意图: 创建安全策略资源 在集群中创建一个宽松限制的 PodSecurityPolicy 资源,命名为 privileged。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 apiVersion: policy/v1beta1 kind: PodSecurityPolicy metadata: name: privileged annotations: seccomp.security.alpha.kubernetes.io/allowedProfileNames: '*' spec: privileged: true allowPrivilegeEscalation: true allowedCapabilities: - '*' volumes: - '*' hostNetwork: true hostPorts: - min: 0 max: 65535 hostIPC: true hostPID: true runAsUser: rule: 'RunAsAny' seLinux: rule: 'RunAsAny' supplementalGroups: rule: 'RunAsAny' fsGroup: rule: 'RunAsAny' 在集群中创建一个严格限制的 PodSecurityPolicy 资源,命名为 restricted。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 apiVersion: policy/v1beta1 kind: PodSecurityPolicy metadata: name: restricted annotations: seccomp.security.alpha.kubernetes.io/allowedProfileNames: 'docker/default,runtime/default' apparmor.security.beta.kubernetes.io/allowedProfileNames: 'runtime/default' apparmor.security.beta.kubernetes.io/defaultProfileName: 'runtime/default' spec: privileged: false # Required to prevent escalations to root. allowPrivilegeEscalation: false requiredDropCapabilities: - ALL # Allow core volume types. volumes: - 'configMap' - 'emptyDir' - 'projected' - 'secret' - 'downwardAPI' # Assume that ephemeral CSI drivers & persistentVolumes set up by the cluster admin are safe to use. - 'csi' - 'persistentVolumeClaim' hostNetwork: false hostIPC: false hostPID: false runAsUser: # Require the container to run without root privileges. rule: 'MustRunAsNonRoot' seLinux: # This policy assumes the nodes are using AppArmor rather than SELinux. rule: 'RunAsAny' supplementalGroups: rule: 'MustRunAs' ranges: # Forbid adding the root group. - min: 1 max: 65535 fsGroup: rule: 'MustRunAs' ranges: # Forbid adding the root group. - min: 1 max: 65535 readOnlyRootFilesystem: false RBAC 身份认证 分别创建可访问两种安全策略资源的 ClusterRole: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: privileged-psp rules: - apiGroups: ['policy'] resources: ['podsecuritypolicies'] verbs: ['use'] resourceNames: - privileged --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: restricted-psp rules: - apiGroups: ['policy'] resources: ['podsecuritypolicies'] verbs: ['use'] resourceNames: - restricted 通过 ClusterRoleBinding (或者 RoleBinding)将创建的 ClusterRole 绑定到指定命名空间下的所有 service account(也可以授权给指定的 user)。 在 Kubernetes 中大多数 pod 并不是直接使用 user 创建的,而是通常作为 Deployment、ReplicaSet 或其他模板 controller 的子资源,由 controller 间接创建。授予 controller 用户对安全策略的使用权等同于为该 controller 创建的所有 pod 授予使用权,因此授权的推荐做法是授权给目标 pod 的 service account。 为了进行后续的测试,我们将 privileged-psp 授权给 kubelet 所使用的 system:nodes 用户和 privileged-ns 命名空间下的所有 service account,将 restricted-psp 授权给 restricted-ns 命名空间下的所有 service account: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: privileged-psp-bind roleRef: kind: ClusterRole name: privileged-psp apiGroup: rbac.authorization.k8s.io subjects: # 授权给指定命名空间下的所有 service account(推荐做法): - kind: Group apiGroup: rbac.authorization.k8s.io name: system:serviceaccounts:privileged-ns - kind: Group apiGroup: rbac.authorization.k8s.io name: system:nodes namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: restricted-psp-bind roleRef: kind: ClusterRole name: restricted-psp apiGroup: rbac.authorization.k8s.io subjects: - kind: Group apiGroup: rbac.authorization.k8s.io name: system:serviceaccounts:restricted-ns 在 subjects 字段下添加更多记录还可以授权给所有的 service account 或者所有已授权的 user: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 subjects: # 授权给指定的 service account 或者用户(不推荐): - kind: ServiceAccount name: <authorized service account name> namespace: <authorized pod namespace> - kind: User apiGroup: rbac.authorization.k8s.io name: <authorized user name> # 授权给所有的 service accounts: - kind: Group apiGroup: rbac.authorization.k8s.io name: system:serviceaccounts # 授权给所有已认证的用户: - kind: Group apiGroup: rbac.authorization.k8s.io name: system:authenticated 启用 admission controller 插件 在 apiserver 启用 admission controller 的 psp 插件有两种方式: 在已存在的集群中通过修改 apiserver 的静态 manifest 文件,为 apiserver 增加启动参数 enable-admission-plugins=PodSecurityPolicy。kubelet 会自动检测到变更并重启 apiserver。下面的示例使用 sed 对原有参数进行了替换: 1 sed -i 's/enable-admission-plugins=NodeRestriction/enable-admission-plugins=NodeRestriction,PodSecurityPolicy/' /etc/kubernetes/manifests/kube-apiserver.yaml 或者在初始化集群时,在 kubeadm 配置文件中添加额外参数(不推荐,默认会拒绝所有 pod 的创建)。 1 2 3 4 5 apiVersion: kubeadm.k8s.io/v1beta2 kind: ClusterConfiguration apiServer: extraArgs: enable-admission-plugins: "PodSecurityPolicy" 验证 psp 的安全限制 接下分别在上文授权过的 privileged-ns 和 restricted-ns 命名空间进行测试,验证 psp 对 pod 请求的限制。 首先尝试在 restricted-ns 命名空间通过 deployment 创建一个需要使用 hostNetwork 的 pod: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 apiVersion: apps/v1 kind: Deployment metadata: name: nginx-hostnetwork spec: selector: matchLabels: run: nginx template: metadata: labels: run: nginx spec: hostNetwork: true containers: - image: nginx imagePullPolicy: Always name: nginx-privileged 创建并查看结果: 1 2 3 4 5 6 7 $ kubectl create -f hostnetwork-pod.yaml -n restricted-ns deployment.apps/nginx-hostnetwork created $ kubectl get deploy -n restricted-ns nginx-hostnetwork NAME READY UP-TO-DATE AVAILABLE AGE nginx-hostnetwork 0/1 1 0 21s $ kubectl -n restricted-ns get event | grep "pod security policy" 103s Warning FailedCreate deployment/nginx-hostnetwork Error creating: pods "nginx-hostnetwork-" is forbidden: unable to validate against any pod security policy: [spec.securityContext.hostNetwork: Invalid value: true: Host network is not allowed to be used] 由于授权给该命名空间 service account 的安全策略资源禁止 pod 使用 hostNetwork,因此该 deployment 创建 pod 的请求被拒绝。 接着在 privileged-ns 命名空间执行相同的操作: 1 2 3 4 5 6 7 8 9 10 $ kubectl create -f deploy.yaml -n privileged-ns deployment.apps/nginx-hostnetwork created $ kubectl get deploy -n privileged-ns nginx-hostnetwork NAME READY UP-TO-DATE AVAILABLE AGE nginx-hostnetwork 1/1 1 1 34s $ kubectl get po -n privileged-ns NAME READY STATUS RESTARTS AGE nginx-hostnetwork-644cdd6598-twds9 0/1 Error 3 77s $ kubectl get pod nginx-hostnetwork-644cdd6598-twds9 -o jsonpath='{.metadata.annotations}' -n privileged-ns map[kubernetes.io/psp:privileged] 授权给该命名空间 service account 的安全策略资源允许 pod 使用 hostNetwork,因此 pod 成功被创建。我们可以通过 pod 的 metadata.annotations 字段检查其适用的安全策略资源。 Pod Security Admission 从 Kubernetes v1.21开始,Pod Security Policy 将被弃用,并将在 v1.25 中删除,Kubernetes 在 1.22 版本引入了 Pod Security Admission 作为其替代者。 为什么要替换 psp KEP-2579 详细阐述了引入 Pod Security Admission 替代 Pod Security Policy 的三点主要理由: 将策略与用户或 service account 绑定的模型削弱了安全性。 功能无法流畅切换,在没有安全策略资源的情况下无法关闭检查。 API 不一致且缺乏灵活性。 新的 Pod Security Admission 机制在易用性和灵活性上都有了很大提升,从使用角度有以下四点显著不同: 可以在集群中默认开启,只要不设置约束条件就不会触发对 pod 的校验。 只在命名空间级别生效,可以为不同命名空间通过添加标签的方式设置不同的安全限制。 可以为特定的用户、命名空间或者运行时设置豁免规则。 根据实践预设了三种安全等级,不需要由用户单独去设置每一项安全条件。 工作方式 Pod Security Admission 将原来 Pod Security Policy 的安全条件划分成三种预设的安全等级: privileged: 不受限,向 pod 提供所有可用的权限。 baseline:最低限度的限制策略,防止已知的特权升级。 restricted:严格限制策略,遵循当前 Pod 加固的最佳实践。 三种等级从宽松到严格递增,各自包含了不同限度的安全条件,适用于不同的 pod 工作场景。此外还可以将安全等级设置为固定的 Kubernetes 版本,这样即使集群升级到了新的版本且新版本的安全等级定义发生变化,依然可以按旧版本的安全条件对 pod 进行检验。 当 pod 与安全等级冲突时,我们可通过三种模式来选择不同的处理方式: enforce:只允许符合安全等级要求的 pod,拒绝与安全等级冲突的 pod。 audit:只将安全等级冲突记录在集群 event 中,不会拒绝 pod。 warn:与安全等级冲突时会向用户返回一个警告信息,但不会拒绝 pod。 audit 和 warn 模式是独立的,如果同时需要两者的功能必须分别设置两种模式。 应用安全策略不再需要创建单独的集群资源,只需在启用 Pod Security Admission 后为命名空间设置如下控制标签: 1 2 pod-security.kubernetes.io/<mode>: <level> pod-security.kubernetes.io/<mode>-version: <version> 在旧版本集群中启用 psa 虽然 Pod Security Admission 是一个在 Kubernetes v1.22 引入的功能,但旧版本可以通过安装 PodSecurity admission webhook 来启用该功能,具体步骤如下: 1 2 3 4 git clone https://github.com/kubernetes/pod-security-admission.git cd pod-security-admission/webhook make certs kubectl apply -k . 以上来自官方文档的步骤在 v1.18.17 集群中执行时会有两个兼容性问题,具体问题和解决方案如下: kubectl 内置的 kustomize 版本不支持 "replacements" 字段: $ kubectl apply -k . error: json: unknown field "replacements" 解决方案:安装最新版本的 kusomize 然后在同一目录执行 1 $ kustomize build . | kubectl apply -f - manifest/50-deployment.yaml 文件中定义的 Deployment.spec.template.spec.containers[0].securityContext 字段在 v1.19 版本才开始引入,因此 v1.18 需要将该字段修改为对应的 annotation 版本,详见 Seccomp: error: error validating "STDIN": error validating data: ValidationError(Deployment.spec.template.spec.containers[0].securityContext): unknown field "seccompProfile" in io.k8s.api.core.v1.SecurityContext; if you choose to ignore these errors, turn validation off with --validate=false 验证 psa 的安全限制 首先创建一个新的命名空间 psa-test 用于测试,并将其定义强制应用 baseline 安全等级,并对 restricted 等级进行警告和审计: 1 2 3 4 5 6 7 8 9 10 11 12 13 apiVersion: v1 kind: Namespace metadata: name: psa-test labels: pod-security.kubernetes.io/enforce: baseline pod-security.kubernetes.io/enforce-version: v1.18 # We are setting these to our _desired_ `enforce` level. pod-security.kubernetes.io/audit: restricted pod-security.kubernetes.io/audit-version: v1.18 pod-security.kubernetes.io/warn: restricted pod-security.kubernetes.io/warn-version: v1.18 接着在该命名空间中创建上文示例中用过的 deployment: 1 2 3 4 5 6 7 $ kubectl create -f hostnetwork-pod.yaml -n psa-test deployment.apps/nginx-hostnetwork created $ kubectl get deploy -n psa-test nginx-hostnetwork NAME READY UP-TO-DATE AVAILABLE AGE nginx-hostnetwork 0/1 0 0 17s $ kubectl -n psa-test get event | grep PodSecurity 104s Warning FailedCreate replicaset/nginx-hostnetwork-644cdd6598 Error creating: admission webhook "pod-security-webhook.kubernetes.io" denied the request: pods "nginx-hostnetwork-644cdd6598-7rb5m" is forbidden: violates PodSecurity "baseline:v1.23": host namespaces (hostNetwork=true) 与 psp 的示例相比,psa 实现了基本一致的安全检查结果,但易用程度有了很大提升。 参考链接 KEP-2579: Pod Security Admission Control Pod Security Admission | Kubernetes Pod Security Standards | Kubernetes Kubernetes: Assigning Pod Security Policies with RBAC An illustrated deepdive into Pod Security Policies · Banzai Cloud

2022/3/30
articleCard.readMore

深入理解 netfilter 和 iptables

Netfilter (配合 iptables)使得用户空间应用程序可以注册内核网络栈在处理数据包时应用的处理规则,实现高效的网络转发和过滤。很多常见的主机防火墙程序以及 Kubernetes 的 Service 转发都是通过 iptables 来实现的。 关于 netfilter 的介绍文章大部分只描述了抽象的概念,实际上其内核代码的基本实现不算复杂,本文主要参考 Linux 内核 2.6 版本代码(早期版本较为简单),与最新的 5.x 版本在实现上可能有较大差异,但基本设计变化不大,不影响理解其原理。 本文假设读者已对 TCP/IP 协议有基本了解。 Netfilter 的设计与实现 netfilter 的定义是一个工作在 Linux 内核的网络数据包处理框架,为了彻底理解 netfilter 的工作方式,我们首先需要对数据包在 Linux 内核中的处理路径建立基本认识。 数据包的内核之旅 数据包在内核中的处理路径,也就是处理网络数据包的内核代码调用链,大体上也可按 TCP/IP 模型分为多个层级,以接收一个 IPv4 的 tcp 数据包为例: 在物理-网络设备层,网卡通过 DMA 将接收到的数据包写入内存中的 ring buffer,经过一系列中断和调度后,操作系统内核调用 __skb_dequeue 将数据包加入对应设备的处理队列中,并转换成 sk_buffer 类型(即 socket buffer - 将在整个内核调用栈中持续作为参数传递的基础数据结构,下文指称的数据包都可以认为是 sk_buffer),最后调用 netif_receive_skb 函数按协议类型对数据包进行分类,并跳转到对应的处理函数。如下图所示: 假设该数据包为 IP 协议包,对应的接收包处理函数 ip_rcv 将被调用,数据包处理进入网络(IP)层。ip_rcv 检查数据包的 IP 首部并丢弃出错的包,必要时还会聚合被分片的 IP 包。然后执行 ip_rcv_finish 函数,对数据包进行路由查询并决定是将数据包交付本机还是转发其他主机。假设数据包的目的地址是本主机,接着执行的 dst_input 函数将调用 ip_local_deliver 函数。ip_local_deliver 函数中将根据 IP 首部中的协议号判断载荷数据的协议类型,最后调用对应类型的包处理函数。本例中将调用 TCP 协议对应的 tcp_v4_rcv 函数,之后数据包处理进入传输层。 tcp_v4_rcv 函数同样读取数据包的 TCP 首部并计算校验和,然后在数据包对应的 TCP control buffer 中维护一些必要状态包括 TCP 序列号以及 SACK 号等。该函数下一步将调用 __tcp_v4_lookup 查询数据包对应的 socket,如果没找到或 socket 的连接状态处于 TCP_TIME_WAIT,数据包将被丢弃。如果 socket 处于未加锁状态,数据包将通过调用 tcp_prequeue 函数进入 prequeue 队列,之后数据包将可被用户态的用户程序所处理。传输层的处理流程超出本文讨论范围,实际上还要复杂很多。 netfilter hooks 接下来我们正式进入主题。netfilter 的首要组成部分是 netfilter hooks。 hook 触发点 对于不同的协议(IPv4、IPv6 或 ARP 等),Linux 内核网络栈会在该协议栈数据包处理路径上的预设位置触发对应的 hook。在不同协议处理流程中的触发点位置以及对应的 hook 名称(蓝色矩形外部的黑体字)如下,本文仅重点关注 IPv4 协议: 所谓的 hook 实质上是代码中的枚举对象(值为从0开始递增的整型): 1 2 3 4 5 6 7 8 enum nf_inet_hooks { NF_INET_PRE_ROUTING, NF_INET_LOCAL_IN, NF_INET_FORWARD, NF_INET_LOCAL_OUT, NF_INET_POST_ROUTING, NF_INET_NUMHOOKS }; 每个 hook 在内核网络栈中对应特定的触发点位置,以 IPv4 协议栈为例,有以下 netfilter hooks 定义: NF_INET_PRE_ROUTING: 这个 hook 在 IPv4 协议栈的 ip_rcv 函数或 IPv6 协议栈的 ipv6_rcv 函数中执行。所有接收数据包到达的第一个 hook 触发点(实际上新版本 Linux 增加了 INGRESS hook 作为最早触发点),在进行路由判断之前执行。 NF_INET_LOCAL_IN: 这个 hook 在 IPv4 协议栈的 ip_local_deliver() 函数或 IPv6 协议栈的 ip6_input() 函数中执行。经过路由判断后,所有目标地址是本机的接收数据包到达此 hook 触发点。 NF_INET_FORWARD: 这个 hook 在 IPv4 协议栈的 ip_forward() 函数或 IPv6 协议栈的 ip6_forward() 函数中执行。经过路由判断后,所有目标地址不是本机的接收数据包到达此 hook 触发点。 NF_INET_LOCAL_OUT: 这个 hook 在 IPv4 协议栈的 __ip_local_out() 函数或 IPv6 协议栈的 __ip6_local_out() 函数中执行。所有本机产生的准备发出的数据包,在进入网络栈后首先到达此 hook 触发点。 NF_INET_POST_ROUTING: 这个 hook 在 IPv4 协议栈的 ip_output() 函数或 IPv6 协议栈的 ip6_finish_output2() 函数中执行。本机产生的准备发出的数据包或者转发的数据包,在经过路由判断之后, 将到达此 hook 触发点。 NF_HOOK 宏和 netfilter 向量 所有的触发点位置统一调用 NF_HOOK 这个宏来触发 hook: 1 2 3 4 5 6 static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct sk_buff *skb, struct net_device *in, struct net_device *out, int (*okfn)(struct sk_buff *)) { return NF_HOOK_THRESH(pf, hook, skb, in, out, okfn, INT_MIN); } NF-HOOK 接收的参数如下: pf: 数据包的协议族,对 IPv4 来说是 NFPROTO_IPV4。 hook: 上图中所示的 netfilter hook 枚举对象,如 NF_INET_PRE_ROUTING 或NF_INET_LOCAL_OUT。 skb: SKB 对象,表示正在被处理的数据包。 in: 数据包的输入网络设备。 out: 数据包的输出网络设备。 okfn: 一个指向函数的指针,该函数将在该 hook 即将终止时调用,通常传入数据包处理路径上的下一个处理函数。 NF-HOOK 的返回值是以下具有特定含义的 netfilter 向量之一: NF_ACCEPT: 在处理路径上正常继续(实际上是在 NF-HOOK 中最后执行传入的 okfn)。 NF_DROP: 丢弃数据包,终止处理。 NF_STOLEN: 数据包已转交,终止处理。 NF_QUEUE: 将数据包入队后供其他处理。 NF_REPEAT: 重新调用当前 hook。 回归到源码,IPv4 内核网络栈会在以下代码模块中调用 NF_HOOK(): 实际调用方式以 net/ipv4/ip_forward.c 对数据包进行转发的源码为例,在 ip_forward 函数结尾部分的第 115 行以 NF_INET_FORWARD hook 作为入参调用了 NF_HOOK 宏,并将网络栈接下来的处理函数 ip_forward_finish 作为 okfn 参数传入**:** 1 2 3 4 5 6 7 8 9 10 11 12 int ip_forward(struct sk_buff *skb) { .....(省略部分代码) if (rt->rt_flags&RTCF_DOREDIRECT && !opt->srr && !skb_sec_path(skb)) ip_rt_send_redirect(skb); skb->priority = rt_tos2priority(iph->tos); return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, skb, skb->dev, rt->dst.dev, ip_forward_finish); .....(省略部分代码) } 回调函数与优先级 netfilter 的另一组成部分是 hook 的回调函数。内核网络栈既使用 hook 来代表特定触发位置,也使用 hook (的整数值)作为数据索引来访问触发点对应的回调函数。 内核的其他模块可以通过 netfilter 提供的 api 向指定的 hook 注册回调函数,同一 hook 可以注册多个回调函数,通过注册时指定的 priority 参数可指定回调函数在执行时的优先级。 注册 hook 的回调函数时,首先需要定义一个 nf_hook_ops 结构(或由多个该结构组成的数组),其定义如下: 1 2 3 4 5 6 7 8 9 10 11 struct nf_hook_ops { struct list_head list; /* User fills in from here down. */ nf_hookfn *hook; struct module *owner; u_int8_t pf; unsigned int hooknum; /* Hooks are ordered in ascending priority. */ int priority; }; 在定义中有 3 个重要成员: hook: 将要注册的回调函数,函数参数定义与 NF_HOOK 类似,可通过 okfn 参数嵌套其他函数。 hooknum: 注册的目标 hook 枚举值。 priority: 回调函数的优先级,较小的值优先执行。 定义结构体后可通过 int nf_register_hook(struct nf_hook_ops *reg) 或 int nf_register_hooks(struct nf_hook_ops *reg, unsigned int n); 分别注册一个或多个回调函数。同一 netfilter hook 下所有的 nf_hook_ops 注册后以 priority 为顺序组成一个链表结构,注册过程会根据 priority 从链表中找到合适的位置,然后执行链表插入操作。 在执行 NF-HOOK 宏触发指定的 hook 时,将调用 nf_iterate 函数迭代这个 hook 对应的 nf_hook_ops 链表,并依次调用每一个 nf_hook_ops 的注册函数成员 hookfn。示意图如下: 这种链式调用回调函数的工作方式,也让 netfilter hook 被称为 Chain,下文的 iptables 介绍中尤其体现了这一关联。 每个回调函数也必须返回一个 netfilter 向量;如果该向量为 NF_ACCEPT,nf_iterate 将会继续调用下一个 nf_hook_ops 的回调函数,直到所有回调函数调用完毕后返回 NF_ACCEPT;如果该向量为 NF_DROP,将中断遍历并直接返回 NF_DROP;如果该向量为 NF_REPEAT,将重新执行该回调函数。 nf_iterate 的返回值也将作为 NF-HOOK 的返回值,网络栈将根据该向量值判断是否继续执行处理函数。示意图如下: netfilter hook 的回调函数机制具有以下特性: 回调函数按优先级依次执行,只有上一回调函数返回 NF_ACCEPT 才会继续执行下一回调函数。 任一回调函数都可以中断该 hook 的回调函数执行链,同时要求整个网络栈中止对数据包的处理。 iptables 基于内核 netfilter 提供的 hook 回调函数机制,netfilter 作者 Rusty Russell 还开发了 iptables,实现在用户空间管理应用于数据包的自定义规则。 iptbles 分为两部分: 用户空间的 iptables 命令向用户提供访问内核 iptables 模块的管理界面。 内核空间的 iptables 模块在内存中维护规则表,实现表的创建及注册。 内核空间模块 xt_table 的初始化 在内核网络栈中,iptables 通过 xt_table 结构对众多的数据包处理规则进行有序管理,一个 xt_table 对应一个规则表,对应的用户空间概念为 table。不同的规则表有以下特征: 对不同的 netfilter hooks 生效。 在同一 hook 中检查不同规则表的优先级不同。 基于规则的最终目的,iptables 默认初始化了 4 个不同的规则表,分别是 raw、 filter、nat 和 mangle。下文以 filter 为例介绍 xt_table的初始化和调用过程。 filter table 的定义如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 #define FILTER_VALID_HOOKS ((1 << NF_INET_LOCAL_IN) | \ (1 << NF_INET_FORWARD) | \ (1 << NF_INET_LOCAL_OUT)) static const struct xt_table packet_filter = { .name = "filter", .valid_hooks = FILTER_VALID_HOOKS, .me = THIS_MODULE, .af = NFPROTO_IPV4, .priority = NF_IP_PRI_FILTER, }; (net/ipv4/netfilter/iptable_filter.c) 在 iptable_filter.c 模块的初始化函数 [iptable_filter_init](https://elixir.bootlin.com/linux/v2.6.39.4/C/ident/iptable_filter_init) ****中,调用xt_hook_link 对 xt_table 结构 packet_filter 执行如下初始化过程: 通过 .valid_hooks 属性迭代 xt_table 将生效的每一个 hook,对于 filter 来说是 NF_INET_LOCAL_IN,NF_INET_FORWARD 和 NF_INET_LOCAL_OUT 这3个hook。 对每一个 hook,使用 xt_table 的 priority 属性向 hook 注册一个回调函数。 不同 table 的 priority 值如下: 1 2 3 4 5 6 7 8 enum nf_ip_hook_priorities { NF_IP_PRI_RAW = -300, NF_IP_PRI_MANGLE = -150, NF_IP_PRI_NAT_DST = -100, NF_IP_PRI_FILTER = 0, NF_IP_PRI_SECURITY = 50, NF_IP_PRI_NAT_SRC = 100, }; 当数据包到达某一 hook 触发点时,会依次执行不同 table 在该 hook 上注册的所有回调函数,这些回调函数总是根据上文的 priority 值以固定的相对顺序执行: ipt_do_table() filter 注册的 hook 回调函数 iptable_filter_hook 将对 xt_table 结构执行公共的规则检查函数 ipt_do_table。ipt_do_table 接收 skb、hook 和 xt_table作为参数,对 skb 执行后两个参数所确定的规则集,返回 netfilter 向量作为回调函数的返回值。 在深入规则执行过程前,需要先了解规则集如何在内存中表示。每一条规则由 3 部分组成: 一个 ipt_entry 结构体。通过 .next_offset 指向下一个 ipt_entry 的内存偏移地址。 0 个或多个 ipt_entry_match 结构体,每个结构体可以动态的添加额外数据。 1 个 ipt_entry_target 结构体, 结构体可以动态的添加额外数据。 ipt_entry 结构体定义如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 struct ipt_entry { struct ipt_ip ip; unsigned int nfcache; /* ipt_entry + matches 在内存中的大小*/ u_int16_t target_offset; /* ipt_entry + matches + target 在内存中的大小 */ u_int16_t next_offset; /* 跳转后指向前一规则 */ unsigned int comefrom; /* 数据包计数器 */ struct xt_counters counters; /* 长度为0数组的特殊用法,作为 match 的内存地址 */ unsigned char elems[0]; }; ipt_do_table 首先根据 hook 类型以及 xt_table.private.entries 属性跳转到对应的规则集内存区域,执行如下过程: 首先检查数据包的 IP 首部与第一条规则 ipt_entry 的 .ipt_ip 属性是否一致,如不匹配根据 next_offset 属性跳转到下一条规则。 若IP 首部匹配 ,则开始依次检查该规则所定义的所有 ipt_entry_match 对象,与对象关联的匹配函数将被调用,根据调用返回值有返回到回调函数(以及是否丢弃数据包)、跳转到下一规则或继续检查等结果。 所有检查通过后读取 ipt_entry_target,根据其属性返回 netfilter 向量到回调函数、继续下一规则或跳转到指定内存地址的其他规则,非标准 ipt_entry_target 还会调用被绑定的函数,但只能返回向量值不能跳转其他规则。 灵活性和更新时延 以上数据结构与执行方式为 iptables 提供了强大的扩展能力,我们可以灵活地自定义每条规则的匹配条件并根据结果执行不同行为,甚至还能在额外的规则集之间栈式跳转。 由于每条规则长度不等、内部结构复杂,且同一规则集位于连续的内存空间,iptables 使用全量替换的方式来更新规则,这使得我们能够从用户空间以原子操作来添加/删除规则,但非增量式的规则更新会在规则数量级较大时带来严重的性能问题:假如在一个大规模 Kubernetes 集群中使用 iptables 方式实现 Service,当 service 数量较多时,哪怕更新一个 service 也会整体修改 iptables 规则表。全量提交的过程会 kernel lock 进行保护,因此会有很大的更新时延。 用户空间的 tables、chains 和 rules 用户空间的 iptables 命令行可以读取指定表的数据并渲染到终端,添加新的规则(实际上是替换整个 table 的规则表)等。 iptables 主要操作以下几种对象: table:对应内核空间的 xt_table 结构,iptable 的所有操作都对指定的 table 执行,默认为 filter。 chain:对应指定 table 通过特定 netfilter hook 调用的规则集,此外还可以自定义规则集,然后从 hook 规则集中跳转过去。 rule:对应上文中 ipt_entry、ipt_entry_match 和 ipt_entry_target,定义了对数据包的匹配规则以及匹配后执行的行为。 match:具有很强扩展性的自定义匹配规则。 target:具有很强扩展性的自定义匹配后行为。 基于上文介绍的代码调用过程流程,chain 和 rule 按如下示意图执行: 对于 iptables 具体的用法和指令本文不做详细介绍,可参考 Iptables Essentials: Common Firewall Rules and Commands | DigitalOcean。 conntrack 仅仅通过3、4层的首部信息对数据包进行过滤是不够的,有时候还需要进一步考虑连接的状态。netfilter 通过另一内置模块 conntrack 进行连接跟踪(connection tracking),以提供根据连接过滤、地址转换(NAT)等更进阶的网络过滤功能。由于需要对连接状态进行判断,conntrack 在整体机制相同的基础上,又针对协议特点有单独的实现。 本来打算继续介绍 conntrack 和 NAT,但考虑到篇幅过长遂作罢,感兴趣的读者推荐阅读连接跟踪(conntrack):原理、应用及 Linux 内核实现。 参考链接 netfilter.h - include/linux/netfilter.h - Linux source code (v2.6.39.4) - Bootlin Network_stack.pdf Linux netfilter Hacking HOWTO Linux Kernel Networking: Implementation and Theory - Chapter 9: Netfilter Netfilter framework providing hooks system for Nftables A Deep Dive into Iptables and Netfilter Architecture | DigitalOcean 连接跟踪(conntrack):原理、应用及 Linux 内核实现 Netfilter hooks - nftables wiki Iptables Essentials: Common Firewall Rules and Commands | DigitalOcean Comparing kube-proxy modes: iptables or IPVS? Linux Conntrack: Why It Breaks Down and Avoiding the Problem 走进Linux内核之Netfilter框架 - 掘金 19.3 The Netfilter Architecture of Linux 2.4 | Linux Network Architecture

2022/3/7
articleCard.readMore

制作开箱即用的 Ubuntu qcow2 镜像

最近负责的一个组件需要针对 Ubuntu 系统做兼容性适配,由于组件运行时会修改系统配置并写入大量文件,有必要使用虚拟机来开发测试。但通常开发使用的 Linux 发行版都是 RedHat 系的,找遍公司也没有能直接用的 Ubuntu 虚拟机镜像,最后自己手搓了一个,过程记录如下。 下载所需版本的 cloud image 首先需要确定所使用的 Ubuntu 版本,然后从官方镜像列表中下载相应的云主机镜像,云主机镜像预装了 cloud-init,可以更方便地运行在 openstack 等云平台中。 在镜像列表页面,需要根据发布频率(如 Release、Daily builds)、镜像大小(完整包和 Minimal)以及 Ubuntu 版本选择合适的镜像,在镜像下载页面有许多不同类型的文件,如校验和文件、manifest 以及不同架构的镜像,我们只需要文件后缀为 .img 的 QEMU qcow2 镜像。`` 本示例将选择 X86 架构的 20.04-lts 版本,最终所下载的镜像为 ubuntu-20.04-server-cloudimg-amd64.img。 使用 wget 命令将其下载到本地: 1 wget https://cloud-images.ubuntu.com/releases/focal/release-20211129/ubuntu-20.04-server-cloudimg-amd64.img cloud-init cloud-init 是用于跨平台初始化云主机实例的行业标准方法。它支持所有的主要公有云供应商、私有云配置系统和裸机安装。 有了 cloud-init,只需要提供系统镜像和规定的元数据(以及可选的用户数据和供应商数据)即可初始化一个云主机实例,在启动过程中,cloud-init 将读取所提供的元数据,识别它所运行的平台并完成相应的系统初始化步骤。初始化过程涉及设置网络和存储设备,配置 SSH 访问密钥及其他系统配置,还可将可选的用户或供应商数据传递给实例。 cloud-init 大幅简化了云主机的复杂配置过程,只需要编写一个统一的配置文件,就可以在不同的云平台创建出相同规格的主机实例。 配置模板镜像 下载的初始镜像并不能直接上传到云主机平台使用,否则创建的虚拟机无法通过 SSH 登录,会提示如下类似错误(SSH 用户只能使用镜像默认的 ubuntu ): 1 ubuntu@10.10.40.125: Permission denied (publickey). 下面需要使用一些虚拟机管理工具,基于模板镜像运行一个虚拟机,在虚拟机中完成所需配置,最终将模板镜像修改至可直接使用的镜像。 安装虚拟机管理工具 以下安装命令基于 CentOS,其他发行版本也可通过对应的包管理系统下载所需工具: 1 $ yum install -y libvirt-client cloud-utils virt-install libguestfs-tools 创建模板镜像并配置磁盘大小 基于初始镜像创建模板镜像,并命名为 root-disk.qcow2: 1 $ qemu-img convert -f qcow2 -O qcow2 ubuntu-20.04-server-cloudimg-amd64.img root-disk.qcow2 根据需要,设置基于该模板镜像所创建的虚拟机磁盘大小,示例设置为 50G: 1 $ qemu-img resize root-disk.qcow2 50G 准备 cloud-init 配置 假设将使用的默认主机名和密码如下: 1 2 $ VM_NAME="ubuntu-vm" $ PASSWORD="thisIsMyPassword" 用于测试开发的虚拟机,可配置为直接使用 root 用户并以密码登录,但切勿用于生产用途。生产环境镜像需使用普通用户加 sudo 权限以及 public key 等更安全的登录途径。 接下来创建一个 cloud-init 的配置文件,并在其中定义主机名和密码等: 1 2 3 4 5 6 7 8 9 10 11 12 13 $ echo "#cloud-config system_info: default_user: name: ubuntu home: /home/ubuntu password: $PASSWORD chpasswd: { expire: False } hostname: $VM_NAME # 配置 sshd 允许使用密码登录 ssh_pwauth: True " | tee cloud-init.cfg 如果还需要对主机做更多配置,可参考 cloud-init 的文档示例: Cloud config examples - cloud-init 21.4 documentation 然后使用 cloud-localds 基于配置文件创建 ISO 镜像: 1 $ cloud-localds cloud-init.iso cloud-init.cfg 基于模板镜像以及配置镜像安装虚拟机: 1 2 3 4 5 6 7 8 9 10 11 $ virt-install \ --name $VM_NAME \ --memory 1024 \ --disk root-disk.qcow2,device=disk,bus=virtio \ --disk cloud-init.iso,device=cdrom \ --os-type linux \ --os-variant ubuntu20.04 \ --virt-type kvm \ --graphics none \ --network network=default,model=virtio \ --import 命令运行成功后会进入一个新的终端会话,在自动执行一系列的初始化操作之后,将可以用上文设置的 default_user 的用户名和密码登录所创建的虚拟机。 初始化虚拟机 进入运行中的虚拟机后,我们希望进行一些与登录相关的初始化配置。以下操作以 Ubuntu 20.04 为例,其他版本可能有所区别。 允许以 root 用户登录 Ubuntu 20.04 默认的 SSH 配置不允许以 root 用户登录,我们需要修改 /etc/ssh/sshd_config 文件将 PermitRootLogin prohibit-password 配置更改为 PermitRootLogin yes: 1 $ sudo sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config 重启 SSH 服务: 1 $ sudo systemctl restart ssh 设置 root 用户密码 默认情况下 Ubuntu 20.04 未设置 root 用户密码,因此无法使用 root 用户登录系统,需要通过 passwd 命令初始化 root 用户的密码: 1 $ sudo passwd root 密码设置完毕后,我们可以输入 ctrl + ] 退出虚拟机会话,并通过 virsh 命令获取运行中虚拟机的 IP 地址,测试是否能通过 root 用户 SSH 登录虚拟机: 1 2 3 4 5 6 7 8 $ virsh domifaddr ubuntu-vm Name MAC address Protocol Address ---------------------------------------------------------------- vnet0 52:54:00:1b:3b:4f ipv4 192.168.122.201/24 $ ssh root@192.168.122.201 root@ubuntu-vm:~$ 登陆成功后,可以在虚拟机中执行其他初始化操作,如安装基础依赖、配置镜像源等。 最后在虚拟机中执行关闭命令退出会话: 1 $ shutdown -h now 清理与压缩镜像 在 virt-install 执行过程中,虚拟机会将虚拟网卡的 Mac 地址记录到文件系统中,但每一次基于镜像启动虚拟机都会生成一个新的 Mac 地址,因此需要将镜像中已经写入配置文件的 Mac 地址清除掉。 除了进入虚拟机手动清除,还可以使用上文已安装的专门工具来清理: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ virt-sysprep -d $VM_NAME [ 0.0] Examining the guest ... [ 25.7] Performing "abrt-data" ... [ 25.7] Performing "backup-files" ... [ 26.4] Performing "bash-history" ... [ 26.4] Performing "blkid-tab" ... [ 26.5] Performing "crash-data" ... [ 26.5] Performing "cron-spool" ... [ 26.5] Performing "dhcp-client-state" ... [ 26.5] Performing "dhcp-server-state" ... [ 26.5] Performing "dovecot-data" ... [ 26.5] Performing "logfiles" ... [ 26.7] Performing "machine-id" ... ...... 该命令还将执行清除操作历史等一系列操作。 接着在 libvirt 中注销已创建的虚拟机实例: 1 2 3 $ virsh undefine $VM_NAME 域 qfusion-vm 已经被取消定义 此时对虚拟机所做的更改都已写入到 root-disk.qcow2 模板镜像中,将其上传到云主机平台的镜像服务器,就可以基于已写入的配置重复创建新的虚拟机实例。 如果此时镜像体积较大,可执行以下命令可对镜像体积进行压缩,并使用压缩得到的 ubuntu-20.04.qcow2 镜像: 1 $ qemu-img convert -f qcow2 -O qcow2 -c root-disk.qcow2 ubuntu-20.04.qcow2 在本示例中,镜像大小从 7 个多 G 压缩到了 500M 左右,效果显著。 参考链接 https://blog.programster.org/create-ubuntu-20-kvm-guest-from-cloud-image https://docs.openstack.org/image-guide/ubuntu-image.html https://medium.com/@art.vasilyev/use-ubuntu-cloud-image-with-kvm-1f28c19f82f8

2021/12/7
articleCard.readMore

如何使用 docker buildx 构建跨平台 Go 镜像

随着国产化和信创的推进,为应用适配多个操作系统和处理器架构的需求越来越普遍。常见做法是为不同平台单独构建一个版本,当用来开发的平台与部署的目标平台不同时,实现这一目标并不容易。例如在 x86 架构上开发一个应用程序并将其部署到 ARM 平台的机器上,通常需要准备 ARM 平台的基础设施用于开发和编译。 一次构建多处部署的镜像分发大幅提高了应用的交付效率,对于需要跨平台部署应用的场景,利用 docker buildx 构建跨平台的镜像也是一种快捷高效的解决方案。 前提 大部分镜像托管平台支持多平台镜像,这意味着镜像仓库中单个标签可以包含不同平台的多个镜像,以 docker hub 的 python 镜像仓库为例,3.9.6 这个标签就包含了 10 个不同系统和架构的镜像(平台 = 系统 + 架构): 通过 docker pull 或 docker run 拉取一个支持跨平台的镜像时,docker 会自动选择与当前运行平台相匹配的镜像。由于该特性的存在,在进行镜像的跨平台分发时,我们不需要对镜像的消费做任何处理,只需要关心镜像的生产,即如何构建跨平台的镜像。 docker buildx 默认的 docker build 命令无法完成跨平台构建任务,我们需要为 docker 命令行安装 buildx 插件扩展其功能。buildx 能够使用由 Moby BuildKit 提供的构建镜像额外特性,它能够创建多个 builder 实例,在多个节点并行地执行构建任务,以及跨平台构建。 启用 Buildx macOS 或 Windows 系统的 Docker Desktop,以及 Linux 发行版通过 deb 或者 rpm 包所安装的 docker 内置了 buildx,不需要另行安装。 如果你的 docker 没有 buildx 命令,可以下载二进制包进行安装: 首先从 Docker buildx 项目的 release 页面找到适合自己平台的二进制文件。 下载二进制文件到本地并重命名为 docker-buildx,移动到 docker 的插件目录 ~/.docker/cli-plugins。 向二进制文件授予可执行权限。 如果本地的 docker 版本高于 19.03,可以通过以下命令直接在本地构建并安装,这种方式更为方便: 1 2 3 $ DOCKER_BUILDKIT=1 docker build --platform=local -o . "https://github.com/docker/buildx.git" $ mkdir -p ~/.docker/cli-plugins $ mv buildx ~/.docker/cli-plugins/docker-buildx 使用 buildx 进行构建的方法如下: 1 docker buildx build . buildx 和 docker build 命令的使用体验基本一致,还支持 build 常用的选项如 -t、-f等。 builder 实例 docker buildx 通过 builder 实例对象来管理构建配置和节点,命令行将构建任务发送至 builder 实例,再由 builder 指派给符合条件的节点执行。我们可以基于同一个 docker 服务程序创建多个 builder 实例,提供给不同的项目使用以隔离各个项目的配置,也可以为一组远程 docker 节点创建一个 builder 实例组成构建阵列,并在不同阵列之间快速切换。 创建初始节点 使用 docker buildx create 命令可以创建 builder 实例,这将以当前使用的 docker 服务为节点创建一个新的 builder 实例。要使用一个远程节点,可以在创建示例时通过 DOCKER_HOST 环境变量指定远程端口或提前切换到远程节点的 docker context。 下面首先以当前节点创建一个新的 builder 实例,并通过命令行选项指定实例名称、驱动以及当前节点的目标平台: 1 2 $ docker buildx create --driver docker-container --platform linux/amd64 --name multi-builder multi-builder 创建 docker-container 驱动实例时可通过 --driver-opt image=moby/buildkit:v0.10.5 选项配置所使用的 buildkit 镜像版本,在拉取 buildkit 镜像存在网络问题时可将其替换为本地 registry 镜像。 如果在构建时遇到 buildkit 容器无法完成 dns 域名解析的问题,可重新创建 builder 实例并添加 --driver-opt network=host --buildkitd-flags '--allow-insecure-entitlement network.host' 选项使其使用宿主机网络。 添加节点到 builder 实例创建之后可以添加新的节点,通过 docker buildx create 命令的 --append 选项可将 --node <node> 节点加入到 --name <builder> 选项指定的 builder 实例。如下将把一个远程节点加入 builder 实例: 1 2 $ export DOCKER_HOST=tcp://10.10.150.66:2375 $ docker buildx create --name multi-builder --append --node remote-builder 启用 builder 刚创建的 builder 处于 inactive 状态,可以在 create 或 inspect 子命令中添加 --bootstrap 选项立即启动实例(可验证节点是否可用): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 $ docker buildx inspect --bootstrap multi-builder [+] Building 3.9s (2/2) FINISHED => [remote-builder internal] booting buildkit 3.9s => => pulling image moby/buildkit:buildx-stable-1 2.8s => => creating container buildx_buildkit_remote-builder 1.2s => [multi-builder0 internal] booting buildkit 3.7s => => pulling image moby/buildkit:buildx-stable-1 2.4s => => creating container buildx_buildkit_multi-builder0 1.3s Name: multi-builder Driver: docker-container Nodes: Name: multi-builder0 Endpoint: unix:///var/run/docker.sock Status: running Buildkit: v0.10.5 Platforms: linux/amd64*, linux/386 Name: remote-builder Endpoint: tcp://10.10.88.20:2375 Status: running Buildkit: v0.10.5 Platforms: linux/arm64 docker buildx ls 将列出所有可用的 builder 实例和实例中的节点: 1 2 3 4 5 6 7 $ docker buildx ls NAME/NODE DRIVER/ENDPOINT STATUS PLATFORMS multi-builder docker-container multi-builder0 unix:///var/run/docker.sock running v0.10.5 linux/amd64*, linux/386 remote-builder tcp://10.10.88.20:2375 running v0.10.5 linux/arm64 default * docker default default running linux/amd64, linux/386 如上就创建了一个支持多平台架构的 builder 实例,执行 docker buildx use <builder> 将切换到所指定的 builder 实例。 还可使用 docker buildx inspect、docker buildx stop 和 docker buildx rm 等命令用于管理一个实例的生命周期。 构建驱动 buildx 实例通过两种方式来执行构建任务,两种执行方式被称为使用不同的「驱动」: docker 驱动:使用 Docker 服务程序中集成的 BuildKit 库执行构建。 docker-container 驱动:启动一个包含 BuildKit 的容器并在容器中执行构建。 docker 驱动无法使用一小部分 buildx 的特性(如在一次运行中同时构建多个平台镜像),此外在镜像的默认输出格式上也有所区别:docker 驱动默认将构建结果以 Docker 镜像格式直接输出到 docker 的镜像目录(通常是 /var/lib/overlay2),之后执行 docker images 命令可以列出所输出的镜像;而 docker container 则需要通过 --output 选项指定输出格式为镜像或其他格式。 为了一次性构建多个平台的镜像,本文使用 docker container 作为默认的 builder 实例驱动。 如何解决 ERROR merging manifest list 如果你使用了较新版本的 buildx(>=0.10) 和 buildkit(>=0.11) 以及较旧版本的镜像仓库,在构建跨平台镜像时可能会遇到构建成功但推送失败的问题,报错如下: => => pushing layers 12.3s => => pushing manifest for registry.xxx.com/xxx/postgres-vb 0.9s => [auth] xxx/postgres-vb:pull,push token for registry.xxx.com 0.0s => ERROR merging manifest list registry.xxx.com/xxx/postgres-vb:v1.2.0 这是因为新版本默认开启了 Build attestations 功能以增强供应链安全,但同时带来了兼容性问题。如果你并不需要,可添加如下 build 选项禁用它以解决该问题: docker buildx build --sbom=false --provenance=false buildx 的跨平台构建策略 根据构建节点和目标程序语言不同,buildx 支持以下三种跨平台构建策略: 通过 QEMU 的用户态模式创建轻量级的虚拟机,在虚拟机系统中构建镜像。 在一个 builder 实例中加入多个不同目标平台的节点,通过原生节点构建对应平台镜像。 分阶段构建并且交叉编译到不同的目标架构。 QEMU 通常用于模拟完整的操作系统,它还可以通过用户态模式运行:以 binfmt_misc 在宿主机系统中注册一个二进制转换处理程序,并在程序运行时动态翻译二进制文件,根据需要将系统调用从目标 CPU 架构转换为当前系统的 CPU 架构。最终的效果就像在一个虚拟机中运行目标 CPU 架构的二进制文件。Docker Desktop 内置了 QEMU 支持,其他满足运行要求的平台可通过以下方式安装: 1 $ docker run --privileged --rm tonistiigi/binfmt --install all 这种方式不需要对已有的 Dockerfile 做任何修改,实现的成本很低,但显而易见效率并不高。 将不同系统架构的原生节点添加到 builder 实例中可以为跨平台编译带来更好的支持,而且效率更高,但需要有足够的基础设施支持。 如果构建项目所使用的程序语言支持交叉编译(如 C 和 Go),可以利用 Dockerfile 提供的分阶段构建特性:首先在和构建节点相同的架构中编译出目标架构的二进制文件,再将这些二进制文件复制到目标架构的另一镜像中。下文会使用 Go 实现一个具体的示例。这种方式不需要额外的硬件,也能得到较好的性能,但只有特定编程语言能够实现。 一次构建多个架构 Go 镜像实践 源代码和 Dockerfile 下面将以一个简单的 Go 项目作为示例,假设示例程序文件 main.go 内容如下: 1 2 3 4 5 6 7 8 9 10 11 package main import ( "fmt" "runtime" ) func main() { fmt.Println("Hello world!") fmt.Printf("Running in [%s] architecture.\n", runtime.GOARCH) } 定义构建过程的 Dockerfile 如下: 1 2 3 4 5 6 7 8 9 10 11 12 FROM --platform=$BUILDPLATFORM golang:1.14 as builder ARG TARGETARCH WORKDIR /app COPY main.go /app/main.go RUN GOOS=linux GOARCH=$TARGETARCH go build -a -o output/main main.go FROM alpine:latest WORKDIR /root COPY --from=builder /app/output/main . CMD /root/main 构建过程分为两个阶段: 在一阶段中,我们将拉取一个和当前构建节点相同平台的 golang 镜像,并使用 Go 的交叉编译特性将其编译为目标架构的二进制文件。 然后拉取目标平台的 alpine 镜像,并将上一阶段的编译结果拷贝到镜像中。 执行跨平台构建 执行构建命令时,除了指定镜像名称,另外两个重要的选项是指定目标平台和输出格式。 docker buildx build 通过 --platform 选项指定构建的目标平台。Dockerfile 中的 FROM 指令如果没有设置 --platform 标志,就会以目标平台拉取基础镜像,最终生成的镜像也将属于目标平台。此外 Dockerfile 中可通过 BUILDPLATFORM、TARGETPLATFORM、BUILDARCH 和 TARGETARCH 等参数使用该选项的值。当使用 docker-container 驱动时,这个选项可以接受用逗号分隔的多个值作为输入以同时指定多个目标平台,所有平台的构建结果将合并为一个整体的镜像列表作为输出,因此无法直接输出为本地的 docker images 镜像。 docker buildx build 支持丰富的输出行为,通过--output=[PATH,-,type=TYPE[,KEY=VALUE] 选项可以指定构建结果的输出类型和路径等,常用的输出类型有以下几种: local:构建结果将以文件系统格式写入 dest 指定的本地路径, 如 --output type=local,dest=./output。 tar:构建结果将在打包后写入 dest 指定的本地路径。 oci:构建结果以 OCI 标准镜像格式写入 dest 指定的本地路径。 docker:构建结果以 Docker 标准镜像格式写入 dest 指定的本地路径或加载到 docker 的镜像库中。同时指定多个目标平台时无法使用该选项。 image:以镜像或者镜像列表输出,并支持 push=true 选项直接推送到远程仓库,同时指定多个目标平台时可使用该选项。 registry:type=image,push=true 的精简表示。 对本示例我们执行如下 docker buildx build 命令: 1 $ docker buildx build --platform linux/amd64,linux/arm64,linux/arm -t registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo -o type=registry . 该命令将在当前目录同时构建 linux/amd64、 linux/arm64 和 linux/arm 三种平台的镜像,并将输出结果直接推送到远程的阿里云镜像仓库中。 构建过程可拆解如下: docker 将构建上下文传输给 builder 实例。 builder 为命令行 --platform 选项指定的每一个目标平台构建镜像,包括拉取基础镜像和执行构建步骤。 导出构建结果,镜像文件层被推送到远程仓库。 生成一个清单 JSON 文件,并将其作为镜像标签推送给远程仓库。 验证构建结果 运行结束后可以通过 docker buildx imagetools 探查已推送到远程仓库的镜像: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ docker buildx imagetools inspect registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest Name: registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest MediaType: application/vnd.docker.distribution.manifest.list.v2+json Digest: sha256:e2c3c5b330c19ac9d09f8aaccc40224f8673e12b88ff59cb68971c36b76e95ca Manifests: Name: registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:cb6a7614ee3db03c8858e3680b1585f32a6fe3de9b371e37e25cf42a83f6e0ba MediaType: application/vnd.docker.distribution.manifest.v2+json Platform: linux/amd64 Name: registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:034aa0077a452a6c2585f8b4969c7c85d5d2bf65f801fcc803a00d0879ce900e MediaType: application/vnd.docker.distribution.manifest.v2+json Platform: linux/arm64 Name: registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:db0ee3a876fb789d2e733471385eef0a056f64ee12d9e7ef94e411469d054eb5 MediaType: application/vnd.docker.distribution.manifest.v2+json Platform: linux/arm/v7 最后在不同的平台以 latest 标签拉取并运行镜像,验证构建结果是否正确。 使用 Docker Desktop 时,其本身集成的虚拟化功能可以运行不同平台的镜像,可以直接以 sha256 值拉取镜像: 1 2 3 $ docker run --rm registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:cb6a7614ee3db03c8858e3680b1585f32a6fe3de9b371e37e25cf42a83f6e0ba Hello world! Running in [amd64] architecture. 1 2 3 $ docker run --rm registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:034aa0077a452a6c2585f8b4969c7c85d5d2bf65f801fcc803a00d0879ce900e Hello world! Running in [arm64] architecture. 如何交叉编译 Golang 的 CGO 项目 支持交叉编译到常见的操作系统和 CPU 架构是 Golang 的一大优势,但以上示例中的解决方案只适用于纯 Go 代码,如果项目中通过 cgo 调用了 C 代码,情况会变得更加复杂。 准备交叉编译环境和依赖 为了能够顺利编译 C 代码到目标平台,首先需要在编译环境中安装目标平台的 C 交叉编译器(通常基于 gcc),常用的 Linux 发行版会提供大部分平台的交叉编译器安装包,可以直接通过包管理器安装。 其次还需要安装目标平台的 C 标准库(通常标准库会作为交叉编译器的安装依赖,不需要单独安装),另外取决于你所调用的 C 代码的依赖关系,可能还需要安装一些额外的 C 依赖库(如 libopus-dev 之类)。 我们将使用 amd64 架构的 golang:1.14 官方镜像作为基础镜像执行编译,其使用的 Linux 发行版为 Debian。假设交叉编译的目标平台是 linux/arm64,则需要准备的交叉编译器为 gcc-aarch64-linux-gnu,C 标准库为 libc6-dev-arm64-cross,安装方式为: 1 2 $ apt-get update $ apt-get install gcc-aarch64-linux-gnu libc6-dev-arm64-cross 会同时被安装。 得益于 Debian 包管理器 dpkg 提供的多架构安装能力,假如我们的代码依赖 libopus-dev 等非标准库,可通过 <library>:<architecture> 的方式安装其 arm64 架构的安装包: 1 2 3 $ dpkg --add-architecture arm64 $ apt-get update $ apt-get install -y libopus-dev:arm64 交叉编译 CGO 示例 假设有如下 cgo 的示例代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package main /* #include <stdlib.h> */ import "C" import "fmt" func Random() int { return int(C.random()) } func Seed(i int) { C.srandom(C.uint(i)) } func main() { rand := Random() fmt.Printf("Hello %d\n", rand) } 将使用的 Dockerfile 如下: 1 2 3 4 5 6 7 8 9 10 FROM --platform=$BUILDPLATFORM golang:1.14 as builder ARG TARGETARCH RUN apt-get update && apt-get install -y gcc-aarch64-linux-gnu WORKDIR /app COPY . /app/ RUN if [ "$TARGETARCH" = "arm64" ]; then CC=aarch64-linux-gnu-gcc && CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ CGO_ENABLED=1 GOOS=linux GOARCH=$TARGETARCH CC=$CC CC_FOR_TARGET=$CC_FOR_TARGET go build -a -ldflags '-extldflags "-static"' -o /main main.go Dockerfile 中通过 apt-get 安装了 gcc-aarch64-linux-gnu 作为交叉编译器,示例程序较为简单因此不需要额外的依赖库。在执行 go build 进行编译时,需要通过 CC 和 CC_FOR_TARGET 环境变量指定所使用的交叉编译器。 为了基于同一份 Dockerfile 执行多个目标平台的编译(假设目标架构只有 amd64/arm64),最下方的 RUN 指令使用了一个小技巧,通过 Bash 的条件判断语法来执行不同的编译命令: 假如构建任务的目标平台是 arm64,则指定 CC 和 CC_FOR_TARGET 环境变量为已安装的交叉编译器(注意它们的值有所不同)。 假如构建任务的目标平台是 amd64,则不指定交叉编译器相关的变量,此时将使用默认的 gcc 作为编译器。 最后使用 buildx 执行构建的命令如下: 1 $ docker buildx build --platform linux/amd64,linux/arm64 -t registry.cn-hangzhou.aliyuncs.com/waynerv/cgo-demo -o type=registry . 总结 有了 Buildx 插件的帮助,在缺少基础设施的情况下,我们也能使用 docker 方便地构建跨平台的应用镜像。 但默认通过 QEMU 虚拟化目标平台指令的方式有明显地性能瓶颈,如果编写应用的语言支持交叉编译,我们可以通过结合 buildx 和交叉编译获得更高的效率。 本文最后介绍了一种进阶场景的解决方案:如何对使用了 CGO 的 Golang 项目进行交叉编译,并给出了编译到 linux/arm64 平台的示例。 参考链接 Leverage multi-CPU architecture support Docker Buildx docker buildx build C? Go? Cgo! - go.dev Cross-Compiling Golang (CGO) Projects

2021/9/2
articleCard.readMore

如何手动安装 Nginx 二进制文件

在一些特殊场景如离线条件下,我们无法使用包管理器而只能用手动方式来安装特定软件,这时推荐的做法是从源码自行编译安装,通常来说 configure + make install 两步即可完成。 假如我们只能获取已编译好的程序或者没有编译环境,就只能手动执行安装步骤了。本文将记录如何将一个已编译好的 Nginx 二进制文件安装到 Linux 系统中。 prefix 配置 对安装 nginx 来说,最需要注意的是 prefix 路径配置,默认值为 /usr/local/nginx,它是 nginx 运行时的基础路径: nginx 将从该路径寻找配置文件 nginx.conf。 nginx 使用的其他路径的默认值也均基于该路径,如 {prefix}/logs/error.log。 nginx 所读取配置文件中的相对路径均相对于该基础路径。 由于该配置在编译时已写入程序中,此时无法再更改,但仍然可以通过 -c 选项在运行 nginx 时指定配置文件的路径。在不确定 prefix 的值时,我们可以执行二进制文件并添加 -V 选项查看: 1 2 3 4 5 6 nginx -V nginx version: nginx/1.21.1 built by gcc 8.3.0 (Debian 8.3.0-6) built with OpenSSL 1.1.1d 10 Sep 2019 configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib/nginx/modules --conf-path=/etc/nginx/nginx.conf 拷贝程序到可执行路径 我们首先需要将二进制文件移动到宿主机,此时建议将其放在可执行路径如 /usr/sbin/ 下,之后就不需要再指定具体的路径而是直接通过 nginx 命令即可执行程序。 1 2 chmod 0755 nginx mv nginx /usr/sbin/nginx 添加 Systemd Unit 文件 对于 nginx 这类长时间运行服务器程序,我们需要对其进行托管,以实现开机启动、自动重启等功能。最推荐的做法是使用 systemd 作为托管程序,它也是目前大部分 Linux 发行版的默认初始化系统。 具体来说,我们需要创建一个名称为 nginx.service 的 Unit 文件,并置于 /usr/lib/systemd/system 目录下: 1 touch /usr/lib/systemd/system/nginx.service nginx.service 的内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 [Unit] Description=The nginx HTTP and reverse proxy server After=network.target remote-fs.target nss-lookup.target [Service] Type=forking PIDFile=/run/nginx.pid # Nginx will fail to start if /run/nginx.pid already exists but has the wrong # SELinux context. This might happen when running `nginx -t` from the cmdline. # https://bugzilla.redhat.com/show_bug.cgi?id=1268621 ExecStartPre=/usr/bin/rm -f /run/nginx.pid ExecStartPre=/usr/sbin/nginx -t -c /etc/nginx/nginx.conf ExecStart=/usr/sbin/nginx -c /etc/nginx/nginx.conf ExecReload=/bin/kill -s HUP $MAINPID KillSignal=SIGQUIT TimeoutStopSec=5 KillMode=process PrivateTmp=true [Install] WantedBy=multi-user.target 在 Unit 文件中,我们定义了 nginx 这一 service 资源,并设置了启动命令和使用的配置文件路径。 nginx.conf 配置文件 我们通过 nginx.conf 配置文件来控制 nginx 的具体行为,在 Unit 文件中我们已将配置文件路径定义为 /etc/nginx/nginx.conf,因此通过 systemctl 工具启动 nginx 时都会从该路径读取配置,现在创建该文件: 1 2 mkdir /etc/nginx touch /etc/nginx/nginx.conf nginx.conf 的内容可根据我们的需要自行定义,但需要注意一下几点: pid 指令的值必须和 Unit 文件保持一致,即 pid /run/nginx.pid;。 所有路径类指令的目录必须提前存在,如 error_log、 access_log 和 include,nginx 会自动创建 log 文件但并不会创建其目录。 推荐将访问日志和异常日志保存于 /var/log/nginx 目录,将 http 配置部分的子配置置于 /etc/nginx/ 目录下。 创建运行所需目录 在 nginx.conf 中指定的配置路径,都需要提前将路径目录创建好,否则启动 nginx 时将会报错,假设我们在 nginx.conf 中指定了如下路径类配置(仅展示部分): 1 2 3 4 5 6 7 8 9 error_log /var/log/nginx/error.log; pid /run/nginx.pid; http { access_log /var/log/nginx/access.log main; include /etc/nginx/mime.types; include /etc/nginx/conf.d/*.conf; } 则需要提前准备好 /etc/nginx/mime.types 文件,并创建以下目录: 1 2 3 mkdir -p /var/log/nginx mkdir -p /run mkdir -p /etc/nginx/conf.d 如果我们在 nginx.conf 中使用了相对路径,则需要基于 prefix 路径创建相应的目录。 到此我们就完成了所有的准备工作。 启动或重启 nginx 现在可以通过 systemd 来启动 nginx 了: 1 systemctl start nginx && systemctl enable nginx 注意在 systemctl status nginx 的结果中,我们可能会在初始的启动日志中看到如下警告: 1 [alert]: could not open error log file: open() "/usr/local/nginx/logs/error.log" failed (13: Permission denied) 这是因为 nginx 在读取 nginx.conf 之前就会先打开错误日志文件,此时将使用编译时指定的 error-log-path 路径,默认值为 {prefix}/logs/error.log,在读取了 nginx.conf 之后就会使用配置文件中通过 error_log 指定的路径,因此该错误只是一个警告,不会影响 nginx 的启动。 在通过 reload 重启 nginx 时,也需要注意添加 -c 选项指定配置文件路径: 1 nginx -s reload -c /etc/nginx/nginx.conf 参考链接 Compiling and Installing from Source Nginx Permission 13 nginx relative path to include

2021/8/11
articleCard.readMore

容器技术原理(五):文件系统的隔离和共享

背景知识 Unix 系统中所有可访问的文件都被组织在一个巨大的树状文件层次结构中,这颗文件树的根节点就是 / 目录。这些文件可以分散保存在不同的设备中,前提是我们使用 mount 系统调用将这些设备上的文件系统挂载到文件树中。 目录 VS 文件系统 了解文件系统和目录之间的区别是很重要的。文件系统是存储设备如硬盘的一个部分,它被分配来保存文件数据。在目录上挂载文件系统后,可以访问这部分存储的文件。文件系统被挂载后,对终端用户来说访问起来就像普通目录一样。 我们常常提到 ext 、xfs 和 zfs 是文件系统的类型,不同类型的文件系统存取和管理数据的方式不同,Linux 支持多种类型的文件系统。 什么是 rootfs rootfs (Root Filesystem)是分层文件树的顶端。它包含对系统运行至关重要的文件和目录,包括设备目录和用于启动系统的程序。rootfs 还包含了许多挂载点,其他文件系统可以通过这些挂载点连接到 rootfs 的文件树中。rootfs 通常由 Linux 发行版提供,一个典型的 rootfs 内容如下: 1 2 3 $ ls / boot etc home lib64 mnt proc run srv tmp var bin dev lib media opt root sbin sys usr 系统启动时,初始化进程会将 rootfs 挂载到 / 目录,之后再挂载其他的文件系统到其子目录中。这期间所有的 mount 系统调用都会被记录到初始化进程的 mount table 中,所有的进程都有一张独立的 mount table,记录于 /proc/{PID}/mounts 中。但一般情况下,系统中的所有进程都会直接使用初始化进程的 mount table。 mount namespace 的本质和工作方式 每个进程可以创建属于自己的 mount table,但前提是必须先复制父进程的 mount table,之后再调用 mount 发生的更改都只会影响当前进程的 mount table,这就是 mount namespace 的工作原理。如果多个进程在同一 mount namespace 内,其中一个进程对 mount table 的更改对其他进程来说也是可见的。 现在我们来实际查看系统中当前存在的所有 mount namespace,这需要借助之前的文章中介绍过的 cinf 工具: 1 2 3 4 5 6 7 8 $ cinf | grep mnt NAMESPACE TYPE NPROCS USERS CMD 4026531840 mnt 130 0,32,70,81,89,998,999 /sbin/init splash 4026531860 mnt 1 0 4026532320 mnt 1 997 /usr/sbin/chronyd 4026532389 mnt 1 0 sh 命令的输出经过一定处理以展示更清晰的信息,从进程数量(NPROCS)可以看到绝大部分进程都位于由初始化进程 /sbin/init 创建的 4026531840 mount namspace 中,一般情况下新的进程并不会创建新的 mount namespace,如果你打印这些进程的 mount table 即 /proc/{PID}/mounts ,会发现输出结果都是相同的。 创建一个新的 mount namespace 时,会在新的 mount namspace 中创建一个来自父命名空间的挂载点副本。我们将通过 unshare -m 创建一个新的 mount namespace 来验证,还将要用到我们在前面的文章中通过 Docker 镜像创建的 filesystem bundle: 1 2 cd /mycontainer/rootfs PS1='\u@new-mnt$ ' unshare -Umr 执行该命令后我们进入到了一个新的 mount namespace 中,但依然能看到宿主机中的所有挂载点: 1 2 3 4 5 6 7 8 9 10 11 $ df -h 文件系统 容量 已用 可用 已用% 挂载点 /dev/sdb3 119G 28G 86G 25% / tmpfs 7.8G 0 7.8G 0% /dev/shm tmpfs 1.6G 1.9M 1.6G 1% /run tmpfs 5.0M 4.0K 5.0M 1% /run/lock tmpfs 1.6G 84K 1.6G 1% /run/user/121 tmpfs 1.6G 68K 1.6G 1% /run/user/0 tmpfs 4.0M 0 4.0M 0% /sys/fs/cgroup /dev/sdb2 94G 5.8G 83G 7% /home /dev/sda2 96M 30M 67M 31% /boot/efi 反过来当我们在新的 mount namespace 执行新的挂载,从宿主机看不到相应的记录: 1 2 3 4 5 6 7 # in new mount namspace $ mount -t tmpfs tmpfs /mnt $ findmnt | grep mnt └─/mnt tmpfs tmpfs rw,relatime,inode64 # in host $ findmnt | grep mnt 这就是 mount namespace 的工作方式。 在容器中创建新的 mount namespace 现在创建一个新的容器来看看 mount namespace 的变化: 1 2 $ cd /mycontainer $ runc run mybox 在一个新的窗口中,我们从宿主机环境使用 cinf 查询 namespace: 1 2 3 4 5 6 $ cinf | grep mnt 4026531840 mnt 134 0,32,70,81,89,998,999 /usr/lib/systemd/systemd --swi 4026531860 mnt 1 0 4026532320 mnt 1 997 /usr/sbin/chronyd 4026532325 mnt 1 0 sh 4026532389 mnt 1 0 sh 会发现增加了一个新的 mount namespace 4026532325,创建该 mount namespace 的进程正是容器的 init 进程: 1 2 3 4 5 6 7 8 9 10 11 12 13 $ cinf --namespace 4026532325 PID PPID NAME CMD NTHREADS CGROUPS STATE 7656 7646 sh sh 1 11:blkio:/mybox 10:devices:/mybox 9:pids:/mybox S (sleeping) 8:cpu,cpuacct:/mybox 7:net_cls,net_prio:/mybox 6:freezer:/mybox 5:memory:/mybox 4:cpuset:/mybox 3:hugetlb:/mybox 2:perf_event:/mybox 1:name=systemd:/user.slice/user-0.slice/session-1231.scope/mybox $ runc ps mybox UID PID PPID C STIME TTY TIME CMD root 7656 7646 0 14:39 pts/0 00:00:00 sh 需要注意的是,创建新的 mount namspace 并不会创建一个全新的 mount table,而是在父进程的 mount namspace 的副本上进行变更,因此在创建容器时,新的容器容器拥有宿主机的全部挂载点,这显然与我们的预期是不符的,我们希望使用 filesystem bundle 中的 rootfs 为容器建立一个隔离的文件系统,这要求我们在新的 mount namespace 中 unmount 当前 rootfs,并将 filesystem bundle 中的 rootfs mount 到 / 目录,我们可以分别在容器和宿主机中使用 ls -id 打印 / 目录的 inode 号码予以验证: 1 2 3 4 5 6 # 在容器中打印 / 目录 $ ls -id / 7077890 / # 在宿主机中打印 filesystem bundle 中 rootfs 所在目录 $ ls -id /mycontainer/rootfs 7077890 /mycontainer/rootfs 通过 mount 无法实现这一点,因为我们无法使所有的进程停止使用当前的 rootfs,当前的 rootfs 一定处于使用状态而不能被 unmount。但可以采取另一种途径:使用 pivot_root 系统调用,它允许我们将 rootfs 重新挂载到一个非 / 的位置,同时在 / 目录上挂载一个新的目录,并将所有当前进程的根目录切换为新目录。之后我们可以顺利 unmount 原来的 rootfs。 使用 pivot_root 或 chroot 切换根目录 pivot_root 是由 Linux 提供的一种系统调用,它能够将一个 mount namespace 中的所有进程的根目录和当前工作目录切换到一个新的目录。pivot_root 的主要用途是在系统启动时,先挂载一个临时的 rootfs 完成特定功能,然后再切换到真正的 rootfs。 创建容器的过程中,在创建新的 mount namespace 之后,我们可以通过 pivot_root() 将容器内进程的根目录切换到 filesystem bundle 中的 rootfs 所在目录。 chroot 命令的使用示例 除了 pivot_root ,Linux 还提供了 chroot 系统调用能够将当前进程的根目录更改为一个新的目录,新的根目录还将被当前进程的所有子进程所继承。 上文提到的 pivot_root 和 chroot 都是由 Linux 内核提供的系统调用,它们分别都有命令行程序实现了对系统调用的简单封装,接下来我们以 chroot 命令为例: 1 2 3 4 5 6 # 切换到有效的 filesystem bundle 目录 $ cd /mycontainer $ chroot rootfs /bin/sh # 进入到一个新的shell中 $ ls / bin dev etc home old proc root sys tmp usr var 在新的 shell 程序中,执行 ls 返回的是 /mycontainer/rootfs 目录下的文件内容,而不是宿主机根目录下的内容。通过 chroot 命令,在不需要 mount namespace 的情况下我们也实现切换容器内进程根目录的效果。 相比 chroot,pivot_root 系统调用配合 mount namspace 更加安全,容器运行时会优先使用这种方式,但 chroot 也是一种可选的方式。在 runC 的实现中有以下(Golang)代码片段: 1 2 3 4 5 6 7 if config.NoPivotRoot { err = msMoveRoot(config.Rootfs) } else if config.Namespaces.Contains(configs.NEWNS) { err = pivotRoot(config.Rootfs) } else { err = chroot() } 忽略第一个由用户指定的「不切换根目录」的选择分支,其余分支代表两种切换根目录的方式: 如果创建了新的 mount namespace ,将使用 pivot_root 系统调用。 如果没有创建新的 mount namespace,直接使用 chroot 。 bind mount :在宿主机和容器间共享文件 建立隔离的文件系统之后,我们还需要一种机制从容器访问宿主机的部分文件系统,或者将容器运行过程产生的数据持久化到宿主机中。 bind mount (绑定挂载)是由 Linux 提供的一种挂载类型,它能够将一个文件或目录再次挂载到一个新的目标路径,挂载后从新旧两个路径都能访问到原来的数据,从两个路径对数据的修改也都会生效,目标路径的原有内容将会被隐藏。以如下目录结构为例: 1 2 3 4 5 6 7 8 $ tree . . ├── A │   ├── a │   └── a.conf └── B    ├── b    └── b.conf 通过如下命令将 A 绑定挂载到 B 目录: 1 $ mount --bind A B 之后从 A 和 B 目录都可以访问原 A 目录的文件内容,而 B 目录的原内容将被隐藏: 1 2 3 4 5 6 7 8 $ tree . . ├── A │   ├── a │   └── a.conf └── B    ├── a    └── a.conf 我们可以使用 bind mount 在宿主机和容器之间共享文件,将宿主机中的目录甚至是块设备挂载到容器中。 在容器中使用 bind mount 下面我们将在容器建立一个连接到宿主机的 bind mount。容器运行时的实现方式是修改 filesystem bundle 中的 config.json,在 JSON 对象的 mounts 列表中加入以下对象: 1 2 3 4 5 6 { "destination": "/host_dir", "type": "bind", "source": "/mycontainer/host", "options": ["bind"] } 这告诉容器运行时,将宿主机中的 /mycontainer/host 目录(如果使用相对路径则是相对于 filesystem bundle 的目录即 host),挂载到容器中 rootfs 的 /host_dir 目录。宿主机中的 host 目录必须提前存在,而容器中的 host_dir 不存在时将由容器运行时自动创建。现在我们在宿主机中创建 host 目录并填充一个文件: 1 2 3 $ cd /mycontainer $ mkdir host $ touch mkdir/hello 然后运行容器,我们将在容器的 /host_dir 目录中看到宿主机 /mycontainer/host 目录的内容,在容器内修改该目录内的文件也将在宿主机可见: 1 2 3 4 5 6 7 # in container $ ls /host_dir hello $ touch /host_dir/world # in host $ ls /mycontainer/host hello world 而且,由于绑定挂载的过程发生在容器的 mount namespace 中,宿主机并不知道该挂载点的存在: 1 2 3 4 5 # in container $ cat /proc/self/mounts | grep host_dir /dev/sdb3 /host_dir ext4 rw,relatime,errors=remount-ro 0 0 # in host $ cat /proc/self/mounts | grep host_dir 在系列的第一篇文章中,我们提到绑定挂载的挂载点位于容器的可写层中,虽然容器删除后整个可写层将被删除,但容器运行过程中的写入数据依然会保留在宿主机的挂载路径,因此可通过该途径持久化容器中的数据。 docker volumes 是什么 Dcoekr 提供了 Volumes(该术语并不存在于 OCI 规范中)来将容器中的数据持久化,底层的实现方式也是使用 bind mount,将宿主机中的路径绑定挂载到容器中,相比之下 Docker 提供了更友好的命令行接口,此外还提供了 named volume,(在 Linux 系统中)本质是不需要在宿主机中指定已存在的目录,而是由 Docker 来管理,在其宿主机的数据目录中建立一个单独的目录。 总结 这篇文章我们讨论了和容器文件系统有关的几个方面: 使用 mount namespace 在容器中建立单独的文件系统环境,但此时并未与宿主机完全隔离。 使用 pivot_root 将容器中的根目录切换到镜像提供的 rootfs 中,使其无法访问宿主机的其他路径而实现隔离。 使用 bind mount 在宿主机和容器间共享数据或将容器运行时生成的数据持久化。 参考链接 Understand Container 4: Mount and Jail mount(8) — Linux manual page Building a container by hand using namespaces: The mount namespace Mounting the Root Filesystem File systems pivot_root(2) — Linux manual page DIfference between chroot & pivot_root What is the purpose of pivot_root system call in Linux? What is a bind mount?

2021/7/27
articleCard.readMore

容器技术原理(四):使用 Capabilities 实现权限控制

如果你使用 runc 运行一个容器并执行以下操作,会得到有趣的结果: 1 2 3 4 5 6 $ whoami root $ id -u root 0 $ hostname mybox hostname: sethostname: Operation not permitted 即使我们使用的是 UID 为 0 的 root 用户,也没有权限执行修改 hostname 的操作。 实际上 root 用户拥有最高特权早就成了过去式,Linux 内核在 2.2 版本就引入了一种新的权限检查机制 - capabilities。 比超级用户更细粒度的权限控制 传统的 Linux 权限检查模型较为简单,内核在进行权限检查时只会区分两类进程: 特权进程,其有效用户 ID为 0,该用户也就是我们常说的超级用户或 root。 非特权进程,有效用户 ID 不为 0。 特权进程将直接绕过内核的所有检查,非特权进程则需要基于进程的有效用户 ID 和有效用户组 ID 等凭证执行检查。 为了适应更复杂的权限需求,从 2.2 版本起 Linux 内核能够进一步将超级用户的权限分解为细颗粒度的单元,这些单元称为 capabilities。例如,capability CAP_CHOWN 允许用户对文件的 UID 和 GID 进行任意修改,即执行 chown 命令。几乎所有与超级用户相关的特权都被分解成了单独的 capability。 capabilities 的引入有以下好处: 从超级用户的权限中移除部分 capability 以削弱其权限,提高系统的安全性。 可以根据需求非常精准地向普通用户授予部分特殊权限。 特权容器的安全风险 容器通过 namespace 来隔离进程和资源,但并不是所有的资源都可以被 namespace 化,容器和宿主机并不是完全隔离的,比如容器和宿主机中的时间就是共享的。如果容器中的进程拥有一切特权,它可以运行直接访问硬件的(恶意)程序甚至直接修改宿主机的文件系统,因此有必要对容器中的操作进行一定的限制,否则会影响到宿主机的稳定性,甚至带来严重的安全风险。 出于以上考虑,默认情况下容器运行时使用白名单的方式在创建容器时加入一部分的 capabilities,在容器中即使你是超级用户也没有权限执行特定的操作。 接下来我们通过实例加深对容器中 capabilities 的认识。 准备工作 我们将在容器中使用额外的工具库 libcap 实现和 capabilities 的交互,为此需要将其安装到一个 filesystem bundle 中,这种方式在之前的文章中已有介绍,具体方式如下: 1 2 3 4 5 6 7 8 9 10 11 12 # 创建 bundle 的顶层目录 mkdir /mycontainer2 cd /mycontainer2 # 创建用于存放 root filesystem 的 rootfs 目录 mkdir rootfs # 利用 Docker 导出已安装 libcap 容器的 root filesystem docker export $(docker create cmd.cat/capsh) | tar -C rootfs -xvf - # 创建一个 config.json 作为整个 bundle 的 spec runc spec 然后就可以使用 runc run 从 /mycontainer2 目录运行一个已安装该库的基础容器了。 创建容器时添加 capabilities 在开头的示例中,我们无法在容器中以 root 用户设置 hostname,是因为缺少了 CAP_SYS_ADMIN 这一 capability,它并未包含在容器默认添加 capabilities 的白名单中。 在之前的一篇文章中,我们介绍过容器运行时会根据 bundle 中的 config.json ,为其创建的容器设置运行参数和执行环境,这一过程也包括了设置容器内进程的 capabilities。 通过修改 config.json ,向 JSON 中的 process.capabilities 对象的 bounding,permitted 和 effective 列表中加入 "CAP_SYS_ADMIN",该 capability 将加入到容器 init 进程的对应 capabilities 集合中。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 "capabilities": { "bounding": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE", "CAP_SYS_ADMIN" ], "effective": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE", "CAP_SYS_ADMIN" ], "inheritable": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ], "permitted": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE", "CAP_SYS_ADMIN" ], "ambient": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ] } capabilities 的技术细节 capabilities 可以应用于文件和进程(或线程,Linux 内核不区分进程和线程),文件的 capabilities 存储在文件的扩展属性中,扩展属性在构建镜像时会被清理掉,所以在容器中我们基本不需要考虑文件的 capabilities。 进程的 capabilities 通过每个进程单独维护的 5 个 capability 集合来控制,每个集合中都包含 0 个或多个 capabilities: Permitted:进程所能够使用的 capabilities 的超集 Inheritable:进程在执行 exec() 系统调用时,能够被新的派生进程所继承的 capabilities Effective:内核对进程执行权限检查时所使用的集合 Bounding:Inheritable 集合的超集,一个capability 必须在 Bounding 集合中才能添加到Inheritable Ambient:非特权程序执行 exec() 系统调用时将保留的 capabilities 如上所示我们向 init 进程的 Permitted、Bounding 和 Effective 集合中加入了 CAP_SYS_ADMIN,因此 init 进程将通过内核对 CAP_SYS_ADMIN 的检查。 下面我们根据新的 config.json 运行一个新的容器,现在可以修改 hostname 了: 1 2 3 4 $ runc run mybox2 $ hostname super $ hostname super 执行以上操作时,我们位于作为容器 init 进程的 sh 进程中,如果在容器中继续创建新的进程,是否也会具有新加入的 capability?我们来试一下,在一个新的窗口中执行以下命令: 1 2 3 4 5 6 $ runc exec -t mybox2 sh $ hostname super $ hostname hello $ hostname hello 修改 hostname 的操作执行成功,因为新创建的进程完全复制了 init 进程的 capabilities。 容器运行时添加 capabilities 除了修改 config.json 加入 capabilities,我们还能够在容器运行时阶段添加 capabilities。 首先将 config.json 还原,然后运行一个新的容器 mybox3,在新的 sh 进程中确认已经不再具有 CAP_SYS_ADMIN。 然后通过 runc exec 在该容器中创建一个新的进程,并通过 --cap 选项为该进程添加 CAP_SYS_ADMIN : 1 runc exec --cap CAP_SYS_ADMIN mybox3 /bin/hostname origin 该操作的原理是,既然 runc 能够根据 config.json 设置 init 进程的 capabilities 集合,它同样也能为容器内运行的其他进程设置。 查看进程具有的 capabilities capsh 在容器内执行 capsh --print 能够获取到更多关于 capabilities 的信息: 1 2 3 4 5 6 7 8 9 10 11 12 13 $ capsh --print Current: = cap_kill,cap_net_bind_service,cap_audit_write+eip cap_sys_admin+ep Bounding set =cap_kill,cap_net_bind_service,cap_sys_admin,cap_audit_write Ambient set =cap_kill,cap_net_bind_service,cap_audit_write Securebits: 00/0x0/1'b0 secure-noroot: no (unlocked) secure-no-suid-fixup: no (unlocked) secure-keep-caps: no (unlocked) secure-no-ambient-raise: no (unlocked) uid=0(root) gid=0(root) groups= 该命令打印了当前进程所具有的 capabilities。 在 Current 和 Bounding set 中包含了我们通过 config.json 加入的 cap_sys_admin。capability 末尾的 +eip 代表了该 capability 同时存在于 Effective,Inheritable 和 Permitted 集合中。 pscap 首先在宿主机获取容器中已运行进程的 PID: 1 2 3 4 $ runc ps mybox2 UID PID PPID C STIME TTY TIME CMD root 9592 9580 0 14:39 pts/0 00:00:00 sh root 9776 9765 0 14:46 pts/1 00:00:00 sh 在宿主机中安装 pscap 程序: 1 $ apt-get install libcap-ng-utils 根据获得的 PID,查看容器中进程所具有的 capabilities: 1 2 3 pscap | grep "9592\|9776" 9580 9592 root sh kill, net_bind_service, sys_admin, audit_write 9765 9776 root sh kill, net_bind_service, sys_admin, audit_write 参考链接 Understand Container 3: Linux Capabilities capabilities(7) — Linux manual page dockerlabs-Capabilities Linux Capabilities 入门教程:概念篇 Why A Privileged Container in Docker Is a Bad Idea

2021/7/22
articleCard.readMore

容器技术原理(三):使用 Cgroups 实现资源限制

cgroups(control groups)是由 Linux 内核提供的一种特性,它能够限制、核算和隔离一组进程所使用的系统资源(如 CPU、内存、磁盘 I/O、网络等)。 在上一篇文章中我们已了解 Namespace 在容器技术中扮演的角色,如果说 Namespace 控制了容器中的进程能看到什么,那么 cgroups 则控制了容器中的进程能使用多少资源。Namespace 实现了进程的隔离,cgroups 则实现了资源的限制,后者同样是构建容器的基础。 本文将沿袭 Namespace 文章的行文思路,实际创建一个容器,观察宿主机中 cgroups 的变化,来实际展示 cgroups 如何工作,然后了解如何自行配置 cgroups。 cgroup 在何时创建 Linux 内核通过一个叫做 cgroupfs 的伪文件系统来提供管理 cgroup 的接口,我们可以通过 lscgroup 命令来列出系统中已有的 cgroup,该命令实际上遍历了 /sys/fs/cgroup/ 目录中的文件: 1 $ lscgroup | tee cgroup.a 如果你使用的 Linux 发行版没有 lscgroup 命令,可通过 command-not-found.com 提供的指令下载安装。 我们将输出结果保存到 cgroup.a 文件中。接着在另一窗口中根据 Namespace 文章中的步骤启动一个容器: 1 2 $ cd /mycontainer $ runc run mybox 回到原来的窗口再次执行 lsgroup 命令: 1 $ lscgroup | tee group.b 现在对比两次 lscgroup 命令的输出结果: 1 2 3 4 5 6 7 8 9 10 11 12 $ diff group.a group.b > perf_event:/mybox > freezer:/mybox > net_cls,net_prio:/mybox > cpu,cpuacct:/user.slice/mybox > blkio:/user.slice/mybox > cpuset:/mybox > hugetlb:/mybox > pids:/user.slice/user-0.slice/session-5.scope/mybox > memory:/user.slice/user-0.slice/session-5.scope/mybox > devices:/user.slice/mybox 从结果中可看到,mybox 容器创建后,系统中专门为其创建了所有类型的新的 cgroup。 cgroup 如何控制容器的资源 cgroup 所控制的对象是进程,它控制一个或一组进程所能使用多少内存/CPU/网络等等。一个 cgroup 的 tasks 列表中记录了其所控制进程的 PID,该 tasks 实际上也是 cgroupfs 中的一个文件。 init 进程 我们首先在宿主机中打印出容器中的进程信息,找到容器的 init 进程: 1 2 3 4 $ runc ps mybox UID PID PPID C STIME TTY TIME CMD root 2250 2240 0 15:28 pts/0 00:00:00 sh 任意打印一些类型的 cgroup 的 tasks 列表: 1 2 3 4 $ cat /sys/fs/cgroup/memory/user.slice/user-0.slice/session-5.scope/mybox/tasks 2250 $ cat /sys/fs/cgroup/blkio/user.slice/mybox/tasks 2250 这一过程简单明了:容器创建之后,容器的 init 进程会被加入到为该容器所创建的 cgroups 之中,我们可以通过 /proc/$PID/cgroup 得到更肯定的结果: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ cat /proc/2250/cgroup 12:devices:/user.slice/mybox 11:memory:/user.slice/user-0.slice/session-5.scope/mybox 10:pids:/user.slice/user-0.slice/session-5.scope/mybox 9:hugetlb:/mybox 8:cpuset:/mybox 7:rdma:/ 6:blkio:/user.slice/mybox 5:cpu,cpuacct:/user.slice/mybox 4:net_cls,net_prio:/mybox 3:freezer:/mybox 2:perf_event:/mybox 1:name=systemd:/user.slice/user-0.slice/session-5.scope/mybox 0::/user.slice/user-0.slice/session-5.scope 容器中的其他进程 接下来我们在 mybox 容器中运行一个新的进程: 1 2 # 在 mybox 容器中运行 $ top -b 看看是否会创建新的 cgroup: 1 2 $ lscgroup | tee group.c $ diff group.b group.c 没有输出任何结果,说明没有创建新的 cgroup。既然 cgroup 可以控制一组进程,我们猜测在已运行容器中新建的进程,也都会加入到 init 进程所属的 cgroups 中。 下面开始验证,首先找到新建进程的 PID: 1 2 3 4 $ runc ps mybox UID PID PPID C STIME TTY TIME CMD root 2250 2240 0 15:28 pts/0 00:00:00 sh root 2576 2250 0 15:59 pts/0 00:00:00 top -b 新进程的 PID 是 2576,然后打印该进程的 cgroups 信息: 1 cat /proc/2576/cgroup 输出和 PID 2250 进程的输出完全一致,我们也可以打印其中一个 cgroup 的 tasks 列表: 1 2 3 cat /sys/fs/cgroup/blkio/user.slice/mybox/tasks 2250 2576 完全符合预期。实际上向 tasks 文件直接写入进程的 PID 就实现了将进程加入到该 cgroup 中。当一个容器被创建时,将为每种类型的资源创建一个新的 cgroup,在容器中运行的所有进程都将加入到这些 cgroup 中。 通过控制容器中运行的所有进程,cgroups 实现了对容器的资源限制。 如何配置 cgroup 下面我们将以内存 cgroup 为例,了解如何配置 cgroup 以实现对 mybox 容器的内存限制。 配置 cgroup 有两种方式,一种是直接修改 cgroupfs 中的指定文件,另一种是通过 runc 或 docker 等高阶工具实现。 文件系统方式 通过 cgroupfs 的方式,查看/修改该 cgroup 目录下的特定文件即可查看/设置该 cgroup 的限额: 1 2 cat /sys/fs/cgroup/memory/user.slice/user-0.slice/session-5.scope/mybox/memory.limit_in_bytes 9223372036854771712 修改 memory.limit_in_bytes 文件即可设置最大可用内存,现在我们并未对该容器设置任何限制,因此内存限制的当前值是一个无意义的特别大的值,现在我们向该文件直接写入新的值: 1 echo "100000000" > /sys/fs/cgroup/memory/user.slice/user-0.slice/session-5.scope/mybox/memory.limit_in_bytes 这样就设置了新的内存限制。写入新的限制值后,容器中的所有进程不能使用总共超过 100M 的内存,超过后将根据 memory.oom_control 文件中设置的 OOM 策略 kill 或 sleep 容器中的进程。 高阶工具方式 通过高阶工具提供的途径来配置 cgroup 是一种更友好的方式,虽然这些工具背后的实现也是如上所述更改 cgroupfs。 对于 runc 来说,需要修改 filesystem bundle 中的 config.json 文件来配置 cgroup。设置内存限制需要如下修改 JSON 对象中的 linux.resources 字段: 1 2 3 4 5 6 7 "resources": { "memory": { "limit": 100000, "reservation": 200000 }, ... } 对于 docker 来说更为简单,它本身就是一个面向用户的封装好的工具,执行 docker run 命令时通过 --memory 选项即可指定内存限制。实际上该参数会被写入到 config.json 由运行时实现 runc 使用,再由 runc 去更改 cgroupfs。 参考链接 Understand Container 2: Linux cgroups cgroups-wikipedia Limit a container’s access to memory Control groups - Memory

2021/7/21
articleCard.readMore

容器技术原理(二):使用 Namespace 实现进程隔离

Namespace 是由 Linux 内核提供的一种特性,它能够将一些系统资源包装到一个抽象的空间中,并使得该空间中的进程以为这些资源是系统中仅有的资源。Namespace 是构建容器技术的基石,它使得容器内的进程只能看到容器内的进程和资源,实现与宿主系统以及其他容器的进程和资源隔离。 Namespace 按操作的系统资源不同有很多种类,比如 cgroup namespace,mount namespace 等等,接下来我们仅以 pid namespace 为例,以 runC 作为容器运行时实现,来演示当我们执行对容器的操作时,namespace 是如何工作的。 在上一篇文章中我们已经介绍过,绝大部分容器系统都使用 runC 作为底层的运行时实现,如果你是在 Linux 发行版系统中使用 docker ,甚至不需要专门安装就能使用 runc 命令。 准备工作 filesystem bundle runC 只能从 filesystem bundle 中执行容器(filesystem bundle 顾名思义就是一个满足特定结构的文件夹),但是我们可以使用 docker 来准备一个可用的 bundle : 1 2 3 4 5 6 7 8 9 10 11 12 # 创建 bundle 的顶层目录 $ mkdir /mycontainer $ cd /mycontainer # 创建用于存放 root filesystem 的 rootfs 目录 $ mkdir rootfs # 利用 Docker 导出 busybox 容器的 root filesystem $ docker export $(docker create busybox) | tar -C rootfs -xvf - # 创建一个 config.json 作为整个 bundle 的 spec $ runc spec 此时整个 bundle 的目录结构如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ tree -L 2 /mycontainer /mycontainer ├── config.json └── rootfs ├── bin ├── dev ├── etc ├── home ├── proc ├── root ├── sys ├── tmp ├── usr └── var 系统监测工具 为了完成演示,我们需要一些第三方的系统监测工具作为辅助: 监测进程的启动以获得容器中运行进程的 PID,如 ubuntu 中的 forkstat ,它可以实时地监测 fork(), exec() 和 exit() 等系统调用,安装方式如下: 1 $ apt install forkstat 查看 namespace 信息,如 [cinf](https://github.com/mhausenblas/cinf) ,它是一个能够方便地列出系统中所有 namespace 或查看某个 namespce 详细信息的命令行工具,安装方式如下: 1 2 3 4 5 $ curl -s -L https://github.com/mhausenblas/cinf/releases/latest/download/cinf_linux_amd64.tar.gz \ -o cinf.tar.gz && \ tar xvzf cinf.tar.gz cinf && \ mv cinf /usr/local/bin && \ rm cinf* 使用 runc 运行容器 首先我们需要在一个窗口中运行 forkstat : 1 $ forkstat -e exec 接着另外新建一个终端窗口,切换到 /mycontainer 目录,使用 runC 运行容器: 1 $ runc run mybox 执行后会直接进入到新创建的容器中,运行 ps 命令: 1 2 3 PID USER TIME COMMAND 1 root 0:00 sh 7 root 0:00 ps forkstat 窗口将会有以下输出: 1 2 3 4 5 6 7 8 Time Event PID Info Duration Process 12:35:22 exec 33040 runc run mybox 12:35:22 exec 33047 runc init 12:35:22 exec 33049 dumpe2fs -h /dev/sdb3 12:35:22 exec 33050 dumpe2fs -h /dev/sdb3 12:35:22 exec 33047 runc init 12:35:22 exec 33052 sh 12:35:37 exec 33062 ps 从同步打印的结果可以判断, ps 和 forkstat 所分别输出的 sh 或 ps 实际上是同一个进程,但由于容器中的进程位于一个单独的 pid namespace 中,它们在容器中拥有另外的 PID,而且它们以为自己是容器中唯一存在的进程,因此 PID 会从 1 开始。 找到进程所属的 namespace 现在来找出容器所使用的 pid namespace,为此需要调整一下 ps 命令的输出格式: 1 2 3 $ ps -p 33052 -o pid,pidns PID PIDNS 33052 4026532395 PIDNS 即 pid namespace,以上命令可得到 PID 为 33052 的 sh 进程属于 4026532395 这个 pid namespace。既然已经有了容器中进程的 PID,实际上我们可以通过宿主机的 /proc 文件系统获得该进程所属的所有 namespace: 1 2 3 4 5 6 7 8 9 10 11 $ ll /proc/33052/ns lrwxrwxrwx 1 root root 0 7月 21 12:37 cgroup -> 'cgroup:[4026531835]' lrwxrwxrwx 1 root root 0 7月 21 12:36 ipc -> 'ipc:[4026532394]' lrwxrwxrwx 1 root root 0 7月 21 12:36 mnt -> 'mnt:[4026532383]' lrwxrwxrwx 1 root root 0 7月 21 12:36 net -> 'net:[4026532397]' lrwxrwxrwx 1 root root 0 7月 21 12:36 pid -> 'pid:[4026532395]' lrwxrwxrwx 1 root root 0 7月 21 12:37 pid_for_children -> 'pid:[4026532395]' lrwxrwxrwx 1 root root 0 7月 21 12:37 time -> 'time:[4026531834]' lrwxrwxrwx 1 root root 0 7月 21 12:37 time_for_children -> 'time:[4026531834]' lrwxrwxrwx 1 root root 0 7月 21 12:36 user -> 'user:[4026531837]' lrwxrwxrwx 1 root root 0 7月 21 12:36 uts -> 'uts:[4026532393]' 打印结果展示了一个进程所属的 namespace: 每个 namespace 都是一个软链接,软链接的名称指示了 namespace 的类型,如 cgroup 表示 cgroup namespace, pid 表示 pid namespace。 每个软链接指向该进程所属的真正 namespace 对象,该对象用 inode 号码表示,每个 inode 号码在宿主系统中都是唯一的。 如果有两个进程的同一类型 namespace 软链接都指向同一个 inode ,说明他们属于同一个 namespace。 实际上所有的进程都会属于至少一个 namespace,Linux 系统在启动时就会为所有类型创建一个默认的 namespace 供进程使用。 我们也可以尝试在容器内获得 sh 所属的 namespace,此时需要在容器内使用 1 这个 PID: 1 2 3 4 5 6 7 8 9 10 11 $ ls -l /proc/1/ns lrwxrwxrwx 1 root root 0 Jul 21 04:37 cgroup -> cgroup:[4026531835] lrwxrwxrwx 1 root root 0 Jul 21 04:37 ipc -> ipc:[4026532394] lrwxrwxrwx 1 root root 0 Jul 21 04:37 mnt -> mnt:[4026532383] lrwxrwxrwx 1 root root 0 Jul 21 04:37 net -> net:[4026532397] lrwxrwxrwx 1 root root 0 Jul 21 04:37 pid -> pid:[4026532395] lrwxrwxrwx 1 root root 0 Jul 21 04:37 pid_for_children -> pid:[4026532395] lrwxrwxrwx 1 root root 0 Jul 21 04:37 time -> time:[4026531834] lrwxrwxrwx 1 root root 0 Jul 21 04:37 time_for_children -> time:[4026531834] lrwxrwxrwx 1 root root 0 Jul 21 04:37 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Jul 21 04:37 uts -> uts:[4026532393] 观察 namespace 中的进程 下面我们将从 namespace 的角度,来观测 pid namespace 中的所有进程。Linux 系统并未提供类似的功能,因此需要借助上文安装的 cinf 工具来实现。 1 2 3 4 5 6 7 8 9 10 11 12 13 $ cinf -namespace 4026532395 PID PPID NAME CMD NTHREADS CGROUPS STATE 33052 33052 sh sh 1 12:devices:/user.slice/mybox S (sleeping) 11:blkio:/user.slice/mybox 10:rdma:/ 9:memory:/user.slice/user-0.slice/session-590.scope/mybox 8:net_cls,net_prio:/mybox 7:freezer:/mybox 6:pids:/user.slice/user-0.slice/session-590.scope/mybox 5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox 3:perf_event:/mybox 2:hugetlb:/mybox 1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox 0::/user.slice/user-0.slice/session-590.scope 目前这个 namespace 中只有一个进程,这个进程也是我们所创建容器的 init 进程。当一个新的容器被创建时,系统将创建一些新的 namespace,容器的 init 进程将被加入到这些 namespace。 对于 pid namespace 来说,容器中运行的所有进程只能看到位于同一 pid namespace 即 pid:[4026532395] 中的其他进程。sh 进程在容器中被认为是系统运行的第一个进程,PID 为 1,但在宿主机中只是一个 PID 为 33052 的普通进程,同一个进程在不同 namespace 中拥有不同的 PID,这就是 pid namespace 的作用。某种程度上,容器就意味着一个新的 namespace 集合。 在容器中创建新的进程 创建一个新的终端窗口,在已运行的容器中运行一个新的进程: 1 $ runc exec mybox /bin/top -b 从 forkstat 窗口中,我们可以看到新创建进程的 PID: 1 2 3 4 5 Time Event PID Info Duration Process 12:40:23 exec 33132 runc exec mybox /bin/top -b 12:40:23 exec 33140 runc init 12:40:23 exec 33140 runc init 12:40:23 exec 33142 /bin/top -b 实际上还有更直接的方式从宿主机中查看容器中运行的进程,我们可以使用 runC 提供的 ps 子命令: 1 2 3 4 $ runc ps mybox UID PID PPID C STIME TTY TIME CMD root 33052 33040 0 12:35 pts/0 00:00:00 sh root 33142 33132 0 12:40 pts/1 00:00:00 /bin/top -b 接下来依然使用 cinf 来找出新创建进程所属的 namespace: 1 2 3 4 5 6 7 8 9 10 $ cinf --pid 33142 NAMESPACE TYPE 4026532383 mnt 4026532393 uts 4026532394 ipc 4026532395 pid 4026532397 net 4026531837 user 从结果来看,并没有新的命名空间被创建,32608 进程的 namespace 和 mybox 容器的 init 进程- sh 所属的 namespace 是完全相同的。也就是说,在容器中创建一个新的进程,只是将这个进程加入到了容器 init 进程所属的 namespace。 下面来列出 4026532395 namespace 所拥有的所有进程: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ cinf --namespace 4026532395 PID PPID NAME CMD NTHREADS CGROUPS STATE 33052 33040 sh sh 1 12:devices:/user.slice/mybox S (sleeping) 11:blkio:/user.slice/mybox 10:rdma:/ 9:memory:/user.slice/user-0.slice/session-590.scope/mybox 8:net_cls,net_prio:/mybox 7:freezer:/mybox 6:pids:/user.slice/user-0.slice/session-590.scope/mybox 5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox 3:perf_event:/mybox 2:hugetlb:/mybox 1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox 0::/user.slice/user-0.slice/session-590.scope 33142 33132 top top -b 1 12:devices:/user.slice/mybox S (sleeping) 11:blkio:/user.slice/mybox 10:rdma:/ 9:memory:/user.slice/user-0.slice/session-590.scope/mybox 8:net_cls,net_prio:/mybox 7:freezer:/mybox 6:pids:/user.slice/user-0.slice/session-590.scope/mybox 5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox 3:perf_event:/mybox 2:hugetlb:/mybox 1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox 0::/user.slice/user-0.slice/session-590.scope 如果在容器内运行 ps -ef ,我们也能看到这些进程,由于 pid namespace 的原因它们的 PID 将会有所不同: 1 2 3 4 PID USER TIME COMMAND 1 root 0:00 sh 19 root 0:00 top -b 20 root 0:00 ps -ef 现在我们知道,docker/runc exec 实际上就是在已创建容器的 namespace 中运行一个新的进程。 总结 运行一个容器时,将创建一些新的 namespace, init 进程将被加入到这些 namespace;在一个容器中运行一个新进程时,新进程将加入创建容器时所创建的 namespace。 实际上创建容器时新建 namespace 这种行为是可以改变的,我们可以指定新建的容器使用已有的 namespace。 参考链接 Understand Container 1: Linux Namespaces Creating an OCI Bundle Monitoring new process creation Linux namespaces

2021/7/21
articleCard.readMore

容器技术原理(一):从根本上认识容器镜像

从 OCI 规范说起 OCI(Open Container Initiative)规范是事实上的容器标准,已经被大部分容器实现以及容器编排系统所采用,包括 Docker 和 Kubernetes。它的出现是一段关于开源商业化的有趣历史:它由 Dokcer 公司作为领头者在 2015 年推出,但如今 Docker 公司在容器行业中已经成了打工仔。 从 OCI 规范开始了解容器镜像,可以让我们对容器技术建立更全面清晰的认知,而不是囿于实现细节。OCI 规范分为 Image spec 和 Runtime spec 两部分,它们分别覆盖了容器生命周期的不同阶段: 镜像规范 镜像规范定义了如何创建一个符合 OCI 规范的镜像,它规定了镜像的构建系统需要输出的内容和格式,输出的容器镜像可以被解包成一个 runtime bundle ,runtime bundle 是由特定文件和目录结构组成的一个文件夹,从中可以根据运行时标准运行容器。 镜像里面都有什么 规范要求镜像内容必须包括以下 3 部分: Image Manifest:提供了镜像的配置和文件系统层定位信息,可以看作是镜像的目录,文件格式为 json 。 Image Layer Filesystem Changeset:序列化之后的文件系统和文件系统变更,它们可按顺序一层层应用为一个容器的 rootfs,因此通常也被称为一个 layer(与下文提到的镜像层同义),文件格式可以是 tar ,gzip 等存档或压缩格式。 Image Configuration:包含了镜像在运行时所使用的执行参数以及有序的 rootfs 变更信息,文件类型为 json。 rootfs (root file system)即 / 根挂载点所挂载的文件系统,是一个操作系统所包含的文件、配置和目录,但并不包括操作系统内核,同一台机器上的所有容器都共享宿主机操作系统的内核。 接下来我们以 Docker 和 nginx 为例探索一个镜像的实际内容。拉取一个最新版本的 nginx 镜像将其 save 为 tar 包后解压: 1 2 3 4 $ docker pull nginx $ docker save nginx -o nginx-img.tar $ mkdir nginx-img $ tar -xf nginx-img.tar --directory=nginx-img 得到 nginx-img 目录中的内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 nginx-img ├── 013a6edf61f54428da349193e7a2077a714697991d802a1c5298b07dbe0519c9 │   ├── json │   ├── layer.tar │   └── VERSION ├── 2bf70c858e6c8243c4713064cf43dea840866afefe52089a3b339f06576b930e │   ├── json │   ├── layer.tar │   └── VERSION ├── 490a3e67a61048564048a15d501b8e075d951d0dbba8098d5788bb8453f2371f │   ├── json │   ├── layer.tar │   └── VERSION ├── 4cdc5dd7eaadff5080649e8d0014f2f8d36d4ddf2eff2fdf577dd13da85c5d2f.json ├── 761c908ee54e7ccd769e815f38e3040f7b3ff51f1c04f55aac12b9ea3d544cfe │   ├── json │   ├── layer.tar │   └── VERSION ├── 96bfd5bf4ab4c2513fb43534d51e816c4876620767858377d14dcc5a7de5f1fd │   ├── json │   ├── layer.tar │   └── VERSION ├── d18832ef411b346c36b7ba42a6c2e3f77097026fb80651c2d870f19c6fd9ccef │   ├── json │   ├── layer.tar │   └── VERSION ├── manifest.json └── repositories 首先查看 manifest.json 文件的内容,即该镜像的 Image Manifest: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $ python -m json.tool manifest.json [ { "Config": "4cdc5dd7eaadff5080649e8d0014f2f8d36d4ddf2eff2fdf577dd13da85c5d2f.json", "Layers": [ "490a3e67a61048564048a15d501b8e075d951d0dbba8098d5788bb8453f2371f/layer.tar", "2bf70c858e6c8243c4713064cf43dea840866afefe52089a3b339f06576b930e/layer.tar", "013a6edf61f54428da349193e7a2077a714697991d802a1c5298b07dbe0519c9/layer.tar", "761c908ee54e7ccd769e815f38e3040f7b3ff51f1c04f55aac12b9ea3d544cfe/layer.tar", "d18832ef411b346c36b7ba42a6c2e3f77097026fb80651c2d870f19c6fd9ccef/layer.tar", "96bfd5bf4ab4c2513fb43534d51e816c4876620767858377d14dcc5a7de5f1fd/layer.tar" ], "RepoTags": [ "nginx:latest" ] } ] 其中记载了 Config 和 Layers 的文件定位信息,也就是标准中所规定的 Image Layer Filesystem Changeset 和 Image Configuration。 Config 存放在另一个 json 文件中,内容较多我们不做展示,具体包含了以下信息: 镜像的配置,在镜像解压成 runtime bundle 后将写入运行时配置文件。 镜像的 layers 之间的 Diff ID。 镜像的构建历史等元信息。 Layers 列表中的 tar 包共同组成了生成容器的 rootfs,容器的镜像是分层构建的, Layers 中的元素顺序还代表了镜像层叠加的顺序,所有 layer 组成一个由下往上叠加的栈式的结构。首先看一下基础层即第一条记录中的内容: 1 2 $ mkdir base $ tar -xf 490a3e67a61048564048a15d501b8e075d951d0dbba8098d5788bb8453f2371f/layer.tar --directory=base base 目录中解压得到的文件内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 drwxr-xr-x 2 root root 4096 6月 21 08:00 bin drwxr-xr-x 2 root root 6 6月 13 18:30 boot drwxr-xr-x 2 root root 6 6月 21 08:00 dev drwxr-xr-x 28 root root 4096 6月 21 08:00 etc drwxr-xr-x 2 root root 6 6月 13 18:30 home drwxr-xr-x 7 root root 85 6月 21 08:00 lib drwxr-xr-x 2 root root 34 6月 21 08:00 lib64 drwxr-xr-x 2 root root 6 6月 21 08:00 media drwxr-xr-x 2 root root 6 6月 21 08:00 mnt drwxr-xr-x 2 root root 6 6月 21 08:00 opt drwxr-xr-x 2 root root 6 6月 13 18:30 proc drwx------ 2 root root 37 6月 21 08:00 root drwxr-xr-x 3 root root 30 6月 21 08:00 run drwxr-xr-x 2 root root 4096 6月 21 08:00 sbin drwxr-xr-x 2 root root 6 6月 21 08:00 srv drwxr-xr-x 2 root root 6 6月 13 18:30 sys drwxrwxrwt 2 root root 6 6月 21 08:00 tmp drwxr-xr-x 10 root root 105 6月 21 08:00 usr drwxr-xr-x 11 root root 139 6月 21 08:00 var 这已经是一个完整的 rootfs,再观察最上面一层 layer 所得到的文件内容: 1 2 3 96bfd5bf4ab4c2513fb43534d51e816c4876620767858377d14dcc5a7de5f1fd/ └── docker-entrypoint.d └── 30-tune-worker-processes.sh 其中只有一个 shell 脚本文件,这说明镜像的构建过程是增量的,每一层都只包含了和更低一层相比所变更的文件内容,这也是容器镜像得以保持较小体积的原因。 如何在镜像层中删除一个文件 Layers 中的每一层都是文件系统的变更集(ChangeSet),变更集包含新增、修改和删除三种变更,新增或修改(替换)文件的情况较好处理,但如何在应用变更集时删除一个文件呢,答案是用 Whiteouts 表示要删除的文件或文件夹。 Whiteouts 文件是一个具有特殊文件名的空文件,文件名中通过在要删除的路径基本名称添加前缀 .wh. 标志一个(更低一层中的)路径应该被删除。假如在某个 layer 有以下文件: 1 2 3 4 ./etc/my-app.d/ ./etc/my-app.d/default.cfg ./bin/my-app-tools ./etc/my-app-config 如果在应用的更高层 layer 中含有 ./etc/.wh.my-app-config ,应用该层变更时原有的 ./etc/my-app-config 路径将被删除。 如何将多个镜像层合并成一个文件系统 规范中对于如何将多个镜像层应用成一个文件系统只有原理性的描述,假如我们要在 layer A 的基础上应用 Layer B : 首先将 Layer A 中的文件系统目录以保留文件属性的方式复制到另一个快照目录 A.snapshot 然后在快照目录中执行 Layer B 所包含的文件变更,所有的更改不会影响原有的变更集。 在实践中会采用联合文件系统等更为高效的实现。 什么是联合文件系统 联合文件系统(Union File System)也叫 UnionFS,主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。 下面以 Ubuntu 发行版以及 unionfs-fuse 实现为例演示联合挂载的效果: 首先使用包管理器安装 unionfs-fuse,这是 UnionFS 的一个实现: 1 $ apt install unionfs-fuse 然后创建如下目录结构: 1 2 3 4 5 6 A ├── a └── x B ├── b └── x 创建目录 C 并将 A、B 目录联合挂载到 C 下: 1 $ unionfs ./B:./A ./C 挂载后 C 目录内容如下: 1 2 3 4 C ├── a ├── b └── x 如果我们分别编辑 A、B 目录中的 x 文件,会发现访问目录 C 中 x 文件得到的是 B/x 的内容(因为 B 在挂载时位于更上层)。 Docker 中的 OverlayFS 是如何工作的 Docker 目前在大部分发行版本中使用的联合文件系统实现是 overlay2 ,相比其他实现它更加的轻量和高效,下面以实例来简单了解其工作方式。 接着上面 nginx 镜像的例子,拉取镜像后相应的 layer 解压在 /var/lib/docker/overlay2 目录中: 1 2 3 4 5 6 7 8 9 10 $ ll /var/lib/docker/overlay2 | tee layers.a drwx-----x 4 root root 72 7月 20 17:20 335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc drwx-----x 3 root root 47 7月 19 10:04 560df35d349e6a750f1139db22d4cb52cba2a1f106616dc1c0c68b3cf11e3df6 drwx-----x 4 root root 72 7月 20 17:20 769a9f5d698522d6e55bd9882520647bd84375a751a67a8ccad1f7bb1ca066dd drwx-----x 4 root root 72 7月 20 17:20 97aaf293fef495f0f06922d422a6187a952ec6ab29c0aa94cd87024c40e1a7e8 drwx-----x 4 root root 72 7月 20 17:20 a91fb6955249dadfb34a3f5f06d083c192f2774fbec5fbb1db42a04e918432c0 brw------- 1 root root 253, 1 7月 19 10:00 backingFsBlockDev drwx-----x 4 root root 72 7月 20 17:20 fa29ec8cfe5a6c0b2cd1486f27a20a02867126edf654faad7f3520a220f3705f drwx-----x 2 root root 278 7月 20 17:25 l 我们将输出结果保存到 layers.a 文件中供之后对比。其中 6 个名称特别长的目录中存放了镜像的 6 个 layer (目录名称和 manifest.json 中的名称并不对应), l 目录中包含了指向 layers 文件夹的软链接,主要目的是在执行 mount 命令时缩短目录标识符的长度以避免超出页大小限制。 每个 layer 文件夹包含的内容如下: 1 2 3 4 5 6 7 8 $ cd /var/lib/docker/overlay2/ $ ll 335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc -rw------- 1 root root 0 7月 15 17:00 committed drwxr-xr-x 3 root root 33 7月 15 17:00 diff -rw-r--r-- 1 root root 26 7月 15 17:00 link -rw-r--r-- 1 root root 86 7月 15 17:00 lower drwx------ 2 root root 6 7月 15 17:00 work link 记录了 l 目录中的短链接, lower 中记录该 layer 的更低一层(如果没有该文件说明当前 layer 已经是最底下一层即基础层), work 目录被 overlay2 内部所使用, diff 目录中存放了该 layer 所包含的文件系统内容: 1 2 3 4 5 6 7 $ ll 335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc/diff/ drwxr-xr-x 2 root root 6 7月 7 03:39 docker-entrypoint.d drwxr-xr-x 20 root root 4096 7月 7 03:39 etc drwxr-xr-x 5 root root 56 7月 7 03:39 lib drwxrwxrwt 2 root root 6 7月 7 03:39 tmp drwxr-xr-x 7 root root 66 6月 21 08:00 usr drwxr-xr-x 5 root root 41 6月 21 08:00 var 现在我们尝试基于该镜像运行一个容器,看看在容器阶段的联合挂载效果: 1 $ docker run -d --name nginx_container nginx 执行 mount 命令可确认新增了一个可读写的 overlay 挂载点: 1 2 3 $ mount | grep overlay overlay on /var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/merged type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/6Y7DPCTGLB6JHUPVBGTOEK2QFN:/var/lib/docker/overlay2/l/QMEEOSTJM2QON4M7PJJBB4KDEF:/var/lib/docker/overlay2/l/XNN2MRN4KWITFTZYLFUSLBP322:/var/lib/docker/overlay2/l/6DC6VDOMBZMLBZBT3QSOWLCR37:/var/lib/docker/overlay2/l/NXYWG253WSMELQKF2E2NH2GWCG:/var/lib/docker/overlay2/l/M4SO5XMO4VXRIJIGUHDMTATWH3:/var/lib/docker/overlay2/l/QI3P6ONJSLQI26DVPFGWIZI2EW,upperdir=/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/diff,workdir=/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/work) 该挂载点中即包含了所有镜像层 layer 组合而成的一个 rootfs: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 $ ll /var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/merged drwxr-xr-x 2 root root 4096 6月 21 08:00 bin drwxr-xr-x 2 root root 6 6月 13 18:30 boot drwxr-xr-x 1 root root 43 7月 16 16:52 dev drwxr-xr-x 1 root root 41 7月 7 03:39 docker-entrypoint.d -rwxrwxr-x 1 root root 1202 7月 7 03:39 docker-entrypoint.sh drwxr-xr-x 1 root root 19 7月 16 16:52 etc drwxr-xr-x 2 root root 6 6月 13 18:30 home drwxr-xr-x 1 root root 56 7月 7 03:39 lib drwxr-xr-x 2 root root 34 6月 21 08:00 lib64 drwxr-xr-x 2 root root 6 6月 21 08:00 media drwxr-xr-x 2 root root 6 6月 21 08:00 mnt drwxr-xr-x 2 root root 6 6月 21 08:00 opt drwxr-xr-x 2 root root 6 6月 13 18:30 proc drwx------ 2 root root 37 6月 21 08:00 root drwxr-xr-x 1 root root 23 7月 16 16:52 run drwxr-xr-x 2 root root 4096 6月 21 08:00 sbin drwxr-xr-x 2 root root 6 6月 21 08:00 srv drwxr-xr-x 2 root root 6 6月 13 18:30 sys drwxrwxrwt 1 root root 6 7月 7 03:39 tmp drwxr-xr-x 1 root root 66 6月 21 08:00 usr drwxr-xr-x 1 root root 19 6月 21 08:00 var 除了将原来的镜像层联合挂载到如上所示的 merged 目录,通过 diff 命令可以看到,容器运行成功后 /var/lib/docker/overlay2 还会新增两个 layer 目录,merged 也位于其中一个目录下: 1 2 3 4 5 6 7 8 $ ll /var/lib/docker/overlay2 | tee layers.b $ diff layers.a layers.b > drwx-----x 5 root root 69 7月 19 10:08 bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011 > drwx-----x 4 root root 72 7月 19 10:08 bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011-init < drwx-----x 2 root root 210 7月 19 10:08 l --- > drwx-----x 2 root root 278 7月 19 10:08 l 通过 inspect 命令探查已运行容器的 GraphDriver,可以更清晰地看到与镜像相比容器的 layers 所发生的变化 : 1 2 3 4 5 6 7 8 9 10 11 12 13 $ docker inspect nginx_container .... "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011-init/diff:/var/lib/docker/overlay2/97aaf293fef495f0f06922d422a6187a952ec6ab29c0aa94cd87024c40e1a7e8/diff:/var/lib/docker/overlay2/fa29ec8cfe5a6c0b2cd1486f27a20a02867126edf654faad7f3520a220f3705f/diff:/var/lib/docker/overlay2/769a9f5d698522d6e55bd9882520647bd84375a751a67a8ccad1f7bb1ca066dd/diff:/var/lib/docker/overlay2/a91fb6955249dadfb34a3f5f06d083c192f2774fbec5fbb1db42a04e918432c0/diff:/var/lib/docker/overlay2/335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc/diff:/var/lib/docker/overlay2/560df35d349e6a750f1139db22d4cb52cba2a1f106616dc1c0c68b3cf11e3df6/diff", "MergedDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/merged", "UpperDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/diff", "WorkDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/work" }, "Name": "overlay2" } .... LowerDir 中记录了原有的镜像层文件系统,另外在最上层还新增了一个 init 层,它们在容器运行阶段都是只读的;MergedDir 中记录了将 LowerDir 的所有目录进行联合挂载的挂载点;UpperDir 也是新增的一个 layer ,此时位于以上所有 layers 的最上层,与其他镜像层相比它是可读写的。容器阶段的 layers 示意图如下: 创建容器时所新增的可写 layer 我们称为 container layer,容器运行阶段对文件系统的变更都只会写入到该 layer 中,包括对文件的新增、修改和删除,而不会改变更低层的原有镜像内容,这极大提升了镜像的分发效率。在镜像层和 container layer 之间的 init 层记录了容器在启动时写入的一些配置文件,这一过程发生在新增读写层之前,我们不希望把这些数据写入到原始镜像中。 这两个新增的层仅在容器运行阶段存在,容器删除后它们也会被删除。同一个镜像可以创建多个不同的容器,仅需要创建多个不同的可写层;运行时更改过的容器也可以重新打包为一个新的镜像,将可写层添加到新镜像的只读层中即可。 为什么容器的读写效率不如原生文件系统 为了最小化 I/O 以及缩减镜像体积,容器的联合文件系统在读写文件时会采取写时复制策略(copy-on-write),如果一个文件或目录存在于镜像中的较低层,而另一个层(包括可写层)需要对其进行读取访问时,会直接访问较低层的文件。当另一个层第一次需要写入该文件时(在构建镜像或运行容器时),该文件会被复制到该层并被修改。这一举措大大减少了容器的启动时间(启动时新建的可写层只有很少的文件写入),但容器运行后每次第一次修改某个文件都需要先将整个文件复制到 container layer 中。 以上原因导致容器运行时的读写效率不如原生文件系统(尤其是写入效率),在 container layer 中不适合进行大量的文件读写,通常建议将频繁写入的数据库、日志文件或目录等单独挂载出去,如使用 Docker 提供的 Volume,此时目录将通过绑定挂载(Bind Mount)直接挂载在可读写层中,绕过了写时复制带来的性能损耗。 运行时规范 运行时规范描述了容器的配置、执行环境和生命周期。它详细描述了不同容器运行时架构的配置文件 config.json 的字段格式,如何在执行环境中应用、注入这些配置,以确保容器内运行的程序在不同运行时之间环境一致,并通过容器的生命周期定义了一套统一的操作行为。 容器的生命周期 规范所定义的容器生命周期,描述了容器从创建到退出所发生的事件构成的时间线,该时间线中定义了 13 个不同的事件,下图描述了时间线中容器状态的变化: 规范中只定义了4种容器状态,运行时实现可以在规范的基础上添加其他状态,同时标准还规定了运行时必须支持的操作: Query State,查询容器的当前状态 Create,根据镜像及配置创建一个新的容器,但是不运行用户指定程序 Start,在一个已创建的容器中运行用户指定程序 Kill,发送特定信号终止容器进程 Delete,删除已停止容器所创建的资源 每个操作之前或之后还会触发不同的 hooks,符合规范的运行时必须执行这些 hooks。 容器的本质是进程 运行时规范中使用了 container process 这一概念,container process 等同于上文提到的用户指定程序和容器进程,有些场景也会称该进程为容器的 init 进程。运行一个容器必须在 config.json 中定义容器的 container process,可定义的字段包括命令参数、环境参数和执行路径等等。 容器状态的变化,实际上反映的是 container process 的变化。我们可以将容器的生命周期和状态变化划分为以下几个阶段: container process 执行之前。 运行时执行 create 命令,根据 config.json 创建指定的资源。 资源创建成功后,容器进入 created 状态。 执行 container process。 运行时执行 start 命令。 运行时运行用户指定程序,即 container process。 容器进入 running 状态。 container process 进程结束,结束的原因可能是该程序执行结束、出错或崩溃,以及运行时通过 kill 命令向其发出终止信号。 容器进入 stoppped 状态。 运行时执行 delete 命令,所有通过 create 命令创建的资源被清除。 容器运行时的核心就是 container process:镜像文件系统满足运行进程所需的依赖,运行时所作的准备工作是为了正确的运行该进程,运行时持续的监测该进程的状态,一旦该进程结束即宣告容器(暂时)死亡,运行时进行收尾的清理工作。 当然容器中也可以运行其他进程,但这些进程只是共用 container process 的环境。 实现和生态 Docker 向 OCI 规范捐献了其容器运行时 runC 项目,作为该规范的标准实现。目前已有的大部分容器项目都直接将 runC 作为运行时实现。 下图可以概括容器生态内 Docker 相关的组织及项目之间的关系: Kubernetes 定义了 CRI(Container Runtime Interface)以实现可替换的容器运行时,目前有 cri-containerd 、cri-o 和 docker 等几种实现,但它们实际上也都基于 runC: 参考链接 Understand Container: OCI Specification Open Container Initiative Image Format Specification Open Container Initiative Runtime Specification About storage drivers Use the OverlayFS storage driver Docker (容器) 的原理 深入剖析Kubernetes - 张磊

2021/7/20
articleCard.readMore

究极的 Python 开源项目模板

我的 Notion 里面躺着很多关于开源项目的 idea,有些 GitHub 仓库都建好了,但真正开始的寥寥无几。除了没有时间或者需要掌握新的技术栈,还有一个重大阻碍是我觉得开始一个新的项目非常繁琐,尤其对于 Python 项目来说。 制作一个项目启动模板,也是开始一个新开源项目的好 idea。两年前我曾经接触过 cookiecutter (一个项目生成工具);在发布了几个项目后,对于 Python 打包发布到 PyPI 的流程也比较熟悉了;何况在 GitHub 上还看过许多类似的项目模板,可以 fork 后在其基础上进行自定义。一切都水到渠成,这应该不是个很难的事。 不过我还是经过一段时间的测试和反复打磨,才终于完成一份 Python 开源项目模板:Cookiecutter PyPackage,标题中的「究极」有夸大之嫌,但内容的确是相当全面。模板的使用方法在项目文档中有详细介绍,这篇文章主要分享我在模板中所选择的工具以及选择它们的理由。 包管理工具 Python 的包管理工具太多了,而且并没有所谓的主流,这是 Python 语言高度社区化的一个历史弊端,很难想象一门发展了近 30 年的语言,还在不断出现新的 PEP 和开源工具来改善其基础的包管理流程,用户需要为此付出相当大的学习成本。最近出现的新语言如 Go 和 Rust,基本都内置了一套完整的工具链。 那就从一众工具中挑一个简单省事的吧。我们需要它来做下面这些事情: 管理包的依赖 将项目打包成能够分发的格式 将打好的包上传到 PyPI 顺便再管理虚拟环境啥的 用传统的 pip + requirements.txt + Setuptools + twine + venv 做完这一套是没有任何问题的,但是用到的工具太多了,如果能用一个工具来做这些也许会更好。 Poetry 就是这样的一个工具,经过一段时间的体验我觉得还不错,最喜欢的特性是可以一次性在命令行中指定依赖版本,不用再手动编辑 requirements.txt 和 setup.py 中的 install_requires 了。 另外 Poetry 还使用了 pyproject.toml 作为包的元信息文件,用来替换了 setup.py + requirements.txt,如果你对 pyproject.toml 这种格式不太熟悉,推荐阅读:What the heck is pyproject.toml? 检查工具 对 Python 开源项目来说 flake8,pylint, isort 这类 linter/formatter 工具应该是必备了,如果你发现哪个流行的项目中没有,这可是一个贡献开源的好机会,赶紧去提 PR 吧。在之前的一篇博客构建保障代码质量的自动化工作流中,我曾经详细介绍过了这些工具的安装和使用方法。 在模板中一共配置了如下检查工具: 规范风格检查: flake8 flake8-docstrings 代码自动格式化: black isort 静态类型检查: mypy 相应的配置项都保存在 setup.cfg 和 pyproject.yaml ,预设的规则不算特别严格,如果想更宽松一点可以把 mypy 和 flake8-docstrings 去掉,并自行修改检查项。 Pre-commit 为了自动化地对代码运行上面介绍的检查工具,我们可以将其集成到 pre-commit hooks 中,这样它们就会在每次通过 git 提交代码时对更改过的文件运行。使用 pre-commit 时需要注意两点: hooks 仅对更改过的文件运行,换句话说在运行时已经通过命令行传入了目标文件参数,通常我们还会在工具的配置文件中通过 include 类似的字段设置作用范围,因此要确认两者是否能够同时生效,比如 isort 就需要在 hooks 配置文件 .pre-commit-config.yaml 中添加 args: [ "--filter-files" ] 选项: 1 2 3 4 5 - repo: https://github.com/pycqa/isort rev: 5.7.0 hooks: - id: isort args: [ "--filter-files" ] 每一个 hooks 会在由 pre-commit 管理的单独虚拟环境中运行,由此会引发一个常见的问题:在项目的开发虚拟环境中运行 mypy 和通过 pre-commit 运行 mypy 可能会得到不同的结果,其原因是 pre-commit 运行的 mypy 所处环境无法检测到其他第三方的包。这时可通过添加 additional_dependencies 参数尝试解决: 1 2 3 4 5 6 7 - repo: https://gitlab.woqutech.com/Quality/python-code-check/mirrors-mypy.git rev: "v0.812" hooks: - id: mypy additional_dependencies: - 'pydantic' - 'click' 运行测试 Pytest 单元测试用 unittest 或者 pytest 都可以,选择后者的原因是之前用的比较多,而且可以通过 pytest-cov 很方便地集成计算测试覆盖率功能,后续还可以在 CI 中集成 Codecov。 Makefile 手动运行一系列检查或者构建工具是非常繁琐的,因此我加入了一个 Makefile 可以将批量的命令和选项通过快捷命令来执行。 Tox 我们的项目通常不止支持一个 Python (major 或 minor)版本,不同的语言版本下对程序运行上述检查可能得到不同的结果,但手动切换版本以检查程序行为的成本非常高昂,这时我们就需要用到 tox 了,它可以自动以不同的 Python 版本创建虚拟环境,在不同环境中分别安装当前项目以及依赖,最后运行一系列预定义的测试流程,对流行的 Python 开源项目来说基本是标配。 除了单元测试, tox 中还加入了风格检查,格式化,文档及包的构建任务。最后配合 tox-gh-actions 这一插件在 GitHub Actions 中运行 tox,可以确保所有的测试任务都将在 CI 中自动运行。 文档生成 对于 Python 项目来说文档生成工具通常有两个选择:Sphinx 或者 Mkdocs,我曾经写过一篇关于使用 Sphinx 的博客:使用 Sphinx 为项目自动生成 API 文档。在模板中我选择后者的理由如下: Mkdocs 以 markdown 作为默认格式,而不需要写任何 .rst 文档。 Mkdocs 的配置更为简单,所有的配置都在 mkdocs.yml 文件中 mkdocs-material 主题非常赞。 Mkdocs 可以通过 mkdocs serve 命令实时预览文档效果。 Mkdocs 拥有众多插件和扩展,大部分 Sphinx 具有的特性都有替代实现,比如通过 mkdocstrings 实现 autodoc 的自动生成代码文档功能。 CI 自动化 我们将使用免费的 GitHub Actions 作为 CI,自动执行一系列的测试、构建和发布任务。 自动运行测试 集成了 tox 的另一大好处,是可以轻松地通过 tox-gh-actions 与 Actions 集成,分别以不同的 Python 版本以及不同的架构平台创建多个运行机器,并行地执行测试任务。运行的效果如下: 每一次 push 或者 pull_request 都将以不同的平台和 Python 版本运行完整的测试流程。 自动发布到 PyPI 参考 PyPa 发布的 Publishing package distribution releases using GitHub Actions CI/CD workflows,我们将项目配置为:当向远程仓库推送标签时,自动将项目打包后分别发布到 TestPyPI 和 PyPI。 自动创建 GitHub Release GitHub 的 Release 功能可以让关注项目的用户快捷地获取最新版本和历史记录,但手动发布是不可能地,一定要做成自动的。这套自动化的工作流如下: 参照 keep a changelog 为项目维护一个符合其标准的 CHANGELOG.md 版本历史文件。 向远程仓库推送标签以发布新版本并触发下面的流程。 通过 changelog-reader-action 这一 action 从 CHANGELOG.md 文件中按规则解析出最新版本的版本信息。 执行项目的构建得到最新版本的包。 通过 action-gh-release 这一 action 根据上面解析得到的版本历史信息自动创建新的 Realease,并以构建好的包作为附件。

2021/6/25
articleCard.readMore

使用 Sphinx 为项目自动生成 API 文档

对于一个优秀的开源项目,高质量的文档与代码同样重要,糟糕的文档会将大部分潜在用户拒之门外。 从流行的开源项目中,我们可以总结出一些关于文档的最佳实践: 文档和代码库在一个仓库中维护以做到同步更新(个别体量超大的项目例外) 使用纯文本格式编写文档,如 markdown 或 reStructText,然后通过生成工具将其转换成易于阅读且样式丰富的静态网页,进一步可通过 CI 将静态网页托管到可公开访问的站点 在文档中直接引用实际的代码,并提供 API 文档 API 文档通常位于文档中的 API Reference 或 References 章节,它能节省用户查阅源码的时间,帮助用户快速理解项目的内部组成。对于作为库(而非应用)的项目而言,提供组织良好的 API 文档尤为重要,如 Flask、Click 等,本文就将介绍如何使用 Sphinx 为项目自动生成 API 文档。 示例项目所使用的的完整源码已推送到 GitHub 的 demo 仓库中。 为什么是 Sphinx 有许多开源工具可以用来将纯文本转换为静态网页,选择 Sphinx 的理由有以下几点: 经典老牌,使用相当广泛,Python 的官方文档也是用它所生成 功能全面,支持各种主题和插件,和 Python 具有语言上的亲和性 能够很方便地根据代码的文档字符串自动生成 API 文档 Sphinx 的默认文本格式是 reStructuredText(简称 rst),它是一种极其强大的标记语言,配合 Sphinx 可以实现很多 markdown 所不具备的解析、引用特性,缺点是规则和指令较为复杂。这原本并不算缺点,但随着 markdown 的流行,人们似乎无法再接受任何比 markdown 更复杂一点的标记语言了。 不过这并不是问题,我们也可以借助 MyST 解析器实现 Sphinx 和 markdown 的完全兼容,接下来的示例中我们将混合使用 md 和 rst 两种格式,当然你也可以只使用任意一种格式。 新建文档项目 安装 Sphinx 示例将是一个 Python 语言项目,因此最直接的方式当然是通过 pip 安装: 1 pip install sphinx 对于其他语言及平台, Sphinx 也提供了丰富的安装途径,详见 Installing Sphinx。 设置文档源目录 我们将在同一个项目中维护代码和文档,因此首先需要在项目根目录新建一个 docs 文件夹(也可以使用其他名称),用来存放所有和文档有关的文件,我们将使用该文件夹作为 Sphinx 工作的源目录(source directory)。 初始化项目 切换到 docs 目录,执行以下命令在该目录初始化一个新的 Sphinx 文档项目: 1 sphinx-quickstart 接下来根据命令行提示完成初始化项目的各项配置,如填写项目名称和作者,对于不理解的选项回车使用默认选项即可。 初始化后的 docs 目录内容如下: 1 2 3 4 5 6 7 8 9 10 docs ├── Makefile ├── _build ├── _static ├── _templates ├── conf.py ├── index.rst └── make.bat 3 directories, 4 files 其中最重要的是 conf.py 文件,它是一个 Sphinx 将会运行的 Python 模块,保存着刚刚通过命令行输入的配置,还可以编辑文件来修改配置或添加插件,并通过该文件运行需要的 Python 代码。 Makefile 和 make.bat 提供了一些快捷命令,在 docs 目录执行 make html 即可通过源文件生成静态网页。 index.rst 是文档源文件的首页,文档里有一些默认的样板内容,通常我们将其作为访问其他页面的入口目录。 此时已经可以运行 make html 生成静态网页用于预览,生成的 HTML 页面保存在 _build/html 目录。使用浏览器打开后效果如下: 如果对默认的样式不满意,还可以在 Sphinx Themes Gallery 找到大量可以轻松替换的文档主题。 基本使用 初识 reStructuredText 接下来我们借助 index.rst 简单认识一下 reStructuredText 格式。 标题 1 2 Welcome to sphinx-demo's documentation! ======================================= 在文本的下一行连续输入同一标点符号,即可将文本标记为标题,另外还有很违反直觉的两点要求: 符号的长度需要超出文本 无法显式地指定标题级别,如果解析到另一种符号标记的标题,将根据出现顺序指定为新的标题级别,如 1 2 3 4 5 Secondary Title ---------------- Third Title ~~~~~~~~~~~~ 相比 markdown 直接使用符号的个数指示标题级别,这种规则并不算直观。 目录树 1 2 3 4 5 6 .. toctree:: :maxdepth: 2 :caption: Contents: install reference 形如 .. {directive}:: 这样的文本在 rst 中被称为指令,可以实现各种特殊效果,比如 .. toctree:: 将在当前位置插入一颗目录树。在指令的下一行可以通过 :{option}: value 指定选项,如上示例中指定了该目录树的最大深度和标题。 再往下就是目录树的条目了,Sphinx 将根据条目名称如 install 作为相对路径在源目录即 docs 中寻找名为 install.rst 或 install.md 的文件,并生成指向该文件的链接。 引用 1 2 3 4 5 6 Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` 这部分内容包含了 3 种格式: 首先是我们上面介绍过的标题。 然后是和 markdown 类似地通过 * 渲染列表项。 最后是 :ref:`label_name` ,生成一条指向 label_name 标签的交叉引用,这时需要通过 .. _label_name: 指令提前创建一个全局唯一的标签,如为一个标题创建标签: 1 2 3 4 .. _label_name: Section to cross-reference -------------------------- 我们仅简单介绍几种在 index.rst 中出现过的格式,实际上 reStructuredText 能实现的功能远不限于此。 添加 markdown 支持 为 Sphinx 添加 markdown 支持非常简单。 首先安装 MyST 插件: 1 pip install myst-parser 接着修改 conf.py 文件,找到 extensions = [] 所在的一行,向该列表中添加 "myst_parser": 1 extensions = ["myst_parser"] 之后源目录中所有的 .md 文件就会像 .rst 一样被 Sphinx 正常解析。 添加新的文档内容 向首页添加新的文本段落 在已有的标题和指令之外直接插入文本即可,如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Welcome to sphinx-demo's documentation! ======================================= .. toctree:: :maxdepth: 2 :caption: Contents: Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` 新内容 ====== 这是一个新的段落 生成 HTML 页面如下: 添加新的 .rst 文件 在 docs 目录中创建一个新的 install.rst ,在文件中写入标题,然后在首页文件中添加相应的目录条目或者引用,本例中我们添加到目录树: 1 2 3 4 5 .. toctree:: :maxdepth: 2 :caption: Contents: install 添加新文件时需要注意两点: 新文件中需要至少有一个标题,否则不会生成相应的链接,且链接默认会使用该标题作为展示文本。 在引用中添加子目录前缀即可在源目录中通过子目录管理文件。 添加新的 .md 文件 我们尝试在 docs 中创建一个子目录 references ,然后在该目录中添加 api_reference.md 文件,接着在 index.rst 文件中添加以下内容引用新添加的文件: 1 2 3 4 Appendix ========= :doc:`references/api_reference` 实质上与添加 .rst 文件完全相同,因此新的文档中也需要包含标题才能生成链接。 最终通过 make html 生成的 HTML 页面效果如下: 生成 API 文档 以上介绍的内容,已经足够为项目编写一般的文本内容文档,最后来看如何通过代码自动生成 API 文档。 以单文件模块作为示例,结果同样适用于多文件的包和模块,假设我们的代码位于根目录下的 main.py 文件(即 main 模块)中,内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Human: """Foo class""" def __init__(self, gender, name): """Make a virtual human. :param sex: gender of human. :param name: name of human. """ self.gender = gender self.name = name def speak(self, words): """speak some words. :param words: words to speak. :return: None """ print(words) def get_intro(self): """get man's introduction. :return: self introduction string. """ return f'Name: {self.name};Gender: {self.gender}' 启用扩展 Sphinx 通过 autodoc 扩展导入源代码并解析其文档字符串转换成文档。此外还可通过 viewcode 扩展直接从文档访问 API 对应的源代码页面。 首先编辑 conf.py 文件以启用扩展,在 extensions 配置项中加入两个扩展项: 1 extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 为了获取文档字符串,Sphinx 需要能够导入文档所在的代码模块。也就是说,我们的源码模块必须位于 Sphinx 的导入层级结构中,这有许多种实现途径,本例选择在 conf.py 中将代码模块所在的目录(即 conf.py 文件所在的上一级目录)加入到 sys.path 中,在 conf.py 中加入以下代码: 1 2 3 4 import os import sys sys.path.insert(0, os.path.abspath('..') 需要注意被 Sphinx 导入时模块中的代码会被执行一次,因此我们应该隔离模块中的执行逻辑以避免产生副作用,如将其包裹在 if __name__ == '__main__' 代码块中。 导入文档 最后,在 references/api_reference.md 文档中通过 automodule 指令导入 main 模块的文档: 1 2 3 4 5 6 # API Reference ​```{eval-rst} .. automodule:: main :members: ​``` 注意: 除了 automodule ,还可使用 autoclass 、 autofunction 等指令为其他类型的 Python 对象自动生成文档。 为了在 markdown 格式中使用 autodoc ,需要使用 eval-rst 指令对导入文档的指令做一层封装,详见 Use sphinx.ext.autodoc in Markdown files,它等价于 .rst 文件的如下写法: .. automodule:: main :members: references/api_reference.md 生成的页面效果如下: 通过页面右侧的 [source] 链接还能够直接从文档页面跳转到相应的源代码: 文档字符串风格 上面的示例代码中,我们使用 reStructuredText 风格编写文档字符串,如果你更喜欢 NumPy 或 Google 风格的文档字符串,可以启用 napoleon 扩展与之兼容。napoleon 是一个预处理器,在 autodoc 处理文档字符串之前将其转换为正确的 reStructuredText 文本。 启用方法同样是修改 conf.py 文件的 extensions 配置项: 1 2 3 4 # conf.py # Add napoleon to the extensions list extensions = ['sphinx.ext.napoleon'] 启用后 autodoc 将能够同时支持 reStructuredText、NumPy 及 Google 三种不同风格的文档字符串。 其他途径 除了 Sphinx ,使用 MkDocs 配合 mkdocstrings 扩展包也能实现自动从源码生成文档。相比之下 MkDocs 配置更加简单,原生支持 markdown 且可以运行预览用的开发服务器,目前在 Python 项目中也非常流行。 参考链接 Getting Started - Sphinx documentation How-To Guides

2021/6/7
articleCard.readMore

Python 时间处理标准库:time 与 datetime 模块

Python 中内置了许多和操作时间有关的 API,它们分布在 time , datetime 等标准库中,用法繁多且容易混淆,本文将力求清晰地阐述这些 API 的关键部分和区别,帮助你了解并掌握其用法。 下文将分别介绍每个模块的主要目的、核心对象、常用方法以及用途,并在最后做分析对比,如果已经了解这些细节可以直接跳转到结尾的总结对比部分。 另外本文将避免涉及字符串格式化、时区、冬夏令时等更复杂深入的话题。 time 模块 概括来说,time 模块通过系统底层的计时器获取自 epoch 以来经过的总秒数(可能为浮点数),即我们常说的 POSIX 时间戳(timestamp)。它的用法较为低阶,适合用做精确计时。对 Unix 系统来说, epoch 为 1970年1月1日 00:00:00(UTC),因此该模块也可以将时间戳转换为具体的日期时间,但表示日期时间的对象结构非常简单,不适合进行复杂的操作和表示。 核心对象 time 模块的 API 中只有一个类: time.struct_time。 struct_time 是一个转换 epoch 以来经过秒数得到的结构化的时间对象,它提供了类似 namedtuple 的 API,可以通过下标或属性名称获取对象的年月日时分秒等属性。调用 gmtime() ,localtime(),strptime() 等方法可得到 struct_time 实例。 1 2 3 4 5 6 7 >>> st = time.localtime() >>> st time.struct_time(tm_year=2021, tm_mon=4, tm_mday=29, tm_hour=12, tm_min=39, tm_sec=14, tm_wday=3, tm_yday=119, tm_isdst=0) >>> st.tm_mon 4 >>> st[1] 4 从示例中可以看到,struct_time 实例实质是一个数字组成的类元祖序列,该模块中接收 struct_time 实例作为参数的函数都可以直接接收一个同样长度的元祖。它只能简单的记录通过换算时间戳得到的年月日时分等属性,没有提供支持额外操作的其他方法,因此实践中的用途非常有限。 1 2 3 4 5 6 7 >>> st1 = time.localtime() >>> st2 = time.localtime() >>> st2 - st1 Traceback (most recent call last): File "<input>", line 1, in <module> st2 - st1 TypeError: unsupported operand type(s) for -: 'time.struct_time' and 'time.struct_time' 常见用途与函数 计时 time.time() 以浮点数的形式返回自 epoch 以来经过的时间秒数。常见用法是通过计算两次调用之间的间隔来得出程序执行时间。 1 2 >>> time.time() 1619665423.683973 time.sleep(seconds) 暂停调用线程的执行,暂停时间为给定的秒数。经常用于测试模拟,实际的暂停时间可能超出给定秒数。 time.perf_counter() 是计算较短时间间隔的更好方法,结果更为精确,在计算执行时间时可替代上述的 time.time()。 1 2 3 4 >>> start = time.perf_counter() >>> end = time.perf_counter() >>> end - start 2.731515233999744 在 struct_time 和时间戳之间进行转换 time.gmtime([secs]) 将给定秒数转换为一个 UTC 时区 struct_time 对象,若未提供秒数将使用 time.time() 得到的返回值。 1 2 3 4 5 >>> now = time.time() >>> time.gmtime(now) time.struct_time(tm_year=2021, tm_mon=4, tm_mday=29, tm_hour=4, tm_min=51, tm_sec=54, tm_wday=3, tm_yday=119, tm_isdst=0) >>> time.gmtime() time.struct_time(tm_year=2021, tm_mon=4, tm_mday=29, tm_hour=4, tm_min=51, tm_sec=56, tm_wday=3, tm_yday=119, tm_isdst=0) time.localtime([secs]) 将给定秒数转换为一个本地时区的 struct_time 对象,若未提供秒数将使用 time.time() 得到的返回值。 1 2 >>> time.localtime() time.struct_time(tm_year=2021, tm_mon=4, tm_mday=29, tm_hour=12, tm_min=53, tm_sec=38, tm_wday=3, tm_yday=119, tm_isdst=0) time.mktime(t) 将一个 struct_time 对象转换为秒数,该对象将被当做本地时区处理,效果刚好与 time.localtime([secs]) 相反。 1 2 >>> time.mktime(time.localtime()) 1619672313.0 在 struct_time 和字符串之间进行转换 time.strftime(format[, t]) 将一个 struct_time 对象按指定的 format 编码格式化为字符串,t 的默认值是 time.localtime() 的返回值。 1 2 >>> time.strftime('%H:%M:%S') '13:10:37' time.strptime(string[, format]) 将一个字符串按指定的 format 编码解析为 struct_time 对象,format 的默认值为 "%a %b %d %H:%M:%S %Y"。 1 2 3 >>> time.strptime("30 Nov 00", "%d %b %y") time.struct_time(tm_year=2000, tm_mon=11, tm_mday=30, tm_hour=0, tm_min=0, tm_sec=0, tm_wday=3, tm_yday=335, tm_isdst=-1) 如上示例,解析时未提供的时间单位将使用默认值填充。 datetime 模块 datetime 模块支持日期和时间的运算,但实现的重点是为输出格式化和操作提供高效的属性提取。 datetime 模块提供了一些用于操作日期和时间的类。该模块的绝大部分功能都围绕着以下 4 个类(以及另外两个关于时区的类)的方法和属性来实现。一个容易让人混淆的点是,虽然它们全都是 Python 类,但在命名中并未遵循首字母大写的惯例,在导入时看上去就像是 datetime 下的子包或者子模块。 我们将简要介绍每一个类常用的实例构造方式、支持的操作符、实例方法以及实例属性。 date 表示日期类型。 实例构造方式 实例化 date 类,需要传入日期对应的年月日参数。 1 2 >>> date(2021, 4, 29) datetime.date(2021, 4, 29) 调用 date.fromtimestamp(timestamp) 类方法,需要传入的参数为通过 time 模块获取的 epoch 以来秒数(即时间戳)。 1 2 >>> date.fromtimestamp(time.time()) datetime.date(2021, 4, 29) 调用 date.today() 类方法,实质是以当前时间戳作为参数调用 date.fromtimestamp() 类方法。 调用 date.fromisoformat(date_string) 类方法,这是一种较为直观的创建方法: 1 2 >>> date.fromisoformat('2021-04-29') datetime.date(2021, 4, 29) 支持的操作符 支持与另一 date 对象进行 ==,≤,<,≥,> 等比较操作。 支持与 timedelta 对象进行加减操作,结果依然为 date 对象。 支持与另一 date 对象进行相减操作,得到 timedelta 对象。 支持哈希。 实例方法 strftime(self, fmt) 按指定的 fmt 格式化编码返回当前 date 对象的字符串表示。 1 2 3 >>> d1 = date.today() >>> d1.strftime('%Y-%m-%d') '2021-04-29' isoformat(self) 返回当前 date 对象的 iso 字符串表示。 1 2 >>> d1.isoformat() '2021-04-29' timetuple(self) 将当前 date 对象转换成 time 模块的 struct_time 对象并返回,时分秒等属性使用默认值填充。 1 2 >>> d1.timetuple() time.struct_time(tm_year=2021, tm_mon=4, tm_mday=29, tm_hour=0, tm_min=0, tm_sec=0, tm_wday=3, tm_yday=119, tm_isdst=-1) replace(self, year=None, month=None, day=None) 返回替换当前 date 对象的某一属性后的副本。 1 2 >>> d1.replace(day=30) datetime.date(2021, 4, 30) weekday(self) 返回当前 date 对象所属的星期,从 0 开始。 1 2 >>> d1.weekday() 3 实例属性 year month day time 表示时间(时分秒)类型。 实例构造方式 time 不支持通过时间戳构造实例。 实例化 time 类并传入对应参数。需要传入时间对应的时分秒微秒等参数,参数均有取值范围且默认值为 0。 1 2 >>> date(2021, 4, 29) datetime.date(2021, 4, 29) 通过调用fromisoformat(cls, time_string) 类方法,从 iso 字符串中创建一个实例: 1 2 >>> time.fromisoformat('17:32:10') datetime.time(17, 32, 10) 支持的操作符 支持与另一 time 对象进行 ==,≤,<,≥,> 等比较操作。 支持哈希。 time 对象不支持与 time 或 timedelta 进行加减操作,如果我们想计算两个 time 对象之间的时间间隔,可以使用 datetime.combine() 将它们处理为日期相同的 datetime 对象再进行计算: 1 2 >>> datetime.combine(date.today(), t2) - datetime.combine(date.today(), t1) datetime.timedelta(seconds=4440) 实例方法 strftime(self, fmt) 按指定的 fmt 格式化编码返回当前 time 对象的字符串表示。 1 2 3 >>> t = time.fromisoformat('17:32:10') >>> t.strftime('%Hh %Mm %Ss') '17h 32m 10s' isoformat(self) 返回当前 time 对象的 iso 字符串表示。 1 2 3 >>> t = time(hour=17, minute=27, second=55) >>> t.isoformat() '17:27:55' replace(self,hour=None,minute=None,second=None,microsecond=None, tzinfo=True, *,fold=None) 返回替换当前 time 对象的某一属性后的副本。 1 2 >>> t.replace(hour=20) datetime.time(20, 27, 55) 实例属性 hour minute second 以及 micorsecond ,tzinfo,fold 等属性 datetime 表示包含日期时分的时间类型,是 date 的子类,因此也继承了 date 的所有属性和方法。它的实例还可以视作 date 和 time 实例的组合体,因此同时具备了两种对象的大部分方法和属性。 下文的介绍中不包含从 date 继承的方法和属性。 实例构造方式 实例化 datetime 类并传入对应参数,接收参数为 date 和 time 实例化参数的组合,其中日期参数为必填参数,其他参数有默认值。 1 2 >>> datetime(year=2021, month=4, day=29) datetime.datetime(2021, 4, 29, 0, 0) 调用 datetime.now() 或 datetime.utcnow() 类方法,区别为实例的对应时区不同。 1 2 3 4 >>> datetime.now() datetime.datetime(2021, 4, 29, 16, 4, 53, 648203) >>> datetime.utcnow() datetime.datetime(2021, 4, 29, 8, 5, 1, 671572) 调用 datetime.fromtimestamp(timestamp) 或 datetime.utcfromtimestamp(timestamp) 类方法并传入时间戳,区别为实例的对应时区不同。 1 2 3 4 5 >>> import time >>> datetime.utcfromtimestamp(time.time()) datetime.datetime(2021, 4, 29, 8, 6, 4, 798136) >>> datetime.fromtimestamp(time.time()) datetime.datetime(2021, 4, 29, 16, 6, 26, 251251) 通过调用 datetime.fromisoformat(time_string) 类方法,从 iso 字符串中创建一个实例: 1 2 >>> datetime.fromisoformat('2021-04-29 16:09:32') datetime.datetime(2021, 4, 29, 16, 9, 32) 通过调用 datetime.combine(date, time) 类方法,从 date 实例和 time 实例中创建一个新的 datetime 实例。 1 2 >>> datetime.combine(date.today(), time(16, 12)) datetime.datetime(2021, 4, 29, 16, 12) 通过调用 datetime.strptime(date_string, format) 类方法,解析格式化字符串并创建一个新的实例。 支持的操作符 datetime 支持与 date 进行相等比较,但结果一定为 False ,除此之外只支持与另一 datetime 对象执行 ==,≤,<,≥,> 等比较操作。 支持与 timedelta 相加,结果为 datetime;支持与 timedelta 对象进行加减,结果依然为 datetime 对象,与另一 datetime 对象进行相减,得到 timedelta 对象。 同样支持哈希。 实例方法 除了从 date 继承的 strftime()、timetuple()、isoformat() 和 replace()等方法外,还拥有以下方法: timestamp(self) 返回一个浮点数格式的 POSIX 时间戳。 1 2 3 >>> dt = datetime.now() >>> dt.timestamp() 1619685580.762657 date(self) 返回一个代表日期部分的 date 对象。 1 2 >>> dt.date() datetime.date(2021, 4, 29) time(self) 返回一个代表时分部分的 time 对象。 1 2 >>> dt.time() datetime.time(16, 39, 40, 762657) 实例属性 同时具有date 和 time 实例的所有属性。 timedelta 表示两个 datetime 对象之间的差异。 实例构造方式 实例化 timedelta 类并传入对应参数,接收参数与 datetime 类基本相同但不包括年,默认值均为 0。 1 2 >>> timedelta(days=2) datetime.timedelta(days=2) 对两个 datetime 执行相减: 1 2 3 4 >>> dt1 = datetime.now() >>> dt2 = datetime.now() >>> dt2 -dt1 datetime.timedelta(seconds=4, microseconds=476390) 支持的操作符 只支持与另一 timedelta 进行比较,进行 ==,≤,<,≥,> 等比较操作。 timedelta 对象支持支持加减操作,datetime 与 timedelta 相加或相减仍然返回 datetime。 timedelta 还支持乘除模除等操作符。 支持哈希。 timedelta 是有符号的,支持 abs() 函数,可返回两个 datetime 之间的绝对间隔。 1 2 3 4 5 6 7 8 9 >>> dt1 = datetime.now() >>> dt2 = datetime.now() >>> td = dt1 - dt2 >>> td datetime.timedelta(days=-1, seconds=86395, microseconds=573188) >>> td.total_seconds() -4.426812 >>> abs(td) datetime.timedelta(seconds=4, microseconds=426812) 实例方法 total_seconds(self) 返回该时间间隔的所有秒数。 1 2 3 >>> d = timedelta(minutes=3, seconds=35) >>> d.total_seconds() 215.0 实例属性 timedelta 只通过 days 、seconds,microseconds 这 3 种单位进行组合来保存时间间隔,可通过对应属性获取数值。 1 2 3 4 5 6 7 8 >>> d1 = timedelta(minutes=3, seconds=35) >>> d1 datetime.timedelta(days=0, seconds=215, microseconds=0) >>> d2 = timedelta(days=1) >>> d2 datetime.timedelta(days=1) >>> d2.seconds 0 总结对比 time 与 datetime 模块的区别: time 模块,获取系统时间戳,主要用于计时或表示某一时间点,可以通过数值元祖表示结构化的日期时间,但不支持进一步的转换或操作。 datetime 模块,基于时间戳构建高阶的日期、时间、间隔等对象,支持丰富的转换方式和操作。 datetime 模块中不同对象的区别: date 只表示日期。支持与 date 或 timedelta 进行加减操作. time 只表示时分。不支持与 time 或 timedelta 进行加减操作,计算间隔需要先转换成 datetime 对象。 datetime 同时表示日期和时分的时间对象。同时具备 date 和 time 对象的行为和属性,可以从中解析出单独的 date 和 time 对象。 timedelta 表示两个时间之间的间隔。只通过 days 、seconds,microseconds 这 3 种单位来表示。 字符串格式化与解析 字符串格式化与解析: time.struct_time、datetime.date、datetime.time、datetime.datetime 等对象都可以通过 strftime() (string format)实例方法或函数转换为指定格式的字符串。 特定格式的字符串仅可以通过 strptime()(string parse)类方法或函数直接转换为time.struct_time、datetime.datetime 对象。 ISO 格式字符串格式化与解析: datetime.date、datetime.time、datetime.datetime 等对象都可以通过 isoformat() 实例方法转换为 ISO 8601 格式的字符串。 ISO 8601 格式的字符串可以通过 fromisoformat() 类方法直接转换为datetime.date、datetime.time、datetime.datetime 对象。 图表 用一张时序图总结上文内容: [ts] 表示该参数具有默认值是可选的。 请注意区分图中的实例方法、类方法以及模块函数: 名称中以 time. 开头的均为 time 模块的函数 名称中以 obj. 开头的均为 date、time 或 datetime 对象的实例方法 其余名称的函数均为类方法

2021/4/29
articleCard.readMore

理解 Python 中的描述符

描述符是 Python 中的一个进阶概念,也是许多 Python 内部机制的实现基础,本文将对其做适当深入的介绍。 描述符的定义 描述符的定义很简单,实现了下列任意一个方法的 Python 对象就是一个描述符(descriptor): __get__(self, obj, type=None) __set__(self, obj, value) __delete__(self, obj) 这些方法的参数含义如下: self 是当前定义的描述符对象实例。 obj 是该描述符将作用的对象实例。 type 是该描述符作用的对象的类型(即所属的类)。 上述方法也被称为描述符协议,Python 会在特定的时机按协议传入参数调用某一方法,如果我们未按协议约定的参数定义方法,调用可能会出错。 描述符的作用 描述符可以用来控制对属性的访问行为,实现计算属性、懒加载属性、属性访问控制等功能,我们先来举个简单的例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Descriptor: def __get__(self, instance, owner): if instance is None: print('__get__(): Accessing x from the class', owner) return self print('__get__(): Accessing x from the object', instance) return 'X from descriptor' def __set__(self, instance, value): print('__set__(): Setting x on the object', instance) instance.__dict__['_x'] = value class Foo: x = Descriptor() 在示例中我们创建了一个描述符实例,并将其赋值给 Foo 类的 x 属性变量。现在访问 Foo.x ,会发现 Python 自动调用了该属性所绑定的描述符实例的 __get__() 方法: 1 2 3 >>> print(Foo.x) __get__(): Accessing x from the class <class '__main__.Foo'> <__main__.Descriptor object at 0x106e138e0> 接下来实例化一个对象 foo,并通过 foo 对象访问 x 属性: 1 2 3 4 >>> foo = Foo() >>> print(foo.x) __get__(): Accessing x from the object <__main__.Foo object at 0x105dc9340> X from descriptor 同样执行了描述符所定义的相应方法。 如果我们尝试对 foo 对象的 x 进行赋值,也会调用描述符的 __set__() 方法: 1 2 3 4 5 6 7 >>> foo.x = 1 __set__(): Setting x on the object <__main__.Foo object at 0x105dc9340> >>> print(foo.x) __get__(): Accessing x from the object <__main__.Foo object at 0x105dc9340> X from descriptor >>> print(foo.__dict__) {'_x': 1} 同理,如果我们在描述符中定义了 __delete__() 方法,该方法将在执行 del foo.x 时被调用。 描述符在属性查找过程中会被 . 点操作符调用,且只有在作为类变量使用时才有效。 如果直接赋值给实例属性,描述符不会生效。 >>> foo.__dict__['y'] = Descriptor() >>> print(foo.y) <__main__.Descriptor object at 0x100f0d130> 如果用 some_class.__dict__[descriptor_name] 的方式间接访问描述符,也不会调用描述符的协议方法,而是返回描述符实例本身。 print(Foo.__dict__['x']) <__main__.Descriptor object at 0x10b66d8e0> 描述符的类型 根据所实现的协议方法不同,描述符又可分为两类: 若实现了 __set__() 或 __delete__() 任一方法,该描述符是一个数据描述符(data descriptor)。 若仅实现 __get__() 方法,该描述符是一个非数据描述符(non-data descriptor)。 两者的在表现行为上存在差异: 数据描述符总是会覆盖实例字典 __dict__ 中的属性。 而非数据描述可能会被实例字典 __dict__ 中定义的属性所覆盖。 在上面的示例中我们已经展示数据描述符的效果,接下来去掉 __set__() 方法实现一个非数据描述符: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class NonDataDescriptor: def __get__(self, instance, owner): if instance is None: print('__get__(): Accessing y from the class', owner) return self print('__get__(): Accessing y from the object', instance) return 'Y from non-data descriptor' class Bar: y = NonDataDescriptor() bar = Bar() 当 bar.__dict__ 不存在键为 y 的属性时,访问 bar.y 和 foo.x 的行为是一致的: 1 2 >>> print(bar.y) Y from non-data descriptor 但如果我们直接修改 bar 对象的 __dict__,向其中添加 y 属性,则该对象属性将覆盖在 Bar 类中定义的 y 描述符,访问 bar.y 将不再调用描述符的 __get__() 方法: 1 2 3 >>> bar.__dict__['y'] = 2 >>> print(bar.y) 2 而在上文的数据描述符示例中,即使我们修改 foo.__dict__,对 x 属性的访问始终都由描述符所控制: 1 2 3 >>> foo.__dict__['x'] = 1 >>> print(foo.x) __get__(): Accessing x from the object <__main__.Foo object at 0x102b40340> 在下文中我们会介绍这两者的差异是如何实现的。 描述符的实现 描述符控制属性访问的关键,在于从执行 foo.x 到 __get()__ 方法被调用这中间所发生的过程。 对象属性如何保存 一般来说,对象的属性保存在 __dict__ 属性中: 根据 Python 文档介绍,object.__dict__ 是一个字典或其他的映射类型对象,用于存储一个对象的(可写)属性。 除了一些 Python 的内置对象以外,大部分自定义的对象都会有一个 __dict__ 属性。 这个属性包含了所有为该对象定义的属性,__dict__ 也被称为 mappingproxy 对象。 我们从之前的示例继续: 1 2 3 4 >>> print(foo.__dict__) {'_x': 1} >>> foo.x 1 当我们访问 foo.x ,Python 是如何判断应该调用描述符方法还是从 __dict__ 中获取对应值的呢?其中起关键作用的是 . 这个点操作符。 对象属性如何访问 点操作符的查找逻辑位于 object.__getattribute__() 方法中,每一次向对象执行点操作符都会调用对象的该方法。CPython 中该方法由 C 实现,我们来看一下它的等价 Python 版本: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def object_getattribute(obj, name): "Emulate PyObject_GenericGetAttr() in Objects/object.c" null = object() objtype = type(obj) cls_var = getattr(objtype, name, null) descr_get = getattr(type(cls_var), '__get__', null) if descr_get is not null: if (hasattr(type(cls_var), '__set__') or hasattr(type(cls_var), '__delete__')): return descr_get(cls_var, obj, objtype) # data descriptor if hasattr(obj, '__dict__') and name in vars(obj): return vars(obj)[name] # instance variable if descr_get is not null: return descr_get(cls_var, obj, objtype) # non-data descriptor if cls_var is not null: return cls_var # class variable raise AttributeError(name) 理解以上代码可知,当我们访问 object.name 时会依次执行下列过程: 首先从 obj 所属的类 objtype 中查找 name 属性,如果对应的类变量 cls_var 存在,尝试获取 cls_var 所属的类的 __get__ 属性。 如果 __get__ 属性存在,即说明 cls_var (至少)是一个非数据描述符。接下来将判断该描述符是否为数据描述符(判断有无 __set__ 或 __delete__ 属性),如果是,则调用在描述符中定义的 __get__ 方法,并传入当前对象 obj 和当前对象所属类 objtype 作为参数,最后返回调用结果,查找结束,数据描述符完全覆盖了对对象本身 __dict__ 的访问。 如果 cls_var 为非数据描述符(也可能并非描述符),此时将尝试在对象的字典 __dict__ 中查找 name 属性,若有则返回该属性对应的值。 如果在 obj 的 __dict__ 中未找到 name 属性,且 cls_var 为非数据描述符,则调用在描述符中定义的 __get__ 方法,和上文一样传入相应参数并返回调用结果。 如果 cls_var 不是描述符,则将其直接返回。 如果最后还没找到,唤起 AttributeError 异常。 在以上过程中,当我们从 obj 所属的类 objtype 中获取 name 属性时,若 objtype 中没找到将尝试从其所继承的父类中查找,具体的顺序取决于 cls.__mro__ 类方法的返回结果: 1 2 >>> print(Foo.__mro__) (<class '__main__.Foo'>, <class 'object'>) 现在我们知道,描述符在 object.__getattribute__() 方法中根据不同条件被调用,这就是描述符控制属性访问的工作机制。如果我们重载 object.__getattribute__() 方法,甚至可以取消所有的描述符调用。 __getattr__ 方法 实际上,属性查找并不会直接调用 object.__getattribute__() ,点操作符会通过一个辅助函数来执行属性查找: 1 2 3 4 5 6 7 8 def getattr_hook(obj, name): "Emulate slot_tp_getattr_hook() in Objects/typeobject.c" try: return obj.__getattribute__(name) except AttributeError: if not hasattr(type(obj), '__getattr__'): raise return type(obj).__getattr__(obj, name) # __getattr__ 因此,如果 obj.__getattribute__() 的结果引发异常,且存在 obj.__getattr__()方法,该方法将被执行。如果用户直接调用 obj.__getattribute__(),__getattr__() 的补充查找机制就会被绕过。 假如为 Foo 类添加该方法: 1 2 3 4 5 6 7 class Foo: x = Descriptor() def __getattr__(self, item): print(f'{item} is indeed not found') foo = Foo() 然后分别调用 foo.z 和 bar.z: 1 2 3 4 >>> foo.z z is indeed not found >>> bar.z AttributeError: 'Bar' object has no attribute 'z' 该行为仅在对象所属的类定义了 __getattr__()方法时才生效,在对象中定义 __getattr__ 方法,即在 obj.__dict__ 中添加该属性是无效的,这一点同样适用于 __getattribute__() 方法: 1 2 3 4 5 >>> bar.__getattr__ = lambda item:print(f'{item} is indeed not found') >>> print(bar.__dict__) {'__getattr__': <function <lambda> at 0x1086e1430>} >>> bar.z AttributeError: 'Bar' object has no attribute 'z' Python 内部的描述符 除了一些自定义的场景,Python 本身的语言机制中就大量使用了描述符。 property property 的具体效果我们不再赘述,下面是其常见的语法糖用法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class C: def __init__(self): self._x = None @property def x(self): """I'm the 'x' property.""" return self._x @x.setter def x(self, value): self._x = value @x.deleter def x(self): del self._x property 本身是一个实现了描述符协议的类,它还可以通过以下等价方式使用: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class C: def __init__(self): self._x = None def getx(self): return self._x def setx(self, value): self._x = value def delx(self): del self._x x = property(getx, setx, delx, "I'm the 'x' property.") 在上面例子中 property(getx, setx, delx, "I'm the 'x' property.") 创建了一个描述符实例,并赋值给了 x。property 类的实现与下面的 Python 代码等价: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class Property: "Emulate PyProperty_Type() in Objects/descrobject.c" def __init__(self, fget=None, fset=None, fdel=None, doc=None): self.fget = fget self.fset = fset self.fdel = fdel if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = doc def __get__(self, obj, objtype=None): # 描述符协议方法 if obj is None: return self if self.fget is None: raise AttributeError("unreadable attribute") return self.fget(obj) def __set__(self, obj, value): # 描述符协议方法 if self.fset is None: raise AttributeError("can't set attribute") self.fset(obj, value) def __delete__(self, obj): # 描述符协议方法 if self.fdel is None: raise AttributeError("can't delete attribute") self.fdel(obj) def getter(self, fget): # 实例化一个拥有 fget 属性的描述符对象 return type(self)(fget, self.fset, self.fdel, self.__doc__) def setter(self, fset): # 实例化一个拥有 fset 属性的描述符对象 return type(self)(self.fget, fset, self.fdel, self.__doc__) def deleter(self, fdel): # 实例化一个拥有 fdel 属性的描述符对象 return type(self)(self.fget, self.fset, fdel, self.__doc__) property 在描述符实例的字典内保存读、写、删除函数,然后在协议方法被调用时判断是否存在相应函数,实现对属性的读、写与删除的控制。 函数 没错,每一个我们定义的函数对象都是一个非数据描述符实例。 这里使用描述符的目的,是让在类定义中所定义的函数在通过对象调用时成为绑定方法(bound method)。 方法在调用时会自动传入对象实例作为第一个参数,这是方法和普通函数的唯一区别。通常我们会在定义方法时,将这个形参指定为 self。方法对象的类定义与下面的代码等价: 1 2 3 4 5 6 7 8 9 10 11 class MethodType: "Emulate PyMethod_Type in Objects/classobject.c" def __init__(self, func, obj): self.__func__ = func self.__self__ = obj def __call__(self, *args, **kwargs): func = self.__func__ obj = self.__self__ return func(obj, *args, **kwargs) 它在初始化方法中接收一个函数 func 和一个对象 obj,并在调用时将 obj 传入 func 中。 我们举一个实际的例子: 1 2 3 4 5 6 7 8 9 10 >>> class D: ... def f(self, x): ... return x ... ... >>> d = D() >>> D.f(None, 2) 2 >>> d.f(2) 2 可以看到,当通过类属性调用 f 时,其行为就是一个正常的函数,可以将任意对象作为 self 参数传入;当通过实例属性访问 f 时,其效果变成了绑定方法调用,因此在调用时会自动将绑定的对象作为第一个参数。 显然在通过实例访问属性时创建一个 MethodType 对象,这正是我们可以通过描述符实现的效果。 函数的具体实现如下: 1 2 3 4 5 6 7 8 class Function: ... def __get__(self, obj, objtype=None): "Simulate func_descr_get() in Objects/funcobject.c" if obj is None: return self return MethodType(self, obj) 通过 def f() 定义函数时,等价于 f = Function() ,即创建一个非数据描述符实例并赋值给 f 变量。 当我们通过类方法访问该属性时,调用 __get__() 方法返回了函数对象本身: >>> D.f <function D.f at 0x10f1903a0> 当我们通过对象实例访问该属性时, 调用 __get__() 方法创建一个使用以上函数和对象所初始化的 MethodType 对象: >>> d.f <bound method D.f of <__main__.D object at 0x10eb6fb50>> 概括地说,函数作为对象有一个 __get__() 方法,使其成为一个非数据描述符实例,这样当它们作为属性访问时就可以转换为绑定方法。非数据描述符将通过实例调用 obj.f(*args) 转换为 f(obj, *args),通过类调用 cls.f(*args) 转换成 f(*args)。 classmethod classmethod 是在函数描述符基础上实现的变种,其用法如下: 1 2 3 4 5 6 7 8 9 class F: @classmethod def f(cls, x): return cls.__name__, x >>> F.f(3) ('F', 3) >>> F().f(3) ('F', 3) 其等价 Python 实现如下,有了上面的铺垫会很容易理解: 1 2 3 4 5 6 7 8 9 10 11 12 class ClassMethod: "Emulate PyClassMethod_Type() in Objects/funcobject.c" def __init__(self, f): self.f = f def __get__(self, obj, cls=None): if cls is None: cls = type(obj) if hasattr(obj, '__get__'): return self.f.__get__(cls) return MethodType(self.f, cls) @classmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(type(obj), *args),通过类调用 cls.f(*args) 转换成 f(*args)。 staticmethod staticmethod 实现的效果是,不管我们通过实例调用还是通过类调用,最终都会调用原始的函数: 1 2 3 4 5 6 7 8 9 class E: @staticmethod def f(x): return x * 10 >>> E.f(3) 30 >>> E().f(3) 30 其等价 Python 实现如下: 1 2 3 4 5 6 7 8 class StaticMethod: "Emulate PyStaticMethod_Type() in Objects/funcobject.c" def __init__(self, f): self.f = f def __get__(self, obj, objtype=None): return self.f 调用 __get__() 方法时返回了保存在 __dict__ 中的函数对象本身,因此不会进一步触发函数的描述符行为。 @staticmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(*args),通过类调用 cls.f(*args) 也转换成 f(*args)。 参考链接 Descriptor HowTo Guide Python Refenrence - Data model - Implementing Descriptors Python Descriptors: An Introduction Python attribute lookup explained in detail

2021/4/15
articleCard.readMore

自己编译一个最新版本的 Python3

最近想看看 Python 3.10 的模式匹配新特性,刚好也了解了一些编译相关的基础知识,于是尝试下在平时使用的测试机上编译一份最新的 Python。 工作环境 测试机的操作系统是 Ubuntu 20.10,已安装有 gcc 、git 等基础的开发工具。经验证以下步骤同样适用于 macOS 11。 1 2 uname -a Linux waynerv-woqutech 5.8.0-48-generic #54-Ubuntu SMP Fri Mar 19 14:25:20 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux 下载源码 从 CPython 的官方仓库 https://github.com/python/cpython 克隆源码: 1 git clone https://github.com/python/cpython.git 这个仓库的文件体积很大,如果克隆时遇到网络问题,可尝试使用 Gitee 的「同步 GitHub 仓库」功能。 配置 核心的 CPython 编译器只需要一个基本的 C 编译器就可以编译,但一些扩展模块会需要开发头文件来提供一些额外的库(如压缩功能需要的zlib库),这也是为什么我们用操作系统提供的包管理器(如apt)安装 Python 时会需要 python3-dev 等等一大堆依赖。 由于我并不打算将自行编译的 Python 用于开发或生产,因此将跳过安装依赖这一步。 首先进入我们所克隆的仓库目录: 1 cd cpython 然后需要进行配置: 1 ./configure --prefix=/root/build-python 默认情况下,后续的 make install 命令会将编译得到的文件安装到 /usr/local/bin 或 /usr/local/lib,这可能会覆盖系统已有的安装文件,为了不和系统已安装的 Python 版本产生冲突,我们通过指定 --prefix 选项将其安装到一个自定义的目录。 编译 配置结束后运行编译: 1 make -s -j 4 执行编译时可通过 -j 选项指定并行的任务数量来加快速度,通常我们将其设置为编译机器的 CPU 数量,可以结合 nproc 命令使用: 1 make -s -j "$(nproc)" -s 选项意为 silence,即不打印编译的过程日志,也可启用。 编译的过程可能会比较耗时,成功后的输出如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Python build finished successfully! The necessary bits to build these optional modules were not found: _bz2 _curses _curses_panel _dbm _gdbm _hashlib _lzma _sqlite3 _ssl _tkinter _uuid readline To find the necessary bits, look in setup.py in detect_modules() for the module's name. The following modules found by detect_modules() in setup.py, have been built by the Makefile instead, as configured by the Setup files: _abc pwd time Could not build the ssl module! Python requires an OpenSSL 1.0.2 or 1.1 compatible libssl with X509_VERIFY_PARAM_set1_host(). LibreSSL 2.6.4 and earlier do not provide the necessary APIs, https://github.com/libressl-portable/portable/issues/381 由于我们没有安装依赖,所以提示有部分模块未找到,但不影响使用 Python 的基本功能。 安装 编译成功后其实已经可以运行 Python 了。上述构建过程会在当前目录(非--prefix指定的目录)生成一个名为 python 的二进制文件(macOS 下是 python.exe),运行它即可启动 Python 解释器: 1 ./python 它会使用当前目录中编译生成的临时文件作为资源文件,现在我们将其安装到在配置步骤中指定的目录: 1 make install 安装过程会进行大量的资源复制,并调用 Python 解释器将使用 Python 实现的标准库编译为字节码(.pyc),此外默认还会安装 setuptools 和 pip 这两个 Python 包,因此当我们安装较新版本的 Python 版本时,基本不再需要手动安装 pip。 安装完成后切换到 /root/build-python 目录下,并查看其目录结构: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 cd /root/build-python tree -L 2 . . ├── bin │   ├── 2to3 -> 2to3-3.10 │   ├── 2to3-3.10 │   ├── idle3 -> idle3.10 │   ├── idle3.10 │   ├── pip3 │   ├── pip3.10 │   ├── pydoc3 -> pydoc3.10 │   ├── pydoc3.10 │   ├── python3 -> python3.10 │   ├── python3.10 # Python 可执行文件 │   ├── python3.10-config │   └── python3-config -> python3.10-config ├── include │   └── python3.10 # 头文件目录 ├── lib │   ├── libpython3.10.a # 静态库文件 │   ├── pkgconfig │   └── python3.10 # 标准库文件及 site-packages └── share └── man 现在执行 ./bin/python3 即可运行我们自己编译安装好的 Python: 1 2 3 Python 3.10.0a6+ (heads/master:80017752ba, Apr 6 2021, 13:47:23) [GCC 10.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> 大功告成!可以看到运行的 Python 版本是 3.10.0a6+,一个尚未发布的开发中版本。 如果我们需要其他稳定版本的 Python,只需要在源码仓库中 git checkout 到指定的 release 标签,然后再重新运行配置-编译-安装 3 个步骤就可以了。 参考链接 Python Developer’s Guide: Getting Started

2021/4/6
articleCard.readMore

[CSAPP] 计算机如何表示浮点数

如果你曾经使用过 Python 或者 JavaScript 进行浮点数计算,可能会遇到类似下面的情况: 1 2 >>> 0.1 + 0.2 0.30000000000000004 其原因是程序在运行时,所使用的浮点数类型并没有精确地表示 0.1 和 0.2 ,自然结果也就不等于 0.3。借着这个问题,我们来了解一下计算机在底层是如何表示这类浮点数的。 计算机无法精确地表示所有实数 这是一个需要明确的前提。 我们若想精确的表示某个集合中的每个元素,必须满足一个条件:每个元素都有一种与其他元素不同的表示。计算机通过多个位的 0 和 1 来表示信息,而实数是无限的,计算机的硬件资源却是有限的,这就注定了计算机无法精确地表示所有实数。最优解是我们可以找到一种合适的表示方法,利用有限的硬件资源(尽可能短的位数),尽可能精确地表示尽可能大的数值范围。 二进制小数 那么我们如何用二进制来表示小数呢? 首先考虑十进制是如何用小数点来表示小数的:$d_md_{m−1}...d_1d_0.d_{−1}d_{−2}...d_{−n}$,每一位的值 $d_i$ 取值范围是 0-9,上述表示所代表的数值 $d$ 为: $d = \displaystyle\sum_{{ i = -n}}^m 10^i × d_i$ 小数点左边第 m 位(从 0 开始)具有 $10^m$ 的加权,右边第 n 位(从 1 开始)具有 $10^{-n}$ 的加权。举例来说 $12.34_{10}$ 即代表 $1×10^1+2×10^0+3×10_{−1}+4×10_{−2}=12\frac{34}{100}$ 。 同样地,对二进制表示:$b_mb_{m−1}...b_1b_0.b_{−1}b_{−2}...b_{−n}$,每一位的值 $b_i$ 的取值范围是 0-1,所代表的数值 $b$ 为: $b = \displaystyle\sum_{{ i = -n}}^m 2^i × b_i$ 小数点左边第 m 位(从 0 开始)具有 $2^m$ 的加权,右边第 n 位(从 1 开始)具有 $2^{-n}$ 的加权。举例来说 $101.11_{2}$ 即代表 $1×2^2+0×2^1+1×2^0+1×2_{−1}+1×2_{−2}=5\frac{3}{4}$ 。 如同十进制小数点左移除以 10,右移乘以 10 的规则,二进制小数点也有左移除以 2,右移乘以 2 的规则。由于计算机只能进行有限位数的编码,因此从上述 b 的求值公式可知,这种方法只能精确的表示能够写作 $x × 2^y$ 的值。 IEEE 754 浮点数 用 $b_mb_{m−1}...b_1b_0.b_{−1}b_{−2}...b_{−n}$ 这种方式来表示很小的数或者很大的数是非常低效的,比如表示 $5 × 2^{500}$ 就需要至少 100 位。因此我们需要一种更聪明的表示方式,既然二进制能够表示的数可以归纳为 $x × 2^y$,我们可以仅表示 $x$ 和 $y$。 IEEE 754 浮点数标准是如今最流行和通用的浮点数表示标准。它用 $V = (−1)^s × M × 2^E$ 的形式来表示一个数字: 符号位 $s$ 决定该数字是正数($s=0$)还是负数($s=1$)。数值 0 是一种特殊的情况,我们会在之后解释。 有效数 $M$ 是一个二进制小数,它的取值范围为 1 和无限趋近于 2 之间,或者为 0 和无限趋近于 1 之间。具体处于哪个取值范围由指数部分的比特位是否全部为 0 来决定。 指数 $E$ 可能为正数也可能为负数,通过 2 的指数幂对最终表示的浮点数进行加权。 下面我们来看看 IEEE 浮点数是如何在计算机中工作的。IEEE 浮点数在计算机中最常见的实际表示分别是 32 位(单精度)和 64 位(双精度),其包含的比特位可以划分为 3 部分(如上图): 最左边的单个比特位 s 代表符号位 $s$ 的值。 由 k 个比特位构成的指数部分 exp $= e_{k-1}...e_1e_0$ 对指数 $E$ 进行编码。 由 n 个比特位构成的小数部分 frac $= f_{n-1}...f_1f_0$ 对有效数 $M$ 进行编码。 在单精度浮点数表示中,s 为 1 位,指数部分为 8 位(k=8),小数部分为 23 位(n=23),总共 32 位。 在双精度浮点数表示中,s 为 1 位,指数部分为 11 位(k=11),小数部分为 52 位(n=52),总共 64 位。 取决于指数部分的比特位的值,通过以上表示编码得到的值可分为下面 3 种情况。 规范化的值(Normalized) 规范化的值是最常见的情况,发生在当指数(exp)部分的比特位既非全部都是 0,也非全部都是 1 时,即其整数表示 $0 < exp < 255$ 时。 这时指数部分会被解释为一个偏置形式的有符号整数 $E$,计算方式为 $E = e - Bias$,其中 $e$ 是由指数部分所有比特位 $e_{k-1}...e_1e_0$ 表示的有符号整数,而 $Bias$ 是一个取决于指数部分比特位长度 k 的常数 $2^{k-1}-1$(单精度浮点数为 127, 双精度浮点数为 1023),因此指数 $E$ 的取值范围为 -126 到 127(单精度浮点数)或者 -1022 到 1023(双精度浮点数)。 小数部分会被解释为一个小数值 $f$,$f$ 的取值范围为 $0 <= f < 1$,其二进制表示为 $0.f_{n-1}...f_1f_0$。我们默认其小数点位于最左边,小数部分的比特位均表示小数点右边的值。而有效数 $M = 1 + f$,此时 $M$ 可看作二进制表示为 $1.f_{n-1}...f_1f_0$,其取值范围为 $1 <= M < 2$。这里的一个技巧是,我们对小数点左边的一位取固定值(0 或 1),因此不需要用比特位显式地去表示它,从而多争取到了一个比特位来表示小数部分,进一步扩大了小数部分的表示范围和精度。 非规范化的值(Denormalized) 当 exp 部分的比特位全部都是 0 时,我们称此时浮点数表示的数值形式为非规范化的值。 这种情况下,指数 $E = 1 - Bias$,$E$ 成为了一个常数,单精度格式下为 -126,双精度格式下为 -1022;有效数 $M = f$,即不再隐式地在最左边添加一个值为 1 的比特位,其取值范围也变为 $0 <= M < 1$。 非规范化表示有两个目的: 一是为了表示 0,由于规范化值中 $1 <= M < 2$,因此不管指数部分取什么值,所表示的数字 $V$ 都不可能为 0。而在非规范化值中 $0 <= M < 1$,当 M 的值为 0 时,所表示的数字 $V$ 也为 0,此时指数部分和小数部分的所有比特位均为 0。在 IEEE 标准中,取决于此时符号位 $s$ 的值,0 也有正负符号。当 $s=0$ 时,用来表示该浮点数的所有比特位全部为 0, $V = +0.0$;当 $s=1$ 时,$V = -0.0$。在 IEEE 标准中, +0.0 和 -0.0 在某些场景下是不同的。 二是为了表示非常趋近于 0 的数。相比规范化值,非规范化表示能够表示更加接近于 0 的数字,且在表示的值非常趋近于 0 时,这些值的分布能保持相对均匀,这种能力也叫「渐进下溢」。 特殊值(Special) 最后一种情况是当 exp 部分的比特位全部都是 1 时。 若小数部分的比特位也全部是 0,其表示的值 $V$ 代表无限大:当 $s=0$ 时, $v = + ∞$;当 $s=1$ 时, $V = -∞$。无限大可以用来表示将会溢出的计算结果,如将两个极大的数相乘,或者除以0时。 若小数部分的比特位不全为 0,其表示的值 $V$ 代表 NaN(Not a Number)。这种值通常用来作为某些计算结果不是真实数字或者无限大的操作的返回值,如 $\sqrt{-1}$ 或者 $∞ - ∞$。在某些应用中还会用来表示尚未初始化的数据。 示例 我们假定一个 8 位的浮点数表示作为示例,其指数部分长度为 4,小数部分长度为 3,偏置值为 7,即 $k=4$,$n=3$,$Bias=7$。其指数(Exponent)以及小数(Fraction)等各个部分的值以及所表示的十进制值如下图: 从中我们可以发现 IEEE 浮点数的一些规律: 虽然使用了不同的计算方式,但非规范化值可以平滑地过渡到规范化值,如上最大的非规范化值为 $\frac{7}{512}$,而最小的规范化值为 $\frac{8}{512}$。 随着浮点数的递增,其比特位表示所解释地无符号整数也随之递增,因此我们可以用相对简单的无符号整数的排序规则来对浮点数进行排序,这并非巧合,而是有意设计的。

2021/3/29
articleCard.readMore

深入理解 git cherry-pick 操作

在前面几篇讲解 Git 进阶用法的文章中,我们已经了解了 Git 的工作原理,以及 rebase,merge,checkout, reset 等多种操作的使用场景和用法,基本上在使用 Git 时你已经可以无所畏惧了,你可以自如的修改提交历史,并保证不会丢失任何更改。今天我们来补上最后一环,了解使用场景不多但却能达到奇效的 cherry-pick 命令。 如何使用 git cherry-pick Git 命令文档的描述不一定直观易懂,但绝对准确,文档对 git cherry-pick 描述是: Apply the changes introduced by some existing commits,即应用某些已有提交所引入的更改。通常我们会说 cherry-pick 是将某个(些)提交从一个分支移动到另一个分支,这种说法更加容易理解,但后面我们会解释为何文档的描述才是最准确的。 假设我们有如下提交: 1 2 3 a - b - c - d master \ e - f - g feature 现在我们想把 e 和 f 两个提交移动到 master 分支,首先需要切换到 master : 1 $ git checkout master cherry-pick 命令的用法简单明了,对需要移动的一个或多个提交执行 cherry-pick 即可,注意这里我们用字母指代实际的提交 SHA-1 ID: 1 $ git cherry-pick f g 执行后的提交历史如下: 1 2 3 a - b - c - d - f' - g' master \ e - f - g feature 实际的结果是在 master 分支创建了 f' 和 g' 两个新的提交,它们拥有和 f 、g 不同的 ID 。 使用场景 从上面的命令解释来看,cherry-pick 实现的效果比较简单,而且和 merge、rebase 看上去有所重合,下面我们来看看 cherry-pick 的实际使用场景。 紧急 bug 修复 通常在一个产品的 Git 工作流中,会有至少一个发布分支和开发主分支。当发现一个 bug 时,我们需要尽快向已发布的产品提供修复补丁,同时也要将补丁整合到开发主分支中。 举个例子,比如我们发布了一个版本并已经开始开发一些新的功能,在新功能开发过程中,又发现了一个已经存在的 bug。我们创建了一个紧急修复提交对这个错误进行修复,并在开发主分支进行集成测试。这个新的补丁提交在合入开发主分支后可以直接 cherry-pick 到发布分支,在影响更多用户之前修复这个 bug。过程示意如下: 在上面的动图中,我们在开发主分支 master 添加了一些新的功能提交,修复了一些 bug 并合入了两个 bugfix 分支,然后又将 bugfix 分支中的所有提交 cherry-pick 到了 release 分支。在有些 Git 工作流中情况会有所不同,可能会基于 release 分支创建 bugfix 分支,并在合入 release 后 cherry-pick 这些提交到 master。 从放弃的分支中挑出个别提交 有时因为需求的变化一个功能分支可能会过时,而不会被合并到主分支中。有时,一个 Pull Request 可能会在没有合并的情况下被关闭。我们可以通过 git log 和 git reflog 等命令,从中找出一些有用的提交,并把它们 cherry-pick 到主分支。 其他场景 还有一些其他的使用场景,比如你在没有意识到的情况下在一个错误的分支上创建了一个提交,你可以使用 cherry-pick 将其移动到正确的分支上去;或者出于某些原因你想将团队成员在另一个分支开发的某个提交拿到你自己的分支,诸如此类。 从以上有限的场景来看,我们使用 rebase 或者 merge 配合 reset 等命令也能实现同样的效果,但是 cherry-pick 的优势在于它足够地简单直接,一条命令就能实现原本需要一系列命令来实现的操作。但我们依然需要谨慎的使用 cherry-pick ,并意识到它的一些危险之处。 深入理解 cherry-pick 假设我们有一个刚刚通过提交 A 添加了 main.py 的代码仓库,main.py 的文件内容如下: 1 2 if __name__ == '__main__': print('Hello world') 现在我们创建一个新的 new-feature 分支进行后续的修改: 1 $ git checkout -b new-feature 首先创建一个提交 B,加入一个新的文件 setup.py 并对 main.py 做如下修改: 1 2 3 if __name__ == '__main__': print('Hello world') print('Git is easy') 接着我们又创建了一个提交 C,加入一个新的文件 README.md 并继续对 main.py 添加一行代码: 1 2 3 4 if __name__ == '__main__': print('Hello world') print('Git is easy') print('But sometimes it can be difficult') 最后我们再切换回一开始的 master ,并对 new-feature 的最新提交 C 执行 cherry-pick: 1 2 $ git checkout master $ git cherry-pick new-feature 执行过程很简单,如下示意: 但现在我们来猜一猜,现在的 master 有几个文件? main.py 会有几行 print 语句? 正确的答案是:我们会遭遇合并冲突 😝。在解决冲突后,我们会拥有一个修改过的 main.py 文件,以及在提交 C 中新添加的 README.md 文件。 为什么结果会是这样?借此示例,我们来深入探究一番 cherry-pick 实际的执行过程。 应用哪些更改 在 Git 撤销操作浅析中我们了解到每一个提交都是一份完整的文件快照,但从示例来看,cherry-pick 的过程显然并没有应用目标提交中的所有文件内容(否则当前 master 将包含在提交 B 中加入的 setup.py 文件),而是仅仅影响了目标提交中更改过的文件( README.md 和 main.py )。由此可见,「将某个提交从一个分支移动到另一个分支」这一说法并不准确, cherry-pick 只会应用在目标提交中引入的更改,即在该提交中更改过的文件。 如何应用 在确定了 cherry-pick 只会应用目标提交中更改过的文件后,我们来看下应用更改的具体过程。「应用」在内部其实就是像 merge 一样执行了一次三路合并。关于 Git 的三路合并算法我们在 Git 合并操作浅析中已有详细介绍。这次我们换一种方式来表示三路合并中的各方,当我们执行 git cherry-pick <commit C> 时: LOCAL:在该提交基础上执行合并(即当前所在分支的 HEAD)。 REMOTE:你正在 cherry-pick 的目标提交(即 <commit C>)。 BASE:你要 cherry-pick 的提交的父提交(即 C^,C 的上一次提交),通常为 LOCAL 和 REMOTE 的共同祖先提交(但也可能不是,比如在本示例中)。 执行 cherry-pick 时,就是以 BASE 作为基础,以 LOCAL 和 REMOTE 作为要合并的内容进行三路合并,并将合并的结果作为一个新的提交添加到 LOCAL 之后(算法执行的具体过程不再赘述)。我们可以通过如下方式进行验证: 首先将示例仓库 Git 的 merge.conflictstyle 更改为 diff3: 1 $ git config merge.conflictstyle diff3 然后重新执行上面的示例步骤,并查看发生合并冲突的 main.py 的文件内容: 1 2 3 4 5 6 7 8 9 if __name__ == '__main__': print('Hello world') <<<<<<< HEAD ||||||| parent of 77b3860 (C) print('Git is easy') ======= print('Git is easy') print('But sometimes it can be difficult') >>>>>>> 77b3860 (C) 相比常规的 diff 展示的 LOCAL 和 REMOTE 两方对比,diff3 会通过 ||||||| 多展示一方来自 BASE 的内容。从结果中我们可以确认,BASE 正是提交 C 的父提交(即提交 B)。 处理冲突 cherry-pick 出现冲突时的处理方式与 rebase 和 merge 一致,我们通过 git status 查看发生冲突的文件,修改这些文件并删除其中的特殊标记,通过 git add 将其标记为冲突已解决,最后 git commit 提交更改。 在解决冲突过程中,我们还可以在解决所有冲突后执行 git cherry-pick --continue 提交所有内容,使用 git cherry-pick --skip 在处理多个提交时跳过此提交,或使用 git cherry-pick --abort 取消 cherry-pick 操作,恢复到执行操作之前的状态。 总结 git cherry-pick 的用途并不广泛,在一些特定场景会很有用,但由于其合并机制有引入意想不到的文件更改的风险,在使用时我们应该谨慎考虑可能发生的结果。 cherry-pick 还有两个容易产生的误解需要澄清: cherry-pick 并不会应用提交所代表的整个文件快照,而是只会影响该在提交中新增、删除或更改的文件。 cherry-pick 并不是简单的应用目标提交与其父提交的 diff 内容,而是会在内部以该父提交作为基础在当前分支指向提交和目标提交之间进行一次三路合并,因此有可能发生合并冲突。 参考链接 git-cherry-pick-documentation Git Cherry Pick Tutorial CHERRY-PICKING EXPLAINED what does git cherry-pick {commit-hash} do? In a Git cherry-pick or rebase merge conflict, how are BASE (aka “the ancestor”), LOCAL, and REMOTE determined? Should diff3 be default conflictstyle on git?

2021/1/26
articleCard.readMore

git 工作原理与撤销操作图解

「如何撤销 git 提交」是绝大部分程序员一定搜索过的问题,就算你可以用某个答案中的命令暂时过关,再次遇到类似问题时又可能一头雾水:为什么这次搜到的命令不一样?git checkout、git reset、git revert 这些命令我到底该用哪个? 之所以这样,是因为你的需求并不能简单地描述为「撤销」某次提交,而可能是: 我在本地修改了一些文件还未提交,但我想放弃某些文件的更改。 我不小心 git add 了错误的文件,现在我不想把它和其他文件一起提交了。 我刚刚执行的提交添加了不该提交的文件,我想取消这次提交,但保留(或不保留)对本地文件所作的修改。 我不小心在一个提交中引入了 Bug 并且还推送到了远程分支,现在想回滚到原来的状态。 对于各种复杂的情形,Git 都提供了对应的方案来解决,但由于命令很多且同一命令还有很多选项,想要记住它们不是一件容易的事情。以 reset 、 checkout 为例,在添加不同选项并对不同参数执行后,就能实现 6 种不同但又很常用的效果。 对于复杂的问题,我们应该尝试去了解其背后的本质。带着这种想法,我们来看看执行这些操作时究竟发生了什么,希望你在阅读本文后,能够对 Git 的撤销操作运用自如,解决大部分与撤销相关的实际问题(实际上本文还非系统地介绍了 Git 的内部对象和工作原理)。 Git 中的撤销 首先需要明确的是,Git 中并没有真正意义上传统文本处理软件都会提供的 undo (撤销)功能,Git 本身也不是一个文本处理软件,它是一个内容寻址文件系统,你所提交的更改都会被保存到系统中。虽然不能 undo ,但它就像时光机一样,可以将保存的文件恢复到过去的某个状态。 然而,Git 同时管理着三颗不同的「树」的状态,当我们讨论「撤销」这个操作时,除了选择需要恢复到的时间点,还需要明确想更改哪几颗树。 取决于你想操作的树,你需要用到 checkout 、reset、revert 等不同的命令。因此在了解具体的命令之前,我们先来认识一下这三棵树。 Git 的三棵树 这三棵树分别是: 工作区(Working Directory) 暂存区(Staging Index) 提交历史(Commit History) 虽然我们用树来形容它们,但需要先明确的一点是,树并不代表它们真实的数据结构。「树」在这里的实际意思是「文件的集合」,而不是指特定的数据结构。在文中我们不会去深入探究它们的底层实现,而是重点了解它们的概念及相互关系。 工作区 工作区即存放当前操作文件的本地文件系统目录。 我们可以把它当成一个沙盒,在其中随意地添加或编辑文件,然后再将修改后的文件添加到暂存区并记录到提交历史中。 Git 可以把工作区中的文件处理、压缩成一个提交对象(稍后会解释这一概念),也能将取得的提交对象解包成文件同步到工作区中。 暂存区 暂存区保存着下一次执行 git commit 时将加入到提交历史中的内容。 Git 把它作为工作区与提交历史之间的中间区域,方便我们对提交内容进行组织:我们可能会在工作区同时更改多个完全不相干的文件,这时可以将它们分别放入暂存区,并在不同的提交中加入提交历史。此外暂存区还用于合并冲突时存放文件的不同版本。 除非是一个刚刚初始化的 Git 仓库,否则暂存区并不是空的,它会填充最近一次提交所对应的文件快照,因此当我们基于最近一次提交在工作区做了一些修改之后,git status 会将工作区的文件与暂存区的文件快照进行对比, 并提示我们有哪些做了修改的文件尚未加入暂存区。 Index 文件 暂存区并不像工作区有可见的文件系统目录,或者像提交历史一样通过 .git/objects 目录保存着所有提交对象,它没有实际存在的目录或文件夹,它的实体是位于 .git 目录的 index 文件。 index 是一个二进制文件,包含着一个由路径名称、权限和 blob 对象的 SHA-1 值组成的有序列表。 我们可以通过 git ls-files 命令查看 index 中的内容: 1 2 3 $ git ls-files --stage 100644 30d74d258442c7c65512eafab474568dd706c430 0 README.md 100644 9c1cab9a57432098de869e202ed73161af33d182 0 main.py index 中记录了暂存区文件的路径名称和 SHA-1 ID,文件内容已经作为 blob 对象保存到了 .git/objects 目录中: 1 2 3 4 5 6 7 8 9 10 $ tree .git/objects -L 2 .git/objects ├── 30 │   └── d74d258442c7c65512eafab474568dd706c430 ├── 9c │   └── 1cab9a57432098de869e202ed73161af33d182 ├── info └── pack 4 directories, 2 files blob 对象是 Git 用来保存文件数据的二进制对象,我们可以通过 ID 取得对应的 blob 对象,用 git cat-file 命令打印其内容: 1 2 $ git cat-file -p 30d74d258442c7c65512eafab474568dd706c430 This is a README file. 当我们将一个修改过的文件加入暂存区后,如果又在工作区对文件进行了新的修改,需要重新将其加入暂存区,因为暂存区以 blob 对象保存的只是文件加入时的内容。 在 index 文件中,还记录了每一个文件的创建时间和最后修改时间等元信息,它通过引用实际的数据对象包含了一份完整的文件快照,因此可以通过对比 SHA-1 校验和实现与工作区文件之间的快速比较。 提交历史 提交历史是工作区文件在不同时间的文件快照(快照即文件或文件夹在特定时间点的状态,包括内容和元信息)。 我们可以通过 git log 命令查看当前分支的提交历史: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $ git log commit ea4c48a0984880bda4031f0713229229c12793e4 (HEAD -> master) Author: Waynerv <waynerv@notmyemail.com> Date: Wed Jan 6 21:05:44 2021 +0800 add main file commit b15cc74d6d85435660fcacce1305a54273880479 Author: Waynerv <waynerv@notmyemail.com> Date: Wed Jan 6 21:05:06 2021 +0800 add ignore file commit e137e9b81cc5dfc5b1c9c7d06b861553d5c42491 Author: Waynerv <waynerv@notmyemail.com> Date: Wed Jan 6 21:04:39 2021 +0800 first commit 每一个提交都会有一个 40 位的「ID」: 1 ea4c48a0984880bda4031f0713229229c12793e4 Git 通过「提交对象」来储存每一次提交。这个 ID 是以对象内容进行 SHA-1 计算得到的哈希值,不同的内容一定会得到不同的结果,Git 既把它作为每一个对象(不仅仅是提交对象)的唯一标识符,也用作 .git/objects 目录中的地址(其中存储着实际的二进制文件),我们可以用 ID 找到对应的对象并打印其内容: 1 2 3 4 5 6 7 $ git cat-file -p ea4c48a0984880bda4031f0713229229c12793e4 tree 9e761342b98484aac2d8734f45fc2d0fde3e29db parent b15cc74d6d85435660fcacce1305a54273880479 author Waynerv <waynerv@notmyemail.com> 1609938344 +0800 committer Waynerv <waynerv@notmyemail.com> 1609938344 +0800 add main application file 这个提交对象的内容包含三部分: 对应的 tree 对象的 ID 父提交对象的 ID 作者、提交者及提交信息等元信息 tree 对象主要由其他 tree 对象和 blob 对象的 ID 以及路径名称组成: 1 2 3 4 5 $ git ls-tree 9e761342b98484aac2d8734f45fc2d0fde3e29db 100644 blob 723ef36f4e4f32c4560383aa5987c575a30c6535 .gitignore 100644 blob 30d74d258442c7c65512eafab474568dd706c430 README.md 100644 blob 9c1cab9a57432098de869e202ed73161af33d182 main.py 040000 tree 556af47de72b597f532f63b63983be433f137e57 tests 就像目录递归地包含其他目录和文件一样,一个 tree 对象即可表示整个工作区中所有已提交目录及文件的内容,也就是说提交历史中的每一个提交都包含着一份完整的某一时刻的文件快照,并通过保存上一次提交的引用形成连续的文件快照历史。 工作流程 在继续前,我们需要简单了解下分支和 HEAD。 在 Git 中我们将 SHA-1 值用做提交对象(以及 tree 和 blob 对象)的 ID,通过 ID 操作提交对象以及提交对象引用的文件快照。但大部分时候,记住一个 ID 是非常困难的,因此 Git 用一个文件来保存 SHA-1 值,这个文件的名字即作为「引用(refs)」来替代原始的 SHA-1 值。 这类包含 SHA-1 值的文件保存在 .git/refs 目录下,我们可以在 .git/refs/heads 目录中找到代表各个分支引用的文件,尝试打印 master 文件的内容: 1 2 $ cat .git/refs/heads/master ea4c48a0984880bda4031f0713229229c12793e4 这基本就是 Git 分支的本质:一个指向某一系列提交之首的指针或引用。 我们还用 HEAD 来指向最近的一次提交,HEAD 文件通常是一个符号引用(symbolic reference),指向目前所在的分支。 所谓符号引用,表示它是一个指向其他引用的引用: 1 2 $ cat .git/HEAD ref: refs/heads/master 但在某些情况下,HEAD 文件可能会包含一个 git 对象的 SHA-1 值。 当你在检出一个标签、提交或远程分支,让你的仓库变成 「分离 HEAD」状态时,就会出现这种情况。 1 2 3 $ git checkout ea4c48a0984880bda4031f0713229229c12793e4 $ cat .git/HEAD ea4c48a0984880bda4031f0713229229c12793e4 最后,让我们来看一下上文介绍的三棵树之间的工作流程: 假设我们进入到一个新目录,其中有一个 README 文件。此时暂存区为空,提交历史为空,HEAD 引用指向未创建的 master 分支。 现在我们想提交该文件,首先需要通过 git add 将其添加到暂存区。此时 Git 将在 .git/objects 目录中以该文件的内容生成一个 blob 对象,并将 blob 对象的信息添加到 .git/index 文件中。 接着运行 git commit ,它会取得暂存区中的内容生成一个 tree 对象,该 tree 对象即为工作区文件的永久快照,然后创建一个指向该 tree 对象的提交对象,最后更新 master 指向本次提交。 假如我们在工作区编辑了文件,Git 会将其与暂存区现有文件快照进行比较,在 git add 了更改的文件后,根据文件当前内容生成新的 blob 对象并更新 .git/index 文件中的引用 ID。git commit 的过程与之前类似,但是新的提交对象会以 HEAD 引用指向的提交作为父提交,然后更新其引用的 master 指向新创建的提交。 当我们 git checkout 一个分支或提交时,它会修改 HEAD 指向新的分支引用或提交,将暂存区填充为该次提交的文件快照,然后将暂存区的内容解包复制到工作区中。 常见的「撤销」命令 接下来我们将使用如下的 Git 仓库作为基准示例,介绍一些常见的「撤销」命令。假设工作区中已存在这些文件,且开始介绍每个命令时示例仓库都会回到初始状态: 1 2 3 4 5 6 7 8 $ git init $ git add README.md && git commit -m "first commit" $ git add .gitignore && git commit -m "add ignore file" $ git add main.py && git commit -m "add main file" $ git log --pretty=oneline ea4c48a (HEAD -> master) add main file b15cc74 add ignore file e137e9b first commit 为了方便展示我们将只取 SHA-1 ID 的前 7 位,但 Git 依然能准确的找到对应的提交。 git checkout checkout 有两种工作方式:在命令参数中带文件路径与不带。两种方式的具体行为有很大区别。 不带路径 不带路径的git checkout [commit or branch] 用于「检出」某个提交或分支,检出可以理解为「拿出来查看」,因此这个操作对工作区是安全的。git checkout [commit] 会更新所有的三棵树,使其和 [commit] 的状态保持一致,但保留工作区和暂存区所做的更改。 假如我们在工作区新增了 tests/test.py 文件,并加入到了暂存区中,然后 checkout 到上一个提交: 1 2 $ git add tests/test.py $ git checkout b15cc74 checkout 命令的执行过程如以下动图所示: 首先 HEAD 会直接指向 b15cc74 提交,进入分离 HEAD 状态,即不再指向分支引用: 1 2 $ cat .git/HEAD b15cc74 然后将提取 b15cc74 提交的文件快照依次更新到暂存区以及工作区。 若工作区与暂存区存在未提交的本地更改,checkout 还会尝试将文件快照与本地更改做简单的合并,若合并失败,将会中止操作并恢复到 checkout 之前的状态。因此checkout 对工作区是安全的,它不会丢弃工作区所做的更改。 git checkout [branch] 的执行过程与上面类似,但是 HEAD 会指向 [branch] 这个分支引用。 带路径 当 git checkout 像下面这样在命令参数中带文件路径时: 1 $ git checkout b15cc74 README.md 执行过程如如以下动图所示: 它会找到该提交,并在该提交的文件快照中匹配文件路径对应的文件,但并不会移动 HEAD: 1 2 $ cat .git/HEAD ref: refs/heads/master 将匹配到的文件快照覆盖到暂存区以及工作区。 若工作区与暂存区存在对该文件的本地更改,该更改将会丢失。因此checkout 带文件路径时对工作区是不安全的,它会丢弃工作区对该文件所做的更改。 git reset git reset 的主要作用是将 HEAD 重置为指定的提交。与 checkout 的区别在于,它对提交历史的更改并不仅仅只是更新 HEAD 本身,如果 HEAD 原来指向某个分支引用,则会将分支引用也更新为指向新的提交。 它的工作方式更多了,有 —soft、 --mixed、--hard 三种主要的命令选项,分别对应更新不同数量的树: --soft 当命令行选项为 --soft 时,git reset 只会对提交历史进行重置: 1 2 3 4 5 6 7 8 9 $ git checkout master && cat .git/refs/heads/master 已经位于 'master' ea4c48a $ git reset --soft b15cc74 $ git status 位于分支 master 要提交的变更: (使用 "git restore --staged <文件>..." 以取消暂存) 新文件: main.py 执行过程如以下动图所示: 首先将 HEAD 及其指向的分支引用指向 b15cc74 提交,本示例中 HEAD 原本指向 master ,执行操作之后依然指向 master: 1 2 $ cat .git/HEAD ref: refs/heads/master 但 master 分支引用却从原来指向 ea4c48a 变成了指向 b15cc74: 1 2 $ cat .git/refs/heads/master b15cc74 若 HEAD 原本处于分离 HEAD 状态,则只会更新 HEAD 本身。 reset --soft 到此就已经结束了,它不会再对暂存区以及工作区进行任何更改,暂存区和工作区依然保留着原来的 ea4c48a 提交之后的文件快照与文件,因此运行 git status 我们将看到暂存区中有待提交的变更,工作区和暂存区中的本地更改也都会得到保留。 --mixed --mixed 选项是 git reset 命令的默认选项,git reset [commit] 即等同于 git reset --mixed [commit]。它除了重置提交历史,还会更新暂存区: 1 2 3 4 5 6 7 8 9 10 11 $ git checkout master && cat .git/refs/heads/master 已经位于 'master' ea4c48a $ git reset --mixed b15cc74 $ git status 位于分支 master 未跟踪的文件: (使用 "git add <文件>..." 以包含要提交的内容) main.py 提交为空,但是存在尚未跟踪的文件(使用 "git add" 建立跟踪) 执行过程如以下动图所示: 更新 HEAD 指向 b15cc74 提交,重置提交历史的过程与 --soft 完全相同。 之后还会更新暂存区,将其填充为 b15cc74 提交的文件快照,暂存区中的原有内容将会丢失。 不会对工作区进行任何更改,工作区依然保留着原来的 ea4c48a 提交之后的文件,因此运行 git status 我们将看到有未跟踪的文件待加入暂存区,工作区中的本地更改也会得到保留。 --hard --hard 是 reset 最直接、最危险以及最常用的选项。 git reset —hard [commit] 会将所有的三棵树都更新为指定提交的状态,工作区和暂存区中所有未提交的更改都会永久丢失,但被重置的提交仍有办法找回。 我们同样执行如下操作: 1 2 3 4 5 6 7 8 $ git checkout master && cat .git/refs/heads/master 已经位于 'master' ea4c48a $ git reset --hard b15cc74 HEAD 现在位于 b15cc74 add gitignore file $ git status 位于分支 master 无文件要提交,干净的工作区 执行过程如以下动图所示: 更新 HEAD 指向 b15cc74 提交,重置提交历史的过程与 --soft 及 --mixed 选项相同。 更新暂存区,将其填充为 b15cc74 提交的文件快照,暂存区中的原有内容将会丢失。 更新工作区,将其填充为 b15cc74 提交的文件快照,工作区中的原有内容将会丢失。 正如上面所说,reset —hard 会将工作区、暂存区和提交历史都重置为刚刚新增了 b15cc74 提交时的状态,并简单粗暴地覆盖掉工作区和暂存区的原有内容。这是一个非常危险的操作,因为工作区和暂存区的未提交更改丢失后无法再通过 Git 找回。 找回提交历史 reset 后丢失的提交历史仍然能够恢复,因为我们只是更新了 HEAD 指向的提交,而没有对实际的提交对象做任何更改。我们可以通过 git reflog 找到 HEAD 曾经指向过的提交: 1 2 3 4 $ git reflog b15cc74 (HEAD -> master) HEAD@{0}: reset: moving to b15cc74 ea4c48a HEAD@{1}: checkout: moving from master to master ...... 从中可以找到 master 原来所指向的 ea4c48a 提交,再执行 git reset --hard ea4c48a 就能恢复原来的提交历史。 不要 reset 公共分支 另一个关于 reset的实践是,不要在公共分支上执行 reset。公共分支是指你与其他团队成员协作开发的分支。 当任何提交被推送到公共分支后,必须假设其他开发者已经依赖它。删除其他人已经在继续开发的提交,会给协作带来严重的问题。而且你需要强制推送才能将你 reset 后的分支提交到远程仓库,当其他人拉取这个公共分支时,他们的提交历史会突然消失一部分。 因此,请确保在本地的实验分支上使用 git reset,而不要重置已经发布到公共分支的提交。如果你需要修复一个公共提交引入的问题,请看之后将介绍的专门为此目的设计的 git revert。 取消暂存文件 和 checkout 一样,git reset 也能对文件路径执行,常用于将已加入暂存区的指定文件或文件集合取消暂存。 假设我们在工作区新增了 hello.py 和 world.py 两个文件,并同时加入了暂存区: 1 $ git add . 现在我们意识到这两个文件不应该放在一个提交中,因此需要将其中一个文件取消暂存: 1 2 3 4 5 6 7 8 9 10 11 12 $ git reset world.py $ git status 位于分支 master 您的分支与上游分支 'origin/master' 一致。 要提交的变更: (使用 "git restore --staged <文件>..." 以取消暂存) 新文件: hello.py 未跟踪的文件: (使用 "git add <文件>..." 以包含要提交的内容) world.py 此时暂存区中只有 hello.py 文件了,我们可以分别提交它们: 1 2 3 4 $ git commit -m "add hello.py" # 在另一个提交中提交 world.py $ git add world.py $ git commit -m "add world.py" 实际上 reset 带文件路径命令的完整形式是下面这样的: 1 git reset [<tree-ish>] <pathspec>… 该操作的实质,是从 <tree-ish> 提取 <pathspec> 对应的文件快照更新到暂存区,<tree-ish>可以是提交或分支,默认值为 HEAD,因此默认会将暂存区的指定路径恢复到 HEAD 提交的状态。 git reset world.py 命令的实际过程是: 从 HEAD 提交中匹配 world.py 对应的文件快照。 将匹配到的文件快照复制到暂存区。 因此,当我们修改了某个文件添加到暂存区,reset 后会被替换成原本的文件版本;新增的文件会从暂存区中移除(因为上一次提交中没有该文件),实际实现了将文件取消暂存的效果。 git revert git revert 命令用于回滚某一个(或多个)提交引入的更改。 其他的「撤销」命令如 git checkout 和 git reset,会将 HEAD 或分支引用重新指向到指定的提交,git revert 命令也可以接受一个指定的提交,但并不会将任何引用移动到这个提交上。revert 操作会接收指定的提交,反转该提交引入的更改,并创建一个新的「回滚提交」记录反转更改,然后更新分支引用,使其指向该提交。如以下动图所示: 相比 reset ,revert 会在提交历史中增加一个新的提交,而不会对之前的提交进行任何更改。 默认情况下 revert 会自动执行如下步骤: 将反转指定提交的更改合并到工作区 将更改添加到暂存区 创建新的提交 因此它要求我们提供一个干净的暂存区(即和 HEAD 提交状态一致),且要求工作区的本地更改不会被合并操作覆盖,否则回滚会失败。我们可以添加 --no-commit 命令选项来进入交互模式手动执行「创建新的提交」,此时 revert 操作会将反转的更改应用到工作区和暂存区等待提交,且不要求暂存区与 HEAD 一致。 我们通过示例来演示这一过程,现在我们想回滚 b15cc74 这个提交,这个提交中加入了 .gitignore 文件,预期的结果是会新增一个删除该文件的提交: 1 $ git revert b15cc74 在终端执行该命令后将直接跳转到一个编辑器界面,可以修改新提交的提交信息: 1 2 3 4 5 6 7 Revert "add gitignore file" This reverts commit b15cc74d6d85435660fcacce1305a54273880479. # 请为您的变更输入提交说明。以 '#' 开始的行将被忽略,而一个空的提交 # 说明将会终止提交。 ...... 保存后 revert 命令执行结束,并输出以下结果: 1 2 3 4 删除 .gitignore [master 6bb25da] Revert "add gitignore file" 1 file changed, 1 deletion(-) delete mode 100644 .gitignore 结果符合预期,新增了一个删除 .gitignore 文件的 6bb25da 提交,并且 master 当前指向了该提交。 但如果我们在一开始对工作区中的文件做过更改且加入到了暂存区,执行 revert 的结果如下: 1 2 3 4 $ git revert b15cc74 error: 您的本地修改将被还原覆盖。 提示:提交您的修改或贮藏后再继续。 fatal: 还原失败 revert 的优势 虽然效果与 reset 相似,但使用 revert 有以下优势: 它不会改变之前的提交历史,这使得 revert 对于已经推送到共享仓库的提交是一个「安全」的操作,它会完整的记录某个提交被加入及回滚的过程。 它可以回滚提交历史上任意一个(或多个)点的提交,而 reset 只能重置从指定提交起之后的所有历史。 使用场景 我们分别介绍了 checkout 、reset 、revert 三个命令的主要用法,下面的表格概括了它们的常见使用场景: 命令 作用对象 常用场景 git reset 提交 放弃私人分支上的提交或者还未提交的本地更改 git reset 文件 将一个文件取消暂存 git checkout 提交 切换分支或者查看一个之前的提交 git checkout 文件 将文件恢复到指定提交时的状态并丢弃在工作区中对该文件的更改 git revert 提交 在公共分支上撤销一个提交 git revert 文件 无 其他替代命令 我们介绍了 checkout 、reset 、revert 三个命令共 7 种和撤销相关的用法,而这些命令还有许多其他的选项和用途,在使用这些命令时,即使是老手也可能需要不时地对照手册。也许是意识到了这个问题,Git 在 2.23 版本中又发布了 resetore 和 switch 两个新命令,新命令能替代上面的部分用法且用途更为专一。 git restore restore 命令用于还原工作区或暂存区中的指定文件或文件集合: 1 git restore [--source=<tree>] [--staged] [--worktree] <pathspec>… 从定义和命令行形式来理解: 还原即恢复到过去某一状态,意味着该命令需要指定已有的某个文件快照(提交、分支等)作为数据源,通过 source 选项设置。 可以选择对工作区(--worktree )、暂存区(--staged )或两者同时生效,默认值为仅工作区。当指定的位置为工作区时,默认数据源为暂存区的文件快照;当指定的位置包含暂存区时,默认数据源为 HEAD。 可以选择对指定的文件或一些文件生效,通过 <pathspec> 参数指定。 我们继续使用之前的 Git 仓库作为示例,假设我们修改了 main.py 并已经加入到了暂存区: 我们想将 main.py 取消暂存,即将暂存区中的 main.py 还原为 HEAD 中的内容,此时 HEAD 是默认的 source ,因此可执行如下命令: 1 git restore --staged main.py 该文件将被取消暂存。 现在我们想放弃工作区中对该文件的更改,可以选择将其还原为暂存区中的内容,因为此时暂存区中的内容和 HEAD 相同: 1 git restore main.py 这只是最基础的用法,还可以指定 --source 为任意提交 ID 将文件还原为该提交中的状态。 git restore [--source=<tree-ish>] --staged <pathspec>... 和 git reset [<tree-ish>] <pathspec> 在使用上是等价的。较新版本的 Git 会在命令行中提示使用 restore 命令来取消暂存或丢弃工作区的改动。 git switch git switch 命令专门用于切换分支,可以用来替代 checkout 的部分用途。 创建并切换到指定分支( -C 大小写皆可): 1 git switch -C <new-branch> 切换到已有分支: 1 git switch <branch> 和 checkout 一样, switch 对工作区是安全的,它会尝试合并工作区和暂存区中的本地更改,如果无法完成合并则会中止操作,本地更改会被保留。 switch 的使用方式简单且专一,它无法像 checkout 一样对指定提交使用: 1 2 $ git switch ea4c48a fatal: 期望一个分支,得到提交 'ea4c48a' 常见问题及解决方案 撤销本地分支提交 使用 git reset ,取决于你是否需要保留该提交之后的更改,添加 --soft 、—hard 等选项。 回滚远程主干分支上的提交 使用 git revert。 修改上一次提交的内容 如果该提交还未进入公共分支,最直接的方式是使用 git commit --amend。如果该提交已经位于公共分支,应该使用 git revert。 暂存更改后再恢复 一个很常见的场景是,我们在当前分支修改了一些文件,但还不足以组织成提交或者包含了多个提交的内容,突然有紧急情况需要开始一项新的任务,此时我们希望可以将工作区和暂存区的本地更改暂时保存起来,以备在其他工作完成后可以从这里继续。 我们当然可以创建一个临时的分支然后重置或合并来实现目的,但那样复杂而繁琐。而 git stash 命令则可以很好的满足需求,它会将本地更改保存起来,并将工作区和暂存区恢复到与 HEAD 提交相匹配的状态。此时我们可以切换到其他分支或者继续在当前分支完成其他任务,之后再将暂存的内容取回。 git stash 的基本用法如下: 1 2 3 4 5 # 保存当前更改(添加 -u 选项以包括未跟踪的新文件) $ git stash -u # 完成其他任务...... # 恢复暂存的更改 $ git stash pop stash 的实质也是将本地更改保存为一次新的提交,然后再将该提交恢复到工作区和暂存区,但它不会影响当前的提交历史。stash 还有更多进阶用法,比如指定暂存的文件路径、暂存多次并择一恢复等。 总结 在本文中我们首先了解了一些必要的 Git 内部机制: 使用 blob 、tree 和提交对象等内部对象保存数据,每次提交都是一份完整的文件快照。 SHA-1 ID、分支引用及 HEAD 的实质。 管理三棵树的状态:工作区、暂存区、提交历史。 创建一次提交的完整工作流程。 然后通过示例分别介绍了 checkout 、reset 和 revert 的基本用法与区别: checkout 不带路径:将工作区、暂存区更新为指定提交的状态,但会保留本地更改。 带路径:将指定文件更新为指定提交的状态,不会保留本地更改 reset —soft:仅将 HEAD 及其指向的分支引用移动到指定提交。 —mixed:除了更改提交历史,还将暂存区也更新为指定提交的内容。 —hard:除了更改提交历史和暂存区,还将工作区也更新为指定提交的内容,工作区的本地更改会永久丢失。 对文件路径使用可以将文件取消暂存。 revert 创建一个新的提交以撤销指定提交引入的更改。 还介绍了两个新版本引入的更专一的命令: restore:将工作区或暂存区的指定文件还原为指定提交时的状态。 switch:切换到已有分支或者创建并切换到新的分支。 最后给出了一些常见问题的解决方案并介绍了 git stash 的用法。 参考链接 Git 工具 - 重置揭密 Git 基础 - 撤消操作 Undoing Commits & Changes Git 2.23.0: Forget about checkout, and switch to restore Git 内部原理 - Git 引用 Understanding Git — Index Commits are snapshots, not diffs The Git Index Git: Understanding the Index File The Git Parable

2021/1/9
articleCard.readMore

构建保障代码质量的自动化工作流

在我看来,代码质量就是程序员的职业底线。维护底线不能全靠自觉,因此本文将介绍几种自动化的工具,并展示如何将它们集成到日常的工作流中,省心省力的持续保障代码质量。 Linter Linter 是一类用于标记程序错误、bug、风格错误和可疑结构的静态代码分析工具,比如 Python 中的 Pylint、 flake8,JavaScript 中的 EsLint,这类工具会找到你代码中的错误,并给出如何修复的提示。 为何要使用 Linter 人总是会粗心和犯错,借助 Linter 我们可以发现代码中不易察觉或遗漏的错误,并进行纠正。使用 Linter 有以下好处: 根据 PEP8 等语言规范持续的提示与纠正,帮助你写出更好的代码 减少代码中的格式错误、笔误及糟糕的风格等低级错误 节省团队成员 review 你代码的时间,提高协作效率 配置简单,仅需几步即可上手 flake8 使用示例 接下来我们以 Python 中的 flake8 为例,介绍其用法。 首先通过 pip 安装 flake8: 1 $ pip install flake8 flake8 的使用非常简单,直接以需要检查的文件或整个目录的路径作为 flake8 命令的参数: 1 2 3 4 5 6 7 $ flake8 . # 检查当前目录下的所有文件 ./__init__.py:4:1: E402 module level import not at top of file ./__init__.py:4:1: F401 '.md_toc.main' imported but unused ./__init__.py:4:25: W292 no newline at end of file ./md_toc.py:6:18: E999 SyntaxError: non-default argument follows default argument ./md_toc.py:28:80: E501 line too long (108 > 79 characters) ./md_toc.py:48:80: E501 line too long (113 > 79 characters) 在运行结果中, flake8 不仅指出了具体的错误原因,还给出了出错的文件位置及代码所在行数。 更多其他的选项可通过以下命令获取帮助: 1 $ flake8 --help flake8 的可自定义程度很高,除了在运行时添加命令选项,我们还可以通过配置文件来自定义检查规则。方法是在运行 flake8 的项目根目录添加 .flake8 文件,并写入如下内容: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 [flake8] max-line-length = 120 # 适当提高最大行长度 max-complexity = 24 # 设置最大复杂度为24 ignore = F401, W503, E203 # 忽略这些错误类型 exclude = # 忽略以下文件 .git, __pycache__, scripts, logs, upload, build, dist, docs, migrations flake8 还可以添加插件实现更多更强大的功能,比如我们想强制要求编写函数 docstring,可以安装 flake8-docstrings : 1 $ pip install flake8-docstrings 重新运行检查后,会发现结果中多出了如下错误: 1 2 ./__init__.py:1:1: D104 Missing docstring in public package ./md_toc.py:6:1: D103 Missing docstring in public function 更多其他功能和插件可查看 flake8 的文档。 除了 flake8 以外,Python 还有以下 Linter 可供选择,它们的用法大同小异,但在检查范围、容忍度等方面有所区别: Pylint Pyflakes Pychecker Pep8 Static Type Checker Python 从 PEP 484 引入 Type Hints (类型提示)以来,配套的工具链生态也在逐渐成熟,出现了如 mypy、 pyright 等静态类型检查工具。 它们可以根据 Python 代码中的类型提示进行静态类型检查,不需要运行程序就可以找到程序中的错误。而且如果有遗留代码不好处理,还可以在程序中混合使用动态和静态类型。 我们以 mypy 做简单示例,假设我们有以下代码: 1 2 3 4 5 # test_mypy.py def greeting(name: str) -> str: return 'Hello ' + name greeting(3) 通过 pip 安装 mypy : 1 $ pip install mypy 对该文件运行检查: 1 2 3 $ mypy test_mypy.py test_mypy.py:4: error: Argument 1 to "greeting" has incompatible type "int"; expected "str" Found 1 error in 1 file (checked 1 source file) 在检查结果中, mypy 提示我们在调用 greeting() 时传入的参数类型出错。 类型提示带来的好处 静态类型检查工具可以帮助我们像静态语言一样在运行代码之前就捕获到某些错误,但需要我们在程序中加入大量的类型提示才能完整发挥其作用。虽然稍微加大了工作量,但引入类型提示还会带来以下好处: 相比传统的 docstrings,类型提示配合良好的命名既能作为解释文档,也能用于自动检查。 可以使 IDE 通过类型推断提供更好的代码补全和提示功能。 强制你去思考动态语言程序的类型可能会帮助你构建更清晰的代码架构。 Autoformatter Autoformatter (自动格式化器)顾名思义是可以将代码按特定规则自动格式化的一类工具。Linter 可以帮助我们发现代码中的格式错误和风格问题,但并不会自动纠正,因此我们还需要借助自动格式化器,进一步将我们从重复琐碎的手动修改中解放出来。 Python 中有 black 、 yapf、 autopep8、 isort 等众多格式化工具,除了整体上遵循 PEP8 以外,各自都有不同的风格规范和适用范围,我们可以根据自己的喜好进行选择。 以 black 和下面这段代码为例: 1 2 3 4 5 6 7 # test_black.py def f ( a: List[ int ]) : return 37-a[42-u : y**3] def very_important_function(template: str,*variables,file: os.PathLike,debug:bool=False,): """Applies `variables` to the `template` and writes to `file`.""" with open(file, "w") as f: ... 首先通过 pip 安装 black: 1 $ pip install black 然后对需要格式化的文件或整个目录的路径执行 black 命令: 1 2 3 4 5 $ black test_black.py reformatted /Users/waynerv/Repos/github-markdown-toc/gfm_toc/__init__.py reformatted /Users/waynerv/Repos/github-markdown-toc/gfm_toc/md_toc.py All done! ✨ 🍰 ✨ 2 files reformatted. black 会首先检查出不符合其规范的文件,然后编辑文件完成修改,并输出修改的文件路径及数量。格式化后的文件内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 # test_black.py def f(a: List[int]): return 37 - a[42 - u : y ** 3] def very_important_function( template: str, *variables, file: os.PathLike, debug: bool = False, ): """Applies `variables` to the `template` and writes to `file`.""" with open(file, "w") as f: ... black 号称「不妥协的代码格式化器」,其不妥协体现在基本没有可供自定义的选项,要么全盘接受它的代码风格,要么就不用它。如果你或者你的团队能够接受它的风格,使用 black 可以节省很多花在代码风格上的精力和时间(再也不用争论某种风格孰优孰劣了)。 实践中我们可以对最大行长度以及引号格式化选项做一些调整,方法是在执行 black 的项目根目录添加 pyproject.tomal 文件,并写入以下内容: 1 2 3 [tool.black] line-length = 120 skip-string-normalization = true IDE 的自带工具 现代的 IDE 和编辑器基本都会自带 Linter 和格式化等功能,比如 PyCharm 就可以通过 ⌥ ⌘ L 快捷键格式化当前文件的代码,这样的话我们还有必要手动集成这些第三方工具吗?答案当然是有必要,我们以第三方和 IDE 自带的代码工具作对比: 自带工具对环境依赖程度很高,必须要有特定的二进制包及配置文件才能执行;而使用与项目相同语言实现的第三方工具,可以直接作为项目的开发依赖,配置也能很方便地整合到项目本身的配置文件中(如pyproject.toml、tox.ini等),且通常都可以很快地集成到不同 IDE 中。 自带工具只能在 IDE 的图形界面中手动执行;而第三方工具只要有基础的代码运行环境就可以执行,因此我们可以把它们很方便的集成到 Hooks、CI 中自动化执行。 自带工具并不一定严格遵循 PEP8 等通用规范,而且不方便在协作成员中分享配置,话说回来,假如你用 PyCharm 但你的同事用 VSCode 呢? 在接下来的 Hooks 和 Pipeline 两节内容中,你将进一步认识到集成第三方工具带来的优势。 Hooks 在上文中我们介绍了很多保障质量的工具,但在日常编码中,我们总不能每一次提交代码前,都把这些工具手动执行一遍吧?这看上去不是高效的做法,庆幸地是我们有现成途径解决这些问题。 Git Hooks Git 可以在仓库中发生特定事件时自动运行自定义的脚本,这些自定义脚本即称为 hooks。 它们让你可以在开发周期的关键点触发可定制的动作,比如每次执行提交前都执行一次 flake8 命令。大部分其他的版本控制系统也会提供类似的功能。 hooks 保存在 Git 仓库的 .git/hooks 目录中,它可以是普通的 shell 脚本,也可以是 Python、Ruby 等语言的可执行脚本,因此我们可以很方便地将常用工具集成到 hooks 中。 hooks 通常按客户端和服务端分为两大类,常用的客户端类有 pre-commit 、 prepare-commit-msg、 commit-msg、 post-commit、 pre-rebase 等 hooks,它们分别在名称所代表的事件触发时运行。 以 pre-commit 为例,该 hooks 在输入提交信息前运行。 它可以检查即将提交的快照,如果该 hooks 以非零值退出,Git 将放弃此次提交,我们可以利用它来检查提交的代码风格是否正确(运行例如 flake8、 black 等程序)、是否引入安全风险等等。整个流程示意如下: 接下来我们将展示,如何将上文介绍的众多工具快速地集成到 pre-commit hooks 中。 pre-commit pre-commit 是一个用于管理和维护多种语言 pre-commit hooks 的框架,就像 Python 的包管理器 pip 一样,我们可以通过 pre-commit 将他人创建并分享的 pre-commit hooks 安装到自己的项目仓库中。 pre-commit 大大减少了我们使用 git hooks 的难度,你只需要在配置文件中指定想要的 hooks,它会替你安装任意语言编写的 hooks 并解决环境依赖问题,然后在每次提交前执行。 pre-commit 的简单使用方法如下: 通过 pip 安装 pre-commit: 1 $ pip install pre-commit pre-commit 是面向多语言的,因此它还支持通过 homebrew 等方式安装。 添加配置文件。 你需要创建一个名为 .pre-commit-config.yml 的文件,通常放在项目根目录下,在配置文件中我们按特定格式添加需要运行的 hooks 并指定参数,示例如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 repos: - repo: https://github.com/psf/black rev: 20.8b1 hooks: - id: black language_version: python3 exclude: ^migrations/|^uploads/|^scripts/|^logs/|^docs/|^dist/|^build/ - repo: https://github.com/pycqa/flake8 rev: 3.8.4 hooks: - id: flake8 exclude: ^migrations/|^uploads/|^scripts/|^logs/|^docs/|^dist/|^build/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.3.0 hooks: - id: check-added-large-files args: [ --maxkb=512 ] 在该示例文件中,我们添加了 black 、 flake8 以及一个检查是否添加了大体积文件到 Git 的 hooks,还分别指定了语言版本、跳过检查路径等选项。 配置文件的选项含义参见 https://pre-commit.com/#plugins,所有支持安装的 hooks 列表参见https://pre-commit.com/hooks.html。 安装 git hook 脚本。在配置文件所在的根目录运行命令: 1 2 $ pre-commit install pre-commit installed at .git/hooks/pre-commit 这会在 .git/hooks 文件夹中创建一个 pre-commit 脚本文件,并在接下来你每一次提交之前运行!现在我们可以尝试提交一次代码了。 (可选步骤)对所有文件运行 hooks。 通常 pre-commit 只会在触发 git hooks 时对发生更改的文件运行,但我们也可以手动对当前仓库的所有文件运行: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 $ pre-commit run --all-files [INFO] Initializing environment for https://github.com/psf/black. [INFO] Initializing environment for https://github.com/pycqa/flake8. [INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks. [INFO] Installing environment for https://github.com/psf/black. [INFO] Once installed this environment will be reused. [INFO] This may take a few minutes... [INFO] Installing environment for https://github.com/pycqa/flake8. [INFO] Once installed this environment will be reused. [INFO] This may take a few minutes... [INFO] Installing environment for https://github.com/pre-commit/pre-commit-hooks. [INFO] Once installed this environment will be reused. [INFO] This may take a few minutes... black....................................................................Failed - hook id: black - files were modified by this hook reformatted /.../setup.py reformatted /.../test/generate_toc_test.py All done! ✨ 🍰 ✨ 2 files reformatted, 2 files left unchanged. flake8...................................................................Passed Check for added large files..............................................Passed 结果显示我们有文件未通过 black 检查,但 black 同时也帮我们自动进行了格式化,因此只需要暂存并重新提交这些文件即可。 pre-commit hooks 的限制 使用 pre-commit ,我们可以很方便的将丰富多样的代码工具集成到 Git 的工作流中,这很大程度上提高了我们的效率,但 pre-commit hooks 本身存在以下限制: 客户端 hooks 并不会随代码库一起被复制,我们必须在本地仓库通过 pre-commit install 执行安装之后 hooks 才会生效。 使用 git commit --no-verify 即可绕过所有的 pre-commit hooks。 pre-commit 解决了在本地集成代码工具的问题,但在团队合作的场景下,我们希望有一种机制可以在服务器端对所有成员推送的代码进行检查,施加特定的约束,这时候就需要用到 Pipelines 了。 Pipelines 首先我们需要了解 CI/CD 这一概念。 CI/CD CI(Continuous Integration,持续集成)是一种软件实践,它要求我们频繁地向共享仓库提交代码。更频繁地提交代码可以更快地发现错误,减少我们在发现错误时需要调试的代码量,也使得团队不同成员间的变更更容易合并。CI 可以节省我们调试错误或解决合并冲突的时间,让我们有更多的时间来编写代码。 当提交代码到共享仓库时,我们需要对每一次提交的代码进行构建和测试,以确保提交不会引入错误。这些测试既包括上文提到的 Linter,也包括安全性检查、测试覆盖率、功能测试和其他自定义检查。我们将需要一个 CI 服务器作为 Runner 以运行对提交代码的构建和检查。 CD(Continuous Deployment,持续部署)是 CI 的下一步。我们不仅在每次推送代码到共享代码库时进行构建和测试,还会在不需要任何人工干预的情况下,自动部署代码到生产环境。此外还有Continuous Delivery (持续交付),它和持续部署的区别在于需要人工干预才会执行部署。 Pipeline Pipeline 是持续集成、交付和部署的顶层具象化组件,它由顺序执行的阶段(stage)以及每个阶段中并行的任务(job)组成。一个简单的 pipeline 如下图所示: 首先并行地执行 build 阶段的所有任务(build_a 和 build_b),所有任务成功后继续执行下一阶段的任务,以此类推。pipeline 还能定义成按更复杂的逻辑规则运行。 下面我们以 GitLab CI 为例,展示如何配置一个 pipeline,并在测试阶段执行上文介绍的检查工具,解决不能对团队成员提交代码进行强制约束的问题。 GitLab CI 示例 配置 Runner 首先我们需要配置一个运行任务的服务器作为 Runner,这需要我们有可用的服务器主机(也可以使用本地的开发机器或购买 GitLab 提供的 Runner)。配置 Runner 整体分两步: 在主机上安装 GitLab Runner,GitLab Runner 是一个用于 GitLab CI/CD 并在主机上管理、执行 pipeline 任务的应用。对于不同的操作系统与架构,具体的安装步骤也有很大区别,详情参见文档。 为项目(或项目组)注册 Runner。这一步首先需要从 GitLab 项目主页的 Settings > CI/CD > Runners settings 获取项目的 URL 和注册令牌,然后在 Runner 所在的机器运行 register 命令使用 URL 和令牌完成注册,并选择合适的 Runner 类型,详细步骤参见文档。 配置完成后,我们将可以在 Runners settings 页面查看可用的 Runner 和状态: 创建 .gitlab.yml 我们将在项目根目录创建一个配置 pipeline 的 .gitlab.yml 文件,GitLab 将在每一次我们推送代码到共享仓库时,读取该文件中的定义和指令,创建一条不同阶段及任务组成的 pipeline,并将阶段中的任务派发给可用的 Runner 执行。我们以如下 .gitlab.yml 文件为例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 before_script: - python --version - pip install -r requirements-ci.txt stages: - Pre-commit Hooks - Static Analysis pre-commit: stage: Pre-commit Hooks variables: PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit cache: key: 20201116dash paths: - ${PRE_COMMIT_HOME} script: - pre-commit run --all-files mypy: stage: Static Analysis allow_failure: true only: refs: - master script: - pwd - ls -l - python -m mypy app flake8: stage: Static Analysis script: - pwd - python -m flake8 git-lint: stage: Static Analysis script: - git log -1 --pretty=%B | gitlint --contrib=CT1 --ignore=body-is-missing 在这个文件中,我们定义了: Runner 应该执行的任务的结构和顺序。 遇到特定条件时,Runner 应做出的决定。 上面的示例定义了如下 pipeline: before_script关键字定义了在每个任务前执行的一组命令,我们通过这里设置的命令安装运行 pipeline 所需的依赖。 stage 关键字定义了 Pre-commit Hooks 和 Static Analysis 两个阶段。 之后则定义了 pre-commit 、 git-lint、 mypy 和 flake8 等多个任务及所属阶段。 每个任务中通过 script 关键字定义该任务所运行的脚本命令。 在 pre-commit 任务中,我们成功地将上一节介绍的 pre-commit 放在服务端运行,这能确保对任何人提交的代码都会运行原先只在本地生效的 pre-commit hooks。我们还通过预定义变量和 cache 关键字缓存运行任务所需的文件以加快执行速度。 在 mypy 任务中,我们通过 allow_failure 关键字更改了任务成功才会进入下一阶段这一默认行为,并通过 only 关键字限制其只对 master 分支的提交执行。 关于 .gitlab.yml 的更多说明请参阅 GitLab CI/CD pipeline 配置参考手册 以及 GitLab CI/CD 示例。 查看 pipeline 的运行状态 现在当我们推送新的代码到共享仓库时,一个新的 pipeline 会被触发运行。我们可以通过以下途径在 GitLab 仓库查看 pipeline 的运行状态: 进入 CI/CD > Pipelines。将展示一个包含两个阶段的 pipeline(当前状态为已通过)。 点击 pipeline 的 ID 即可查看该 pipeline 的运行示意图: 点击一个任务名称,可查看该任务的详细运行信息: 以上即为一个完整的 GitLab CI pipeline 示例。在该 pipeline 中,我们会在每一次提交代码后: 对所有文件运行集成了众多 hooks 的 pre-commit,确保代码已在本地通过了 pre-commit 检查。 单独执行 mypy 、git-lint 等检查工具。 还能够执行打包、运行测试、安全检查等更多更复杂的任务。 接下来我们将通过 GitHub Actions 为例,展示如何运行一个简单的 CD pipeline。 GitHub CD 示例 和 GitLab CI/CD 的区别 GitHub Actions 是 GitHub 在 2018年10月推出的持续集成服务,它和上文介绍的 GitLab CI 原理和使用方法基本相同,但存在以下区别: 术语定义有所不同,GitHub Actions 有以下组成部分: workflow (工作流程):持续集成一次运行的过程,即 GitLab 中的 pipeline。 job (任务):一个 workflow 由一个或多个 jobs 构成,类似于 GitLab 中的 stage,但默认在不同的 Runner 并行执行,也可以设置为顺序执行。 step(步骤):每个 job 由多个 step 构成,类似于 GitLab 中的 job,但 step 要么是一行命令,要么就是一个action,step 之间按顺序执行且能够共享数据。 action (动作):你可以将多个脚本命令封装成一个 action,通过 GitHub 市场分享给其他人,然后在 step 中使用自建或来自社区的 action。 GitHub 免费提供常见类型的 Runner,因此一般情况不需要再自行配置服务器及注册。 workflow 的配置文件也是类似于 .gitlab.yml 的 yaml 文件,但需要添加到项目根目录的 .github/workflows 路径中。 可以很方便地共享或使用他人创建的脚本命令,即 action。 接下来我将演示如何使用 GitHub Actions 自动发布一个 Hugo 静态站点到 GitHub Pages。 使用 GitHub Actions 自动发布静态站点 大致的工作流程如下: 在本地内容仓库开发站点内容,完成后将内容推送到共享仓库。该共享仓库可能是私有的。 共享仓库接受推送后触发 workflow,执行以下步骤: 在 Runner 中拉取提交的内容。 配置环境并安装指定版本的 Hugo。 运行 Hugo 根据提交内容生成静态站点。 将静态文件推送到发布仓库。 发布仓库在接收到推送内容后会自动更新 Pages 页面。 GitHub 上有许多这类自动化部署任务的开源 Actions 项目,我们选择了其中一个简单易用的 GitHub Actions for Hugo。具体的操作步骤截图和详细配置项可以查看该项目的 README。下面简单介绍下配置过程: 在本地内容仓库中添加目录和文件:.github/workflows/gh-pages.yml,gh-pages.yml 文件内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 name: github pages on: push: branches: - main # 每次推送到 main 分支都会触发部署任务 jobs: deploy: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 with: submodules: true # Fetch Hugo themes (true OR recursive) fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod - name: Setup Hugo uses: peaceiris/actions-hugo@v2 with: hugo-version: '0.79.1' extended: true - name: Build run: hugo --minify - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} external_repository: <USERNAME>/<USERNAME>.github.io # 发布仓库名称 publish_branch: main publish_dir: ./public 在该配置文件中,我们定义了如下的 workflow: workflow 名称为 github pages。 仅在推送提交到 main 分支时执行。 含有一个名为 deploy 的任务,并需要在ubuntu-18.04平台的 Runner 执行。 任务中的第一个步骤使用了名为 actions/checkout@v2 的 action,以拉取提交的内容到 Runner。 第二个步骤使用 peaceiris/actions-hugo@v2 action 配置 Hugo,并指定版本。 第三个步骤直接执行 hugo --minify 命令,生成静态站点(默认输出文件到 public 目录) 第四个步骤使用 peaceiris/actions-gh-pages@v3 action 将生成的静态文件推送到指定的外部仓库。 这个 workflow 基于内容仓库运行,但我们需要将运行过程生成的静态文件推送到发布仓库进行发布,因此还需要在两个仓库中分别设置密钥。 在本地生成 SSH 部署密钥: 1 2 3 4 ssh-keygen -t rsa -b 4096 -C "$(git config user.email)" -f gh-pages -N "" # 将在当前目录生成如下密钥文件: # gh-pages.pub (公钥) # gh-pages (私钥) 在 GitHub 分别进入内容仓库和发布仓库的 Settings 页面: 将公钥 gh-pages.pub 作为 Secret 添加到内容仓库,并设置 Name 为 ACTIONS_DEPLOY_KEY。 将私钥 gh-pages 作为 Deploy Key 添加到发布仓库,并设置为 Allow write access。 接下来我们测试一下效果。 在本地内容仓库做一些更改,预览效果后提交并推送,然后在共享仓库的 GitHub Actions 页面检查相应 workflow 的运行状态与详细结果: 运行成功后,很快发布仓库将新增一个由该 workflow 创建的提交,相应的 GitHub Pages 也会更新相应的内容。 总结 本文中,我们围绕保障代码质量这一目的: 首先介绍了 Linter、Static Type Checker、Autoformatter 三种工具并各展示了一个代表性工具的使用方法。 将这些工具通过 pre-commit 集成到 git hooks 中,在代码开发工作流中自动执行。 介绍了CI/CD,并以 GitLab CI pipeline 为例将以上检查部署到共享仓库,在每次代码提交后自动执行。 顺便介绍了一个通过 GitHub Actions 实现持续部署的示例,演示了 GitHub 持续集成服务的基本用法。 经过简单几步的一次性配置,你就可以拥有一套高度自动化的代码质量工具工作流程。但工具只是保障代码质量众多环节中最容易发力,见效快,但作用也很有限的一环。真正实现保证代码质量这一目标任重而道远,还需要我们在个人和团队这两个方面认识到代码质量的重要性,并不断践行各种代码质量设计与活动。 更新: 如果你觉得手动集成以上工具过于繁琐,可以尝试我制作的 Python 项目模板 Cookiecutter PyPackage,使用方式很简单,使用了 GitHub Actions 自动执行测试、预览和发布。 如果你想学习/提高自己的 Python 技能,我推荐一个学习网站 https://jobtensor.com/Tutorial/Python/en/Introduction,它们的内容很棒而且完全免费。 参考链接 Linter-Wikipedia Flake8 Mypy Python Type Checking (Guide) Black Customizing Git - Git Hooks pre-commit About continuous integration GitLab CI/CD Quickstart for GitHub Actions Introduction to GitHub Actions GitHub Actions 入门教程

2021/1/4
articleCard.readMore

使用 Hugo 和 GitHub Pages 搭建静态博客

作为一个已经入行了一年多的(老)技术人,维护一个看得过去的个人博客是很有必要的。 刚学编程的时候,还开源过一个基于 Flask 和 MongoDB 的博客项目,但后面就没怎么使用和维护了,除开主观上的懒,还因为: 自建博客绕不开主机和域名,这是一笔持续的经济成本。国内的主机往往是第一年割肉第二年宰猪,国外的主机访问延迟很高。 国内主机更麻烦的是还需要定期备案,第一个博客就是因为备案到期中断了。 博客功能的实现技术难度不大,开始还有一些新鲜感,有了工作经验后就很难有兴趣继续维护了。 刚好最近有写一些文章的打算,决定找个简单、省事(最后发现并没有)且不花钱的路子把博客再搞起来,一番研究后,选择了生成静态站点发布到 GitHub Pages 的方案。 工作流 整个方案的流程大致如下: 用 Markdown 格式写作文章。 使用生成器将 markdown 文件转换成静态站点。 将生成的站点内容推送到 GitHub 并发布。 写 markdown 没啥好说的,什么编辑器都可以,我一直用的是 Typora。 静态站点生成器我选择了 Hugo,原因是最近刚好在学 Go,此外还有 Gatsby、Jekyll、Hexo等很多选项。 接下来要做的工作是生成静态站点并通过 GitHub Pages 发布。 生成静态站点 使用 Hugo 生成静态博客站点非常简单,具体的步骤和用法可以参考官方文档的 Quick Start。下面简单介绍下整个过程: 安装 Hugo。macOS 下可以直接使用 homebrew安装:brew install hugo。 创建一个新的站点。这会生成一个特定目录结构的项目文件夹,用来维护所有的站点内容。假设我们想把它命名为 hugo-blog,则使用以下命令创建并切换到该目录,后续的操作和命令都会在这个根目录下执行: hugo new site hugo-blog cd hugo-blog 安装一个主题。这一步是必需的,否则会因为缺少基础模板无法生成站点。安装主题有 3 种方式,以 eureka 主题为例: 直接下载主题的压缩文件,将解压得到的文件夹重命名为主题名称 eureka 放到 themes/目录下。 通过 git submodule 安装: git init git submodule add https://github.com/wangchucheng/hugo-eureka.git themes/eureka 通过 Hugo Modules 安装(这种方式要求本机安装有 Go 1.12 及以上版本,且只有部分主题支持): hugo mod init <module_name> <module_name> 并不重要,随便起个名字就行。 安装后需要启用主题,方法是将主题名称写入到根目录下的默认配置文件 config.yml 中: echo 'theme = "eureka"' >> config.toml 如果是通过 Hugo Modules 安装,需要把主题名称替换成模块名称: echo 'theme = "github.com/wangchucheng/hugo-eureka"' >> config.toml 添加一篇文章。可以直接在 content/posts 目录下创建 markdown 文件,但需要手动写入一些元信息,因此推荐使用 Hugo 自带的命令:hugo new posts/my-first-post.md,添加的文件会以如下元信息开头: --- title: "My First Post" date: 2019-03-26T08:47:11+01:00 draft: true --- 在下方接着写入文章内容即可。注意此时该文件为草稿状态,写作完成后需要改成 draft: false 才能部署。 启动 Hugo 预览服务器。Hugo 可以启动一个 Web 服务器,同时构建站点内容到内存中并在检测到文件更改后重新渲染,方便我们在开发环境实时预览对站点所做的更改。 hugo server -D 添加 -D 选项以输出草稿状态的文章,执行成功后可以通过 http://localhost:1313/ 访问站点。 自定义主题配置。站点的配置项默认保存在根目录的 config.toml 文件中,配置项较多时通常会用主题提供的预设配置文件来替换该文件,还可以通过config 目录加多个文件的方式来组织配置。默认配置文件如下: baseURL = "http://example.org/" # 发布地址,由主机名以及路径组成 languageCode = "en-us" # 语言代码,中文可以设置为"zh" title = "My New Hugo Site" # 站点标题 这一步应该是整个过程中最麻烦也是最容易出问题的一步,视乎你选择的主题与想要的功能不同,需要自定义的配置项也不同,数量从几个到上百个不等。有些主题会有详细的文档解释配置过程,有些则一笔带过只能自己去摸索,配置较多时相互间可能还有依赖关系,最好更改一个配置就刷新一次页面确认下结果。 建议起步时一切从简,花大把时间搞各种花里胡哨的样式和功能,还不如多写几篇文章。 构建静态页面。站点配置成我们理想的效果之后就可以构建静态页面了: hugo -D 添加 -D 选项可以在结果中包括草稿内容,默认情况下静态页面会输出到根目录下的 public 文件夹中。 通过 GitHub Pages 发布 这一步 Hugo 的官方文档同样在 Host on GitHub 中进行了详细的介绍,并且还很贴心的提供了自动化操作的 Shell 脚本。 有两种方式: 通过个人主页发布:必须创建一个 <USERNAME>.github.io 仓库来托管生成的静态内容,发布后的域名为 https://<USERNAME>.github.io。 通过项目主页发布:可以随意创建 <PROJECT_NAME> 仓库,发布后的域名为 https://<USERNAME>.github.io/<PROJECT_NAME>。 视选择的发布方式不同,我们需要将 config.yml 中的 baseUrl 设置为不同的值。 通过个人主页发布 建议非特殊情况下使用第 1 种方式,原因是许多主题都不能很好的支持第 2 种,具体来说是将 config.toml 的 baseURL 设置为含子路径的地址时,不能正确的处理所有资源的构建位置。我尝试了 3 个主题,均遭遇了不同的问题: eureka: 构建失败,提示 : Error: Error building site: POSTCSS: failed to transform "css/eureka.css" (text/css): resource "css/waynerv.github.io/css/eureka.css_fc3f76d7bee2760c3a903059afc3d9b2" not found in file cache LoveIt: 构建成功,但除主页以外的文章、分类和标签的页面均提示 404。 MemE: 构建成功,但文章中插入的图片加载 404(放在同一文件夹的favicon 却能正常展示)。 这个问题我在 eureka 项目提交了 issue,开发者回复可能是 Hugo 本身的机制所导致,并已经在 Hugo 论坛中提出了此问题,有兴趣的可以关注后续进展。 发布步骤 在 GitHub 创建个人主页仓库,仓库名称必须设置为 <USERNAME>.github.io,这个仓库仅存放生成的静态内容。 在 GitHub 创建一个项目仓库 hugo-blog 并添加为我们本地项目文件夹的远程仓库。这个仓库用来维护站点配置和原始的文章内容。 假设我们在已经通过上文的步骤在 public 文件夹中生成了想发布的静态内容,运行: git submodule add -b main https://github.com/<USERNAME>/<USERNAME>.github.io.git public 在 public 目录中创建一个 git 子模块,之后这个目录将以 https://github.com/<USERNAME>/<USERNAME>.github.io 作为远程仓库。 确保配置文件中的 baseUrl 已经设置为了 <USERNAME>.github.io。 Hugo 为我们接下来的部署操作提供了一个自动化的 Shell 脚本: #!/bin/sh # 任一步骤执行失败都会终止整个部署过程 set -e printf "\033[0;32mDeploying updates to GitHub...\033[0m\n" # 构建静态内容 hugo # if using a theme, replace with `hugo -t <YOURTHEME>` # 切换到 Public 文件夹 cd public # 添加更改到 git git add . # 提交更改 msg="rebuilding site $(date)" if [ -n "$*" ]; then msg="$*" fi git commit -m "$msg" # 推送到远程仓库 git push origin main 将如上内容保存到 deploy.sh 文件中,并执行 chmod +x deploy.sh 为其添加可执行权限。接着执行部署脚本: ./deploy.sh 大功告成!稍等几分钟就可以在 https://<USERNAME>.github.io 看到我们的个人博客了。 通过 GitHub Actions 自动部署 目前我们的「创作-发布」流程如下: 在项目仓库编辑原始内容并进行版本管理。 执行自动脚本生成静态站点并推送到个人主页仓库完成发布。 这套流程已经很流畅,但还有一些改进空间:我们可以使用 GitHub Actions,在每次向远程的项目仓库推送原始内容更改时自动执行第 2 步进行发布。 GitHub 上有许多这类自动化部署任务的开源 Actions 项目,我们选择了其中一个简单易用的 GitHub Actions for Hugo。具体的操作步骤截图和详细配置项可以查看该项目的 README。下面简单介绍下配置过程: 在项目文件夹中添加目录和文件:.github/workflows/gh-pages.yml,gh-pages.yml 文件内容如下: name: github pages on: push: branches: - main # 每次推送到 main 分支都会触发部署任务 jobs: deploy: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 with: submodules: true # Fetch Hugo themes (true OR recursive) fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod - name: Setup Hugo uses: peaceiris/actions-hugo@v2 with: hugo-version: '0.79.1' extended: true - name: Build run: hugo --minify - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} external_repository: <USERNAME>/<USERNAME>.github.io # 替换成上文所创建的个人主页仓库 publish_branch: main publish_dir: ./public 这个文件所定义的 workflow 基于项目仓库运行,但我们需要将运行过程生成的静态文件推送到个人主页仓库 <USERNAME>.github.io 完成发布,因此在 Deploy 任务中按照文档的 Deploy to external repository external_repository 一节做了专门的配置。 在本地生成 SSH 部署密钥: ssh-keygen -t rsa -b 4096 -C "$(git config user.email)" -f gh-pages -N "" # 将在当前目录生成如下密钥文件: # gh-pages.pub (公钥) # gh-pages (私钥) 在 GitHub 分别进入项目仓库和个人主页仓库的 Settings 页面: 将公钥 gh-pages.pub 作为 Secret 添加到项目仓库,并设置 Name 为 ACTIONS_DEPLOY_KEY。 将私钥 gh-pages 作为 Deploy Key 添加到个人主页仓库,并设置为 Allow write access。 接下来我们测试一下效果。 在本地做一些更改,预览效果后提交并推送,然后在项目仓库的 GitHub Actions 页面检查相应的 workflow 是否运行成功。不出意外的话,很快个人主页仓库将新增一个由该 workflow 创建的提交,访问个人博客页面也会发现页面已经更新。 个人体验 由于先后选择的 3 个主题均遭遇了上述发布地址不能包含子路径的问题,我在基本按照官方文档操作的前提下,依然花了超过 10 个小时才把博客上线,浪费了很多时间在配置主题以及寻找问题的解决方案上。本以为选择了一个简单快捷的省心方案,结果还是免不了过程的一顿踩坑和折腾。 虽然搭建博客的流程不算省心,但我所遇到的这些问题也算是个例。一切准备就绪后,我们可以像写代码一样写博客,对文章修改提交即自动发布,也不需要考虑博客的样式、后台功能及主机维护等问题,对提升写作效率会有所帮助,可以省下来很多的时间和精力,综合来看体验还是不错的。

2020/12/24
articleCard.readMore

深入理解 git 合并操作

合并在 Git 中是一个十分常见的操作:整合不同分支之间的更改,或者对远程分支执行 pull 及 push 操作,都需要进行合并。 但对新手来说, git merge 这一命令有些令人生畏,因为在不同情况下,执行 merge 可能会得到不同的结果。这种对于结果的不确定性,使我很长一段时间都不敢主动去使用它,而是依赖 GitHub 的 Pull Request 或者 GitLab 的 Merge Request 等可视化界面手动合并。 为了今后可以放心大胆的 merge,今天我们就来对 merge 一探究竟。 认识合并 在版本控制系统中,合并是将一组文件中所发生的不同更改进行整合的基础操作。通常来说,我们在使用 Git 时会建立不同的分支,由不同的人对同一组文件执行新增、编辑等操作,最终我们需要合并这些协作的分支,整合所有的更改形成一份文件版本。 合并一般由 Git 根据算法自动执行,但如果发生了冲突,比如对同一文件的同一处内容执行了不同的更改,则需要我们手动合并。 递归三路合并算法 Git 在自动合并时会使用「递归三路合并」算法对不同文件进行差异分析,接下来我们简单了解一下该算法。 首先从「三路合并」算法开始,假设我们有以下提交历史: 上图中我们在 master 合并了 feature 分支,现在我们回溯一下合并的过程: 此时 master 正指向提交 C,Git 首先找到两个分支最近的唯一共同祖先提交 A,然后分别对 A、C、F 提交的文件快照进行对比,我们下文称呼它们为 A、C、F 文件。接下来 Git 将逐「行」对三个文件的内容进行比较,如果三个文件中有两个文件该行的内容一致,则丢弃 A 文件中该行的内容,保留与 A 文件中不同的内容放到结果文件中。 具体来说,假如 A、C 内容一致,说明这是在 F 中更改的内容,需要保留该更改;A、F 内容一致同理;假如 C、F 内容一致,说明 C 和 F 都相对于 A 做了同样的更改,同样需要保留。除此之外的内容差异仅剩两种情况:如果 A、C、F 的内容都一致,说明什么都没有发生;如果该行在 A、C、F 的内容都不一致,说明发生了冲突,需要我们手动合并选择需要保留的内容。 结束对比后 Git 会以最终的结果文件快照创建一个新的 Merge 提交并指向它。 三路合并算法的基础是找到被合并文件的共同祖先,在一些简单的场景中这还能行的通,但在遇到十字交叉合并(criss-cross merge)时,不存在唯一的最近共同祖先,如下图: 现在我们需要从 main 分支合并 feature 分支,即把 C7 合并到 C8,会发现 C8 和 C7 有两个共同祖先,这下怎么办呢?Git 采取的是递归三路合并(Recursive three-way merge),会先合并 C3 和 C5 这两个共同祖先创建一个虚拟的唯一最近祖先(假设为 C9),接着在 C9、C7、C8 之间执行三路合并,如果在合并 C3 和 C5 的过程中又发生没有唯一共同祖先的情况,则递归执行上述过程。 关于递归三路合并算法我们就了解到这里。 合并冲突 如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就无法自动地合并它们,而是会暂停合并过程,等待你去手动解决冲突。 首先我们需要找到这些需要解决冲突的文件,使用 git status 可以查看这些因包含合并冲突而处于未合并状态的文件: $ git status On branch master You have unmerged paths. (fix conflicts and run "git commit") Unmerged paths: (use "git add <file>..." to mark resolution) both modified: main.py no changes added to commit (use "git add" and/or "git commit -a") 手动解决冲突类似于二选一的过程,Git 会在有冲突的文件中加入特殊的标记,看起来像下面这样: <<<<<<< HEAD:main.py print("Hello World") ======= print("World Hello") >>>>>>> feature:main.py 通过 ======= 进行分割,以 <<<<<<< HEAD:main.py 标记为上界的上半部分是当前分支 master 所做的更改,以 >>>>>>> feature:main.py 标记为下界的下半部分是要合并的 feature 对同一内容所做的不同更改。我们需要编辑文件删除这些标记,仅保留我们需要的内容: print("Hello World") 当然也可以不从中选择,而是用一段全新的内容去替换它。 在解决了所有文件里的冲突之后,需要使用 git add 暂存这些文件来将其标记为冲突已解决。然后再执行 git commit 来完成合并提交。 Git 会将解决的这些冲突,加入到上文提到的新增的 Merge 提交里。 快进合并 也有些时候,我们在执行了合并操作后,会发现并没有增加一个新的 Merge 提交。这种情况我们称之为快进(fast-forward)合并。 假设我们基于 master 创建了 feature 分支,并新增了一些提交。现在我们将 feature 的更改合入 master 分支: $ git checkout master $ git merge feature Updating f42c576..3a0874c Fast-forward main.py | 2 ++ task.py | 3 ++ worker.py | 1 ++ 3 file changed, 6 insertions(+) 过程示意如下: 由于我们想要合并的分支 feature 所指向的提交 D 是 master 的直接后继, 因此 Git 会直接将 HEAD 指针向前移动。换句话说,如果顺着一个分支走下去一定能够到达另一个分支,那么 Git 在合并两者时只会简单的将指针向前推进(右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做快进(fast-forward)。 Git 的不同合并策略 我们在使用 Git 时,通常会基于主分支拉出若干条功能分支进行开发,开发完毕后再将功能分支合入主分支。有以下不同的分支合并策略: 通过 merge 显式合并 通过 rebase 或 fast-forward 隐式合并 squash 后隐式合并 通过 merge 显式合并 这是最常见和最直接的合并方式,也是 GitHub 和 GitLab 等代码托管平台的默认实现方式。 当我们将功能分支合入主分支时,Git 会对两个分支进行递归三路合并,并以合并结果创建一个新的 Merge 提交。这个 Merge 提交和普通的提交本质上是一样的,但是它有两个父提交: $ git cat-file -p 44ba027 tree 5a1692ba62ef346b59e65e4aa441c731bebc51ff parent 75bf5c59c2e7e493c98e026a415f16b8f0445e4a parent bbbe6a4c02aa709299ac891779448daf8203df53 author xx <xx@xx.com> 1609141855 +0800 committer xx <xx@xx.com> 1609141855 +0800 Merge branch 'feature' into 'master' 我们能在提交历史中,很明了地根据 Merge 提交查看发生的合并事件。但另一方面,大量的 Merge 提交会使你的提交历史有很多分叉,甚至十分凌乱,有些开发者或者团队可能会想要一个看上去更加整洁的线性提交历史。 需要注意的是,默认情况下 Git 不会在快进合并的情况下创建单独的 Merge 提交。假如我们想在所有情况下都创建一个 Merge 提交,需要在执行 git merge 命令时添加 --no-ff 选项。 通过 rebase 或 fast-forward 隐式合并 我们可以用 rebase 替换 merge 进行合并,我在之前的一篇文章git-rebase 浅析中详细介绍过 rebase 的原理和用法,简单来说 rebase 操作会找到两个分支的最近的祖先提交,并基于目标分支按顺序重新应用当前分支在祖先提交之后的更改。假设我们有如下图的 master 和 feature 两个分支,执行下列操作: $ git checkout feature $ git rebase master $ git checkout master $ git merge feature 过程如下图所示: 我们首先用 rebase 将 master 合并到了 feature,即使两个分支都有不同的提交,也得到了一条完全线性的 feature 分支,而且没有额外的 Merge 提交。 接着又切换到 master 分支合并了 feature。rebase 之后的 feature 分支上,所有提交都是 master 的后继提交,因此我们将直接执行快进合并。快进合并只有在 master 分支中没有比 feature 更新的提交时才会发生(使用 rebase 能够确保该结果),在这种情况下,master 的 HEAD 可以直接右移到 feature 分支的最新提交。这样合并也不会生成单独的 Merge 提交,它只是将分支标签快速指向了新的提交。 通过 rebase 或 fast-forward 隐式的合并,我们能够得到一条整洁线性的提交历史,但同时也会丢失这些提交曾经的上下文信息。 squash 后隐式合并 还有一种合并变更的策略是,在执行快进合并或 rebase 之前,将所有功能分支的提交通过 rebase 交互模式的 squash 命令压缩成一个提交。这样可以进一步保持主分支提交历史的线性和整洁。它将一个完整的功能单独保存在一次提交中,但也失去了对整个功能分支开发过程的记录和细节。具体的操作方法可参考重写提交历史。 这三种策略都有明显的优缺点,我们可以根据具体的场景以及自己的需求进行选择。 参考链接 Merge (version control) git-merge - Join two or more development histories together Pull Request Merge Strategies: The Great Debate Git 分支 - 分支的新建与合并 git merge的原理(递归三路合并算法)

2020/11/29
articleCard.readMore

git rebase 用法详解与工作原理

以前对 git rebase -i 的用法一直是一知半解,一次在需要合并多个提交时刚好用到,一顿操作差点把提交都搞丢了,幸好后面顺利找回,因此记录一下学习 rebase 命令的过程。 理解 Rebase 命令 git rebase 命令的文档描述是 Reapply commits on top of another base tip,从字面上理解是「在另一个基端之上重新应用提交」,这个定义听起来有点抽象,换个角度可以理解为「将分支的基础从一个提交改成另一个提交,使其看起来就像是从另一个提交中创建了分支一样」,如下图: 假设我们从 Master 的提交 A 创建了 Feature 分支进行新的功能开发,这时 A 就是 Feature 的基端。接着 Matser 新增了两个提交 B 和 C, Feature 新增了两个提交 D 和 E。现在我们出于某种原因,比如新功能的开发依赖 B、C 提交,需要将 Master 的两个新提交整合到 Feature 分支,为了保持提交历史的整洁,我们可以切换到 Feature 分支执行 rebase 操作: 1 git rebase master rebase 的执行过程是首先找到这两个分支(即当前分支 Feature、 rebase 操作的目标基底分支 Master) 的最近共同祖先提交 A,然后对比当前分支相对于该祖先提交的历次提交(D 和 E),提取相应的修改并存为临时文件,然后将当前分支指向目标基底 Master 所指向的提交 C, 最后以此作为新的基端将之前另存为临时文件的修改依序应用。 我们也可以按上文理解成将 Feature 分支的基础从提交 A 改成了提交 C,看起来就像是从提交 C 创建了该分支,并提交了 D 和 E。但实际上这只是「看起来」,在内部 Git 复制了提交 D 和 E 的内容,创建新的提交 D' 和 E' 并将其应用到特定基础上(A→B→C)。尽管新的 Feature 分支和之前看起来是一样的,但它是由全新的提交组成的。 rebase 操作的实质是丢弃一些现有的提交,然后相应地新建一些内容一样但实际上不同的提交。 主要用途 rebase 通常用于重写提交历史。下面的使用场景在大多数 Git 工作流中是十分常见的: 我们从 master 分支拉取了一条 feature 分支在本地进行功能开发 远程的 master 分支在之后又合并了一些新的提交 我们想在 feature 分支集成 master 的最新更改 rebase 和 merge 的区别 以上场景同样可以使用 merge 来达成目的,但使用 rebase 可以使我们保持一个线性且更加整洁的提交历史。假设我们有如下分支: 1 2 3 D---E feature / A---B---C master 现在我们将分别使用 merge 和 rebase,把 master 分支的 B、C 提交集成到 feature 分支,并在 feature 分支新增一个提交 F,然后再将 feature 分支合入 master ,最后对比两种方法所形成的提交历史的区别。 使用 merge 切换到 feature 分支: git checkout feature。 合并 master 分支的更新: git merge master。 新增一个提交 F: git add . && git commit -m "commit F" 。 切回 master 分支并执行快进合并: git chekcout master && git merge feature。 执行过程如下图所示: 我们将得到如下提交历史: 1 2 3 4 5 6 7 8 9 * 6fa5484 (HEAD -> master, feature) commit F * 875906b Merge branch 'master' into feature |\ | | 5b05585 commit E | | f5b0fc0 commit D * * d017dff commit C * * 9df916f commit B |/ * cb932a6 commit A 使用 rebase 步骤与使用 merge 基本相同,唯一的区别是第 2 步的命令替换成: git rebase master。 执行过程如下图所示: 我们将得到如下提交历史: 1 2 3 4 5 6 * 74199ce (HEAD -> master, feature) commit F * e7c7111 commit E * d9623b0 commit D * 73deeed commit C * c50221f commit B * ef13725 commit A 可以看到,使用 rebase 方法形成的提交历史是完全线性的,同时相比 merge 方法少了一次 merge 提交,看上去更加整洁。 为什么要保持提交历史的整洁 一个看上更整洁的提交历史有什么好处? 满足某些开发者的洁癖。 当你因为某些 bug 需要回溯提交历史时,更容易定位到 bug 是从哪一个提交引入。尤其是当你需要通过 git bisect 从几十上百个提交中排查 bug,或者有一些体量较大的功能分支需要频繁的从远程的主分支拉取更新时。 使用 rebase 来将远程的变更整合到本地仓库是一种更好的选择。用 merge 拉取远程变更的结果是,每次你想获取项目的最新进展时,都会有一个多余的 merge 提交。而使用 rebase 的结果更符合我们的本意:我想在其他人的已完成工作的基础上进行我的更改。 其他重写提交历史的方法 当我们仅仅只想修改最近的一次提交时,使用 git commit --amend 会更加方便。 它适用于以下场景: 我们刚刚完成了一次提交,但还没有推送到公共的分支。 突然发现上个提交还留了些小尾巴没有完成,比如一行忘记删除的注释或者一个很小的笔误,我们可以很快速的完成修改,但又不想再新增一个单独的提交。 或者我们只是觉得上一次提交的提交信息写的不够好,想做一些修改。 这时候我们可以添加新增的修改(或跳过),使用 git commit --amend 命令执行提交,执行后会进入一个新的编辑器窗口,可以对上一次提交的提交信息进行修改,保存后就会将所做的这些更改应用到上一次提交。 如果我们已经将上一次提交推送到了远程的分支,现在再执行推送将会提示出错并被拒绝,在确保该分支不是一个公共分支的前提下,我们可以使用 git push --force 强制推送。 注意与 rebase 一样,Git 在内部并不会真正地修改并替换上一个提交,而是创建了一个全新的提交并重新指向这个新的提交。 使用 rebase 的交互模式重写提交历史 git rebase 命令有标准和交互两种模式,之前的示例我们用的都是默认的标准模式,在命令后添加 -i 或 --interactive 选项即可使用交互模式。 两种模式的区别 我们前面提到, rebase 是「在另一个基端之上重新应用提交」,而在重新应用的过程中,这些提交会被重新创建,自然也可以进行修改。在 rebase 的标准模式下,当前工作分支的提交会被直接应用到传入分支的顶端;而在交互模式下,则允许我们在重新应用之前通过编辑器以及特定的命令规则对这些提交进行合并、重新排序及删除等重写操作。 两者最常见的使用场景也因此有所不同: 标准模式常用于在当前分支中集成来自其他分支的最新修改。 交互模式常用于对当前分支的提交历史进行编辑,如将多个小提交合并成大的提交。 不仅仅是分支 虽然我们之前的示例都是在不同的两个分支之间执行 rebase 操作,但事实上 rebase 命令传入的参数并不仅限于分支。 任何的提交引用,都可以被视作有效的 rebase 基底对象,包括一个提交 ID、分支名称、标签名称或 HEAD~1 这样的相对引用。 自然地,假如我们对当前分支的某次历史提交执行 rebase,其结果就是会将这次提交之后的所有提交重新应用在当前分支,在交互模式下,即允许我们对这些提交进行更改。 重写提交历史 终于进入到本文的主题,前面提到,假如我们在交互模式对当前分支的某次提交执行 rebase,即(间接)实现了对这次提交之后的所有提交进行重写。接下来我们将通过下面的示例进行详细介绍。 假设我们在 feature 分支有如下提交: 1 2 3 4 5 6 74199cebdd34d107bb67b6da5533a2e405f4c330 (HEAD -> feature) commit F e7c7111d807c1d5209b97a9c75b09da5cd2810d4 commit E d9623b0ef9d722b4a83d58a334e1ce85545ea524 commit D 73deeedaa944ef459b17d42601677c2fcc4c4703 commit C c50221f93a39f3474ac59228d69732402556c93b commit B ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A 接下来我们将要执行的操作是: 将 B、C 合并为一个新的提交 ,并仅保留原提交 C 的提交信息 删除提交 D 将提交 E 移动到提交 F 之后并重新命名(即修改提交信息)为提交 H 在提交 F 中加入一个新的文件更改,并重新命名为提交 G 由于我们需要修改的提交是 B→C→D→E,因此我们需要将提交 A 作为新的「基端」,提交 A 之后的所有提交会被重新应用: 1 git rebase -i ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 # 参数是提交 A 的 ID 接下来会进入到如下的编辑器界面: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 pick c50221f commit B pick 73deeed commit C pick d9623b0 commit D pick e7c7111 commit E pick 74199ce commit F # 变基 ef13725..74199ce 到 ef13725(5 个提交) # # 命令: # p, pick <提交> = 使用提交 # r, reword <提交> = 使用提交,但修改提交说明 # e, edit <提交> = 使用提交,进入 shell 以便进行提交修补 # s, squash <提交> = 使用提交,但融合到前一个提交 # f, fixup <提交> = 类似于 "squash",但丢弃提交说明日志 # x, exec <命令> = 使用 shell 运行命令(此行剩余部分) # b, break = 在此处停止(使用 'git rebase --continue' 继续变基) # d, drop <提交> = 删除提交 ...... (注意上面提交 ID 之后的提交信息只起到描述作用,在这里修改它们不会有任何效果。) 具体的操作命令在编辑器的注释中已解释的相当详细,所以我们直接进行如下操作: 对提交 B、C 作如下修改: 1 2 pick c50221f commit B f 73deeed commit C 由于提交 B 是这些提交中的第一个,因此我们无法对其执行 squash 或者 fixup 命令(没有前一个提交了),我们也不需要对提交 B 执行 reword 命令以修改其提交信息,因为之后在将提交 C 融合到提交 B 中时,会允许我们对融合之后的提交信息进行修改。 注意该界面提交的展示顺序是从上到下由旧到新,因此我们将提交 C 的命令改为 s(或 squash) 或者 f(或 fixup) 会将其融合到(上方的)前一个提交 B,两个命令的区别为是否保留 C 的提交信息。 删除提交 D: 1 d d9623b0 commit D 移动提交 E 到提交 F 之后并修改其提交信息: 1 2 pick 74199ce commit F r e7c7111 commit E 在提交 F 中加入一个新的文件更改: 1 e 74199ce commit F 保存退出。 接下来会按照从上到下的顺序依次执行我们对每一个提交所修改或保留的命令: 对提交 B 的 pick 命令会自动执行,因此不需要交互。 接着执行对提交 C 的 squash 命令,会进入一个新的编辑器界面允许我们修改合并了B、C 之后的提交信息: 1 2 3 4 5 6 7 8 9 # 这是一个 2 个提交的组合。 # 这是第一个提交说明: commit B # 这是提交说明 #2: commit C ...... 我们将 commit B 这一行删除后保存退出,融合之后的提交将使用 commit C 作为提交信息。 对提交 D 的 drop 操作也会自动执行,没有交互步骤。 执行 rebase 的过程中可能会发生冲突,这时候 rebase 会暂时中止,需要我们编辑冲突的文件去手动合并冲突。解决冲突后通过 git add/rm <conflicted_files> 将其标记为已解决,然后执行 git rebase --continue 可以继续之后的 rebase 步骤;或者也可以执行 git rebase --abort 放弃 rebase 操作并恢复到操作之前的状态。 由于我们上移了提交 F 的位置,因此接下来将执行对 F 的 edit 操作。这时将进入一个新的 Shell 会话: 1 2 3 4 5 6 7 8 停止在 74199ce... commit F 您现在可以修补这个提交,使用 git commit --amend 当您对变更感到满意,执行 git rebase --continue 我们添加一个新的代码文件并执行 git commit --amend 将其合并到当前的上一个提交(即 F),然后在编辑器界面中将其提交信息修改为 commit G,最后执行 git rebase --continue 继续 rebase 操作。 最后执行对提交 E 的 reword 操作,在编辑器界面中将其提交信息修改为 commit H 。 大功告成!最后让我们确认一下 rebase 之后的提交历史: 1 2 3 4 64710dc88ef4fbe8fe7aac206ec2e3ef12e7bca9 (HEAD -> feature) commit H 8ab4506a672dac5c1a55db34779a185f045d7dd3 commit G 1e186f890710291aab5b508a4999134044f6f846 commit C ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A 完全符合预期,同时也可以看到提交 A之后的所有提交 ID 都已经发生了改变,这也印证了我们之前所说的 Git 重新创建了这些提交。 Rebase 的进阶用法 合并之前执行 rebase 另一种使用 rebase 的常见场景是在推送到远程进行合并之前执行 rebase,一般这样做的目的是为了确保提交历史的整洁。 我们首先在自己的功能分支里进行开发,当开发完成时需要先将当前功能分支 rebase 到最新的主分支上,提前解决可能出现的冲突,然后再向远程提交修改。 这样的话,远程仓库的主分支维护者就不再需要进行整合且创建一条额外的 merge 提交,只需要执行快进合并即可。即使是在多个分支并行开发的情况,最终也能得到一条完全线性的提交历史。 rebase 到其他分支 我们可以通过 rebase 对两个分支进行对比,取出相应的修改,然后应用到另一个分支上。例如: 1 2 3 4 5 F---G patch / D---E feature / A---B---C master 假设我们基于 feature 分支的提交 D 创建了分支 patch,并且新增了提交 F、G,现在我们想将 patch 所做的更改合并到 master 并发布,但暂时还不想合并 feature ,这种情况下可以使用 rebase 的 --onto <branch> 选项: 1 git rebase —onto master feature patch 以上操作将取出 patch 分支,对比它基于 feature 所做的更改, 然后把这些更改在 master 分支上重新应用,让 patch 看起来就像直接基于 master 进行更改一样。执行后的 patch 如下: 1 A---B---C---F'---G' patch 然后我们可以切换到 master 分支,并对 patch 执行快进合并: 1 2 git checkout master git merge patch 通过 rebase 策略执行 git pull Git 在最近的某个版本起,直接运行 git pull 会有如下提示消息: 1 2 3 4 5 6 7 warning: 不建议在没有为偏离分支指定合并策略时执行 pull 操作。 您可以在执行下一次 pull 操作之前执行下面一条命令来抑制本消息: git config pull.rebase false # 合并(缺省策略) git config pull.rebase true # 变基 git config pull.ff only # 仅快进 ...... 原来 git pull 时也可以通过 rebase 来进行合并,这是因为 git pull 实际上等于 git fetch + git merge ,我们可以在第二步直接用 git rebase 替换 git merge来合并 fetch 取得的变更,作用同样是避免额外的 merge 提交以保持线性的提交历史。 两者的区别在上文中已进行过对比,我们可以把对比示例中的 Matser 分支当成远程分支,把 Feature 分支当成本地分支,当我们在本地执行 git pull 时,其实就是拉取 Master 的更改然后合并到 Feature 分支。如果两个分支都有不同的提交,默认的 git merge 方式会生成一个单独的 merge 提交以整合这些提交;而使用 git rebase 则相当于基于远程分支的最新提交重新创建本地分支,然后再重新应用本地所添加的提交。 具体的使用方式有多种: 每次执行 pull 命令时添加特定选项: git pull --rebase 。 为当前仓库设定配置项: git config pull.rebase true,在 git config 后添加 --global 选项可以使该配置项对所有仓库生效。 潜在弊端和反对意见 从以上场景来看 rebase 功能非常强大,但我们也需要意识到它不是万能的,甚至对新手来说有些危险,稍有不慎就会发现 git log 里的提交不见了,或者卡在 rebase 的某个步骤不知道如何恢复。 我们上面已经提到了 rebase 有保持整洁的线性提交历史的优点,但也需要意识到它有以下潜在的弊端: 如果涉及到已经推送过的提交,需要强制推送才能将本地 rebase 后的提交推送到远程。因此绝对不要在一个公共分支(也就是说还有其他人基于这个分支进行开发)执行 rebase,否则其他人之后执行 git pull 会合并出一条令人困惑的本地提交历史,进一步推送回远程分支后又会将远程的提交历史打乱(详见Rebase and the golden rule explained),较严重的情况下可能会对你的人身安全带来风险。 对新手不友好,新手很有可能在交互模式中误操作「丢失」某些提交(但其实是能够找回的)。 假如你频繁的使用 rebase 来集成主分支的更新,一个潜在的后果是你会遇到越来越多需要合并的冲突。尽管你可以在 rebase 过程中处理这些冲突,但这并非长久之计,更推荐的做法是频繁的合入主分支然后创建新的功能分支,而不是使用一个长时间存在的功能分支。 另外有一些观点是我们应该尽量避免重写提交历史: 有一种观点认为,仓库的提交历史即是 记录实际发生过什么。 它是针对历史的文档,本身就有价值,不能乱改。 从这个角度看来,改变提交历史是一种亵渎,你使用 谎言 掩盖了实际发生过的事情。 如果由合并产生的提交历史是一团糟怎么办? 既然事实就是如此,那么这些痕迹就应该被保留下来,让后人能够查阅。 以及频繁的使用 rebase 可能会使从历史提交中定位 bug 变得更加困难,详见 Why you should stop using Git rebase。 找回丢失的提交 在交互式模式下进行 rebase 并对提交执行 squash 或 drop 等命令后,会从分支的 git log 中直接删除提交。如果你不小心操作失误,会以为这些提交已经永久消失了而吓出一身冷汗。 但这些提交并没有真正地被删除,如上所说,Git 并不会修改(或删除)原来的提交,而是重新创建了一批新的提交,并将当前分支顶端指向了新提交。因此我们可以使用 git reflog 找到并且重新指向原来的提交来恢复它们,这会撤销整个 rebase。感谢 Git ,即使你执行 rebase 或者 commit --amend 等重写提交历史的操作,它也不会真正地丢失任何提交。 git reflog 命令 reflogs 是 Git 用来记录本地仓库分支顶端的更新的一种机制,它会记录所有分支顶端曾经指向过的提交,因此 reflogs 允许我们找到并切换到一个当前没有被任何分支或标签引用的提交。 每当分支顶端由于任何原因被更新(通过切换分支、拉取新的变更、重写历史或者添加新的提交),一条新的记录将被添加到 reflogs 中。如此一来,我们在本地所创建过的每一次提交都一定会被记录在 reflogs 中。即使在重写了提交历史之后, reflogs 也会包含关于分支的旧状态的信息,并允许我们在需要时恢复到该状态。 注意 reflogs 并不会永久保存,它有 90 天的过期时间。 还原提交历史 我们从上一个例子继续,假设我们想恢复 feature 分支在 rebase 之前的 A→B→C→D→E→F 提交历史,但这时候的 git log 中已经没有后面 5 个提交,所以需要从 reflogs 中寻找,运行 git reflog 结果如下: 1 2 3 4 5 6 7 64710dc (HEAD -> feature) HEAD@{0}: rebase (continue) (finish): returning to refs/heads/feature 64710dc (HEAD -> feature) HEAD@{1}: rebase (continue): commit H 8ab4506 HEAD@{2}: rebase (continue): commit G 1e186f8 HEAD@{3}: rebase (squash): commit C c50221f HEAD@{4}: rebase (start): checkout ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 74199ce HEAD@{5}: checkout: moving from master to feature ...... reflogs 完整的记录了我们切换分支并进行 rebase 的全过程,继续向下检索,我们找到了从 git log 中消失的提交 F: 1 74199ce HEAD@{15}: commit: commit F 接下来我们通过 git reset 将 feature 分支的顶端重新指向原来的提交 F: 1 2 3 # 我们想将工作区中的文件也一并还原,因此使用了--hard选项 $ git reset --hard 74199ce HEAD 现在位于 74199ce commit F 再运行 git log 会发现一切又回到了从前: 1 2 3 4 5 6 74199cebdd34d107bb67b6da5533a2e405f4c330 (HEAD -> feature) commit F e7c7111d807c1d5209b97a9c75b09da5cd2810d4 commit E d9623b0ef9d722b4a83d58a334e1ce85545ea524 commit D 73deeedaa944ef459b17d42601677c2fcc4c4703 commit C c50221f93a39f3474ac59228d69732402556c93b commit B ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A 参考链接 git rebase | Atlassian Git Tutorial git amend | Atlassian Git Tutorial Git Pull | Atlassian Git Tutorial Git - 变基 Git - git-rebase Documentation Git - git-reflog Documentation Why you should stop using Git rebase Understand how does git rebase work and compare with git merge and git interactive rebase

2020/10/23
articleCard.readMore

写了一份 Web API 设计规范

基于 GitHub REST API v3 和 RFC7807 ,并结合自己在工作中的一些实践,编写了这份 Web API 规范。 本规范虽然整体上遵循 RESTful 风格,但并未强制要求使用超媒体表示,严格来说不符合 REST API 的要求,因此基于此规范构建的项目应该定义为 Web API。 概览 所有 API 应该使用统一的根域名。 所有访问 API 的请求均基于 HTTP 协议,出于安全性考虑尽量使用 HTTPS 。 除文件上传下载外所有的数据均通过 JSON 格式发送和接收,请求和响应的 Content Type 首部均设置为 application/json。 1 2 3 4 5 6 7 8 9 10 curl -i -H "Authorization: Bearer TOKEN" http://qcs.woqutech.com/api/employees HTTP/1.1 200 OK Server: nginx/1.12.2 Date: Thu, 19 Sep 2019 09:07:27 GMT Content-Type: application/json Content-Length: 1115 Connection: keep-alive Access-Control-Allow-Origin: * {RESPONSE BODY} 空白字段将提供为 null 的值,而不是忽略该字段或提供 "" 空字符串值。 所有响应中的时间戳以 ISO 8601 格式表示: 1 2019-09-16T08:42:47Z URI命名 动词 or 名词? 基于 RESTful 的基本原则: 将所有的 API 划分为逻辑的资源,并通过 HTTP 请求方法来对资源进行操作 所有的资源都应该表述为名词而不是动词,且该资源并不需要和应用程序的数据模型完全对应。 单数 or 复数? 为了保持简单和一致,不管是资源的单个实例还是集合,在 URI 中都将使用复数形式来表示。如 /tickets 和 /tickets/12 。 如何表示集合关系? 当资源之间存在集合关系时(即某一资源存在于另一资源的作用域中),我们应当在请求 URI 中体现集合关系,以工单中的评论为例: GET /tickets/12/comments - 获取编号为12的工单的所有评论 GET /tickets/12/comments/5 - 获取编号为12的工单中编号为5的评论 若想获取某一集合中的子资源,必须通过其父资源端点进行访问,在接口中也将对其上一级资源进行校验。 1 GET https://{serviceRoot}/{collection}/{id}/{subcollection}/{id} 但当集合层级过于复杂时,会考虑分拆集合关系以构造简单易懂的 URI,如: 1 2 /api/tickets/12/comments/5/replies/20/likes ---> /api/replies/20/likes 如何表示不适用于 CRUD 的行为? 将该行为重新组织为资源下的某一字段,通过 PATCH 方法更新该字段执行操作。 将其视为一种 RESTful 原则下的子资源,如 GitHub's API 通过 PUT /gists/:id/star 来 star 某个仓库,通过 DELETE /gists/:id/star 取消 star。 某些动作实在无法映射到合理的 RESTful 结构, 例如对多种资源同时进行搜索,很难对应到特定资源的端点。在这种情况下,即使它不是资源也可以使用 /search 作为端点,只需要API的使用者能够清晰地理解其意义即可。 HTTP 方法 REST 以资源为核心,HTTP 方法所操作的对象也是资源,对资源的操作即为状态转移。 数据的增删改查则是持久层的具体实现,一次状态转移可能会对应多个数据库表的增删改操作,所以 HTTP 方法和数据库 CRUD 之间也不是严格的一一对应关系。 对资源的操作在任何时候都必须使用正确的HTTP方法,并且必须遵守操作幂等性。 支持的HTTP方法: 方法 描述 成功状态码 是否幂等 GET 获取资源 200 OK Yes POST 创建资源 201 Created No PUT 替换资源 200 OK Yes PATCH 对资源进行局部更新 200 OK No DELETE 删除资源 204 No Content Yes OPTIONS 获取接受的请求方法 200 OK Yes HEAD 获取HTTP首部信息 200 OK Yes POST 方法的响应 POST 请求成功时将返回 201 Created 状态码,此外响应根据需要有两种形式: 在响应中添加 Location 首部并指定被创建资源的位置 1 Location: http://qcs.woqutech.com/api/employees/123 在响应中返回被创建资源的数据表示 PUT 和 PATCH方法 PUT 和 PATCH 都表示更新资源的操作,但是 PUT 是全量替换而 PATCH 是部分更新。理论上来说前者应当将未传入的字段视作为 null 或默认值并且更新到数据库中。目前我们的实现中分别使用了 PUT 和 PATCH,但是 PUT 方法会忽略未传字段,本质上也是一种部分更新。有置空需求的业务需要显式地传入字段并设为 null。 注意置空或表示 无 统一使用 null (后端及数据库可能表示为 None 或 NULL),不使用空字符串 ""。 身份认证 访问需要身份认证的端点时,若请求未携带凭据或凭据无效,将返回 401 Unauthorized 。 进行身份认证所需的 OAuth2 令牌需要放置在请求的 Authorization 首部中,并使用 Bearer 认证方式: 1 curl -H "Authorization: Bearer TOKEN" http://qcs.woqutech.com/api/employees 请求参数 对于 GET 请求,将参数作为 HTTP 查询字符串添加到 URL 中可对获取的结果进行查询和过滤。 对于 POST, PATCH, PUT 和 DELETE 请求,仍然可以在 URL 中使用查询字符串,URL中未包含的参数应编码为 JSON 。 请求体内的内容不应该进行包装,若发送的内容为集合,应当直接发送包含对象集合的数组类型。 响应格式 所有的响应都使用 200 OK 状态码是错误的。 包装与可见性 在约定响应的格式时,应始终考虑可见性。API 客户端可以根据约定理解和解析响应,从消息体获取所需要的详细信息,但服务端并非直接与客户端交互,还需考虑反向代理、网关、缓存、监控中间件等 HTTP 中间处理层。HTTP 消息体是用来表述资源的,HTTP 分层系统的中间层不会解析消息体,而是根据头部信息进行相应处理(如缓存层根据状态码决定是否缓存响应内容,监控层根据状态码监控可用状态)。 使用包装结构的响应并弃用状态码时,请求是否成功以及响应内容仅在消息体中体现,对中间层组件不可见,可能会导致其执行错误的逻辑或增加不必要的解析逻辑。 为了确保可见性,应始终保证响应具有丰富和准确的头部信息,消息体中不应包含除资源表述之外的其他信息,除非资源的表述需要(如集合资源的分页)不进行多余的包装。 错误 对于未成功的请求,服务端应当对请求过程中的错误或异常进行处理,并返回统一的响应对象。 API 客户端需要(通过状态码)被告知响应的高级错误类,比如当用户无权限访问端点时,返回的 403 Forbidden 状态码会告知 HTTP 中间组件(如客户端库、缓存和代理)响应的整体语义。 同时,API 客户端还需要获取关于错误的详细信息,比如发生错误的具体原因以及解决方案,当这些信息以机器可读的方式包含在响应体中时,客户端还能根据错误响应触发相应操作。 对象示例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 HTTP/1.1 422 Unprocessable Entity Content-Type: application/json { "type": "ValadationError", "message": "请求数据验证失败", "status": 422, "detail": [ { "loc": ["name"], "msg": "field required", "type": "value_error.missing" } ] } 对象成员: type (string) required 由服务端定义,一个简短的、人类可读的、指示问题类型的标识。在不同服务间对于同类问题的类型标识唯一,API 客户端能够对所有的问题类型进行处理。 message (string) required 一个人类可读的关于发生该问题的简要解释。该信息返回给客户端开发者使用。 status (number) optional 由源头服务器生成的关于发生该问题的 HTTP 状态码 API 客户端可以使用该字段来确定发生该问题时使用的原始状态代码,以防它在传递时被改变(如中间设备或缓存),而消息体在没有 HTTP 信息的情况下能够维持一致。 HTTP 中间组件仍将使用响应的 HTTP 状态码。 detail (object) optional 关于发生该问题的详细信息,可用于帮助 API 客户端修正错误。 注意:在必要时,可对错误响应对象的成员进行扩展。 不要轻易去定义新的问题类型,只有当区别对待问题类型对客户端有意义时,才设计对应的问题类型。尽量将问题归类到已有的能够自解释的 HTTP 错误状态码中,仅通过 message 字段传递必要的提示信息,同时注意不应在错误响应对象描述过多和过于详细的服务端错误细节,避免引入安全风险。 详情表示 详情表示无需进行任何封装。 获取单个独立的资源时,响应通常包括该资源的所有属性。 这种情况定义为资源的详情表示。(API 客户端的身份认证有时会影响详情表示中包含的信息量。) 1 2 3 4 5 { "isbn": "9780321125217", "name": "Domain-Driven Design" "authors": ["Matin Fowler", "Levis Knuth"] } 集合表示 获取资源列表时,响应包括该资源的属性子集,这种情况定义为资源的集合表示。(出于计算和IO性能原因,集合表示会排除资源的部分属性,可通过详情表示获取这部分属性。) 集合资源通过标准的 JSON 数组来表示,对于集合资源,分页是一种常规需求,集合表示响应中可能会包括资源集合表示和分页信息表示两部分。为了保持简单和一致,所有的集合表示响应无论是否包含分页信息都会对结构进行包装,示例如下: 包含分页信息 1 2 3 4 5 6 7 8 9 10 11 12 13 { "data": [ {"isbn": "9780321125217", "name": "Domain-Driven Design"}, ...... {"isbn": "9780596805821", "name": "REST in Practice"} ], "pagination": { "page": 1, "per_page": 30, "pages": 9, "total": 256 } } 不包含分页信息 1 2 3 4 5 6 7 8 { "data": [ {"isbn": "9780321125217", "name": "Domain-Driven Design"}, ...... {"isbn": "9780596805821", "name": "REST in Practice"} ], "pagination": null } 分页 默认情况下,集合表示的分页行为由 API 客户端驱动,使用 ?page 参数指定要返回页面编号(服务端默认值为 1), 使用 ?per_page 参数指定每一页包含的资源数量(服务端默认值不大于 30)。 注意页码编号从 1 开始,省略 ?page 参数将使用服务端的默认值而不是返回集合表示的所有资源,以免返回的数据量过大造成服务端数据库阻塞以及网络阻塞。API 客户端必须能够对消费任何给定请求的分页或非分页集合表示具有弹性,并进行相应约束(如设置分页参数的默认值和最大值)。 过滤与排序 在事先约定的情况下,可根据需要对指定的资源字段进行过滤或排序。 CORS API 支持指定来源的跨域资源共享(CORS)请求。示例: 1 2 3 4 5 6 7 curl -i -H "Authorization: Bearer TOKEN" http://qcs.woqutech.com -H "Origin: http://example.com" HTTP/1.1 200 OK ... Access-Control-Allow-Origin: http://example.com Vary: Origin RESPONSE BODY 版本化 本规范暂不涉及。 超媒体 对资源进行操作的请求地址由 API 客户端自行构造,不使用超媒体表示。 接口文档 使用 OpenAPI Specification (OAS) 3.0 作为 API 的交互式文档标准。 API 客户端可访问指定地址获取拥有 UI 界面的接口文档,以及 JSON 格式的文档文本。 参考 GitHub REST API v3 Microsoft api guidelines Best Practices for Designing a Pragmatic RESTful API RFC7807

2020/10/17
articleCard.readMore

记录一次迁移MySQL数据文件到新硬盘的过程

迁移前的环境 MySQL Server 程序编译安装于 qmysql 用户的 /home/qmysql/qmysql/packages 目录下,文件夹结构如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 . ├── data │   ├── auto.cnf │   ├── binlog │   ├── conf │   ├── ib_buffer_pool │   ├── ibdata1 │   ├── ib_logfile0 │   ├── ib_logfile1 │   ├── innodb_log │   ├── innodb_ts │   ├── log │   ├── mydata │   ├── qcs-server.err │   ├── relaylog │   ├── slowlog │   ├── sock │   ├── tmpdir │   └── undo ├── mysql -> /home/qmysql/qmysql/packages/mysql-5.7.21-linux-glibc2.12-x86_64 └── mysql-5.7.21-linux-glibc2.12-x86_64 ├── bin ├── COPYING ├── data -> /home/qmysql/qmysql/packages/data [recursive, not followed] ├── docs ├── include ├── lib ├── man ├── README ├── share └── support-files 现在在服务器上新加了一块固态硬盘,想将原来存放在机械硬盘的数据库的所有数据文件即 data 文件夹迁移到新硬盘中,以加快数据库的读写效率。 通过 fdisk -l 获取硬盘信息如下: 1 2 3 4 Disk /dev/sdb: 1197.8 GB, 1197759004672 bytes, 2339373056 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 4096 bytes I/O size (minimum/optimal): 4096 bytes / 4096 bytes 考虑到现在挂载的硬盘均以 lvm 的方式进行分卷,计划从固态硬盘中创建 300G 的逻辑卷,挂载到系统根目录的 /data 目录,然后将 mysql 的数据文件拷贝至该位置,再软链接到原来的位置,这样可以在不更改任何mysql配置的情况下完成迁移。 创建数据卷 初始化物理卷 将物理硬盘分区初始化为物理卷,以便LVM使用。参数为要创建的物理卷对应的设备文件名。 1 pvcreate /dev/sdb 创建卷组 vgcreate命令用于创建LVM卷组。卷组(Volume Group)将多个物理卷组织成一个整体,屏蔽了底层物理卷细节。在卷组上创建逻辑卷时不用考虑具体的物理卷信息。 1 vgcreate data_vg /dev/sdb 创建逻辑卷 创建大小300G大小的逻辑卷 ,-L 指定大小,-n指定名称,最后指定卷组名称 1 lvcreate -L 300G -n mysqldata data_vg 格式化逻辑卷 对逻辑卷进行xfs格式化 1 mkfs.xfs /dev/data_vg/mysqldata 挂载文件目录 1 mount /dev/data_vg/mysqldata /data 设置开机磁盘自动挂载 要让系统开机自动挂载磁盘,需要将挂载信息写入到/etc/fstab文件中,否则重启后需要手动挂载。 1 2 3 4 5 6 7 8 $ vim /etc/fstab /dev/mapper/centos00-root / xfs defaults 0 0 UUID=80f8fe62-70ab-4c9e-8d28-52c0d5e97979 /boot xfs defaults 0 0 /dev/mapper/centos00-home /home xfs defaults 0 0 /dev/mapper/centos00-swap swap swap defaults 0 0 /dev/centos00/backup /backup xfs defaults 0 2 /dev/mapper/data_vg-mysqldata /data xfs defaults 0 2 添加最下面一行命令后保存退出。完成。 迁移数据文件 开始迁移前需要关闭 mysql 服务,终止运行数据库。 切换用户 为保证文件复制前后的所有者一致,避免出现文件权限问题,首先切换到当前mysql程序下文件的所有者用户 qmysql。 复制文件 复制所有 mysql 数据文件到固态硬盘的新目录,并保留所有文件目录属性。 1 cp -ar /home/qmysql/qmysql/packages/data /data/ 备份原文件 保险起见,将原来的数据文件进行备份。 1 mv data data.bak 重新链接数据文件 将固态硬盘中的数据文件目录软链接到原来的数据文件位置 1 ln -s /data/data /home/qmysql/qmysql/packages/ 重启数据库服务,大功告成!

2020/9/8
articleCard.readMore