普通视图

发现新文章,点击刷新页面。
昨天以前Shall We Code?

Kubernetes 故障排查指南

2023年3月19日 15:42

基础流程和方法

  1. 查询 pod 的状态,适用于 pod Pending 的场景:

    1
    
    kubectl describe <pod-name> -n <namespace>
    
  2. 获取集群中的异常事件,作为排查 pod Pending 原因的补充:

    1
    
    kubectl get events -n <namespace> --sort-by .lastTimestamp [-w]
    
  3. 获取 pod 的日志,适用于 pod Error 或者 CrashLoopBack 的场景:

    1
    
    kubectl logs <pod-name> -n <namespace> [name-of-container, if multiple] [-f]
    
  4. 如果 pod 已经处于 Running 状态,并且现有的日志未能直接指出问题,则需要进入 pod 容器进一步测试,例如验证一个正在运行的进程的状态、配置,或者检查容器的网络连接。

如何访问容器

  1. 通过 exec 命令:kubectl exec -it <podName> sh
  2. 通过 ephemeral container(需要 Kubernetes v1.23 以上版本)。
  3. 在镜像中缺乏程序二进制的前提下,执行常用排查工具和命令:
    1. 获取容器在宿主机对应的 PID:docker ps | grep k8s_<containerName>_<podName> | awk '{print $1}' | xargs docker inspect --format '{{ .State.Pid }}'
    2. 在容器的网络命名空间中执行安装于宿主机的工具:nsenter -t <PID> -n bash
    3. 退出容器的命名空间(即退出运行于该命名空间中的 shell):exit

如何访问节点

  1. ssh 到节点。
  2. journalctl -xeu kubelet 查看 kubelet 日志,适用于节点 NotReady 的场景。

常见问题及排查方法

kubectl 执行结果异常

表现:

执行任意 kubectl 命令输出以下结果:

  • Error from server (InternalError): an error on the server (“”) has prevented the request from succeeding
  • etcdserver: leader changed

原因:

  • kubectl 和 apiserver 认证失败
  • apiserver 异常
    • 通常由于 etcd 导致 apiserver 异常
  • etcd 异常
    • 选举节点拓扑不满足奇数导致频繁切主
    • 磁盘性能太差导致延迟太高甚至频繁切主

排查方法:

  1. 检查 kubectl 当前所使用的配置文件是否正确:kubectl config view

    1. 当前所使用的配置文件 ~/.kube/config 是否由该集群生成。
    2. 检查配置文件中的 server 地址和访问协议是否正确。
  2. apiserver 和 etcd 均属于静态 pod,如果无法使用 kubectl 命令,可直接用容器运行时如 docker 检查容器的日志:

    1
    2
    3
    4
    
    # 获取 apiserver 和 etcd 对应的 container_id
    docker ps -a | grep -e k8s_kube-apiserver -e k8s_etcd
    # 获取日志
    docker logs <container_id> [-f]
    
  3. 测试 etcd 所使用的文件系统性能:

    1
    
    fio --rw=write --ioengine=sync --fdatasync=1 --filename=<etcd-data-dir>/iotest --size=22m --bs=2300 --name=etcdio-bench
    

    该测试重点关注 etcd 所使用文件系统的 fsync 性能:

    fsync/fdatasync/sync_file_range:
      sync (usec): min=534, max=15766, avg=1273.08, stdev=1084.70
      sync percentiles (usec):
       | 1.00th=[ 553], 5.00th=[ 578], 10.00th=[ 594], 20.00th=[ 627],
       | 30.00th=[ 709], 40.00th=[ 750], 50.00th=[ 783], 60.00th=[ 1549],
       | 70.00th=[ 1729], 80.00th=[ 1991], 90.00th=[ 2180], 95.00th=[ 2278],
       | 99.00th=[ 2376], 99.50th=[ 9634], 99.90th=[15795], 99.95th=[15795],
       | 99.99th=[15795]
    

    测试结果中,如上述 fsync 99.00th 的值大于 10000 usec(即 P99 > 10ms),通常认为不满足使用条件,建议更换磁盘硬件。

DNS 解析异常

表现:

应用无法连接到 kubernetes apiserver 或其他 DB、proxy 等依赖服务,错误信息里通常包含 53 端口访问超时、can’t resolve host 等。

原因与排查方法:

  • 无法访问 kube-dns service 对应的后端 pod

    • 应用容器内 dns 配置(/etc/resolv.conf)出错导致无法发出正常的解析请求(部分旧版本发行版和 libc 库存在该问题)

      检查容器内的 /etc/resolv.conf 文件是否包含正确的 nameserver 和 search path,如:

      1
      2
      3
      4
      
      $ cat /etc/resolv.conf
      nameserver 11.96.0.10
      search qfusion-admin.svc.cluster.local svc.cluster.local cluster.local
      options ndots:5
      
    • service 的负载均衡功能异常,出站请求无法 DNAT 到实际的 coreDNS 后端 pod

      1. 检查 kube-proxy pod 的运行状态。

      2. 检查应用容器所在节点是否能正常匹配 service 并完成 DNAT。

        1. 检查节点路由表是否有默认路由或 service 的路由规则,否则数据包不会被 DNAT。

        2. 检查是否存在 service 对应的 DNAT 规则,以 iptables 为例:

          1
          
          iptables-save | grep <serviceName>
          

          对于每个 service 的每个端口,在 KUBE-SERVICES 中应该有1条规则和一个 KUBE-SVC-<hash> 链。

          对于每个 Pod 端点,在该 KUBE-SVC-<hash> 中应该有少量的规则,以及一个KUBE-SEP-<hash> 链,其中有少量的规则。确切的规则将根据你的具体配置(包括节点端口和负载平衡器)而变化。

        3. 查询当前 NAT 记录:

        1
        2
        3
        
        conntrack -L | grep <dns-ip>
        # destination
        conntrack -L -d 10.32.0.1
        

      详见 https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/

    • 跨节点容器网络异常,请求无法到达 DNS pod 所在节点

      1. 检查 CNI 插件相关组件的运行状态,执行 CNI 插件自带的健康检查或状态检查命令,以 cilium 为例:

        1
        2
        
        cilium status [--verbose]
        kubectl -n kube-system exec -it cilium-xrd4d -- cilium-health status
        
      2. 检查应用容器和 coredns 之间节点和 pod 的连通性

        • 使用 nc 测试两个节点及 pod 之间的四层连通性:

          1
          2
          3
          4
          
          # host1
          nc -l 9999
          # host2
          nc -vz <host1> 9999
          
        • 如果容器网络(大部分情况下)采用的是 vxlan 方案,测试 vxlan 默认使用的宿主机 8472 端口是否被阻断。

          • 使用 netstat -lnup | grep 8472 或者 nc -lu 8472 检查 8472 UDP 端口是否已被其他程序占用。

          • 使用 tcpdump 对 8472 端口进行抓包,判断数据包能否经由网络到达。例如:

            1
            
            tcpdump -i p4p1 dst port 8472 -c 1 -Xvv -nn
            
          • 测试 8472 UDP 是否可使用:

            1
            2
            3
            4
            
            #  on the server side
            iperf -s -p 8472 -u
            # on the client side
            iperf -c 172.28.128.103 -u -p 8472 -b 1K
            
    • MTU 问题,网络拓扑中的某一中间设备设置了比发送端网卡更小的 MTU,导致部分大小超过的包被 drop。具体表现为能 ping 通,同一协议和端口有些请求能通,有些请求不通。

      1. 获取发送端网卡的 MTU:

        1
        2
        3
        
        ifconfig
        # or
        ip -4 -s address
        
      2. 使用 ping 命令对目标主机的 MTU 进行测试:

        1
        
        ping -M do -s 1472 [dest IP]
        

        ICMP头部使用 8 个字节,以太网头部使用 20 个字节,ping 命令再额外发送了 1472 个字节,测试 MTU 1500 字节能否到达目标主机。

      3. 修改发送端网卡的 MTU

        1
        2
        
        echo MTU=1450 >> /etc/sysconfig/network-scripts/ifcfg-eth0 # 文件名修改为网卡名称
        systemctl restart network
        
  • kube-dns 对应的后端 pod 异常导致无法响应解析请求或者未包含 dns 记录

    • kube-dns service 不存在

      检查 kube-system 命名空间下 service 以及 endpoint 的状态:

      1
      2
      
      kubectl -n kube-system get svc | grep dns
      kubectl -n kube-system get ep | grep dns
      
    • coreDNS pod 状态异常

      使用上文介绍的 kubectl describekubectl logs 命令获取 coreDNS 的 pod 状态及日志。

    • coreDNS 无法响应解析或未包含指定的 dns 记录

      进入应用容器以及另一任意容器,测试 coreDNS 能否完成其他容器、节点及域名的解析

      • 请求 DNS server 的 53 端口,判断能够访问 DNS server 及其是否正在监听:nc -vz <ip-of-dns> 53
      • 测试域名解析:dig example.com @<ip-of-dns> +trace (+trace 输出追踪信息以区分是否使用缓存)
      • 其他域名解析工具:nslookup, host

更多排查 dns 解析有关的资料详见 https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/

TLS 证书异常

tls 证书的关键概念是多个证书形成一个有效的信任链,从主机上的服务器/叶子证书到多个中间证书,最后到根/CA证书。正是通过形成的信任链,客户端和服务器之间才能开始加密通信会话。

表现:因为 ssl/tls 原因无法完成预期的请求,错误信息通常包含 ssl/tls handshake error

原因:

  • 未将根/CA 证书添加到可信任证书中
  • 服务端/CA 证书已过期或未到开始生效时间(通常是因为系统时间不一致导致生成了还未到生效时间的证书)

