惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
爱范儿
爱范儿
罗磊的独立博客
V
V2EX
量子位
Last Week in AI
Last Week in AI
Hugging Face - Blog
Hugging Face - Blog
博客园 - 司徒正美
Jina AI
Jina AI
博客园 - 叶小钗
小众软件
小众软件
博客园 - 【当耐特】
Y
Y Combinator Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Tailwind CSS Blog
博客园 - 聂微东
Microsoft Security Blog
Microsoft Security Blog
美团技术团队
P
Proofpoint News Feed
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
有赞技术团队
有赞技术团队
MongoDB | Blog
MongoDB | Blog
Recent Announcements
Recent Announcements
酷 壳 – CoolShell
酷 壳 – CoolShell

卡瓦邦噶!

服务器高性能网络调优 | 卡瓦邦噶! 为何写作 | 卡瓦邦噶! 读《金阁寺》 | 卡瓦邦噶! 雨季又来 | 卡瓦邦噶! MTU Probe 引起的初始延迟 | 卡瓦邦噶! 3.5 秒的固定延迟问题 | 卡瓦邦噶! 学习网络的一点经验 | 卡瓦邦噶! ARP 问题诊断 | 卡瓦邦噶! 网络断断续续…… | 卡瓦邦噶! Piccolo P2P 镜像分发 | 卡瓦邦噶! 一起看电影 | 卡瓦邦噶! 《征服C指针》 | 卡瓦邦噶! 我的姥姥 | 卡瓦邦噶! Python的哲学 Python 3.5的新特性 学校不教的计算机课 垃圾回收(GC)的三种基本方式 在编程中体验纯粹的快乐 从《美丽新世界》谈自由 在快钱实习 迷人的嗓音和迷人的故事——《Sleepyhead》 Python 的十个自然语言处理工具 记一个愚蠢的bug 一年炉石传说的游戏体验 《以撒的结合:重生》网页版图鉴 分清 C++的指针、引用和数组 笑话三则 自由比皇帝更伟大——《悲惨世界》笔记 Git 10 周年访谈:Linus 讲述背后故事 用 0x3f3f3f3f 设定最大int值的优点
Debug 一个在 uWSGI 下使用 subprocess 卡住的问题
laixintao · 2021-10-22 · via 卡瓦邦噶!

今天花了很长的时间在排查一个诡异的问题,值得记录一下。

本来是想写一个 HTTP 的服务,你告诉这个 HTTP 服务一个 IP 地址和端口,这个 HTTP 服务就可以返回通过 TCP 访问这个 IP 端口的延迟。因为我们每次做 chaos 注入的时候都要测试一下注入延迟成功了没有,有了这个服务,这个测试就可以自动化。

其实很简单,收到请求之后用 TCP ping 几次拿到延迟,然后返回就可以了。

之前测量 TCP 的延迟使用的都是 hping3,Redis 的作者 antirez 写的。然后就想到用这个工具来做测试好了。

搜索了一下发现没有 Python 的 binding,所以打算粗暴一些,直接在 HTTP 服务里面 fork 出来一个进程做测试,然后去处理 stdout,grep 出来延迟的数据。很快就写好了,代码大体如下:

Python

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

def hping3(request):

    """

    test latency using hping3

    params in json body:

        dest_ip: str

        dest_port: int

        count: int

    :return: list of floats, each one is a latency, if total count less than ``count``,

        it means some ping get timeout.

    """

    data = request.body

    logger.info(

        "received hping3 request, headers: {}, body: {}".format(request.headers, data)

    )

    data = json.loads(data)

    dest_ip = data.get("dest_ip")

    dest_port = data.get("dest_port")

    count = data.get("count")

    command = "/usr/sbin/hping3 {} -p {} -c {} --syn".format(dest_ip, dest_port, count)

    logger.info("hping3 run command: {}".format(command))

    complete_process = subprocess.run(

        command,

        capture_output=True,

        shell=True,

    )

    returncode = complete_process.returncode

    stdout = complete_process.stdout.decode()

    stderr = complete_process.stderr

    logger.info(

        "result returncode={}, stderr={}, stdout={}".format(returncode, stderr, stdout)

    )

    latency = []

    for line in stdout.split("\n"):

        matched = match_hping_ms.search(line)

        if matched:

            latency.append(float(matched.group(1)))

    return JsonResponse({"latency_ms": latency, "hping3_returncode": returncode})

很简单的一段代码,收到请求,调用命令,返回结果。框架使用的是 Django,在本地测试一切正常,然后发布到 staging, 噩梦开始了……

在 staging 环境中,测试的时候发现,HTTP 请求发过去永远收不到回应,最后会得到一个 504 Gateway Timeout 的结果。去容器(应用运行在一个容器里面)看,发现 hping3 进程一直没有结束,像是卡住了。

