一次网络延迟高的问题排查

问题背景

客户的机房部署了多套不同架构的 k8s 集群:Intel CPU + redhat OS,海光 CPU + 麒麟 OS。其中 Intel CPU + redhat OS 的 k8s 已经稳定运行了很久。海光 CPU + 麒麟 OS 是后来上线的,主要是为了满足信创的要求。但是在实际使用中发现,海光 CPU + 麒麟 OS 的 k8s 集群中,运行的服务在压测时会偶现服务请求 redis 超时的异常。

问题定位

因为同样的服务,在非信创的机器上可以正常运行,因此排除服务本身的问题。主要排查硬件+操作系统+ k8s CNI 这几个环节。这里之所以要排查 CNI 的问题,主要还是考虑这个 CNI 是专门为这个客户开发的,用来实现 Underlay 的网络,以及一系列的特殊网络需求。

整个网络的链路如图所示:

network1

抓包分析

首先是在 Pod 内用 tcpdump 进行长时间的抓包,保存的本地文件。然后进行压测来复现问题。根据日志找到出问题的时间点,用 wireshark 自带的命令对抓包文件按时间切割。

# 每 10s 保存一个分片。
editcap -i 10 tcpdump.cap pod1111

用 wireshark 打开对应时间片段的抓包文件进行分析。这里因为安全要求不方便放上异常包的截图。大概描述一下问题现象:分析的是 tcp 包,表现为异常时间点 redis 回包存在大量重传,且重传的包几乎都在同一时刻到达 Pod 内。

因此下一步要排查 tcp 包的延迟发生链路上的什么位置。因此选择同时在 redis 虚拟机,物理机网卡 eth0,pod 内网卡抓包。然后用同样的方式进行问题复现。

因为有了多个位置点的抓包数据,根据 tcp 的 seq 号就可以分析同一个数据包从 redis 虚拟机发出来的时间,以及到达物理机 eth0 以及 Pod 内的时间。然后根据时间就可以找到延迟点在哪。

分析后发现延迟在 redis→物理机这条链路上。redis 出来的包因为延迟到达了物理机,因此也延迟到达 Pod 内。redis 所在的虚拟机发包后因为一直没有收到回报,因此会触发 tcp 的重传机制。但是重传的包也出现了延迟。最终原始包和重传包在某一刻同时进入了物理机。

因此怀疑是交换机上出现了延迟,但是如果是交换机的问题,那么接入该交换机的其他机器也一定会出问题才对。但是现象仅局限于信创服务器上。所以也和麒麟的供应商沟通了,他们怀疑是一个已知的 cgroup 问题导致的,出现在 4.19 前的内核里。升级内核后果然问题就解决了。

问题分析

上面说的 cgroup 问题,详细来说是 kubelet 中的 cadvisor 在采集 node 的内存信息时,会读取 /sys/fs/cgroup/memory/memory.numa_stat 信息。但是因为内核的实现会导致这个读取信息的系统调用很慢。慢的原因有两点:

  1. cgroup 是通过 cgroup 伪文件系统来管理的,可以通过删除伪文件系统中的文件目录来删除相应的 cgroup。但是内核中代表 cgroup 的结构体会仍然存在,直到所有对它的引用被释放。只有当被删除的 memory cgroup 中的页都被回收掉,相应的引用都被释放,该 memory cgroup 才会被彻底删除。系统中所有的 memory cgroup 数量可以通过 cat /proc/cgroups 来查看。而内存页的回收时间与内核的回收机制有关,如果当中有一些页一直活跃的被使用,就可能永远不会被回收。
  2. cadvisor 读取 /sys/fs/cgroup/memory/memory.numa_stat 信息时,其实是一个系统调用。这个调用的实现也存在性能问题,它会遍历所有的子 cgroup 层级,累加 memory 的使用信息求和,得到总的 memory 使用情况。

因此,在一台一直运行的服务器上,memory cgroup 可能会达到 1w+。cadvisor 在获取 memory cgroup 时可能耗费 1s 以上的时间。在这段时间内,CPU 没法被调度给其他地方使用。

那为什么会导致网络的延迟呢?linux 网络数据包的接收,在之前的文章 linux 网络数据包接收流程(一)中整理过。数据包到达网卡后,依赖硬中断(3)+软中断(6)来触发 CPU 对数据包进行处理。

network2

并且现在的网卡很多都是多队列的,每条队列和某个 CPU 进行绑定,由该 CPU 进行处理。因此如果这个软中断发生在 cadvisor 统计 memory cgroup,进行系统调用时,软中断的处理就可能因此而延迟。如果这个过程持续 1s+,那么引起的现象可能就是对端 tcp 出现重传。如果这个过程持续 2s+,那么因为服务本身读取 redis 的超时时间设置为 2s,就可能出现超时了。

解决方案

长期解决方案就是升级内核。在更高的版本内核中,对 cgroup memory 的计算进行了优化,这里不再会遍历所有的子 memory cgroup 进行统计了。因为本身 cgroup 就已经维护了该信息,直接读取并返回就行了。内核相关修复:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?h=v6.2-rc7&id=dd8657b6c1cb5e65b13445b4a038736e81cf80ea