排查方法:

  • 使用 openssl 获取指定服务的证书信息:

    1
    
    openssl s_client -connect <fqdn/ip>:<port>
    
  • 使用 openssl 获取指定服务(以 [localhost:6443](http://localhost:6443) 为例)的服务端证书并打印成可读格式:

    1
    
    openssl s_client -showcerts -connect localhost:6443 </dev/null 2>/dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text
    

    通常可检查输出中的 Issuer(签发者)、Validity(生效时间)和 Subject(CN)。

  • 如果遇到 x509: certificate signed by unknown authority(如 docker CLI 在访问内部的 https 镜像仓库时会抛出以上错误),说明目标服务器使用了不受信任的签发证书(如自签发证书),需要按照应用程序文档安装 CA 证书或变更与信任证书有关的配置。

路由和内核参数配置错误

表现:

无法完成对集群内部或外部某些状态正常服务的访问,报错信息中通常包含 Unreachable 或者 Connection timeout。

原因:

  • 应用所在的 pod 或主机的路由表中无访问地址对应的路由项,且没有配置默认路由。
  • 与路由有关的内核参数未正确配置或在安装 kubernetes 时开启之后被其他服务重置。

排查方法:

  • 使用 netcat 或者 curl 测试服务端是否可通过四层网络访问:

    1
    2
    
    nc -vz <ip/fqdn> <port>
    curl -kL telnet://<ip/fqdn>:<port> -vvv
    
  • 使用 curl 简单测试网络延迟:

    1
    
    curl -s -w 'Total time: %{time_total}s\n' http://example.com
    
  • 打印系统路由表,注意检查是否存在默认路由:

    1
    2
    3
    
    route -n
    # or
    ip route show
    
  • 查询指定 IP 的路由规则:

    1
    
    ip route get 10.101.203.141
    
  • 跟踪请求的路由路径:

    1
    
    traceroute 10.101.203.141
    

    traceroute 默认使用 ICMP 协议的 ECHO 包,部分设备可能不会响应。

  • net.ipv4.ip_forward:允许 Linux 在不同网络接口间转发流量的核心参数,绝大部分 CNI 要求启用此参数才能实现 pod 间访问。

    1
    2
    3
    4
    5
    
    sysctl net.ipv4.ip_forward
    # this will turn things back on a live server
    sysctl -w net.ipv4.ip_forward=1
    # on Centos this will make the setting apply after reboot
    echo net.ipv4.ip_forward=1 >> /etc/sysconf.d/10-ipv4-forwarding-on.conf
    
  • net.bridge.bridge-nf-call-iptables:允许对 Linux Bridge 设备使用 iptables 规则,部分基于 Linux Bridge 的 CNI 需要启用此参数实现容器请求外部网络的 NAT。

    1
    2
    3
    4
    5
    
    sysctl net.bridge.bridge-nf-call-iptables
    modprobe br_netfilter
    # turn the iptables setting on
    sysctl -w net.bridge.bridge-nf-call-iptables=1
    echo net.bridge.bridge-nf-call-iptables=1 >> /etc/sysconf.d/10-bridge-nf-call-iptables.conf
    
  • net.ipv4.conf.all.rp_filter:部分 CNI (如 Cilium)要求启用此参数实现 pod 间访问,但一些发行版版本的 systemd 可能会覆盖此参数。

    1
    2
    3
    4
    5
    6
    
    cat <<EOF > /etc/sysctl.d/99-override_cilium_rp_filter.conf
    net.ipv4.conf.all.rp_filter = 0
    net.ipv4.conf.default.rp_filter = 0
    net.ipv4.conf.lxc*.rp_filter = 0
    EOF
    systemctl restart systemd-sysctl
    

防火墙

表现:

请求被某些 4 层或者 7 层防火墙规则拦截,通常表现为可以访问节点但无法访问部分端口;部分请求无法完成,报错信息中通常包含 Connection Reset。

原因:

  • 4 层防火墙禁止访问某些端口或者特定协议。
  • 7 层防火墙审计请求的目标路径和请求体内容。

排查方法:

  1. nc -vz 可测试四层连通性,curl -kL 可测试七层连通性,如果一个服务四层通但是七层不通,表明可能有七层防火墙。

  2. 使用 tcpdump 进行抓包分析,重点关注 ACK 为 RST 或者 Reject 的 TCP 包。

  3. 检查 iptables 是否有 reject 规则,-reject-with icmp-host-unreachable 也可能表现为有路由规则但是请求返回 no route to host

    1
    
    iptables -nvL | grep -i reject
    

配置错误和程序 BUG

表现:

部分应用无法正常启动或按照预定配置运行,错误信息通常包含 syntax error

原因:

空格或者 tab 导致配置文件格式出现错误

如果以上排查步骤均未发现问题,且在应用容器的日志中发现了可疑的信息,可直接向开发者报告或讨论该问题,但注意不要急于给出结论。

Further reading

读《Building a Second Brain》

2022年7月28日 13:06

book cover

这本书出版于六月份,花了些时间看了大半。笔记中掺杂了一些个人的总结和看法,一般会放在原文内容归纳的后面。

为何要构建外脑

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

信息技术的进步极大地丰富了人们获取信息的手段,但并未带来显著的学习效率提升,因为人类的大脑🧠结构和几万年之前没有区别,只能处理有限的信息,而且很快就会遗忘。人类的精力和专注力也是有限的,每天获取信息的容量已经严重过载,大量的无效信息反而会消耗人的专注力和精力导致效率降低。

如果你每天不断被各种事情打断,不断在多个任务之间切换,花费过多时间去整理检索信息,可能会有下面的体验:

  1. 为了实现自我成长或达成某个目标,你会为自己设定一些耗时长且有难度的任务事项,这些任务往往不与当下的工作或职责直接关联,但对你个人来说比重复的日常工作更有意义。
  2. 过多的临时任务和琐碎事务使你不得不调整优先级,将对你来说真正重要的任务不断延后。
  3. 等到你终于有时间处理自己的任务,会发现此时已经没有足够的专注力和精力。

作者提出的一种解决方案是建立外脑,在人脑外部建立数字空间集中存放、处理输入的信息。其本质是建立一种有效应对过载信息的手段,使得我们有足够的时间和精力,来处理真正重要的信息并进行有效产出。外脑的作用不仅仅只是知识管理,还兼有时间管理、精力管理等自我管理功能。

外脑带来的好处

  1. 使得想法和理念具象化,大脑更擅长处理具象而不是抽象,获得诺贝尔奖的科学家也曾经需要借助物理模型来构建抽象理论,具象化能够增强大脑处理信息的能力。
  2. 发现已有事物间的新关联。将不同种类的材料组织在一起,我们会自然地去发现它们之间的相同点和关联,并发散式地去建立新的关联。
  3. 在更长的时间跨度内积累孵化我们的想法。大脑在短时间内只能基于已有材料生产有限的信息,借助外脑可以在一个想法或主题之上不断积累和发展,随着时间的推移就有可能产生更高价值的结果。
  4. 强化我们的独特视角。

个人知识管理的三个阶段

  1. 记录
  2. 连接
  3. 创造

CODE 四步骤方法

CODE

构建外脑的主要方法就是多记笔记,作者在此基础上扩展了一套 CODE 四步骤作为方法论。

Capture

什么是可以记录的信息?

来自外部:Highlights,Quotes,Bookmarks and favorites,Voice memos,Meeting notes,Images,Takeaways

来自内部:Stories,Insights,Memories,Reflections,Musings

什么信息不适合记录?

  • 私人的
  • 特定格式的
  • 大文件
  • 协作编辑的

记录哪些信息?

如何确定哪些信息需要记录下来,核心在于不要被动和随意地去接收信息,而是建立自己的关注领域,在关注领域内提出最多 12 个待解决的问题或疑惑,然后主动寻找或检查被动接收的信息,是否有助于解决自己提出的问题。

对于开发者来讲,则是建立一个有限的技术问题列表,在一个时间段尽量只关注和记录和解决这些问题有关的信息。比方说我个人近期(至少一年内)关注以下问题:

  • golang 的调度器如何工作,内存模型以及垃圾回收机制是怎样的
  • 如何熟练掌握 client-go,controller-runtime 等 k8s 库的使用方法
  • 容器网络的工作机制
  • linux 网络的内核实现,如何快速定位网络问题和排除故障
  • 数据包在 cilium 和 ebpf 构建的容器网络中是如何流动的
  • k8s 的调度器如何工作的,如何自定义调度策略
  • postgresql 的备份恢复机制,主从同步机制
  • linux 文件系统的内核实现,如何监控磁盘io
  • 分布式领域需要关注的技术问题与解决方案,raft 共识算法的原理和实现
  • 如何提高日常工作的效率,终端和 vim 相关的技巧
  • 如何提升自己的技术写作水平和效率,扩大自己的技术影响力
  • 如何提高自己的 leetcode 水平

如何避免记录太多或太少信息?

信息内容中的价值并不是均匀分布的,小部分内容蕴含了最具帮助和有趣的价值(这里也许可以套二八法则?),因此我们只需要从输入的信息中选取一小部分记录下来(最多不要超过10%,保留回到原文的链接)。

选取的条件:

  • 在情感上有鼓舞、驱动作用
  • 有实际作用的
  • 个人化的
  • 能改变现有认知,令人惊讶的

最重要也是唯一的判断条件:Capture What Resonates。记录在直觉上能够引起你共鸣的信息。

为什么靠直觉?我们不应该依赖死板的标准判断信息是否值得记录,而应该练习使用直觉去辨别,这样一是降低了辨别信息的难度和工作量,二是发展了自己快速识别有价值信息的能力。

“it’s often helpful to capture chapter titles, headings, and bullet-point lists, since they add structure to your notes and represent distillation already performed by the author on your behalf.”

记录时尽量保留原文的多级标题和列表等已有的组织结构。

选择合适的记录工具

“don’t leave all the knowledge they contain scattered across dozens of places you’ll never think to look. Make sure your best findings get routed back to your notes app where you can put them all together and act on them.”

任何趁手的记录工具都可以,只要能将其收集的信息汇总到一处。

外部化思维过程

研究者发现,与阅读一段话相比,口述或写出同一段话时大脑的活跃区域更广。

外部化思维过程能够显著增强我们的记忆和关联发散能力,甚至有益身心健康。

“Don’t worry about whether you’re capturing “correctly.” There’s no right way to do this, and therefore, no wrong way.”

Organize

整理信息的重要性

“大教堂效应”(Cathedral Effect)描述了建筑屋顶的高度对人类思考行为的心理学影响。高屋顶促进抽象思维(激发创造力),低屋顶促进具象思维(聚焦细节)。

外脑作为我们工作和创作的数字环境,也需要得到良好的整理和组织。

组织信息的最大诱惑是完美主义,将组织过程本身视为目的。许多人希望能找到一套像图书馆分类书籍一样完美的分类方法对信息进行整理,然而这种完美的方法是不存在的。

PARA 系统

PARA 系统和传统分类方法的区别在于,其目的不是为了设计一套大而全的分类系统将信息放到正确的位置,而是尽量减少组织信息的负担同时不影响信息的功用。

PARA 系统将所有的信息分为四类:

  1. Projects:围绕当前正在进行的,短期的工作或生活项目,有明确的起止时间或产出结果。设定 Projects 也是一项设定目标、分配时间和优先级的计划过程。
  2. Areas:长期关注或从事的领域。不需要很具体的目标,但应该设定预期到达的掌握程度或技能水平。
  3. Resources:在未来可能用得上的主题。与个人的兴趣、研究有关,并且在未来能够被引用或参考。
  4. Archives:以上三个分类中不在活跃的项目。已完成的 projects 或者不再关注的 areas 等。

PARA 的核心思想是按笔记被使用的紧急程度和频率进行组织,它的依据不是信息从哪里来,而是信息将到哪里去。

知识是否有价值的判断条件不是它是否井井有条整洁有序,而是它能否对个人或个人关注的目标产生影响。使用 PARA 系统的过程不仅仅只是对记录的信息进行整理分类,更是对个人的工作和生活进行组织和管理。

看完这章,发现自己基本上已经在 Notion 中使用这套所谓的「系统」了,于是在 Dashboard 页面中重新整理归类了原有的页面(套了个 PARA 的标题):

Dashboard

  1. Projects:每周的任务安排,Todo Inbox,个人OKR,博客写作看板和阅读清单,以及工作中用到的日志等时效性内容。
  2. Areas:基本上就是技术 wiki 了。
  3. Resources:纯粹用来整理通过 Save to Notion 收藏的页面。
  4. Archives:对 Projects 里面往周往年的项目进行归档。

整理的时机

记录信息的时刻并不是对其整理的最佳时刻,可以尝试在笔记中建立一个 Inbox 分类存放所有的未处理信息,并定期比如每周对其进行整理。

Distill

看到这里,感觉作者有点为了写书而写书了。

这一章讲的是,如何提取信息中的关键点,使得记录和组织信息一段时间之后能够快速找到并投入使用,即增强信息的可发现性(Discoverability)。

没有人能够过目不忘,在第一次阅读一篇文章时,我们会深入其中的细节;但过段时间想用到这篇文章时,你可能需要再从头到尾阅读一遍,才能从中找到对自己有价值的那部分信息。

解决方法是在记录和组织之后增加一个提取环节,在提取环节中,提前设想哪些部分的信息会在未来对自己有帮助,并标记出来。

渐进式概要

作者在书中花了十几页介绍了一套名为「渐进式概要」的分层提取方法,其实几句话就能讲清楚:

  1. 第一个层级,是直接摘取原文中的关键段落,并保留原文链接。
  2. 在上一层基础上,对关键信息加粗。
  3. 高亮更关键的信息。
  4. 用自己的话总结关键点。

需要避免的三个误区

  1. 每一节都是重点,就等于没有重点。重点只是起到原文索引的作用,不要超过上一层内容的 10-20%。
  2. 在准备用到信息的时候再去做提取。即根据哪些信息在未来最有可能用到,来辨别信息中的关键点。
  3. 不要花太多时间和精力来提取信息,运用你的直觉。

这章讲的其实就是在保存信息后,如何对信息进行简单处理,使得在需要时能够快速找到信息。

解决这种问题还有比数据库更在行的吗?作者介绍的方法其实就是为信息建立索引,为了提高效率我们会为最常用到的数据列建立索引。一层索引不够就再加一层 redis 作为缓存,缓存还不够就把最常用的数据 hardcode 在代码中作为常量……

Express

这章的大部分内容已经是在充字数了。

CODE 的最后阶段是 Express 即表达,是指不应该彻底掌握了一项知识再对外分享,而是应该更早、更频繁、更小规模地表达你的想法,以验证是否有效,并从他人那里收集反馈。这些反馈有助于开始下一轮的表达。

具体来说应该将一个有挑战性的大的计划,分解成难度更小需要资源更少的子目标。作者又引入了一个新的概念 Intermediate Packet (IP?),即完成目标过程中所记录的信息或者中间产物,这些对实现新的目标会很有用。

可能是我英语水平不到位,这章的大部分段落读起来云里雾里,干脆尝试根据标题归纳下每节内容:

  1. How to Protect Your Most Precious Resource。

    注意力是知识工作者最稀缺的资源,因为注意力很宝贵,所以应该尽量复用知识工作的中间产物。(主题、内容以及最后得出结论之间的逻辑联系很牵强)

  2. Intermediate Packets: The Power of Thinking Small。

    引入了 Intermediate Packet 这个概念,意思就是完成大的项目或目标过程中所产生的笔记、文档、PPT 等中间产物。

  3. Assembling Building Blocks: The Secret to Frictionless Output。

    这段的大概是讲复用上面的中间产物组合起来就能实现快速产出新的成果。

  4. How to Resurface and Reuse Your Past Work。

    大意是讲通过搜索、浏览、打标签和碰运气等方式更好地从已完成的工作中,发掘对当前目标有用的信息。

  5. Three Stages of Expressing: What Does It Look Like to Show Our Work?

    重新回顾知识管理的三个阶段:记忆、连接和创作。有了外脑实现这三者更轻松和高效了。

  6. Creativity Is Inherently Collaborative。

    创造力本质上是协作的,所以要经常向他人展示阶段性的成果寻求反馈。

  7. Everything Is a Remix。

    任何新事物都建立在已有事物的基础上。

这章感觉作者在写长篇大论且正确的废话了,每一句话都写的很对,但看完只能体会到时间白白流逝。洋洋洒洒几十页并没有提出什么实际的观点和方法,一直在强调通过外脑保留知识创作的中间产物,能够加速新项目的成功。但其实根本不需要所谓的外脑就天然如此。

根据输入-内化-输出这样一个笼统的学习流程,个人理解最后一个阶段是要将知识尽量地学以致用,「用」的方式可以是在工作或个人项目中实践,通过写作或其他媒介教授给他人,以及直接参与有关该知识的讨论,总之要将吸收归纳的知识表达到外部环境获得反馈。

后续章节

后面的章节从标题来看十分抽象,没有继续看下去的欲望了。

  • Chapter 8:The Art of Creative Execution
  • Chapter 9:The Essential Habits of Digital Organizers
  • Chapter 10:The Path of Self-Expression

书评

不足之处

  • 书中每一章都会以名人轶事作为开头,来佐证本节所抛出的方案或论点的必要性和有效性,但逻辑十分牵强,个人建议直接跳过这部分。
  • 后半部分的章节有充字数的嫌疑,充斥着对外脑概念的刻意强化和转化为效益的承诺,实际上言之无物。小节之间缺乏逻辑连贯性,从名称来看很多章节都是独立的,更像是博客文章的堆砌组合。
  • 这类自我成长书籍的共性:为解决某一问题,作者会尝试创造一套适合所有读者的体系或方法,但当你发现这些方法不适合自己,或者已经在实践类似的或更有效的方法时,就会失去继续读下去的动力。

这本书值得一读吗

说说我的个人体验,开始读这本书的时候期待很高,因为检视目录时发现作者提出了许多新的概念和方法论,之前也被推荐过作者的 Youtube 频道和个人网站。但读到一半发现如食鸡肋:想就此放弃,但已经花了时间读了一半多了;继续读下去,后面的章节注水严重,也嚼不出什么味来。最后实在不想浪费时间半途而废了。

书中有用的内容还是有的,作者提倡的多记笔记以及提炼的 CODE 四步骤和 PARA 系统都有可取之处。对我来说,最大的收获是强化了知识管理系统的重要性,推动了自己对现有知识管理系统的思考和优化。

如果你平时已经在实践终身学习理念,对 Notion,Obsidian,Logseq,Roam Research 等笔记应用如数家珍,有使用 Pocket、InstaPaper 以及 RSS 阅读器的习惯,对双向链接、滑箱卡片笔记、卢曼等名词都不陌生(尤其是少数派网站读者),这本书里对你有用的信息掐掉水分顶多只有一篇博客,我不建议你浪费时间去读英文原文。

如果你不满足上述条件,但想提升自己基于数字媒体的学习能力,我推荐你看看本书的前两部分。

为 Kubernetes 集群启用 Pod 安全策略

2022年3月30日 18:13

最近有客户反馈在开启了安全策略的集群中部署产品失败,因此研究了一下 Kubernetes 提供的 pod 安全策略。

文中的演示和示例均在 v1.18.17 集群中通过验证。

Pod Security Policies

Pod Security Policies (下文简称 psp 或 pod 安全策略)是一种集群级别的全局资源,能够对 pod 的创建和更新进行细粒度的授权控制。具体来说,一个 psp 对象定义了一组安全性条件,一个 pod 的 spec 字段必须满足这些条件以及适用相关字段的默认值,其创建或更新请求才会被 apiserver 所接受。

具体的 pod 字段和安全条件可见文档 what-is-a-pod-security-policy

启用 Pod Security Policies

Kubernetes 默认不开启 pod 安全策略功能,在集群中启用 pod 安全策略的步骤大体上分为三步:

  1. 在集群中创建指定的安全策略资源。
  2. 通过 RBAC 机制授予创建 pod 的 user 或者被创建 pod 的 service account 使用安全策略资源的权限,通常会将使用权限授予一组 users 或 service accounts。
  3. 启用 apiserver 的 admission-controller 插件。

注意步骤 1、2 可以单独执行,因为它们不会对集群产生实际影响,但需要确保步骤 3 在前两步之后执行。

因为一旦启用 admission-controller 插件,apiserver 会对所有的 pod 创建/更新请求强制执行安全策略检查,如果集群中没有可用的 pod 安全策略资源或者未对安全策略资源预先授权,所有的 pod 创建/更新请求都会被拒绝。包括 kube-system 命名空间下的系统管理组件如 apiserver 本身(由于 apiserver 是受 kubelet 管理的静态 pod,实际上容器依然会运行)。

启用的整体流程如下示意图:

podsecuritypolicy

创建安全策略资源

  1. 在集群中创建一个宽松限制的 PodSecurityPolicy 资源,命名为 privileged

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
    apiVersion: policy/v1beta1
    kind: PodSecurityPolicy
    metadata:
      name: privileged
      annotations:
        seccomp.security.alpha.kubernetes.io/allowedProfileNames: '*'
    spec:
      privileged: true
      allowPrivilegeEscalation: true
      allowedCapabilities:
      - '*'
      volumes:
      - '*'
      hostNetwork: true
      hostPorts:
      - min: 0
        max: 65535
      hostIPC: true
      hostPID: true
      runAsUser:
        rule: 'RunAsAny'
      seLinux:
        rule: 'RunAsAny'
      supplementalGroups:
        rule: 'RunAsAny'
      fsGroup:
        rule: 'RunAsAny'
    
  2. 在集群中创建一个严格限制的 PodSecurityPolicy 资源,命名为 restricted

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    
    apiVersion: policy/v1beta1
    kind: PodSecurityPolicy
    metadata:
      name: restricted
      annotations:
        seccomp.security.alpha.kubernetes.io/allowedProfileNames: 'docker/default,runtime/default'
        apparmor.security.beta.kubernetes.io/allowedProfileNames: 'runtime/default'
        apparmor.security.beta.kubernetes.io/defaultProfileName:  'runtime/default'
    spec:
      privileged: false
      # Required to prevent escalations to root.
      allowPrivilegeEscalation: false
      requiredDropCapabilities:
        - ALL
      # Allow core volume types.
      volumes:
        - 'configMap'
        - 'emptyDir'
        - 'projected'
        - 'secret'
        - 'downwardAPI'
        # Assume that ephemeral CSI drivers & persistentVolumes set up by the cluster admin are safe to use.
        - 'csi'
        - 'persistentVolumeClaim'
      hostNetwork: false
      hostIPC: false
      hostPID: false
      runAsUser:
        # Require the container to run without root privileges.
        rule: 'MustRunAsNonRoot'
      seLinux:
        # This policy assumes the nodes are using AppArmor rather than SELinux.
        rule: 'RunAsAny'
      supplementalGroups:
        rule: 'MustRunAs'
        ranges:
          # Forbid adding the root group.
          - min: 1
            max: 65535
      fsGroup:
        rule: 'MustRunAs'
        ranges:
          # Forbid adding the root group.
          - min: 1
            max: 65535
      readOnlyRootFilesystem: false
    

RBAC 身份认证

  1. 分别创建可访问两种安全策略资源的 ClusterRole:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRole
    metadata:
      name: privileged-psp
    rules:
    - apiGroups: ['policy']
      resources: ['podsecuritypolicies']
      verbs:     ['use']
      resourceNames:
      - privileged
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRole
    metadata:
      name: restricted-psp
    rules:
    - apiGroups: ['policy']
      resources: ['podsecuritypolicies']
      verbs:     ['use']
      resourceNames:
      - restricted
    
  2. 通过 ClusterRoleBinding (或者 RoleBinding)将创建的 ClusterRole 绑定到指定命名空间下的所有 service account(也可以授权给指定的 user)。

    在 Kubernetes 中大多数 pod 并不是直接使用 user 创建的,而是通常作为 Deployment、ReplicaSet 或其他模板 controller 的子资源,由 controller 间接创建。授予 controller 用户对安全策略的使用权等同于为该 controller 创建的所有 pod 授予使用权,因此授权的推荐做法是授权给目标 pod 的 service account。

    为了进行后续的测试,我们将 privileged-psp 授权给 kubelet 所使用的 system:nodes 用户和 privileged-ns 命名空间下的所有 service account,将 restricted-psp 授权给 restricted-ns 命名空间下的所有 service account:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRoleBinding
    metadata:
      name: privileged-psp-bind
    roleRef:
      kind: ClusterRole
      name: privileged-psp
      apiGroup: rbac.authorization.k8s.io
    subjects:
    # 授权给指定命名空间下的所有 service account(推荐做法):
    - kind: Group
      apiGroup: rbac.authorization.k8s.io
      name: system:serviceaccounts:privileged-ns
    - kind: Group
      apiGroup: rbac.authorization.k8s.io
      name: system:nodes
      namespace: kube-system
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRoleBinding
    metadata:
      name: restricted-psp-bind
    roleRef:
      kind: ClusterRole
      name: restricted-psp
      apiGroup: rbac.authorization.k8s.io
    subjects:
    - kind: Group
      apiGroup: rbac.authorization.k8s.io
      name: system:serviceaccounts:restricted-ns
    

    subjects 字段下添加更多记录还可以授权给所有的 service account 或者所有已授权的 user:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    subjects:
    # 授权给指定的 service account 或者用户(不推荐):
    - kind: ServiceAccount
    name: <authorized service account name>
    namespace: <authorized pod namespace>
    - kind: User
    apiGroup: rbac.authorization.k8s.io
    name: <authorized user name>
    # 授权给所有的 service accounts:
    - kind: Group
    apiGroup: rbac.authorization.k8s.io
    name: system:serviceaccounts
    # 授权给所有已认证的用户:
    - kind: Group
    apiGroup: rbac.authorization.k8s.io
    name: system:authenticated
    

启用 admission controller 插件

在 apiserver 启用 admission controller 的 psp 插件有两种方式:

  1. 在已存在的集群中通过修改 apiserver 的静态 manifest 文件,为 apiserver 增加启动参数 enable-admission-plugins=PodSecurityPolicy。kubelet 会自动检测到变更并重启 apiserver。下面的示例使用 sed 对原有参数进行了替换:

    1
    
    sed -i 's/enable-admission-plugins=NodeRestriction/enable-admission-plugins=NodeRestriction,PodSecurityPolicy/' /etc/kubernetes/manifests/kube-apiserver.yaml
    
  2. 或者在初始化集群时,在 kubeadm 配置文件中添加额外参数(不推荐,默认会拒绝所有 pod 的创建)。

    1
    2
    3
    4
    5
    
    apiVersion: kubeadm.k8s.io/v1beta2
    kind: ClusterConfiguration
    apiServer:
    	extraArgs:
        enable-admission-plugins: "PodSecurityPolicy"
    

验证 psp 的安全限制

接下分别在上文授权过的 privileged-nsrestricted-ns 命名空间进行测试,验证 psp 对 pod 请求的限制。

首先尝试在 restricted-ns 命名空间通过 deployment 创建一个需要使用 hostNetwork 的 pod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-hostnetwork
spec:
  selector:
    matchLabels:
      run: nginx
  template:
    metadata:
      labels:
        run: nginx
    spec:
      hostNetwork: true
      containers:
      - image: nginx
        imagePullPolicy: Always
        name: nginx-privileged

创建并查看结果:

1
2
3
4
5
6
7
$ kubectl create -f hostnetwork-pod.yaml -n restricted-ns
deployment.apps/nginx-hostnetwork created
$ kubectl get deploy -n restricted-ns nginx-hostnetwork
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
nginx-hostnetwork   0/1     1            0           21s
$ kubectl -n restricted-ns get event | grep "pod security policy"
103s        Warning   FailedCreate             deployment/nginx-hostnetwork                                Error creating: pods "nginx-hostnetwork-" is forbidden: unable to validate against any pod security policy: [spec.securityContext.hostNetwork: Invalid value: true: Host network is not allowed to be used]

由于授权给该命名空间 service account 的安全策略资源禁止 pod 使用 hostNetwork,因此该 deployment 创建 pod 的请求被拒绝。

接着在 privileged-ns 命名空间执行相同的操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ kubectl create -f deploy.yaml -n privileged-ns
deployment.apps/nginx-hostnetwork created
$ kubectl get deploy -n privileged-ns nginx-hostnetwork
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
nginx-hostnetwork   1/1     1            1           34s
$ kubectl get po -n privileged-ns
NAME                                 READY   STATUS   RESTARTS   AGE
nginx-hostnetwork-644cdd6598-twds9   0/1     Error    3          77s
$ kubectl get pod nginx-hostnetwork-644cdd6598-twds9 -o jsonpath='{.metadata.annotations}' -n privileged-ns
map[kubernetes.io/psp:privileged]

授权给该命名空间 service account 的安全策略资源允许 pod 使用 hostNetwork,因此 pod 成功被创建。我们可以通过 pod 的 metadata.annotations 字段检查其适用的安全策略资源。

Pod Security Admission

从 Kubernetes v1.21开始,Pod Security Policy 将被弃用,并将在 v1.25 中删除,Kubernetes 在 1.22 版本引入了 Pod Security Admission 作为其替代者。

为什么要替换 psp

KEP-2579 详细阐述了引入 Pod Security Admission 替代 Pod Security Policy 的三点主要理由:

  1. 将策略与用户或 service account 绑定的模型削弱了安全性。
  2. 功能无法流畅切换,在没有安全策略资源的情况下无法关闭检查。
  3. API 不一致且缺乏灵活性。

新的 Pod Security Admission 机制在易用性和灵活性上都有了很大提升,从使用角度有以下四点显著不同:

  1. 可以在集群中默认开启,只要不设置约束条件就不会触发对 pod 的校验。
  2. 只在命名空间级别生效,可以为不同命名空间通过添加标签的方式设置不同的安全限制。
  3. 可以为特定的用户、命名空间或者运行时设置豁免规则。
  4. 根据实践预设了三种安全等级,不需要由用户单独去设置每一项安全条件。

工作方式

Pod Security Admission 将原来 Pod Security Policy 的安全条件划分成三种预设的安全等级:

  • privileged: 不受限,向 pod 提供所有可用的权限。
  • baseline:最低限度的限制策略,防止已知的特权升级。
  • restricted:严格限制策略,遵循当前 Pod 加固的最佳实践。

三种等级从宽松到严格递增,各自包含了不同限度的安全条件,适用于不同的 pod 工作场景。此外还可以将安全等级设置为固定的 Kubernetes 版本,这样即使集群升级到了新的版本且新版本的安全等级定义发生变化,依然可以按旧版本的安全条件对 pod 进行检验。

当 pod 与安全等级冲突时,我们可通过三种模式来选择不同的处理方式:

  • enforce:只允许符合安全等级要求的 pod,拒绝与安全等级冲突的 pod。
  • audit:只将安全等级冲突记录在集群 event 中,不会拒绝 pod。
  • warn:与安全等级冲突时会向用户返回一个警告信息,但不会拒绝 pod。

audit 和 warn 模式是独立的,如果同时需要两者的功能必须分别设置两种模式。

应用安全策略不再需要创建单独的集群资源,只需在启用 Pod Security Admission 后为命名空间设置如下控制标签:

1
2
pod-security.kubernetes.io/<mode>: <level>
pod-security.kubernetes.io/<mode>-version: <version>

在旧版本集群中启用 psa

虽然 Pod Security Admission 是一个在 Kubernetes v1.22 引入的功能,但旧版本可以通过安装 PodSecurity admission webhook 来启用该功能,具体步骤如下:

1
2
3
4
git clone https://github.com/kubernetes/pod-security-admission.git
cd pod-security-admission/webhook
make certs
kubectl apply -k .

以上来自官方文档的步骤在 v1.18.17 集群中执行时会有两个兼容性问题,具体问题和解决方案如下:

  1. kubectl 内置的 kustomize 版本不支持 "replacements" 字段:

    $ kubectl apply -k .
    error: json: unknown field "replacements"
    

    解决方案:安装最新版本的 kusomize 然后在同一目录执行

    1
    
    $ kustomize build . | kubectl apply -f -
    
  2. manifest/50-deployment.yaml 文件中定义的 Deployment.spec.template.spec.containers[0].securityContext 字段在 v1.19 版本才开始引入,因此 v1.18 需要将该字段修改为对应的 annotation 版本,详见 Seccomp

    error: error validating "STDIN": error validating data: ValidationError(Deployment.spec.template.spec.containers[0].securityContext): unknown field "seccompProfile" in io.k8s.api.core.v1.SecurityContext; if you choose to ignore these errors, turn validation off with --validate=false
    

验证 psa 的安全限制

首先创建一个新的命名空间 psa-test 用于测试,并将其定义强制应用 baseline 安全等级,并对 restricted 等级进行警告和审计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: v1
kind: Namespace
metadata:
  name: psa-test
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: v1.18

    # We are setting these to our _desired_ `enforce` level.
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.18
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.18

接着在该命名空间中创建上文示例中用过的 deployment:

1
2
3
4
5
6
7
$ kubectl create -f hostnetwork-pod.yaml -n psa-test
deployment.apps/nginx-hostnetwork created
$ kubectl get deploy -n psa-test nginx-hostnetwork
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
nginx-hostnetwork   0/1     0            0           17s
$ kubectl -n psa-test get event | grep PodSecurity
104s        Warning   FailedCreate        replicaset/nginx-hostnetwork-644cdd6598   Error creating: admission webhook "pod-security-webhook.kubernetes.io" denied the request: pods "nginx-hostnetwork-644cdd6598-7rb5m" is forbidden: violates PodSecurity "baseline:v1.23": host namespaces (hostNetwork=true)

与 psp 的示例相比,psa 实现了基本一致的安全检查结果,但易用程度有了很大提升。

参考链接

制作开箱即用的 Ubuntu qcow2 镜像

2021年12月7日 23:08

最近负责的一个组件需要针对 Ubuntu 系统做兼容性适配,由于组件运行时会修改系统配置并写入大量文件,有必要使用虚拟机来开发测试。但通常开发使用的 Linux 发行版都是 RedHat 系的,找遍公司也没有能直接用的 Ubuntu 虚拟机镜像,最后自己手搓了一个,过程记录如下。

下载所需版本的 cloud image

首先需要确定所使用的 Ubuntu 版本,然后从官方镜像列表中下载相应的云主机镜像,云主机镜像预装了 cloud-init,可以更方便地运行在 openstack 等云平台中。

在镜像列表页面,需要根据发布频率(如 Release、Daily builds)、镜像大小(完整包和 Minimal)以及 Ubuntu 版本选择合适的镜像,在镜像下载页面有许多不同类型的文件,如校验和文件、manifest 以及不同架构的镜像,我们只需要文件后缀为 .img 的 QEMU qcow2 镜像。``

本示例将选择 X86 架构的 20.04-lts 版本,最终所下载的镜像为 ubuntu-20.04-server-cloudimg-amd64.img

使用 wget 命令将其下载到本地:

1
wget https://cloud-images.ubuntu.com/releases/focal/release-20211129/ubuntu-20.04-server-cloudimg-amd64.img

cloud-init

cloud-init 是用于跨平台初始化云主机实例的行业标准方法。它支持所有的主要公有云供应商、私有云配置系统和裸机安装。

有了 cloud-init,只需要提供系统镜像和规定的元数据(以及可选的用户数据和供应商数据)即可初始化一个云主机实例,在启动过程中,cloud-init 将读取所提供的元数据,识别它所运行的平台并完成相应的系统初始化步骤。初始化过程涉及设置网络和存储设备,配置 SSH 访问密钥及其他系统配置,还可将可选的用户或供应商数据传递给实例。

cloud-init 大幅简化了云主机的复杂配置过程,只需要编写一个统一的配置文件,就可以在不同的云平台创建出相同规格的主机实例。

配置模板镜像

下载的初始镜像并不能直接上传到云主机平台使用,否则创建的虚拟机无法通过 SSH 登录,会提示如下类似错误(SSH 用户只能使用镜像默认的 ubuntu ):

1
ubuntu@10.10.40.125: Permission denied (publickey).

下面需要使用一些虚拟机管理工具,基于模板镜像运行一个虚拟机,在虚拟机中完成所需配置,最终将模板镜像修改至可直接使用的镜像。

安装虚拟机管理工具

以下安装命令基于 CentOS,其他发行版本也可通过对应的包管理系统下载所需工具:

1
$ yum install -y libvirt-client cloud-utils virt-install libguestfs-tools

创建模板镜像并配置磁盘大小

基于初始镜像创建模板镜像,并命名为 root-disk.qcow2

1
$ qemu-img convert -f qcow2 -O qcow2 ubuntu-20.04-server-cloudimg-amd64.img root-disk.qcow2

根据需要,设置基于该模板镜像所创建的虚拟机磁盘大小,示例设置为 50G:

1
$ qemu-img resize root-disk.qcow2 50G

准备 cloud-init 配置

假设将使用的默认主机名和密码如下:

1
2
$ VM_NAME="ubuntu-vm"
$ PASSWORD="thisIsMyPassword"

用于测试开发的虚拟机,可配置为直接使用 root 用户并以密码登录,但切勿用于生产用途。生产环境镜像需使用普通用户加 sudo 权限以及 public key 等更安全的登录途径。

接下来创建一个 cloud-init 的配置文件,并在其中定义主机名和密码等:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ echo "#cloud-config
system_info:
  default_user:
    name: ubuntu
    home: /home/ubuntu

password: $PASSWORD
chpasswd: { expire: False }
hostname: $VM_NAME

# 配置 sshd 允许使用密码登录
ssh_pwauth: True
" | tee cloud-init.cfg

如果还需要对主机做更多配置,可参考 cloud-init 的文档示例:

Cloud config examples - cloud-init 21.4 documentation

然后使用 cloud-localds 基于配置文件创建 ISO 镜像:

1
$ cloud-localds cloud-init.iso cloud-init.cfg

基于模板镜像以及配置镜像安装虚拟机:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ virt-install \
  --name $VM_NAME \
  --memory 1024 \
  --disk root-disk.qcow2,device=disk,bus=virtio \
  --disk cloud-init.iso,device=cdrom \
  --os-type linux \
  --os-variant ubuntu20.04 \
  --virt-type kvm \
  --graphics none \
  --network network=default,model=virtio \
  --import

命令运行成功后会进入一个新的终端会话,在自动执行一系列的初始化操作之后,将可以用上文设置的 default_user 的用户名和密码登录所创建的虚拟机。

初始化虚拟机

进入运行中的虚拟机后,我们希望进行一些与登录相关的初始化配置。以下操作以 Ubuntu 20.04 为例,其他版本可能有所区别。

允许以 root 用户登录

Ubuntu 20.04 默认的 SSH 配置不允许以 root 用户登录,我们需要修改 /etc/ssh/sshd_config 文件将 PermitRootLogin prohibit-password 配置更改为 PermitRootLogin yes

1
$ sudo sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

重启 SSH 服务:

1
$ sudo systemctl restart ssh

设置 root 用户密码

默认情况下 Ubuntu 20.04 未设置 root 用户密码,因此无法使用 root 用户登录系统,需要通过 passwd 命令初始化 root 用户的密码:

1
$ sudo passwd root

密码设置完毕后,我们可以输入 ctrl + ] 退出虚拟机会话,并通过 virsh 命令获取运行中虚拟机的 IP 地址,测试是否能通过 root 用户 SSH 登录虚拟机:

1
2
3
4
5
6
7
8
$ virsh domifaddr ubuntu-vm
 Name       MAC address          Protocol     Address
----------------------------------------------------------------
 vnet0      52:54:00:1b:3b:4f    ipv4         192.168.122.201/24

$ ssh root@192.168.122.201

root@ubuntu-vm:~$

登陆成功后,可以在虚拟机中执行其他初始化操作,如安装基础依赖、配置镜像源等。

最后在虚拟机中执行关闭命令退出会话:

1
$ shutdown -h now

清理与压缩镜像

virt-install 执行过程中,虚拟机会将虚拟网卡的 Mac 地址记录到文件系统中,但每一次基于镜像启动虚拟机都会生成一个新的 Mac 地址,因此需要将镜像中已经写入配置文件的 Mac 地址清除掉。

除了进入虚拟机手动清除,还可以使用上文已安装的专门工具来清理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ virt-sysprep -d $VM_NAME

[   0.0] Examining the guest ...
[  25.7] Performing "abrt-data" ...
[  25.7] Performing "backup-files" ...
[  26.4] Performing "bash-history" ...
[  26.4] Performing "blkid-tab" ...
[  26.5] Performing "crash-data" ...
[  26.5] Performing "cron-spool" ...
[  26.5] Performing "dhcp-client-state" ...
[  26.5] Performing "dhcp-server-state" ...
[  26.5] Performing "dovecot-data" ...
[  26.5] Performing "logfiles" ...
[  26.7] Performing "machine-id" ...
......

该命令还将执行清除操作历史等一系列操作。

接着在 libvirt 中注销已创建的虚拟机实例:

1
2
3
$ virsh undefine $VM_NAME

域 qfusion-vm 已经被取消定义

此时对虚拟机所做的更改都已写入到 root-disk.qcow2 模板镜像中,将其上传到云主机平台的镜像服务器,就可以基于已写入的配置重复创建新的虚拟机实例。

如果此时镜像体积较大,可执行以下命令可对镜像体积进行压缩,并使用压缩得到的 ubuntu-20.04.qcow2 镜像:

1
$ qemu-img convert -f qcow2 -O qcow2 -c root-disk.qcow2 ubuntu-20.04.qcow2

在本示例中,镜像大小从 7 个多 G 压缩到了 500M 左右,效果显著。

参考链接

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

2021年7月22日 15:45

如果你使用 runc 运行一个容器并执行以下操作,会得到有趣的结果:

1
2
3
4
5
6
$ whoami
root
$ id -u root
0
$ hostname mybox
hostname: sethostname: Operation not permitted

即使我们使用的是 UID 为 0 的 root 用户,也没有权限执行修改 hostname 的操作。

实际上 root 用户拥有最高特权早就成了过去式,Linux 内核在 2.2 版本就引入了一种新的权限检查机制 - capabilities。

比超级用户更细粒度的权限控制

传统的 Linux 权限检查模型较为简单,内核在进行权限检查时只会区分两类进程:

  • 特权进程,其有效用户 ID为 0,该用户也就是我们常说的超级用户或 root
  • 非特权进程,有效用户 ID 不为 0。

特权进程将直接绕过内核的所有检查,非特权进程则需要基于进程的有效用户 ID 和有效用户组 ID 等凭证执行检查。

为了适应更复杂的权限需求,从 2.2 版本起 Linux 内核能够进一步将超级用户的权限分解为细颗粒度的单元,这些单元称为 capabilities。例如,capability CAP_CHOWN 允许用户对文件的 UID 和 GID 进行任意修改,即执行 chown 命令。几乎所有与超级用户相关的特权都被分解成了单独的 capability。

capabilities 的引入有以下好处:

  • 从超级用户的权限中移除部分 capability 以削弱其权限,提高系统的安全性。
  • 可以根据需求非常精准地向普通用户授予部分特殊权限。

特权容器的安全风险

容器通过 namespace 来隔离进程和资源,但并不是所有的资源都可以被 namespace 化,容器和宿主机并不是完全隔离的,比如容器和宿主机中的时间就是共享的。如果容器中的进程拥有一切特权,它可以运行直接访问硬件的(恶意)程序甚至直接修改宿主机的文件系统,因此有必要对容器中的操作进行一定的限制,否则会影响到宿主机的稳定性,甚至带来严重的安全风险。

出于以上考虑,默认情况下容器运行时使用白名单的方式在创建容器时加入一部分的 capabilities,在容器中即使你是超级用户也没有权限执行特定的操作。

接下来我们通过实例加深对容器中 capabilities 的认识。

准备工作

我们将在容器中使用额外的工具库 libcap 实现和 capabilities 的交互,为此需要将其安装到一个 filesystem bundle 中,这种方式在之前的文章中已有介绍,具体方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 创建 bundle 的顶层目录
mkdir /mycontainer2
cd /mycontainer2

# 创建用于存放 root filesystem 的 rootfs 目录
mkdir rootfs

# 利用 Docker 导出已安装 libcap 容器的 root filesystem 
docker export $(docker create cmd.cat/capsh) | tar -C rootfs -xvf -

# 创建一个 config.json 作为整个 bundle 的 spec
runc spec

然后就可以使用 runc run/mycontainer2 目录运行一个已安装该库的基础容器了。

创建容器时添加 capabilities

在开头的示例中,我们无法在容器中以 root 用户设置 hostname,是因为缺少了 CAP_SYS_ADMIN 这一 capability,它并未包含在容器默认添加 capabilities 的白名单中。

在之前的一篇文章中,我们介绍过容器运行时会根据 bundle 中的 config.json ,为其创建的容器设置运行参数和执行环境,这一过程也包括了设置容器内进程的 capabilities。

通过修改 config.json ,向 JSON 中的 process.capabilities 对象的 boundingpermittedeffective 列表中加入 "CAP_SYS_ADMIN",该 capability 将加入到容器 init 进程的对应 capabilities 集合中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
"capabilities": {
			"bounding": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE",
				"CAP_SYS_ADMIN"
			],
			"effective": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE",
				"CAP_SYS_ADMIN"
			],
			"inheritable": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE"
			],
			"permitted": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE",
				"CAP_SYS_ADMIN"
			],
			"ambient": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE"
			]
		}