一开始有很多错误的怀疑,比如怀疑 hping3 需要 TTY 才能执行,以为 hping3 需要使用绝对路径等…… 但是想想同样的代码在本地可以运行正常,就应该不是这些原因。一个验证就是,我去应用运行的环境中开一个 Python 的 REPL 执行这段代码,是能正常得到结果的。在应用运行的环境直接运行 hping3 命令,也是没有问题的。

然后怀疑 hping3 没有足够的权限来运行,因为 hping3 发送的是 raw TCP/IP 包,需要 CAP_NET_RAW 才能执行。去容器里面检查了一下,发现这个 capability 也是有的。

到这里,其实已经花费了很多时间了,得到的事实有:

  1. 容器里面执行 hping3 是完全没有问题的,权限是足够的
  2. 直接使用 Python3 的 REPL 执行这段代码也是没有问题,代码逻辑是对的

到这里你能猜到问题出在哪里了吗?

其实还有一个运行环境没有提到,就是 uWSGI. 这个 Python 写的服务是作为 WSGI 应用跑在 uWSGI 里面的。不知道和 uWSGI 有没有关系(直觉告诉我是有的,比直觉更厉害的同事也告诉是有关系的)。于是我打算直接使用 python manage.py runserver 在容器里面跑起来试试……

一切正常了。

所以 python 直接跑应用没问题,用 uWSGI 运行就有问题。现在问题锁定在 uWSGI 上面了。为了复现这个问题,我写了一个最小的测试用例。

首先需要一个文件,叫做 pingapp.py

pingapp.py

Python

1

2

3

4

5

6

7

8

vagrant@vagrant:~$ cat pingapp.py

import subprocess

def application(env, start_response):

    start_response('200 OK', [('Content-Type','text/html')])

    p = subprocess.run("hping3 --syn 104.244.42.1 -p 80 -c 3", shell=True, capture_output=True)

    print("return code={}".format(p.returncode))

    return [p.stdout]

然后使用 uWSGI 运行这个程序:uwsgi --http-socket :9090 --wsgi-file pingapp.py --threads=4.

uWSGI 的版本是 2.0.20,Python 的版本是 3.7.5, 但其实这不重要。

最后,访问这个程序,即 curl localhost:9090.

如果是 uWSGI 的问题的话,我们期望这里已经可以复现问题了,即 curl 命令会卡在这里,然后进程( ps -ef )里面出现一个 hping3 的进程,结束不了。

如同……下面这样:

图1 – 卡住的 hping3

但现实是……这个程序一点问题没有,运行地丝般顺滑。

这就见鬼了,直接没了思路。我的应用和这个最小的复现代码根本没什么(太大的)区别啊!我又没有用一些奇奇怪怪的 lib。

后面我实在解决不了了,找了(大佬)同事帮忙,花了很多时间,找到以下事实:

  1. hping3 卡住了,发现 SIGTERM 结束不了它,只能 kill -9
  2. 然后发现 uwsgi 进程也有一样的行为了,只能 kill -9 去杀它
  3. hping3 程序被 uwsgi 正常起起来是没问题的,起来之后运行不了
  4. …… 以及中间奇奇怪怪的现象,就不细说了,其实都不重要

最后虽然也找到了根因,但是走得路太弯了。本文从这里开始,就以事后诸葛亮的视角,看看有了上面的信息,我们怎么从正确的思路一步一步找到问题。

首先,同样的环境,在 shell 里面可以正常执行 hping3 但是 uWSGI 里面却不可以,既然 uWSGI 能正常开 hping3 进程,我们就可以看看这个进程到底卡在了哪里?

通过 strace 可以发现它一直在 poll 4 这个 fd,然后查看这个 fd,发现它是一个正常的 socket,应该就是 ping tcp 端口使用的那个 socket.

然后,我们应该去看一下,正常的 hping3 的 trace 是什么样子的。

strace hping3 104.244.42.1 -p 80 --syn -c 1

可以看到 poll 附近很神奇,下面突然就开始 write 到 stdout 内容了。不知道怎么结束的。

但是往上看,有一段代码获取了当前的 pid,然后给自己发送了 SIGALRM。再往前,发现注册了 SIGALRM 的 handler.

SIGALRM 是什么呢?

SIGALRM is an asynchronous signal. The SIGALRM signal is raised when a time interval specified in a call to the alarm or alarmd function expires.

看起来是用来做异步的。

搜索了一下 hping3代码。发现代码里确实是用这个信号的。

C

1

2

3

4

5

6

7

/* use SIGALRM to send packets like ping do */

Signal(SIGALRM, send_packet);

/* binding */

if (ctrlzbind != BIND_NONE) Signal(SIGTSTP, inc_destparm);

Signal(SIGINT, print_statistics);

Signal(SIGTERM, print_statistics);