短期解决方案是周期执行这条命令:echo 3 > /proc/sys/vm/drop_caches。这会触发内核清理 PageCache, dentries 和 inodes 缓存。这里如果是 echo 1,则只清理 PageCache。echo 2,只清理 dentries 和 inodes 缓存。

calico IPIP 分析

概述

当集群中所有的主机都在同一个二层时,calico cni 可以仅靠路由,使得所有的 Pod 网络互通。但是纯二层的环境在很多场景下都不一定能满足,因此当主机之间仅3层互通时,就可以使用 calico IPIP(全称 IP in IP) 模式。

IP in IP 是一种 IP 隧道协议,其核心技术点就是发送方将一个 IP 数据包封装到另一个 IP 数据包之中发送,接受方收到后,从外层 IP 数据包中解析出内部的 IP 数据包进行处理。常用在 VPN 等技术中,用来打通两个内网环境。

calico IPIP 流量分析

之前的文章proxy_arp在calico中的妙用简单讲了 calico 是如何通过路由打通不同主机上的 Pod 网络的,其实这个方案有一个前提,就是不同的主机之间需要二层互通。当网络环境满足不了时,就可以通过使用路由 + IPIP 的方式来打通网络。

这里可以通过一个简单的实验来验证一下该方案。

# node.sh
ip netns add n1
ip link add veth1 type veth peer name veth2
ip link set veth2 netns n1
ip netns exec n1 ip link set veth2 up
ip netns exec n1 ip route add 169.254.1.1 dev veth2 scope link
ip netns exec n1 ip route add default via 169.254.1.1
ip netns exec n1 ip addr add 172.19.1.10/24 dev veth2
ip link set veth1 up
ip route add 172.19.1.10 dev veth1 # 这个路由必须有
ip netns exec n1 ip route del 172.19.1.0/24 dev veth2 proto kernel scope link src 172.19.1.10
echo 1 > /proc/sys/net/ipv4/conf/veth1/proxy_arp
echo 1 > /proc/sys/net/ipv4/ip_forward

上面的脚本是用来创建一个虚拟的 Pod 的,可以在不同的主机上执行一下,这里要记得修改一下 IP 地址,来保证两个 Pod 的 IP 不同。

之后在宿主机上创建 IP 隧道。也是两台主机都要执行。

ip tunnel add mode ipip
ip link set tunl0 up
ip route add 172.19.1.0/24 via 192.168.105.135 dev tunl0 proto bird onlink

这里在创建 IP 隧道时,并没有指定隧道对端的地址,因为在实际的集群中,1对1的隧道是没使用场景的。而是使用路由告诉这个隧道的对端地址。这时候在 netns n1 内就可以 ping 通对端的 IP 了。

流程图如下

calico ipip

proxy_arp在calico中的妙用

概述

proxy_arp 是网卡的一个配置,在开启后,该网卡会使用自己的 MAC 地址应答非自身 IP 的 ARP Request。常见的用途就是当两台主机的 IP 在同一个网段内,二层却不通,就可以使用额外的一台主机作为 proxy,将这台主机的网卡开启 proxy_arp,来作为中间代理打通网络。如下图所示:

img

开启网卡的 proxy_arp 也很简单:

echo 1 > /proc/sys/net/ipv4/conf/veth1/proxy_arp

calico 是一个使用路由方案打通网络的网络插件,在作为 k8s cni 时,其也使用了 proxy_arp,作为打通路由的一个环节。在了解 calico 如何使用 proxy_arp 之前,我们先看一下 flannel 的 host-gw 是如何使用路由打通 pod 网络的。

flannel host-gw 路由方案

两台二层互通的主机上的 pod,如果要通过路由来互相访问,常见的方式是类似于 flannel 的 host-gw 模式。其流量路径如下:

  1. 每台主机上都有一个 bridge,pod 通过 veth pair 接入到 bridge 上。
  2. pod 将 bridge 的 ip 作为网关。这样 pod 访问其他网段的 IP 时,流量就会到达 bridge 上。
  3. 流量到达 bridge 后,就可以根据宿主机上的路由表转发到对端主机。
  4. 对端主机也会根据路由表,将流量从 bridge 转发到 pod 内。

flannel-host-gw

calico 的路由方案

相比于 flannel host-gw 模式,calico 采用了更巧妙的方法,省掉了 bridge。

其 veth pair 的一端在 Pod 内,设置为 pod 的 IP,另一端在宿主机中,没有设置 IP,也没有接入 bridge,但是设置了 proxy_arp=1。

pod 内有以下的路由表:

default via 169.254.1.1 dev veth2 
169.254.1.1 dev veth2 scope link 

169.254.0.0/16 是一个特殊的 IP 段,只会在主机内出现。不过这里这个 IP 并不重要,只是为了防止冲突才选择了这个特殊值。当 Pod 要访问其他 IP 时,如果该 IP 在同一个网段,那就需要获取该 IP 的 MAC 地址。如果不在一个网段,那么根据路由表,就要获取网关的 IP 地址。所以无论如何,arp 请求都会到达下图中的 veth1。