capabilities 的技术细节

capabilities 可以应用于文件和进程(或线程,Linux 内核不区分进程和线程),文件的 capabilities 存储在文件的扩展属性中,扩展属性在构建镜像时会被清理掉,所以在容器中我们基本不需要考虑文件的 capabilities。

进程的 capabilities 通过每个进程单独维护的 5 个 capability 集合来控制,每个集合中都包含 0 个或多个 capabilities:

  • Permitted:进程所能够使用的 capabilities 的超集
  • Inheritable:进程在执行 exec() 系统调用时,能够被新的派生进程所继承的 capabilities
  • Effective:内核对进程执行权限检查时所使用的集合
  • Bounding:Inheritable 集合的超集,一个capability 必须在 Bounding 集合中才能添加到Inheritable
  • Ambient:非特权程序执行 exec() 系统调用时将保留的 capabilities

如上所示我们向 init 进程的 Permitted、Bounding 和 Effective 集合中加入了 CAP_SYS_ADMIN,因此 init 进程将通过内核对 CAP_SYS_ADMIN 的检查。

下面我们根据新的 config.json 运行一个新的容器,现在可以修改 hostname 了:

1
2
3
4
$ runc run mybox2
$ hostname super
$ hostname
super

执行以上操作时,我们位于作为容器 init 进程的 sh 进程中,如果在容器中继续创建新的进程,是否也会具有新加入的 capability?我们来试一下,在一个新的窗口中执行以下命令:

