S

Shall We Code?

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

Kubernetes 故障排查指南

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

2023/3/19
阅读更多

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

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

2022/10/7
阅读更多

读《Building a Second Brain》

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

2022/7/28
阅读更多

如何优化 docker 镜像体积

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

2022/7/17
阅读更多

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

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

2022/7/10
阅读更多

浅析 Linux 如何接收网络帧

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

2022/4/25
阅读更多

为 Kubernetes 集群启用 Pod 安全策略

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

2022/3/30
阅读更多

深入理解 netfilter 和 iptables

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

2022/3/7
阅读更多

制作开箱即用的 Ubuntu qcow2 镜像

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

2021/12/7
阅读更多

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

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

2021/9/2
阅读更多

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

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

2021/8/11
阅读更多

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

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

2021/7/27
阅读更多

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

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

2021/7/22
阅读更多

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

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

2021/7/21
阅读更多

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

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

2021/7/21
阅读更多

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

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

2021/7/20
阅读更多

究极的 Python 开源项目模板

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

2021/6/25
阅读更多

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

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

2021/6/7
阅读更多

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

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

2021/4/29
阅读更多

理解 Python 中的描述符

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

2021/4/15
阅读更多

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

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

2021/4/6
阅读更多

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

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

2021/3/29
阅读更多

深入理解 git cherry-pick 操作

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

2021/1/26
阅读更多

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

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

2021/1/9
阅读更多

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

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

2021/1/4
阅读更多

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

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

2020/12/24
阅读更多

深入理解 git 合并操作

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

2020/11/28
阅读更多

git rebase 用法详解与工作原理

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

2020/10/23
阅读更多

写了一份 Web API 设计规范

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

2020/10/17
阅读更多

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

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

2020/9/8
阅读更多