因为 veth1 设置了 proxy_arp=1,所以就会返回自己的 MAC 地址,然后 Pod 的流量就发到了主机的网络协议栈。到达网络协议栈之后,就和 flannel host-gw 一样,被转发到对端的主机上。

流量到达对端主机后,和 flannel host-gw 不一样的是,主机上直接设置了 pod 的路由:

172.19.2.10 dev veth1 scope link

也就是直接从 veth1 发到 pod 内。

proxy_arp

参考

2.2. Proxy ARP

戳穿 Calico 的谎言

linux 网络数据包接收流程(一)

概述

Linux 作为最流行的服务器操作系统,其提供的网络能力也是经过了各种各样场景的考验。因此如果经常和 linux server 打交道的话,了解 linux 的数据包处理流程也是很有必要的。

网络数据包的接收处理可以分成两个部分,一是从物理网卡进入到达 linux 内核的网络协议栈,二是经网络协议栈处理后交给上层应用或者转发出去。本篇文档主要说明第一部分,并且不会去深入细节点(因为我也不太熟)。

重要概念和数据结构

在说明网络数据包的处理流程之前,有必要提前讲一下一些相关的概念,因为这些概念决定了后面的内容是否能够理解。

硬中断

硬中断是由硬件在发生某些事件后发出的,称为中断请求(IRQ),CPU 会响应硬中断,并执行对应的 IRQ Handler。对于网卡来说,在有网络流量进入后,网卡会通过硬中断通知 CPU 有网络流量进来了,CPU 会调用对应网卡驱动中的处理函数。

硬中断在处理期间,是屏蔽外部中断的,所以硬中断的处理时间要尽可能的短。

软中断

软中断是由软件执行指令发出的,因为硬中断的特点不能处理耗时的任务,所以软中断往往用来替代硬中断来处理耗时任务。

比如网络流量的处理,网卡在发出硬中断通知 CPU 处理后,这次硬中断的处理方法中又会触发软中断,由软中断接着去处理网络流量数据。

网卡驱动

驱动是打通硬件和操作系统的通道,linux 通过网卡驱动,可以支持不同厂商,不同型号,不同特性的网卡。网卡驱动主要负责将从网卡中进来的流量解析并转换成 sk_buff,交给内核协议栈。

DMA

DMA是一种无需CPU的参与就可以让外设和系统内存之间进行双向数据传输的硬件机制。网卡会通过 DMA 直接将网络流量数据存储到一块提前申请好的内存区域中。

NAPI

全称 New API,因为没有更好的名字,所以就直接用 NAPI 了。这是用于支持高速网卡处理网络数据包的一种机制。非 NAPI 往往是只依靠硬中断的方式让 CPU 来处理数据包,NAPI 引入了硬中断+轮询的方式,有效的缓解了硬中断带来的性能问题。

sk_buff

sk_buff 是一个非常大而通用的 struct,可以用来表示2,3,4层的数据包。它被分成两个部分:head 和 data。

head 部分有单独的字段表示不同层的网络头:

  • transport_header:用来表示传输层(4层)的 header,包括 tcp, udp, icmp 等协议头
  • network_header:用来表示网络层(3层)的 header,包括 ip, ipv6, arp 等协议头
  • mac_header:用来表示链路层(2层)的 header。

当数据包进入网络协议栈之前,需要先被转换成 sk_buff。

流程梳理

数据包进入触发硬中断

流量进入到硬件中断

  1. 数据包进入网卡设备

  2. 网卡设备通过 DMA 直接写入的内存中。如果写不下就直接 drop 掉

  3. 网卡产生硬中断

  4. CPU 收到硬中断后,会直接提前注册好的该硬中断的 handler。这个 handler 是写在网卡驱动中的一个方法

  5. IRQ handler 禁用网卡的 IRQ。这是后面处理内存中的数据包是采用的 poll 模式。也就是说 cpu 会自己去内存中轮询数据包,直到一定时间/数量,或者全部处理完之后。这段时间内就不需要网卡通过硬中断来通知 CPU 了,并且硬中断会打断 CPU 的工作,带来一定的性能问题。

  6. 网卡驱动产生软中断。

软中断触发数据包的处理

软中断触发数据包的处理