1
2
3
4
5
6
$ runc exec -t mybox2 sh
$ hostname
super
$ hostname hello
$ hostname
hello

修改 hostname 的操作执行成功,因为新创建的进程完全复制了 init 进程的 capabilities。

容器运行时添加 capabilities

除了修改 config.json 加入 capabilities,我们还能够在容器运行时阶段添加 capabilities。

首先将 config.json 还原,然后运行一个新的容器 mybox3,在新的 sh 进程中确认已经不再具有 CAP_SYS_ADMIN

然后通过 runc exec 在该容器中创建一个新的进程,并通过 --cap 选项为该进程添加 CAP_SYS_ADMIN :

1
runc exec --cap CAP_SYS_ADMIN mybox3 /bin/hostname origin

该操作的原理是,既然 runc 能够根据 config.json 设置 init 进程的 capabilities 集合,它同样也能为容器内运行的其他进程设置。

查看进程具有的 capabilities

capsh

在容器内执行 capsh --print 能够获取到更多关于 capabilities 的信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ capsh --print

Current: = cap_kill,cap_net_bind_service,cap_audit_write+eip cap_sys_admin+ep
Bounding set =cap_kill,cap_net_bind_service,cap_sys_admin,cap_audit_write
Ambient set =cap_kill,cap_net_bind_service,cap_audit_write
Securebits: 00/0x0/1'b0
 secure-noroot: no (unlocked)
 secure-no-suid-fixup: no (unlocked)
 secure-keep-caps: no (unlocked)
 secure-no-ambient-raise: no (unlocked)
