🐧

Otel Injector や Datadog SSI 自動計装の裏側を垣間見る(LD_PRELOAD の検証)

に公開

はじめに

OpenTelemetry Injector や Datadog Single Step Instrumentation (SSI) を使うと、アプリケーションのコードを書き換えなくてもトレースが収集できます[1][2]。しかし「なぜコードを変えずにそんなことができるの?」と疑問に思う方も多いのではないでしょうか。

その裏側にあるのが LD_PRELOAD という Linux の仕組みです。本記事ではその仕組みを簡単な実験を通して体感し、自動計装の中身を理解するきっかけを提供します。

[1]https://github.com/open-telemetry/opentelemetry-injector
[2]https://docs.datadoghq.com/ja/tracing/guide/injectors/

LD_PRELOAD とは?これがあると何がいいのか

Linux でプログラムを実行するとき、動的リンカ (ld.so) が必要な共有ライブラリを探し、関数を解決します。このとき LD_PRELOAD=/path/to/lib.so を指定すると、本来ロードされるライブラリより先に指定した .so が読み込まれます。

▼通常のプログラム実行
アプリ ──▶ 標準ライブラリ ──▶ 実際の処理

▼LD_PRELOAD=/path/to/lib.so を指定した場合
アプリ ──▶ 自作ライブラリ (lib.so) ──▶ 標準ライブラリ ──▶ 実際の処理

つまり、LD_PRELOAD を利用することでアプリケーションコードを変更せずに挙動を変えられるようになります。
Otel Injector や Datadog SSI は、この仕組みを利用してブラックボックス的にアプリを計測可能にしています。

LD_PRELOAD を簡単に体感してみる

Python の簡単なスクリプトを用意し、LD_PRELOAD の有無で出力がどう変わるかを体感してみます。

実行環境/環境セットアップ

本記事では Amazon Linux 2023 (EC2) を利用しました。Python で requests を使った HTTP リクエストを送るシンプルなスクリプトを用意します。

前準備

$ sudo dnf groupinstall -y "Development Tools"
$ sudo dnf install -y python3 python3-pip python3-venv

$ python3 -m venv venv
$ source venv/bin/activate
$ pip install --upgrade pip
$ pip install requests

Python スクリプト(app.py)

import requests
print("HTTP GET start")
r = requests.get("https://example.com", timeout=5)
print("status:", r.status_code)

フック用ライブラリ(hook_connect.c)

#define _GNU_SOURCE
#include <dlfcn.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <errno.h>

static int (*real_connect)(int, const struct sockaddr*, socklen_t) = NULL;

__attribute__((constructor))
static void init(void) {
    real_connect = (int (*)(int, const struct sockaddr*, socklen_t))
                   dlsym(RTLD_NEXT, "connect");
    if (real_connect) {
        fprintf(stderr, "[hook] preload init: connect interposed\n");
    } else {
        fprintf(stderr, "[hook] dlsym(connect) failed\n");
    }
}

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
    // 宛先アドレスをログに出す
    if (addr && addr->sa_family == AF_INET) {
        struct sockaddr_in *in = (struct sockaddr_in*)addr;
        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &(in->sin_addr), ip, sizeof(ip));
        fprintf(stderr, "[hook] connect() -> %s:%d\n", ip, ntohs(in->sin_port));
    }

    // 本物の connect を呼び出す (RTLD_NEXT で libc 側に委譲)
    int rc = real_connect(sockfd, addr, addrlen);

    return rc;
}

ビルド

$ gcc -shared -fPIC hook_connect.c -o libhook.so -ldl

検証

通常実行(LD_PRELOAD なし)

(venv)$ python app.py
HTTP GET start
status: 200

LD_PRELOAD あり

(venv)$ LD_PRELOAD=$PWD/libhook.so python app.py
[hook] preload init: connect interposed
HTTP GET start
[hook] connect() -> 23.192.228.80:443
status: 200

Python コードは一切変更していないのに、通信先がログに出力されました。
これこそが LD_PRELOAD による関数の横取りで、今回試した検証を図示するなら以下のようになると言えます。

アプリ(python app.py)
        ▼
① 動的リンカ(ld-linux)
   └─ 共有ライブラリを解決
        └─ LD_PRELOAD=libhook.so が指定されているので libhook.so を"先に"読み込む
        ▼
② libhook.so の constructor 実行
   └─ dlsym(RTLD_NEXT, "connect") で本物の libc の関数を取得
        ▼
③ アプリ本体の実行 (requests.get(...))
   └─ urllib3 → http.client → socket → glibc
        ▼
④ 通常時は glibc の connect を呼び出すが…
   └─ シンボル解決順により先に libhook.so の connect が呼ばれる
        ├─ 前処理(ログ出力・計測開始)
        ├─ real_connect(...) で本物の glibc connect 呼び出し
        └─ 後処理(ログ出力・計測終了)
        ▼
⑤ カーネルのシステムコールへ

余談: libhook.so が標準ライブラリより前に検索されていることは、デバッグログからも確認できます。

(venv)$ LD_DEBUG=libs,symbols LD_PRELOAD=$PWD/libhook.so python app.py 2>&1 | head -n 30
   ...
   2280567:	symbol=_res;  lookup in file=/home/ec2-user/test/libhook.so [0]
   2280567:	symbol=_res;  lookup in file=/lib64/libpython3.9.so.1.0 [0]
   2280567:	symbol=_res;  lookup in file=/lib64/libc.so.6 [0]
   ...

まとめ

  • LD_PRELOAD は ライブラリのロード順を変えて関数を横取りできる仕組み
  • Datadog SSI や OTel Injector は、この仕組みを応用して自動計装を実現している
  • 実験を通して「アプリを変更せずに挙動を差し込める」ことを実感できた

まずはシンプルな実験で「LD_PRELOAD が何をしているか」を体感することが、仕組み理解の第一歩になると考え、記事を書きました。
この記事が、同じ疑問を持った方の理解の助けになれば嬉しいです。

Discussion