这里为了方便表述,使用目前最常用的 NAPI 的处理流程进行说明。

  1. 在系统启动时,net_dev_init 方法中注册了 NET_RX_SOFTIRQ 对应的 handler 是 net_rx_action。上面触发软中断的方式是 __raise_softirq_irqoff(NET_RX_SOFTIRQ)。所以开始执行 net_rx_action
  2. net_rx_action 会从 poll_list 链表中获取第一个 poll,使用 napi_poll 轮询内存中的数据包。napi_poll 调用到网卡驱动提供的 poll 方法
  3. poll 方法中从内存中取出数据包
  4. 网卡驱动调用 napi_gro_receive 来处理数据包
  5. napi gro 会合并多个 skb 数据包,比如一个 IP 包会被分成多个 frame 这种。那么如果在接收的时候,在到达协议栈之前直接合并,会有一定的性能提升。这里最终会调用到 gro_normal_list 来批量处理 skb。
  6. 最终调用到 netif_receive_skb_list_internal,从 napi.rx_list 上处理 sk_buff 链表。
  7. 如果开启了 RPS,会根据 skb 的 hash 值找到对应的 cpu,将 skb 存储到该 cpu 上的 backlog 队列。backlog 队列是一种用软件方式将数据包处理负载均衡到多个 cpu 上的一种方法。
  8. 最终都会调用到 __netif_receive_skb_core。
  9. 如果有 AF_PACKET 的 socket,还会拷贝一份给它(tcpdump 的实现原理)。
  10. 最后递交给内核协议栈

参考

Linux协议栈–NAPI机制

Monitoring and Tuning the Linux Networking Stack: Receiving Data

linux kernel 网络协议栈之GRO(Generic receive offload)

kube-proxy iptables 流量处理流程

kube-proxy 在 iptables 模式下,主要是通过使用 iptables 提供从 service 到 pod 的访问。主要作用在两个表上:

  • NAT:访问 service 时,需要 DNAT 到 pod IP 上
  • Filter: 对流量做过滤,比如如果一个 service 没有 endpoints,就直接 REJECT 掉访问 cluster IP 的流量等。

NAT 主要作用在三个关键点:

  • PREROUTING: 在这里为进入 node 流量进行处理,如果是访问 service,则选择一个后端 pod DNAT,并在流量上做标记
  • OUTPUT: 在这里为从本机进程出来的流量进行处理,如果是访问 service,则选择一个后端 pod DNAT,并在流量上做标记。
  • POSTROUTING: 为做了标记的流量做 MASQUERADE,MASQUIERADE 可以理解为加强版的 SNAT,会自动根据出去的网卡选择 src IP。

Filter 主要作用在三个点:

  • INPUT: 发往本机的流量
  • FORWARD: 转发到其他 host 的流量
  • OUTPUT: 从本机进程出去的流量

分析 kube-proxy iptables 时,主要就是从上述的几个点去看,iptables 规则本身比较枯燥,没有太多可说的。下面是整理的 kube-proxy 使用 iptables 的流量处理流程。可以用来作参考。

kube-proxy-iptables

cgroup cpu子系统

概述

cgroup 全名是 control groups,在 linux 上负责对进程的一系列资源进行管控。比如 CPU,Memory,Huge Pages 等。cgroup 下通过子系统(subsystem)来划分模块,每种资源都通过一个子系统来实现。

cgroup 通过文件系统的方式对外提供调用,并可以用层级的方式进行组合。这种层级通过文件系统目录的方式进行呈现。比如在 cgroup cpu 目录下创建子目录,就相当于在根 cpu cgroup 下创建了一个子 cgroup。并且子 cgroup 会继承父 cgroup 的限制。

cgroup 目前有两个版本:v1 和 v2,并且两个版本的设计差异较大。但是理念类似,因此即使版本不同,也可以一样来理解。下面会以 cgroup v1 cpu 子系统进行讲解。

cpu 子系统的使用

cgroup 描述起来一直是一个比较抽象的概念。下面用一个简单的例子来帮助认识 cgroup 是如何工作的。

首先在机器上启动一个 stress 进程,分配一个 cpu,然后查看该进程 cpu 占用情况:

$ stress -c 1

$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/01/2021  _x86_64_    (8 CPU)
02:36:56 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:36:57 PM  1001    480164  100.00    0.00    0.00  100.00     6  stress

可以看到,stress 进程已经占用了 1 cpu。现在我们创建一个名叫 stress 的 cgroup 来限制 cpu:

$ cd /sys/fs/cgroup/cpu

$ mkdir stress && cd stress

# 将 pid 写入到 cgroup.procs 中,就等同于将这个进程移到该 cgroup 中
$ echo 480164 > cgroup.procs

$ echo 100000 > cpu.cfs_period_us

$ echo 50000 > cpu.cfs_quota_us

# 再看看当前的 CPU 占用
$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/04/2021  _x86_64_    (8 CPU)

05:17:49 AM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
05:17:50 AM  1001   480164   50.00    0.00    0.00   50.00     6  stress

上述操作通过配置 cpu.cfs_period_uscpu.cfs_quota_us 参数达到了限制进程使用 CPU 的目的。

cgroup 还提供了一个 cpu.shares 参数,当 CPU 资源繁忙时,这个参数可以配置进程使用 CPU 的权重。下面我们在 cpu 为 1 的虚拟机演示。 在 cgroup 下创建两个子 cgroup 来展示这个参数的效果。

$ cd /sys/fs/cgroup/cpu,cpuacct
$ mkdir stress1 && cd stress1
$ stress -c 1
$ echo 3475127 > cgroup.procs
$ echo 1024 > cpu.shares

此时 PID 3475127 的 stress 进程 CPU 占用率接近 100%。在新的终端中执行以下命令:

$ mkdir stress2 && cd stress2
$ stress -c 1
$ echo 3479833 > cgroup.procs

此时两个 stress 进程的 CPU 占用大致相等,接近 50%。因为 stress2 cgroup 中没有设置 cpu.shares,所以取默认值为 1024。现在设置 stress2 cgroup 的 cpu.shares 参数:

$ echo 512 > cpu.shares

# 使用 top 查看
    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM
  3475127 root      20   0    7948     96      0 R  65.1   0.0
  3479833 root      20   0    7948     92      0 R  32.2   0.0

stress1 中的进程 CPU 占用率大概是 stress2 中的两倍。这是因为 stress1 中 cpu.shares 的值是 stress2 中的两倍。当然上述情况必须在 CPU 资源不够时,cpu.shares 才会起作用。如果这是一个 2 cpu 的虚拟机,那么 stress1 和 stress2 都会占用 100%。

参数说明

上述出现了一些 cpu 的参数,这里统一解释一下:

  • cpu.cfs_period_us: 重新分配 CPU 资源的时间周期长度,单位是 us。cfs 是 linux 进程调度器的一种,全称为完全公平调度器。因此这个参数只针对使用 cfs 调度的进程。

  • cpu.cfs_quota_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。结合 cpu.cfs_period_us 就可以限制一个进程可以使用的总 CPU 时间了。计算方式为 (cpu.cfs_quota_us / cpu.cfs_period_us)*count(cpu)。这个参数只针对使用 cfs 调度的进程。

  • cpu.shares: 这个参数只有在 CPU 资源忙时才生效,它可以用来设置进程使用的 CPU 权重。上面的例子中,虚拟机只有 1 CPU,进程 1,2 都会占用一个 CPU,因此根据设置进程 1 的 cpu.shares 为 1024,进程 2 的 cpu.shares 为 512,就可以将 2/3 的 cpu 分配给进程 1,1/3 的 cpu 分配给进程 2 了。

除了上述例子中的几个参数,cgroup cpu 子系统还提供了以下的参数:

  • cpu.rt_period_us: 重新分配 CPU 资源的时间周期长度。 针对使用了实时调度器的进程
  • cpu.rt_runtime_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。这个和上面说的 cfs 的两个参数类似。
  • cpu.nr_periods: 这是一个统计参数。用来表示已经过去的 cpu 周期数(使用 cpu.cfs_period_us 来指定)
  • cpu.nr_throttled: cgroup 中进程被限制的次数(因为这些进程用完了分配的 cpu 时间)。
  • cpu.throttled_time: cgroup 中进程被限制的总时间(单位是 ns)。

参考

Linux进程调度:完全公平调度器CFS

redhat cfs cpu

容器中程序的信号捕捉

一、问题描述

项目中使用了 argo 在 kubernetes 集群中做工作流的调度。argo 提供了工作流的停止功能,其原理大致是检查正在运行的 Pod,向该 Pod 中的 wait 容器发送 USR2 信号,wait 容器收到 USR2 信号后,在主机上的调用 docker kill --signal TERM main_container_id 来停止我们的程序容器, 如果 10s 后容器还未停止,则发送 SIGKILL 来强制终止。但是我在实现 argo 工作流中调度 tfjob 时出现了一些问题。

argo_scheduler_tfjob

在argo停止工作流时,正在运行的 step2 中的 manager 监听了 TERM 信号,以便在工作流停止时同步停止 tfjob。但是事实情况却是 manager 退出了,但是没有收到任何的 TERM 信号。

二、问题剖析

检查这个问题的第一步是弄清楚 docker kill 背后发生了什么,官网的资料中有以下的描述:

Note: ENTRYPOINT and CMD in the shell form run as a subcommand of /bin/sh -c, which does not pass signals. This means that the executable is not the container’s PID 1 and does not receive Unix signals.

当我们用 sh 执行一段 shell script 时,在 shell script 中的可执行文件的 PID 不是1,并且 sh 也不会帮忙转发 TERM 信号,导致我们的可执行文件无法接收到终止信号,并执行清理逻辑。

我们的 manager 确实是用了一段 shell script 来启动的,可能就是因为这个原因导致无法收到 TERM 信号。

三、问题复现

我写了一段很简单的 go 程序,监听了 TERM 信号,然后打印一段文字。

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)

    s, ok := <-sigs
    if !ok {
        log.Println("信号接收出错")
        os.Exit(1)
    }

    log.Println("收到信号:", s.String())
}

我的 Dockerfile 如下:

FROM alpine:latest
LABEL maintainr="jiangpengfei <jiangpengfei12@gmail.com>"

COPY main /usr/bin/main
COPY run.sh /usr/bin/run.sh
RUN chmod +x /usr/bin/main && chmod +x /usr/bin/run.sh

CMD ["sh", "-c", "/usr/bin/run.sh"]

run.sh 如下:

#!/bin/sh
/usr/bin/main

执行这个容器后,查看容器内的进程:

PID   USER     TIME  COMMAND
    1 root      0:00 {busybox} ash /usr/bin/run.sh
    6 root      0:00 /usr/bin/main
   12 root      0:00 sh
   17 root      0:00 ps