uid=0(root)
gid=0(root)
groups=

该命令打印了当前进程所具有的 capabilities。

CurrentBounding set 中包含了我们通过 config.json 加入的 cap_sys_admin。capability 末尾的 +eip 代表了该 capability 同时存在于 Effective,Inheritable 和 Permitted 集合中。

pscap

首先在宿主机获取容器中已运行进程的 PID:

1
2
3
4
$ runc ps mybox2
UID          PID    PPID  C STIME TTY          TIME CMD
root        9592    9580  0 14:39 pts/0    00:00:00 sh
root        9776    9765  0 14:46 pts/1    00:00:00 sh

在宿主机中安装 pscap 程序:

1
$ apt-get install libcap-ng-utils

根据获得的 PID,查看容器中进程所具有的 capabilities:

1
2
3
pscap | grep "9592\|9776"
9580  9592  root        sh                kill, net_bind_service, sys_admin, audit_write
9765  9776  root        sh                kill, net_bind_service, sys_admin, audit_write

参考链接

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

2021年7月21日 17:38

cgroups(control groups)是由 Linux 内核提供的一种特性,它能够限制、核算和隔离一组进程所使用的系统资源(如 CPU、内存、磁盘 I/O、网络等)。

在上一篇文章中我们已了解 Namespace 在容器技术中扮演的角色,如果说 Namespace 控制了容器中的进程能看到什么,那么 cgroups 则控制了容器中的进程能使用多少资源。Namespace 实现了进程的隔离,cgroups 则实现了资源的限制,后者同样是构建容器的基础。

本文将沿袭 Namespace 文章的行文思路,实际创建一个容器,观察宿主机中 cgroups 的变化,来实际展示 cgroups 如何工作,然后了解如何自行配置 cgroups。

