python进程调试改变运行中进程的代码

6次阅读

共计 2313 个字符,预计需要花费 6 分钟才能阅读完成。

后端开发中有时会遇到这种问题:进程运行中偶现,重启进程问题就消失;或者是,进程一定要运行一段时间才会出现问题。

对于这些情况,尽管大部分时候,我们可以通过在可能的地方加 log,然后重启进程等待问题复现,但这样相对被动。我们都知道如果要调试 C /C++ 程序,gdb attach 上进程就可以,而 python 虽然有相似的工具 pdb,但它无法附加到一个进程上,必须要用 pdb 启动进程,在实际环境中显然不管用,那么 python 是否有类似的办法来 改变运行中进程的代码 呢?这样我们就可以实时加 log,能实时加 log 了,还有什么问题不能定位呢:p

可以参考两篇文章:

https://mozillazg.com/2018/07…
https://mozillazg.com/2017/07…

简单来说,可以直接用 gdb 使用类似调试 c 程序的方式,但要求 python 进程是使用 python-debug 这种版本的 python,同样不够实用。这里介绍博客中提到的“纯 gdb”的方式,通过 github 上一个开源 python 包 pyrasite,本质上是通过 gdb 的-eval-command 命令。另外,我们还依赖于 python 作为动态语言的特性,可以在运行时对函数赋值来改变函数。

这个库有一些附加功能,可以通过它的文档去了解。这里只说实现进程注入的核心,是其中一个很短的文件injector.py,核心代码如下:

import os
import subprocess
import platform

def inject(pid, filename, verbose=False, gdb_prefix=''):"""Executes a file in a running Python process."""
    filename = os.path.abspath(filename)
    gdb_cmds = ['PyGILState_Ensure()',
        'PyRun_SimpleString("'
            'import sys; sys.path.insert(0, \\"%s\\");'
            'sys.path.insert(0, \\"%s\\");'
            'exec(open(\\"%s\\").read())")' %
                (os.path.dirname(filename),
                os.path.abspath(os.path.join(os.path.dirname(__file__), '..')),
                filename),
        'PyGILState_Release($1)',
        ]
    p = subprocess.Popen('%sgdb -p %d -batch %s' % (gdb_prefix, pid,
        ''.join(["-eval-command='call %s'" % cmd for cmd in gdb_cmds])),
        shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    out, err = p.communicate()
    if verbose:
        print(out)
        print(err)
        

这个函数做的事很简单,不难看懂,所以,我们需要做的就是调用这个函数,传入 pid 和文件名,文件是一个你要对这个进程执行的 python 代码。现在我们运行一个很简单的 python 进程test.py

import time
def b():
  print('b')

while 1:
  b()
  time.sleep(1)
  

然后创建一个文件patch.py

print('injecting')
def newb():
  print('new b')
b = newb

injector.py 的末尾加上一段,以便接收命令行调用:

import sys
pid = sys.argv[1]
filename = sys.argv[2]

inject(int(pid), filename)

通过 ps aux|grep test.py 查看上面进程的 pid,然后执行python injector.py pid patch.py,为方便反复测试可以这样:

pid=`ps aux | grep test.py | grep -v grep | awk '{print $2}'`;python injector.py $pid patch.py;echo $pid injected

输出如下:

至此就实现了进程注入。

注意点:

  • 修改类或类方法和函数同理,改变类的方法时,直接使用类名 classA.method = new_method 会将变化应用到所有实例,注意对类方法来说在 patch.py 中定义时也要加上 self 参数
  • patch.py 中,我们可以直接对 b 赋值,因为我们 gdb 进入一个进程后,所在的上下文环境就是该进程的入口模块,可以通过打印 globals() 来看到有哪些全局变量,这些就是可以直接访问的对象。如果是在一个普通的业务进程中,必然有大量import,这种情况下你需要 import 相应模块再对该模块的函数或类进行修改,如import x.y.z as z; z.b = newb
  • 特别需要注意的是,如果一个模块 A 使用了 from B import func,那么如果你想改变A 中运行的 func,需要import A; A.func = newfunc,像这样改变B 是没有用的:import B; B.func = newfunc,因为 from .. import .. 会将对象复制一份到本地命名空间。反之,如果 A 是使用 import B 并通过 B.func 进行调用,那么就应当 import B 进行修改
  • 如果一个函数内部有阻塞式的while True,那么改变这个函数是没有用的,必须要跳出循环后函数下一次被调用。这个应该不难理解,即使不考虑注入,在同个进程里异步修改也是这样的情况
正文完
 0