可以发现,run.sh 是 PID 为1, main 程序是6。此时我们使用 docker kill --signal TERM main_container_id 来停止容器,发现确实是没有反应的。因为 TERM 信号会发送给 PID 为 1 的进程。同时也因为 sh 不响应 TERM 信号,也不会转发该信号给子进程,所以容器也不会退出。如果我们使用 docker stop 退出的话,会发现很慢,这是因为 docker stop 会尝试先用 TERM 信号来终止进程,一段时间后发现没有退出的话再使用 KILL 信号。

四、解决方案

这个问题的解决方案有很多,要么让我们的程序进程成为 PID 1,要么让 PID 为 1 的进程转发这个 TERM 信号给我们的子进程。

方法一: 在 shell script 中使用 exec

将我们的 run.sh 改成如下:

#!/bin/sh
exec /usr/bin/main

然后再查看容器内的进程列表:

PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/main
   11 root      0:00 sh
   16 root      0:00 ps

可以发现,main 进程的PID 是 1, 我们使用 docker kill --signal TERM main_container_id 来杀死进程,出现如下打印语句:

2020/01/17 23:46:24 收到信号: terminated

可见,exec 可以让我们的 main 进程成为 PID 为 1, 关于 exec 的作用描述如下:

The exec() family of functions replaces the current process image with a new process image.

即使用新进程的镜像替换当前进程的镜像数据,可以理解为exec系统调用并没有创建新的进程,只是替换了原来进程上下文的内容。原进程的代码段,数据段,堆栈段被新的进程所代替。这样我们的 main 进程就顺利成章的替换了 sh 进程成为 PID 为 1 的进程了。

方法二: 直接使用 main 作为镜像入口

这是最简单的方法了,但是很多时候会有限制,因为我们希望在 shell script 中写一些逻辑来调用程序。

方法三: 借助第三方程序

一些第三方的程序专门提供了这样的作用,以它们作为启动的入口,这些第三方程序会 watch 所有它产生的子进程,在这些子进程退出后自动退出,并且在其收到 TERM 信号后发送给子进程。

这里我们用 smell-baron 这个应用作为例子

修改 Dockerfile:

FROM alpine:latest
LABEL maintainr="jiangpengfei <jiangpengfei12@gmail.com>"

COPY main /usr/bin/main
COPY run.sh /usr/bin/run.sh
RUN chmod +x /usr/bin/main && chmod +x /usr/bin/run.sh
RUN wget -O /usr/bin/smell-baron https://github.com/insidewhy/smell-baron/releases/download/v0.4.2/smell-baron.musl && chmod +x /usr/bin/smell-baron

CMD ["/usr/bin/smell-baron", "/usr/bin/run.sh"]

查看容器内的进程:

PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/smell-baron /usr/bin/run.sh
    6 root      0:00 /usr/bin/main
   14 root      0:00 sh
   19 root      0:00 ps

使用 docker kill 发现 main 收到了 TERM 信号。

1.Multiple commands can be run, smell-baron will exit when all the watched processes have exited.

2.Whether a spawned process is watched can be configured.

3.smell-baron can be told to signal all child processes on termination, this allows it to cleanly deal with processes that spawn a subprocess in a different process group then fail to clean it up on exit.

linux ip 命令的使用

简介