cgroup 在何时创建

Linux 内核通过一个叫做 cgroupfs 的伪文件系统来提供管理 cgroup 的接口,我们可以通过 lscgroup 命令来列出系统中已有的 cgroup,该命令实际上遍历了 /sys/fs/cgroup/ 目录中的文件:

1
$ lscgroup | tee cgroup.a

如果你使用的 Linux 发行版没有 lscgroup 命令,可通过 command-not-found.com 提供的指令下载安装。

我们将输出结果保存到 cgroup.a 文件中。接着在另一窗口中根据 Namespace 文章中的步骤启动一个容器:

1
2
$ cd /mycontainer
$ runc run mybox

回到原来的窗口再次执行 lsgroup 命令:

1
$ lscgroup | tee group.b

现在对比两次 lscgroup 命令的输出结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ diff group.a group.b

> perf_event:/mybox
> freezer:/mybox
> net_cls,net_prio:/mybox
> cpu,cpuacct:/user.slice/mybox
> blkio:/user.slice/mybox
> cpuset:/mybox
> hugetlb:/mybox
> pids:/user.slice/user-0.slice/session-5.scope/mybox
> memory:/user.slice/user-0.slice/session-5.scope/mybox
> devices:/user.slice/mybox

从结果中可看到,mybox 容器创建后,系统中专门为其创建了所有类型的新的 cgroup。

cgroup 如何控制容器的资源

cgroup 所控制的对象是进程,它控制一个或一组进程所能使用多少内存/CPU/网络等等。一个 cgroup 的 tasks 列表中记录了其所控制进程的 PID,该 tasks 实际上也是 cgroupfs 中的一个文件。

init 进程

我们首先在宿主机中打印出容器中的进程信息,找到容器的 init 进程:

1
2
3
4
$ runc ps mybox

UID          PID    PPID  C STIME TTY          TIME CMD
root        2250    2240  0 15:28 pts/0    00:00:00 sh

任意打印一些类型的 cgroup 的 tasks 列表:

1
2
3
4
$ cat /sys/fs/cgroup/memory/user.slice/user-0.slice/session-5.scope/mybox/tasks
2250
$ cat /sys/fs/cgroup/blkio/user.slice/mybox/tasks
2250

这一过程简单明了:容器创建之后,容器的 init 进程会被加入到为该容器所创建的 cgroups 之中,我们可以通过 /proc/$PID/cgroup 得到更肯定的结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cat /proc/2250/cgroup
12:devices:/user.slice/mybox
11:memory:/user.slice/user-0.slice/session-5.scope/mybox
10:pids:/user.slice/user-0.slice/session-5.scope/mybox
9:hugetlb:/mybox
8:cpuset:/mybox
7:rdma:/
6:blkio:/user.slice/mybox
5:cpu,cpuacct:/user.slice/mybox
4:net_cls,net_prio:/mybox
3:freezer:/mybox
2:perf_event:/mybox
1:name=systemd:/user.slice/user-0.slice/session-5.scope/mybox
0::/user.slice/user-0.slice/session-5.scope

容器中的其他进程

接下来我们在 mybox 容器中运行一个新的进程:

1
2
# 在 mybox 容器中运行
$ top -b

看看是否会创建新的 cgroup:

1
2
$ lscgroup | tee group.c
$ diff group.b group.c

没有输出任何结果,说明没有创建新的 cgroup。既然 cgroup 可以控制一组进程,我们猜测在已运行容器中新建的进程,也都会加入到 init 进程所属的 cgroups 中。

下面开始验证,首先找到新建进程的 PID:

1
2
3
4
$ runc ps mybox
UID          PID    PPID  C STIME TTY          TIME CMD
root        2250    2240  0 15:28 pts/0    00:00:00 sh
root        2576    2250  0 15:59 pts/0    00:00:00 top -b

新进程的 PID 是 2576,然后打印该进程的 cgroups 信息:

1
cat /proc/2576/cgroup

输出和 PID 2250 进程的输出完全一致,我们也可以打印其中一个 cgroup 的 tasks 列表:

1
2
3
cat /sys/fs/cgroup/blkio/user.slice/mybox/tasks
2250
2576

完全符合预期。实际上向 tasks 文件直接写入进程的 PID 就实现了将进程加入到该 cgroup 中。当一个容器被创建时,将为每种类型的资源创建一个新的 cgroup,在容器中运行的所有进程都将加入到这些 cgroup 中。

通过控制容器中运行的所有进程,cgroups 实现了对容器的资源限制。

如何配置 cgroup

下面我们将以内存 cgroup 为例,了解如何配置 cgroup 以实现对 mybox 容器的内存限制。

配置 cgroup 有两种方式,一种是直接修改 cgroupfs 中的指定文件,另一种是通过 runcdocker 等高阶工具实现。

文件系统方式

通过 cgroupfs 的方式,查看/修改该 cgroup 目录下的特定文件即可查看/设置该 cgroup 的限额:

1
2
cat /sys/fs/cgroup/memory/user.slice/user-0.slice/session-5.scope/mybox/memory.limit_in_bytes
9223372036854771712

修改 memory.limit_in_bytes 文件即可设置最大可用内存,现在我们并未对该容器设置任何限制,因此内存限制的当前值是一个无意义的特别大的值,现在我们向该文件直接写入新的值:

1
echo "100000000" > /sys/fs/cgroup/memory/user.slice/user-0.slice/session-5.scope/mybox/memory.limit_in_bytes

这样就设置了新的内存限制。写入新的限制值后,容器中的所有进程不能使用总共超过 100M 的内存,超过后将根据 memory.oom_control 文件中设置的 OOM 策略 killsleep 容器中的进程。

高阶工具方式

通过高阶工具提供的途径来配置 cgroup 是一种更友好的方式,虽然这些工具背后的实现也是如上所述更改 cgroupfs

对于 runc 来说,需要修改 filesystem bundle 中的 config.json 文件来配置 cgroup。设置内存限制需要如下修改 JSON 对象中的 linux.resources 字段:

1
2
3
4
5
6
7
"resources": {
    "memory": {
    "limit": 100000,
    "reservation": 200000
    },
    ...
}

对于 docker 来说更为简单,它本身就是一个面向用户的封装好的工具,执行 docker run 命令时通过 --memory 选项即可指定内存限制。实际上该参数会被写入到 config.json 由运行时实现 runc 使用,再由 runc 去更改 cgroupfs

参考链接

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

2021年7月21日 12:45

Namespace 是由 Linux 内核提供的一种特性,它能够将一些系统资源包装到一个抽象的空间中,并使得该空间中的进程以为这些资源是系统中仅有的资源。Namespace 是构建容器技术的基石,它使得容器内的进程只能看到容器内的进程和资源,实现与宿主系统以及其他容器的进程和资源隔离。

Namespace 按操作的系统资源不同有很多种类,比如 cgroup namespace,mount namespace 等等,接下来我们仅以 pid namespace 为例,以 runC 作为容器运行时实现,来演示当我们执行对容器的操作时,namespace 是如何工作的。

在上一篇文章中我们已经介绍过,绝大部分容器系统都使用 runC 作为底层的运行时实现,如果你是在 Linux 发行版系统中使用 docker ,甚至不需要专门安装就能使用 runc 命令。

准备工作

filesystem bundle

runC 只能从 filesystem bundle 中执行容器(filesystem bundle 顾名思义就是一个满足特定结构的文件夹),但是我们可以使用 docker 来准备一个可用的 bundle :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 创建 bundle 的顶层目录
$ mkdir /mycontainer
$ cd /mycontainer

# 创建用于存放 root filesystem 的 rootfs 目录
$ mkdir rootfs

# 利用 Docker 导出 busybox 容器的 root filesystem 
$ docker export $(docker create busybox) | tar -C rootfs -xvf -

# 创建一个 config.json 作为整个 bundle 的 spec
$ runc spec

此时整个 bundle 的目录结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ tree -L 2 /mycontainer

/mycontainer
├── config.json
└── rootfs
    ├── bin
    ├── dev
    ├── etc
    ├── home
    ├── proc
    ├── root
    ├── sys
    ├── tmp
    ├── usr
    └── var

系统监测工具

