域名解析超时导致卡住整个 Eventlet 协程问题
/ / 阅读数:2262摘要 :Eventlet/Gevent 通过 monkey patch 的方式,让 Python 2 同步代码变成异步协程的方式来运行,但是总有一些意向不到的地方能会 block 住你的整个进程。
问题表现
我们采用 Python 2.7 + Eventlet 异步协程框架来开发微服务,线上微服务偶尔出现连续几次 5s 以上的 HTTP 请求,当出现 5s 超时期间,服务没有任何日志输出。
查证过程
通过tcpdump
持续抓包,发现是在通过SysLogHandler
向log.xxx.com
域名发送 UDP 日志时,调用socket.sendto
时解析域名卡住了。
重现方法
使用iptalbes
将域名服务器的返回全部 drop 掉,模拟丢包:
# iptables -I INPUT 1 -p udp -s 192.168.45.50 --sport 53 -j DROP # iptables -I INPUT 2 -p udp -s 192.168.45.51 --sport 53 -j DROP |
然后执行socket.sendto
方法,不管是否启用了 Eventlet monkey patch,都会卡住 40s 左右(len(nameservers) * attempts * timeout * 2) = 2 * 2 * 5 * 2 = 40
):
import socket import time # import eventlet # eventlet.monkey_patch() start = time.time() try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.sendto('test', ('log.xxx.com', 10516)) finally: print 'elapsed', time.time() - start # elapsed 40.0392360687 # Traceback (most recent call last): # File "test_getaddrinfo.py", line 16, in <module> # sock.sendto('test', ('log.xxx.com', 10516)) # socket.gaierror: [Errno -3] Temporary failure in name resolution |
而测试发现,线上实际情况不会出现持续丢包请求,一般是偶尔丢包,超时一次(5s)后重试一次就可以正常。
使用以下脚本测试线上 DNS 丢包率大概在万分之一(晚上高峰期更高一些)。
import socket import timeit def single_dns_lookup(): start = timeit.default_timer() socket.getaddrinfo('stackoverflow.com', 443) end = timeit.default_timer() return int(end - start) timings = {} for _ in range(0, 50000): time = single_dns_lookup() try: timings[time] += 1 except KeyError: timings[time] = 1 print timings # {0: 49996, 5: 4} |
分析原因
由于 docker 部署原因,暂不支持本地持久化存储日志,所以线上暂时使用了SysLogHandler
将日志通过 UDP 发送到 ELK:
elk: address: - log.xxx.com - 10516 class: logging.handlers.SysLogHandler formatter: elk |
SysLogHandler.emit 每次打印日志时,都会调用socket.sendto
方法,其中address
就是配置文件中的日志接收地址:
if self.unixsocket: try: self.socket.send(msg) except socket.error: self.socket.close() # See issue 17981 self._connect_unixsocket(self.address) self.socket.send(msg) elif self.socktype == socket.SOCK_DGRAM: self.socket.sendto(msg, self.address) else: self.socket.sendall(msg) |
我们再看看 socket.sendto
底层实现:
微服务使用eventlet.monkey_patch
之后,socket.socket()
创建的是eventlet.greenio.base.GreenSocket
实例:
>>> import eventlet, socket >>> eventlet.monkey_patch() >>> socket.socket <class 'eventlet.greenio.base.GreenSocket'> |
class GreenSocket(object): def __init__(self, family=socket.AF_INET, *args, **kwargs): should_set_nonblocking = kwargs.pop('set_nonblocking', True) if isinstance(family, six.integer_types): fd = _original_socket(family, *args, **kwargs) ... self.fd = fd # fd为原始未patch过的socket(_original_socket) def sendto(self, data, *args): return self._send_loop(self.fd.sendto, data, *args) |
可以看到实际调用的是self.fd.sendto
( 原始 socket 的 sendto 方法 ):
/* s.sendto(data, [flags,] sockaddr) method */ static PyObject * sock_sendto(PySocketSockObject *s, PyObject *args) { ... if (!getsockaddrarg(s, addro, SAS2SA(&addrbuf), &addrlen)) { PyBuffer_Release(&pbuf); return NULL; } ... } /* Parse a socket address argument according to the socket object's address family. Return 1 if the address was in the proper format, 0 of not. The address is returned through addr_ret, its length through len_ret. */ static int getsockaddrarg(PySocketSockObject *s, PyObject *args, struct sockaddr *addr_ret, int *len_ret) { ... addr=(struct sockaddr_in*)addr_ret; result = setipaddr(host, (struct sockaddr *)addr, sizeof(*addr), AF_INET); ... } /* Convert a string specifying a host name or one of a few symbolic names to a numeric IP address. This usually calls gethostbyname() to do the work; the names "" and "<broadcast>" are special. Return the length (IPv4 should be 4 bytes), or negative if an error occurred; then an exception is raised. */ static int setipaddr(char *name, struct sockaddr *addr_ret, size_t addr_ret_size, int af) { ... error = getaddrinfo(name, NULL, &hints, &res); ... } |
该方法没有被 Eventlet green patch,且其内部调用的getaddrinfo
系统调用恰恰就是一个 默认情况下可能 block 5s 的 DNS 查询 :
# strace -p XXX connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.2.3")}, 16) = 0 poll([{fd=3, events=POLLOUT}], 1, 0) = 1 ([{fd=3, revents=POLLOUT}]) sendmmsg(3, {{{msg_name(0)=NULL, msg_iov(1)=[{"\244>\1\0\0\1\0\0\0\0\0\0\6google\3com\0\0\1\0\1", 28}], msg_controllen=0, msg_flags=0}, 28}..., MSG_NOSIGNAL) = 2 poll([{fd=3, events=POLLIN}], 1, 5000) = 1 ([{fd=3, revents=POLLIN}]) # timeout=5000ms,如果DNS查询丢包,则会卡住5s ioctl(3, FIONREAD, [44]) = 0 recvfrom(3, "\244>\201\200\0\1\0\1\0\0\0\0\6google\3com\0\0\1\0\1\300\f\0\1"..., 2048, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.2.3")}, [16]) = 44 |
所以当该方法内部出现卡顿时,整个进程也会卡住。但为什么是卡住 5s 呢,我们来看看getaddrinfo
域名解析过程,大概如下:
- 读取
/etc/gai.conf
配置来修改该函数的一些默认行为(我们没有配置这个) 首先根据
/etc/nsswitch.conf
中的hosts
配置加载域名解析顺序$ cat /etc/nsswitch.conf passwd: compat group: compat shadow: compat gshadow: files hosts: files dns networks: files protocols: db files services: db files ethers: db files rpc: db files netgroup: nis
如果
nsswitch.hosts
包含files
则读取/etc/hosts
中的配置$ cat /etc/hosts # Kubernetes-managed hosts file. 127.0.0.1 localhost ::1 localhost ip6-localhost ip6-loopback fe00::0 ip6-localnet fe00::0 ip6-mcastprefix fe00::1 ip6-allnodes fe00::2 ip6-allrouters 10.203.20.15 mdockerstress-621667462-tm68n
如果要查的域名在 hosts 文件中,则直接返回(比如 localhost),否则继续下一种查询方法。
如果
nsswitch.hosts
包含dns
则读取/etc/resolv.conf
中的nameserver
地址$ cat /etc/resolv.conf nameserver 10.204.2.202 nameserver 10.120.227.17
轮流向 nameserver 各发送
attempts
次 DNS 请求,每次请求超时时间默认为 5s(可以如下 修改 DNS 超时时间 ,默认为options timeout:5 attempts:2 rotate
)。options timeout:2 attempts:2 rotate nameserver 10.204.2.202 nameserver 10.120.227.17
注意,对于微服务使用的
eventlet.greendns
,它不会读取resolve.conf
中的超时配置,而是 固定超时时间为 30s 。它会使用 2s 的超时时间,循环轮流重试查询 所有的 nameserver ,直到查询成功或重试时间超过 30s 报异常。
解决办法
有三种办法:
- 使用物理机类似的方式,将日志打印到本地磁盘,然后通过
filebeat
同步到 ELK(绕过 DNS ) - 不使用域名打 UDP 日志,或者配置本机
dnsmq
(避免 DNS 解析丢包) - 修改 Evenlet,对
sendto
的address
以非 block 的方式域名解析( 参考 issuse )
这个问题对基于 Eventlet/Gevent 协程类服务影响较大,当出现丢包时,会导致整个进程卡住一段时间。而对于非协程类进程,可能只会导致偶尔某个请求出现超时 5s 的情况。
另外,对于普通的 DNS 解析(比如常用的socket.getaddrinfo()
,socket.gethostbyname()
调用),Eventlet 已经通过 greendns 来 patch,不会卡住整个进程。
PS. 当出现类似卡住整个 Eventlet 情况,使用 eventlet.debug.hub_blocking_detection 也是无济于事的:(