데이터독은 모니터링 SaaS 강자 중 하나로 손꼽히는 솔루션이다. 그중 APM 기능을 사용하려면 dd-trace-py 라이브러리를 사용하여 일부 세팅을 해주어야 한다. 그런데, APM 기능을 켜서 확인해 보면, 다양한 외부 솔루션/라이브러리(Cache, DB, Celery 등)에 대한 Tracing도 풍부하게 제공하는 것을 알 수 있다. 해당 라이브러리나 솔루션은 모두 데이터독에서 만든 것이 아닌데, 데이터독은 어떻게 Tracing을 할 수 있을까?
ddtrace 라이브러리 문서를 찾아보면, patch, patch_all 함수에 대한 사용법이 나온다. patch 함수는 기존에 모듈 코드에서 일부 기능을 Monkey Patch 해주는 함수이다. patch_all()은 가능한 disable 처리한 모듈을 제외한 모든 라이브러리에 대한 Monkey Patch 작업을 해주는 함수이다.
Python에서의 "Monkey Patch"는 런타임 시 객체나 모듈의 행위를 동적으로 변경하는 기법이다. 이는 주로 코드의 동작을 수정하거나 확장하기 위해 사용된다. Monkey Patch는 개발자가 직접 작성하지 않은 외부 라이브러리나 프레임워크의 기능을 수정하거나 확장해야 할 때 유용하다. 데이터독이 자신이 개발하지 않은 라이브러리에 Tracing 기능을 붙일 수 있는 이유도 바로 이러한 Monkey Patch 기법을 사용했기 때문이다.
그러면 patch 함수를 한번 확인해 보자. 예컨대, fastapi와 mysql을 사용한다고 가정해 보자. 그러면 ddtrace를 통해서 다음과 같이 fastapi와 mysql 모듈을 patch 할 수 있다.
patch() 함수를 보면, 먼저 패치할 모듈의 이름을 key=value 형태로 받아온다. 그 후에는 해당 모듈을 데이터독이 지원하는지 확인한다. 확인하는 방법은 contrib/ 하위에 해당 모듈에 대한 코드를 찾는 것이다. 만약 코드가 없으면, 아직 지원하지 않는 것이다.
지원하는 모듈을 찾게 되면, 전달받은 모듈에 대한 실제 모듈 이름을 찾는다. 경우에 따라 여러 버전의 모듈 코드를 제공하거나, 모듈 이름과 실제 패치해야 하는 패키지 이름이 다른 경우가 생긴다. 만약 이름이 같다면 그냥 전달받은 이름의 모듈을 그대로 패치하면 된다.
패치해야 할 모듈 이름까지 확인이 끝나면 바로 패치에 들어간다. 패치 과정을 살펴보면 다음과 같다. 패치를 할 때는 <prefix>. <module>로 된 경로를 import 한다.
importlib.import_module은 모듈 이름을 통해서 실제 모듈 코드를 메모리에 올려주는 작업을 수행한다. 우리가 흔히 import 구문을 쓰는 것과 같은 방식으로 진행된다고 보면 된다. import_module을 사용하는 이유는 Path에 따라서 모듈을 동적으로 불러오기 위함이다. 모듈 이름에 따라서 동적으로 모듈 코드를 불러와서 해당 함수를 호출하고 싶은데, import 구문을 사용해서는 이를 구현하기 어렵다. 위 코드처럼 import_module 함수를 사용하면, 어떤 모듈이든 동적으로 불러와서 사용할 수 있다.
예컨대, 다음과 같이 math 모듈을 사용할 수 있다.
Patch 모듈을 담은 ddtrace/contrib/ 디렉터리를 보면 다음과 같은 구조로 구성되어 있다. 대부분 __init__. py와 patch.py 파일을 가지고 있다. 설령 patch.py 파일이 없다고 하더라도, 어딘가에는 patch 함수가 들어있다.
다음은 fastapi에 대한 patch 함수이다. ddtrace는 fastapi 프레임워크 구조를 tracing 하기 위해서 middleware, response, backgroundTasks 등과 요소를 patch 한다. _w 함수는 wrap_function_wrapper 함수인데, 기존의 함수를 Wrapping 하여 다른 함수가 되도록 만들어준다.
예컨대, _w("fastapi.applications", "FastAPI.build_middleware_stack", wrap_middleware_stack) 함수를 분석하면 다음과 같다.
fastapi.applications 모듈 중에
FastAPI 클래스의 build_middleware_stack 함수를
wrap_middleware_stack으로 감싼다.
감싼다는 의미는 대체한다는 의미와는 약간 어감이 다르다. 호출했을 때 코드가 변경되기는 하지만, 그렇다고 기존 코드를 아예 호출하지 않는 것은 아니다. 다만, 실제 코드가 호출되기 전/후에 원하는 작업을 추가하기 위한 함수를 더했을 뿐이다.
wrap_middleware_stack 함수를 보면 첫 번째 인자로 wrapped라는 원래 함수 객체를 전달받는다. wrapped 함수가 호출되고 나면, 그 이후에 TraceMiddleware 인스턴스가 생성되는 구조이다.
MySQL 모듈을 살펴봐도 비슷하다. 다음이 MySQL 모듈에 대한 patch 함수이다. wrapt.wrap_function_wrapper는 이전에 나온 _w와 동일한 함수이다.
_connect 함수를 보면, 동일하게 원래 함수가 func 인자로 넘어온다. connection을 만들 때는 mysql.connector.connect 함수를 사용해서 만들고, 해당 conn 객체를 통해서 Tracing을 위한 작업을 수행하기 위해 TracedConnection 인스턴스를 생성한다.
사용자는 conn.commit, conn.rollback 등과 같은 함수를 호출할 때, MySQL 모듈을 사용한다고 생각할 수 있지만, 실제로는 TracedConnection 객체의 commit, rollback 함수가 수행된다. 그 안에서 원래 함수였던 __wrapped__. commit, __wrapped__. rollback 함수가 수행되는 것이다.
마지막으로 실제로 함수가 바뀌어서 호출되는지 직접 눈으로 확인해 보자. sys.settrace() 함수를 사용해서, 함수가 호출되는 순서를 찍어보았다. 이때, Standard library는 제외하고, 사용자가 import 한 라이브러리만 대상으로 Trace를 수행했다.
fastapi의 build_middleware_stack 함수를 그냥 호출하면 다음과 같이 나온다.
하지만, ddtrace.patch(fastapi=True) 함수를 수행하면, 다음과 같이 결과가 출력된다.
참고자료
- https://wrapt.readthedocs.io/en/master/wrappers.html
- https://ddtrace.readthedocs.io/en/stable/api.html#ddtrace.patch_all