为了完成演示,我们需要一些第三方的系统监测工具作为辅助:

  1. 监测进程的启动以获得容器中运行进程的 PID,如 ubuntu 中的 forkstat ,它可以实时地监测 fork(), exec()exit() 等系统调用,安装方式如下:

    1
    
    $ apt install forkstat
    
  2. 查看 namespace 信息,如 [cinf](https://github.com/mhausenblas/cinf) ,它是一个能够方便地列出系统中所有 namespace 或查看某个 namespce 详细信息的命令行工具,安装方式如下:

    1
    2
    3
    4
    5
    
    $ curl -s -L https://github.com/mhausenblas/cinf/releases/latest/download/cinf_linux_amd64.tar.gz \
         -o cinf.tar.gz && \
         tar xvzf cinf.tar.gz cinf && \
         mv cinf /usr/local/bin && \
         rm cinf*
    

使用 runc 运行容器

首先我们需要在一个窗口中运行 forkstat

1
$ forkstat -e exec

接着另外新建一个终端窗口,切换到 /mycontainer 目录,使用 runC 运行容器:

1
$ runc run mybox

执行后会直接进入到新创建的容器中,运行 ps 命令:

1
2
3
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    7 root      0:00 ps

forkstat 窗口将会有以下输出:

1
2
3
4
5
6
7
8
Time     Event     PID Info   Duration Process
12:35:22 exec    33040                 runc run mybox
12:35:22 exec    33047                 runc init
12:35:22 exec    33049                 dumpe2fs -h /dev/sdb3
12:35:22 exec    33050                 dumpe2fs -h /dev/sdb3
12:35:22 exec    33047                 runc init
12:35:22 exec    33052                 sh
12:35:37 exec    33062                 ps

从同步打印的结果可以判断, psforkstat 所分别输出的 shps 实际上是同一个进程,但由于容器中的进程位于一个单独的 pid namespace 中,它们在容器中拥有另外的 PID,而且它们以为自己是容器中唯一存在的进程,因此 PID 会从 1 开始。

找到进程所属的 namespace

现在来找出容器所使用的 pid namespace,为此需要调整一下 ps 命令的输出格式:

1
2
3
$ ps -p 33052 -o pid,pidns
PID      PIDNS  
33052 4026532395

PIDNS 即 pid namespace,以上命令可得到 PID 为 33052 的 sh 进程属于 4026532395 这个 pid namespace。既然已经有了容器中进程的 PID,实际上我们可以通过宿主机的 /proc 文件系统获得该进程所属的所有 namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ll /proc/33052/ns
lrwxrwxrwx 1 root root 0  7月 21 12:37 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 ipc -> 'ipc:[4026532394]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 mnt -> 'mnt:[4026532383]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 net -> 'net:[4026532397]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 pid -> 'pid:[4026532395]'
lrwxrwxrwx 1 root root 0  7月 21 12:37 pid_for_children -> 'pid:[4026532395]'
lrwxrwxrwx 1 root root 0  7月 21 12:37 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0  7月 21 12:37 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 uts -> 'uts:[4026532393]'

打印结果展示了一个进程所属的 namespace:

  1. 每个 namespace 都是一个软链接,软链接的名称指示了 namespace 的类型,如 cgroup 表示 cgroup namespace, pid 表示 pid namespace。
  2. 每个软链接指向该进程所属的真正 namespace 对象,该对象用 inode 号码表示,每个 inode 号码在宿主系统中都是唯一的。
  3. 如果有两个进程的同一类型 namespace 软链接都指向同一个 inode ,说明他们属于同一个 namespace。

实际上所有的进程都会属于至少一个 namespace,Linux 系统在启动时就会为所有类型创建一个默认的 namespace 供进程使用。

我们也可以尝试在容器内获得 sh 所属的 namespace,此时需要在容器内使用 1 这个 PID:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ls -l /proc/1/ns
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 cgroup -> cgroup:[4026531835]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 ipc -> ipc:[4026532394]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 mnt -> mnt:[4026532383]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 net -> net:[4026532397]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 pid -> pid:[4026532395]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 pid_for_children -> pid:[4026532395]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 time -> time:[4026531834]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 time_for_children -> time:[4026531834]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 user -> user:[4026531837]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 uts -> uts:[4026532393]

观察 namespace 中的进程

下面我们将从 namespace 的角度,来观测 pid namespace 中的所有进程。Linux 系统并未提供类似的功能,因此需要借助上文安装的 cinf 工具来实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ cinf -namespace 4026532395

 PID    PPID   NAME  CMD  NTHREADS  CGROUPS                                                          STATE

 33052  33052  sh    sh   1         12:devices:/user.slice/mybox                                     S (sleeping)
                                    11:blkio:/user.slice/mybox 10:rdma:/
                                    9:memory:/user.slice/user-0.slice/session-590.scope/mybox
                                    8:net_cls,net_prio:/mybox 7:freezer:/mybox
                                    6:pids:/user.slice/user-0.slice/session-590.scope/mybox
                                    5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox
                                    3:perf_event:/mybox 2:hugetlb:/mybox
                                    1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox
                                    0::/user.slice/user-0.slice/session-590.scope

目前这个 namespace 中只有一个进程,这个进程也是我们所创建容器的 init 进程。当一个新的容器被创建时,系统将创建一些新的 namespace,容器的 init 进程将被加入到这些 namespace。

对于 pid namespace 来说,容器中运行的所有进程只能看到位于同一 pid namespace 即 pid:[4026532395] 中的其他进程。sh 进程在容器中被认为是系统运行的第一个进程,PID 为 1,但在宿主机中只是一个 PID 为 33052 的普通进程,同一个进程在不同 namespace 中拥有不同的 PID,这就是 pid namespace 的作用。某种程度上,容器就意味着一个新的 namespace 集合。

在容器中创建新的进程

创建一个新的终端窗口,在已运行的容器中运行一个新的进程:

1
$ runc exec mybox /bin/top -b

forkstat 窗口中,我们可以看到新创建进程的 PID:

1
2
3
4
5
Time     Event     PID Info   Duration Process
12:40:23 exec    33132                 runc exec mybox /bin/top -b
12:40:23 exec    33140                 runc init
12:40:23 exec    33140                 runc init
12:40:23 exec    33142                 /bin/top -b

实际上还有更直接的方式从宿主机中查看容器中运行的进程,我们可以使用 runC 提供的 ps 子命令:

1
2
3
4
$ runc ps mybox
UID          PID    PPID  C STIME TTY          TIME CMD
root       33052   33040  0 12:35 pts/0    00:00:00 sh
root       33142   33132  0 12:40 pts/1    00:00:00 /bin/top -b

接下来依然使用 cinf 来找出新创建进程所属的 namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ cinf --pid 33142

 NAMESPACE   TYPE

 4026532383  mnt
 4026532393  uts
 4026532394  ipc
 4026532395  pid
 4026532397  net
 4026531837  user

从结果来看,并没有新的命名空间被创建,32608 进程的 namespace 和 mybox 容器的 init 进程- sh 所属的 namespace 是完全相同的。也就是说,在容器中创建一个新的进程,只是将这个进程加入到了容器 init 进程所属的 namespace。

下面来列出 4026532395 namespace 所拥有的所有进程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cinf --namespace 4026532395

 PID    PPID   NAME  CMD     NTHREADS  CGROUPS                                                          STATE

 33052  33040  sh    sh      1         12:devices:/user.slice/mybox                                     S (sleeping)
                                       11:blkio:/user.slice/mybox 10:rdma:/
                                       9:memory:/user.slice/user-0.slice/session-590.scope/mybox
                                       8:net_cls,net_prio:/mybox 7:freezer:/mybox
                                       6:pids:/user.slice/user-0.slice/session-590.scope/mybox
                                       5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox
                                       3:perf_event:/mybox 2:hugetlb:/mybox
                                       1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox
                                       0::/user.slice/user-0.slice/session-590.scope
 33142  33132  top   top -b  1         12:devices:/user.slice/mybox                                     S (sleeping)
                                       11:blkio:/user.slice/mybox 10:rdma:/
                                       9:memory:/user.slice/user-0.slice/session-590.scope/mybox
                                       8:net_cls,net_prio:/mybox 7:freezer:/mybox
                                       6:pids:/user.slice/user-0.slice/session-590.scope/mybox
                                       5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox
                                       3:perf_event:/mybox 2:hugetlb:/mybox
                                       1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox
                                       0::/user.slice/user-0.slice/session-590.scope

如果在容器内运行 ps -ef ,我们也能看到这些进程,由于 pid namespace 的原因它们的 PID 将会有所不同:

1
2
3
4
PID   USER     TIME  COMMAND
    1 root      0:00 sh
   19 root      0:00 top -b
   20 root      0:00 ps -ef

现在我们知道,docker/runc exec 实际上就是在已创建容器的 namespace 中运行一个新的进程。

总结

运行一个容器时,将创建一些新的 namespace, init 进程将被加入到这些 namespace;在一个容器中运行一个新进程时,新进程将加入创建容器时所创建的 namespace。

实际上创建容器时新建 namespace 这种行为是可以改变的,我们可以指定新建的容器使用已有的 namespace。

参考链接

究极的 Python 开源项目模板

2021年6月25日 18:08

我的 Notion 里面躺着很多关于开源项目的 idea,有些 GitHub 仓库都建好了,但真正开始的寥寥无几。除了没有时间或者需要掌握新的技术栈,还有一个重大阻碍是我觉得开始一个新的项目非常繁琐,尤其对于 Python 项目来说。

制作一个项目启动模板,也是开始一个新开源项目的好 idea。两年前我曾经接触过 cookiecutter (一个项目生成工具);在发布了几个项目后,对于 Python 打包发布到 PyPI 的流程也比较熟悉了;何况在 GitHub 上还看过许多类似的项目模板,可以 fork 后在其基础上进行自定义。一切都水到渠成,这应该不是个很难的事。

不过我还是经过一段时间的测试和反复打磨,才终于完成一份 Python 开源项目模板:Cookiecutter PyPackage,标题中的「究极」有夸大之嫌,但内容的确是相当全面。模板的使用方法在项目文档中有详细介绍,这篇文章主要分享我在模板中所选择的工具以及选择它们的理由。

包管理工具

Python 的包管理工具太多了,而且并没有所谓的主流,这是 Python 语言高度社区化的一个历史弊端,很难想象一门发展了近 30 年的语言,还在不断出现新的 PEP 和开源工具来改善其基础的包管理流程,用户需要为此付出相当大的学习成本。最近出现的新语言如 Go 和 Rust,基本都内置了一套完整的工具链。

那就从一众工具中挑一个简单省事的吧。我们需要它来做下面这些事情:

  • 管理包的依赖
  • 将项目打包成能够分发的格式
  • 将打好的包上传到 PyPI
  • 顺便再管理虚拟环境啥的

用传统的 pip + requirements.txt + Setuptools + twine + venv 做完这一套是没有任何问题的,但是用到的工具太多了,如果能用一个工具来做这些也许会更好。 Poetry 就是这样的一个工具,经过一段时间的体验我觉得还不错,最喜欢的特性是可以一次性在命令行中指定依赖版本,不用再手动编辑 requirements.txtsetup.py 中的 install_requires 了。

另外 Poetry 还使用了 pyproject.toml 作为包的元信息文件,用来替换了 setup.py + requirements.txt,如果你对 pyproject.toml 这种格式不太熟悉,推荐阅读:What the heck is pyproject.toml?

检查工具

对 Python 开源项目来说 flake8,pylint, isort 这类 linter/formatter 工具应该是必备了,如果你发现哪个流行的项目中没有,这可是一个贡献开源的好机会,赶紧去提 PR 吧。在之前的一篇博客构建保障代码质量的自动化工作流中,我曾经详细介绍过了这些工具的安装和使用方法。

在模板中一共配置了如下检查工具:

  • 规范风格检查:

    • flake8
    • flake8-docstrings
  • 代码自动格式化:

    • black
    • isort
  • 静态类型检查:

    • mypy

相应的配置项都保存在 setup.cfgpyproject.yaml ,预设的规则不算特别严格,如果想更宽松一点可以把 mypyflake8-docstrings 去掉,并自行修改检查项。

Pre-commit

为了自动化地对代码运行上面介绍的检查工具,我们可以将其集成到 pre-commit hooks 中,这样它们就会在每次通过 git 提交代码时对更改过的文件运行。使用 pre-commit 时需要注意两点:

  • hooks 仅对更改过的文件运行,换句话说在运行时已经通过命令行传入了目标文件参数,通常我们还会在工具的配置文件中通过 include 类似的字段设置作用范围,因此要确认两者是否能够同时生效,比如 isort 就需要在 hooks 配置文件 .pre-commit-config.yaml 中添加 args: [ "--filter-files" ] 选项:

    1
    2
    3
    4
    5
    
    - repo: https://github.com/pycqa/isort
        rev: 5.7.0
        hooks:
          - id: isort
            args: [ "--filter-files" ]
    
  • 每一个 hooks 会在由 pre-commit 管理的单独虚拟环境中运行,由此会引发一个常见的问题:在项目的开发虚拟环境中运行 mypy 和通过 pre-commit 运行 mypy 可能会得到不同的结果,其原因是 pre-commit 运行的 mypy 所处环境无法检测到其他第三方的包。这时可通过添加 additional_dependencies 参数尝试解决:

    1
    2
    3
    4
    5
    6
    7
    
    - repo: https://gitlab.woqutech.com/Quality/python-code-check/mirrors-mypy.git
        rev: "v0.812"
        hooks:
          - id: mypy
            additional_dependencies:
              - 'pydantic'
              - 'click'
    

运行测试

Pytest

单元测试用 unittest 或者 pytest 都可以,选择后者的原因是之前用的比较多,而且可以通过 pytest-cov 很方便地集成计算测试覆盖率功能,后续还可以在 CI 中集成 Codecov

Makefile

手动运行一系列检查或者构建工具是非常繁琐的,因此我加入了一个 Makefile 可以将批量的命令和选项通过快捷命令来执行。

Tox

我们的项目通常不止支持一个 Python (major 或 minor)版本,不同的语言版本下对程序运行上述检查可能得到不同的结果,但手动切换版本以检查程序行为的成本非常高昂,这时我们就需要用到 tox 了,它可以自动以不同的 Python 版本创建虚拟环境,在不同环境中分别安装当前项目以及依赖,最后运行一系列预定义的测试流程,对流行的 Python 开源项目来说基本是标配。

除了单元测试, tox 中还加入了风格检查,格式化,文档及包的构建任务。最后配合 tox-gh-actions 这一插件在 GitHub Actions 中运行 tox,可以确保所有的测试任务都将在 CI 中自动运行。

文档生成

对于 Python 项目来说文档生成工具通常有两个选择:Sphinx 或者 Mkdocs,我曾经写过一篇关于使用 Sphinx 的博客:使用 Sphinx 为项目自动生成 API 文档。在模板中我选择后者的理由如下:

  • Mkdocs 以 markdown 作为默认格式,而不需要写任何 .rst 文档。
  • Mkdocs 的配置更为简单,所有的配置都在 mkdocs.yml 文件中
  • mkdocs-material 主题非常赞。
  • Mkdocs 可以通过 mkdocs serve 命令实时预览文档效果。
  • Mkdocs 拥有众多插件和扩展,大部分 Sphinx 具有的特性都有替代实现,比如通过 mkdocstrings 实现 autodoc 的自动生成代码文档功能。

CI 自动化

我们将使用免费的 GitHub Actions 作为 CI,自动执行一系列的测试、构建和发布任务。

自动运行测试

集成了 tox 的另一大好处,是可以轻松地通过 tox-gh-actions 与 Actions 集成,分别以不同的 Python 版本以及不同的架构平台创建多个运行机器,并行地执行测试任务。运行的效果如下:

每一次 push 或者 pull_request 都将以不同的平台和 Python 版本运行完整的测试流程。

自动发布到 PyPI

参考 PyPa 发布的 Publishing package distribution releases using GitHub Actions CI/CD workflows,我们将项目配置为:当向远程仓库推送标签时,自动将项目打包后分别发布到 TestPyPI 和 PyPI。

自动创建 GitHub Release

GitHub 的 Release 功能可以让关注项目的用户快捷地获取最新版本和历史记录,但手动发布是不可能地,一定要做成自动的。这套自动化的工作流如下:

  • 参照 keep a changelog 为项目维护一个符合其标准的 CHANGELOG.md 版本历史文件。
  • 向远程仓库推送标签以发布新版本并触发下面的流程。
  • 通过 changelog-reader-action 这一 action 从 CHANGELOG.md 文件中按规则解析出最新版本的版本信息。
  • 执行项目的构建得到最新版本的包。
  • 通过 action-gh-release 这一 action 根据上面解析得到的版本历史信息自动创建新的 Realease,并以构建好的包作为附件。

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

2021年6月7日 21:15

对于一个优秀的开源项目,高质量的文档与代码同样重要,糟糕的文档会将大部分潜在用户拒之门外。

从流行的开源项目中,我们可以总结出一些关于文档的最佳实践:

  • 文档和代码库在一个仓库中维护以做到同步更新(个别体量超大的项目例外)
  • 使用纯文本格式编写文档,如 markdownreStructText,然后通过生成工具将其转换成易于阅读且样式丰富的静态网页,进一步可通过 CI 将静态网页托管到可公开访问的站点
  • 在文档中直接引用实际的代码,并提供 API 文档

API 文档通常位于文档中的 API ReferenceReferences 章节,它能节省用户查阅源码的时间,帮助用户快速理解项目的内部组成。对于作为库(而非应用)的项目而言,提供组织良好的 API 文档尤为重要,如 FlaskClick 等,本文就将介绍如何使用 Sphinx 为项目自动生成 API 文档。

示例项目所使用的的完整源码已推送到 GitHub 的 demo 仓库中。

为什么是 Sphinx

有许多开源工具可以用来将纯文本转换为静态网页,选择 Sphinx 的理由有以下几点:

  • 经典老牌,使用相当广泛,Python 的官方文档也是用它所生成
  • 功能全面,支持各种主题和插件,和 Python 具有语言上的亲和性
  • 能够很方便地根据代码的文档字符串自动生成 API 文档

Sphinx 的默认文本格式是 reStructuredText(简称 rst),它是一种极其强大的标记语言,配合 Sphinx 可以实现很多 markdown 所不具备的解析、引用特性,缺点是规则和指令较为复杂。这原本并不算缺点,但随着 markdown 的流行,人们似乎无法再接受任何比 markdown 更复杂一点的标记语言了。

不过这并不是问题,我们也可以借助 MyST 解析器实现 Sphinx 和 markdown 的完全兼容,接下来的示例中我们将混合使用 mdrst 两种格式,当然你也可以只使用任意一种格式。

新建文档项目

安装 Sphinx

示例将是一个 Python 语言项目,因此最直接的方式当然是通过 pip 安装:

1
pip install sphinx

对于其他语言及平台, Sphinx 也提供了丰富的安装途径,详见 Installing Sphinx

设置文档源目录

我们将在同一个项目中维护代码和文档,因此首先需要在项目根目录新建一个 docs 文件夹(也可以使用其他名称),用来存放所有和文档有关的文件,我们将使用该文件夹作为 Sphinx 工作的源目录(source directory)。

初始化项目

切换到 docs 目录,执行以下命令在该目录初始化一个新的 Sphinx 文档项目:

1
sphinx-quickstart

接下来根据命令行提示完成初始化项目的各项配置,如填写项目名称和作者,对于不理解的选项回车使用默认选项即可。

初始化后的 docs 目录内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
docs
├── Makefile
├── _build
├── _static
├── _templates
├── conf.py
├── index.rst
└── make.bat

3 directories, 4 files

其中最重要的是 conf.py 文件,它是一个 Sphinx 将会运行的 Python 模块,保存着刚刚通过命令行输入的配置,还可以编辑文件来修改配置或添加插件,并通过该文件运行需要的 Python 代码。

Makefilemake.bat 提供了一些快捷命令,在 docs 目录执行 make html 即可通过源文件生成静态网页。

index.rst 是文档源文件的首页,文档里有一些默认的样板内容,通常我们将其作为访问其他页面的入口目录。

此时已经可以运行 make html 生成静态网页用于预览,生成的 HTML 页面保存在 _build/html 目录。使用浏览器打开后效果如下:

如果对默认的样式不满意,还可以在 Sphinx Themes Gallery 找到大量可以轻松替换的文档主题。

基本使用

初识 reStructuredText

接下来我们借助 index.rst 简单认识一下 reStructuredText 格式。

  1. 标题

    1
    2
    
    Welcome to sphinx-demo's documentation!
    =======================================
    

    在文本的下一行连续输入同一标点符号,即可将文本标记为标题,另外还有很违反直觉的两点要求:

    • 符号的长度需要超出文本
    • 无法显式地指定标题级别,如果解析到另一种符号标记的标题,将根据出现顺序指定为新的标题级别,如
    1
    2
    3
    4
    5
    
    Secondary Title
    ----------------
    
    Third Title
    ~~~~~~~~~~~~
    

    相比 markdown 直接使用符号的个数指示标题级别,这种规则并不算直观。

  2. 目录树

    1
    2
    3
    4
    5
    6
    
    .. toctree::
       :maxdepth: 2
       :caption: Contents:
    
       install
       reference
    

    形如 .. {directive}:: 这样的文本在 rst 中被称为指令,可以实现各种特殊效果,比如 .. toctree:: 将在当前位置插入一颗目录树。在指令的下一行可以通过 :{option}: value 指定选项,如上示例中指定了该目录树的最大深度和标题。

    再往下就是目录树的条目了,Sphinx 将根据条目名称如 install 作为相对路径在源目录即 docs 中寻找名为 install.rstinstall.md 的文件,并生成指向该文件的链接。

  3. 引用

    1
    2
    3
    4
    5
    6
    
    Indices and tables
    ==================
    
    * :ref:`genindex`
    * :ref:`modindex`
    * :ref:`search`
    

    这部分内容包含了 3 种格式:

    • 首先是我们上面介绍过的标题。

    • 然后是和 markdown 类似地通过 * 渲染列表项。

    • 最后是 :ref:`label_name` ,生成一条指向 label_name 标签的交叉引用,这时需要通过 .. _label_name: 指令提前创建一个全局唯一的标签,如为一个标题创建标签:

      1
      2
      3
      4
      
      .. _label_name:
      
      Section to cross-reference
      -------------------------- 
      

我们仅简单介绍几种在 index.rst 中出现过的格式,实际上 reStructuredText 能实现的功能远不限于此。

添加 markdown 支持

为 Sphinx 添加 markdown 支持非常简单。

首先安装 MyST 插件:

1
pip install myst-parser

接着修改 conf.py 文件,找到 extensions = [] 所在的一行,向该列表中添加 "myst_parser"

1
extensions = ["myst_parser"]

之后源目录中所有的 .md 文件就会像 .rst 一样被 Sphinx 正常解析。

添加新的文档内容

  1. 向首页添加新的文本段落

    在已有的标题和指令之外直接插入文本即可,如:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    Welcome to sphinx-demo's documentation!
    =======================================
    
    .. toctree::
       :maxdepth: 2
       :caption: Contents:
    
    
    Indices and tables
    ==================
    
    * :ref:`genindex`
    * :ref:`modindex`
    * :ref:`search`
    
    新内容
    ======
    
    这是一个新的段落
    

    生成 HTML 页面如下:

  2. 添加新的 .rst 文件

    docs 目录中创建一个新的 install.rst ,在文件中写入标题,然后在首页文件中添加相应的目录条目或者引用,本例中我们添加到目录树:

    1
    2
    3
    4
    5
    
    .. toctree::   
       :maxdepth: 2   
       :caption: Contents: 
    
       install
    

    添加新文件时需要注意两点:

    • 新文件中需要至少有一个标题,否则不会生成相应的链接,且链接默认会使用该标题作为展示文本。
    • 在引用中添加子目录前缀即可在源目录中通过子目录管理文件。
  3. 添加新的 .md 文件

    我们尝试在 docs 中创建一个子目录 references ,然后在该目录中添加 api_reference.md 文件,接着在 index.rst 文件中添加以下内容引用新添加的文件:

    1
    2
    3
    4
    
    Appendix
    =========
    
    :doc:`references/api_reference`
    

    实质上与添加 .rst 文件完全相同,因此新的文档中也需要包含标题才能生成链接。

最终通过 make html 生成的 HTML 页面效果如下:

生成 API 文档

以上介绍的内容,已经足够为项目编写一般的文本内容文档,最后来看如何通过代码自动生成 API 文档。

以单文件模块作为示例,结果同样适用于多文件的包和模块,假设我们的代码位于根目录下的 main.py 文件(即 main 模块)中,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Human:
    """Foo class"""

    def __init__(self, gender, name):
        """Make a virtual human.

        :param sex: gender of human.
        :param name: name of human.
        """
        self.gender = gender
        self.name = name

    def speak(self, words):
        """speak some words.

        :param words: words to speak.
        :return: None
        """
        print(words)

    def get_intro(self):
        """get man's introduction.

        :return: self introduction string.
        """
        return f'Name: {self.name};Gender: {self.gender}'

启用扩展

Sphinx 通过 autodoc 扩展导入源代码并解析其文档字符串转换成文档。此外还可通过 viewcode 扩展直接从文档访问 API 对应的源代码页面。

首先编辑 conf.py 文件以启用扩展,在 extensions 配置项中加入两个扩展项:

1
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']

为了获取文档字符串,Sphinx 需要能够导入文档所在的代码模块。也就是说,我们的源码模块必须位于 Sphinx 的导入层级结构中,这有许多种实现途径,本例选择在 conf.py 中将代码模块所在的目录(即 conf.py 文件所在的上一级目录)加入到 sys.path 中,在 conf.py 中加入以下代码:

1
2
3
4
import os
import sys

sys.path.insert(0, os.path.abspath('..')

需要注意被 Sphinx 导入时模块中的代码会被执行一次,因此我们应该隔离模块中的执行逻辑以避免产生副作用,如将其包裹在 if __name__ == '__main__' 代码块中。

导入文档

最后,在 references/api_reference.md 文档中通过 automodule 指令导入 main 模块的文档:

1
2
3
4
5
6
# API Reference

​```{eval-rst}
.. automodule:: main    
    :members:
​```

注意:

  • 除了 automodule ,还可使用 autoclassautofunction 等指令为其他类型的 Python 对象自动生成文档。

  • 为了在 markdown 格式中使用 autodoc ,需要使用 eval-rst 指令对导入文档的指令做一层封装,详见 Use sphinx.ext.autodoc in Markdown files,它等价于 .rst 文件的如下写法:

    .. automodule:: main
    	:members:
    

references/api_reference.md 生成的页面效果如下:

通过页面右侧的 [source] 链接还能够直接从文档页面跳转到相应的源代码:

文档字符串风格

上面的示例代码中,我们使用 reStructuredText 风格编写文档字符串,如果你更喜欢 NumPyGoogle 风格的文档字符串,可以启用 napoleon 扩展与之兼容。napoleon 是一个预处理器,在 autodoc 处理文档字符串之前将其转换为正确的 reStructuredText 文本。

启用方法同样是修改 conf.py 文件的 extensions 配置项:

1
2
3
4
# conf.py

# Add napoleon to the extensions list
extensions = ['sphinx.ext.napoleon']

启用后 autodoc 将能够同时支持 reStructuredText、NumPy 及 Google 三种不同风格的文档字符串。

其他途径

除了 Sphinx ,使用 MkDocs 配合 mkdocstrings 扩展包也能实现自动从源码生成文档。相比之下 MkDocs 配置更加简单,原生支持 markdown 且可以运行预览用的开发服务器,目前在 Python 项目中也非常流行。

参考链接

❌
❌