linux 下的 ip 命令是一个很强大的工具,在这之前,我通常只会使用 ifconfig 命令来查看本机网络接口和 ip 地址等等。或者 netstat 命令查看端口占用等等。ip 命令属于 iproute2 套件中的一个命令,关于 iproute2 和 linux net-tools 中的命令对比如下(图片来源:https://linux.cn/article-3144-1.html):

net-tools vs iproute2

可以看出,除了部分 netstat 命令用 ss 来替代,其它都可以用 ip 命令替代。并且,iproute2 已经是大多数 linux 发行版默认安装了,而 net-tools 则需要另外安装。

ip 命令可以分为下面几个模块:

  • 网卡设备相关: ip link
  • 网卡地址相关: ip addr
  • 路由表相关: ip route
  • arp 相关: ip neigh

下面会列出一些常用的操作,最好在虚拟机中操作,防止影响个人机器。

ip link

查看 ip link 的帮助

$ ip link help

查看网络接口

$ ip link list

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 52:54:00:8a:fe:e6 brd ff:ff:ff:ff:ff:ff
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 08:00:27:15:ee:5c brd ff:ff:ff:ff:ff:ff

这里显示了三个网络接口,lo代表的本机的回环网卡,eth0eth1 分别是两个网卡

添加网络接口

$ sudo ip link add link eth0 mydev type bridge

这里添加了一个网桥,连接在 eth0 上。使用 ip link list 查看可以发现多了下面一个设备

6: mydev: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 5e:0c:36:7b:ce:0d brd ff:ff:ff:ff:ff:ff

删除网络接口

$ sudo ip link delete link dev mydev

关闭网络接口

$ sudo ip link set eth1 down

打开网络接口

$ sudo ip link set eht1 up

ip addr

查看帮助

$ ip addr help

查看网络地址

$ ip addr list

查看某一个网络接口的地址

$ ip addr show eth1

添加 ip 地址

$ sudo ip addr add 192.168.31.131/24 dev eth1

查看 eth1 的地址

$ ip addr show eth1

3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:15:ee:5c brd ff:ff:ff:ff:ff:ff
    inet 192.168.31.77/24 brd 192.168.31.255 scope global noprefixroute dynamic eth1
       valid_lft 42769sec preferred_lft 42769sec
    inet 192.168.31.131/24 scope global secondary eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::a00:27ff:fe15:ee5c/64 scope link 
       valid_lft forever preferred_lft forever

我们也可以 ping 一下这个地址:

$ ping 192.168.31.131

PING 192.168.31.131 (192.168.31.131) 56(84) bytes of data.
64 bytes from 192.168.31.131: icmp_seq=1 ttl=64 time=0.109 ms
64 bytes from 192.168.31.131: icmp_seq=2 ttl=64 time=0.155 ms

删除 ip 地址

$ sudo ip addr del 192.168.31.131/24 dev eth1

改变设备地址的配置

这里有一篇很好的文章: understanding ip addr change and ip addr replace commands

为了演示的方便,我添加了一个网卡设备

$ sudo ip link add link eth0 name dummy0 type dummy

为它分配地址:

$ sudo ip addr add 192.168.31.132/24 dummy0
$ ip addr show dummy0

5: dummy0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 9e:dc:6e:0b:70:99 brd ff:ff:ff:ff:ff:ff
    inet 192.168.31.132/24 scope global dummy0
       valid_lft forever preferred_lft forever

如果你想要修改 valid_lftpreferred_lft 配置,可以使用 ip change命令:

$ sudo ip addr change 192.168.31.132 dev dummy0 preferred_lft 300 valid_lft 300
$ ip addr show dummpy0

5: dummy0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 9e:dc:6e:0b:70:99 brd ff:ff:ff:ff:ff:ff
    inet 192.168.31.132/24 scope global dynamic dummy0
       valid_lft 299sec preferred_lft 299sec

ip route

查看帮助

$ ip route help

查看路由

$ ip route list

添加路由

添加一条普通的路由

$ sudo ip route add 39.156.0.0/16 via 192.168.31.133 dev dummy0

添加默认路由

$ sudo ip route add default via 192.168.31.133 dev dummy0

删除路由

删除默认路由

$ sudo ip route del default via 192.168.31.133 dev dummy0

删除普通路由

$ sudo ip route del 39.156.0.0/16 via 192.168.31.133 dev dummy0 

**查看一个 ip 地址的路由包来源

$ ip route get 39.156.69.79

39.156.69.79 via 10.0.2.2 dev eth0 src 10.0.2.15 
    cache 

ip neigh

查看帮助

$ ip neigh help

查看同一个网络的邻居设备

$ ip neigh show

192.168.31.1 dev eth1 lladdr 34:ce:00:2e:88:b9 STALE
10.0.2.2 dev eth0 lladdr 52:54:00:12:35:02 REACHABLE
10.0.2.3 dev eth0 lladdr 52:54:00:12:35:03 STALE

用c写php扩展的笔记

编写php扩展的步骤:

1.使用php-src中ext文件夹中的ext_skel生成项目框架
2.编辑config.m4,将其中三句话前面的dnl删除,改成下面这样。

PHP_ARG_WITH(md2pic, for md2pic support,
Make sure that the comment is aligned:
[  --with-md2pic             Include md2pic support])

3.执行phpize
4.执行./configure
5.使用make编译
6.使用make install安装扩展
7.将扩展加入php.ini中
8.使用php -m检查扩展是否正常加载

关于config.m4

config.m4相当于一个构建系统,在php扩展的开发中,我的理解就是它可以用来配置lib,include,flags等编译时的属性以及其他的一些功能。这里给出一个配置了其他的lib和include信息的config.m4文件

dnl Id
dnl config.m4 for extension md2pic

dnl Comments in this file start with the string 'dnl'.
dnl Remove where necessary. This file will not work
dnl without editing.

dnl If your extension references something external, use with:

PHP_ARG_WITH(md2pic, for md2pic support,
Make sure that the comment is aligned:
[  --with-md2pic             Include md2pic support])

dnl Otherwise use enable:

dnl PHP_ARG_ENABLE(md2pic, whether to enable md2pic support,
dnl Make sure that the comment is aligned:
dnl [  --enable-md2pic           Enable md2pic support])

if test "PHP_MD2PIC" != "no"; then
  dnl Write more examples of tests here...

  dnl # --with-md2pic -> check with-path
  dnl SEARCH_PATH="/usr/local /usr"     # you might want to change this
  dnl SEARCH_FOR="/include/md2pic.h"  # you most likely want to change this
  dnl if test -rPHP_MD2PIC/SEARCH_FOR; then # path given as parameter
  dnl   MD2PIC_DIR=PHP_MD2PIC
  dnl else # search default path list
  dnl   AC_MSG_CHECKING([for md2pic files in default path])
  dnl   for i in SEARCH_PATH ; do
  dnl     if test -ri/SEARCH_FOR; then
  dnl       MD2PIC_DIR=i
  dnl       AC_MSG_RESULT(found in i)
  dnl     fi
  dnl   done
  dnl fi
  dnl
  dnl if test -z "MD2PIC_DIR"; then
  dnl   AC_MSG_RESULT([not found])
  dnl   AC_MSG_ERROR([Please reinstall the md2pic distribution])
  dnl fi

  dnl # --with-md2pic -> add include path

  PHP_ADD_INCLUDE(src/libMultiMarkdown/include)

  LIBNAME=gd # you may want to change this
  LIBSYMBOL=gdImageCreate # you most likely want to change this 

  PHP_CHECK_LIBRARY(LIBNAME,LIBSYMBOL,
  [
    PHP_ADD_LIBRARY_WITH_PATH(gd,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(curl,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(png,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(z,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(jpeg,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(freetype,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(m,"/usr/lib", MD2PIC_SHARED_LIBADD)
    AC_DEFINE(HAVE_MD2PICLIB,1,[ ])
  ],[
    AC_MSG_ERROR([wrong md2pic lib version or lib not found])
  ],[

  ])


  dnl
  PHP_SUBST(MD2PIC_SHARED_LIBADD)
  PHP_NEW_EXTENSION(md2pic, [md2pic.c \
  src/libMultiMarkdown/aho-corasick.c \
  src/libMultiMarkdown/beamer.c \
  src/libMultiMarkdown/char.c \
  src/libMultiMarkdown/critic_markup.c \
  src/libMultiMarkdown/d_string.c \
  src/libMultiMarkdown/epub.c \
  src/libMultiMarkdown/file.c \
  src/libMultiMarkdown/html.c \
  src/libMultiMarkdown/latex.c \
  src/libMultiMarkdown/lexer.c \
  src/libMultiMarkdown/memoir.c \
  src/libMultiMarkdown/miniz.c \
  src/libMultiMarkdown/mmd.c \
  src/libMultiMarkdown/object_pool.c \
  src/libMultiMarkdown/opendocument-content.c \
  src/libMultiMarkdown/opendocument.c \
  src/libMultiMarkdown/scanners.c \
  src/libMultiMarkdown/stack.c \
  src/libMultiMarkdown/textbundle.c \
  src/libMultiMarkdown/token_pairs.c \
  src/libMultiMarkdown/token.c \
  src/libMultiMarkdown/transclude.c \
  src/libMultiMarkdown/rng.c \
  src/libMultiMarkdown/uuid.c \
  src/libMultiMarkdown/writer.c \
  src/libMultiMarkdown/zip.c \
  src/libMultiMarkdown/parser.c \
  src/libMultiMarkdown/pic.c], $ext_shared,, [-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1 ] )
fi

编写php扩展的资料

我这里主要参考的是 php内核剖析这本书。

php的扩展其实也可以用c++开发。这里有一个很好的项目php-x,并且开发扩展也要容易很多。

ubuntu服务器部署ipv6访问

整个部署过程分为:
1.启用服务器的ipv6支持
2.申请ipv6通道
3.开启nginx的ipv6地址监听

1.ubuntu上开启ipv6支持,需要修改几个地方

1.开启ipv6支持,/etc/sysctl.conf
确保有以下设置:

net.ipv6.conf.all.disable_ipv6 = 0
net.ipv6.conf.default.disable_ipv6 = 0
net.ipv6.conf.lo.disable_ipv6 = 0

设置完毕后使用sysctl -p使设置生效

2.设置服务器的ipv6 dns服务器,/etc/network/interfaces
添加以下设置:

dns-nameserver 2001:4860:4860::8888
dns-nameserver 2001:4860:4860::8844

设置完毕后使用sudo resolvconf -u使设置生效

这样能够保证服务器在连接ipv6地址时有合适的dns服务器

测试:ping6 ipv6.google.com
如果能够ping通则表示已经设置成功

2.申请ipv6通道

因为我的服务器是没有分配ipv6地址的,所以需要去http://tunnelbroker.net申请ipv6的通道。
1.注册账号
2.邮箱激活
3.登录
4.create regular tunnel
5.输入你的服务器的ipv4地址,创建通道
6.在example configurations处选择操作系统,获取配置
7.在 /etc/network/interfaces中拷贝6中的配置
8.sudo resolvconf -u使配置生效
9.通过ifconfig查看是否设置成功,如果出现了ipv6的地址则表示成功

3.开启nginx的ipv6地址监听

在所有的站点配置文件中加上:

listen [::]:80
listen [::]:443

分别监听80端口和443端口(如果有https)
重启nginx即可。
通过http://ipv6-test.com/validate.php测试域名能否在ipv6下访问成功。如果是阿里云,那么有可能在IPv6 DNS server这一项上失败。因为阿里云的DNS服务器没有IPv6 DNS server。这种情况下,如果用户只有ipv6的环境,则会因为无法访问DNS服务器而失败