那么我们的 uWSGI 下的异常 hping3 是否是因为没有收到这个 SIGALRM 而一直在傻 poll 呢?

在上面的 图1 中,卡住的 hping3 pid 是 4285,我们看下这个进程能否处理信号:

1

2

3

4

5

6

root@vagrant:/home/vagrant# cat /proc/4285/status | grep Sig

SigQ:   5/3571

SigPnd: 0000000000000000

SigBlk: fffffffe7ffbfeff

SigIgn: 0000000000000000

SigCgt: 0000000180086002

这里可以发现 SigBlk 不是全 0 的,说明有 signal 被 block 了。SigBlk 是个 64 个 bit 组成的 bitmask,每一位代表一个 signal 是否被 block,1 是 block,0 是没有 block。从右到左分别表示 1-64 号信号。

举例:

1

2

3

4

5

6

7

8

9

10

11

00000000280b2603 ==> 101000000010110010011000000011

                     | |       | ||  |  ||       |`->  1 = SIGHUP

                     | |       | ||  |  ||       `-->  2 = SIGINT

                     | |       | ||  |  |`----------> 10 = SIGUSR1

                     | |       | ||  |  `-----------> 11 = SIGSEGV

                     | |       | ||  `--------------> 14 = SIGALRM

                     | |       | |`-----------------> 17 = SIGCHLD

                     | |       | `------------------> 18 = SIGCONT

                     | |       `--------------------> 20 = SIGTSTP

                     | `----------------------------> 28 = SIGWINCH

                     `------------------------------> 30 = SIGPWR

可以使用下面这段脚本去 parse 到底哪些信号被 block 了:

Shell

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

sigparse () {

    i=0

    # bits="$(printf "16i 2o %X p" "0x$1" | dc)" # variant for busybox

    bits="$(printf "ibase=16; obase=2; %X\n" "0x$1" | bc)"

    while [ -n "$bits" ] ; do

        i="$(expr "$i" + 1)"

        case "$bits" in

            *1) printf " %s(%s)" "$(kill -l "$i")" "$i" ;;

        esac

        bits="${bits%?}"

    done

}

grep "^Sig...:" "/proc/4285/status" | while read a b ; do

        printf "%s%s\n" "$a" "$(sigparse "$b")"

    done # | fmt -t  # uncomment for pretty-printing

可以看到,hping3 需要的 14 号信号正好被 block 了。

manual 中得知:

A child created via fork(2) inherits a copy of its parent’s signal mask; the signal mask is preserved across execve(2).

所以说,我们正常的 hping3 可以收到 signal,但是 uWSGI 里面 fork 出来的进程就不可以,可能是 hping3 从 uWSGI 里面继承了 signal mask,导致它也去 block 14 这个 signal 了。

我们可以去看下 uWSGI 里面是否也是 block 了这个 signal 的:

果然是的。

代码中发现,里面只有 core_id = 0 的 thread 是处理信号的,其余的 thread block 了所有的信号。

C

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

void uwsgi_setup_thread_req(long core_id, struct wsgi_request *wsgi_req) {

int i;

sigset_t smask;

pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, &i);

pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &i);

pthread_setspecific(uwsgi.tur_key, (void *) wsgi_req);

if (core_id > 0) {

// block all signals on new threads

sigfillset(&smask);

#ifdef UWSGI_DEBUG

sigdelset(&smask, SIGSEGV);

#endif

pthread_sigmask(SIG_BLOCK, &smask, NULL);

这段代码是 core_id 如果大于0,那么 block 所有的信号,如果不大于 0,就处理信号。

所以到现在也就明白我写的那个最小的 case 为什么不能复现了:我使用了默认配置,只有一个 thread,core_id =0,它永远可以处理信号。而生产环境开了 64 个 thread,只有 1/64 的几率能够处理信号从而让应用正确返回。

我们可以重新验证一下,开 2 个 thread,预期是它会有 50% 的几率卡住:

uwsgi --http-socket :9090 --wsgi-file pingapp.py --threads=2

果然是,50% 能得到结果,50% 会卡住。

到这里就真相大白了。至于修改呢,我打算直接用 socket.connect 来测量 tcp 的连接时间。因为 TCP 是 3 次握手的,但是对于客户端来说基本上只花费了一个 RTT 的时间。测试下来,这样得到的时间和 hping3 也是一致的。

另外这个故事告诉我们,uWSGI 下 fork 出来的子进程最好都默认信号不工作,虽然 core_id =0 是可以处理信号的,但是这个作为 uWSGI 本身回应信号的设计就可以。发现 uWSGI 的代码中还有很多别的地方调用了 sigprocmask(2),可能还有其他屏蔽信号的地方。

有一位智者同事曾经跟我说过这么一句话:

Learn some eBPF, xintao!

看来是时候认认真真学一学 eBPF 啦。