DevOps 잡다구리

[ebpf] aya rust uprobe 실습 및 설명

WhiteGoblin 2024. 7. 10. 17:49
반응형

 

지난 packet drop 이후 다른 옵션들을 찾아 보다가 uprobe 에 관심이 생겨서 따로 실습을 해보았습니다. 

0. Prerequisite 

기본적으로 https://aya-rs.dev/book/start/development/ 의 개발 환경 구성을 참고 해서 구축 했습니다. 해당 안내를 보시고 기본적으로 설치해야 하는 부분들을 설치하고 진행 주셔야 합니다.
* rust, bpf-linker
IDE 는 visual studio code 를 사용합니다. 


1. uprobe 란? [1]

 "사용자 공간 안에서 동적 계측을 가능하게 하는 프로그램이다." 라고  참조 되어 있는 링크에서는 설명하지만 뭔가 직관적으로 다가오지 않아서 제가 조금 풀어서 이야기 하자면 저희가 사용하는 APM 같은 툴 처럼 어떠한 프로그램의 사용자 공간 ( user space ) 에서 함수 추적에 용이하다고 이해하면 조금더 직관적일거 같습니다.

2.  Uprobe 를 통해 무엇을 추적할 것인가? 

 이번 예시는 간단한 C 언어 프로그램을 를 추적 하고자 합니다. 

 

example.c

#include <stdio.h>
#include <unistd.h>

int add_rust_aya(int a, int b) {
    return a + b;
}

int main() {

    pid_t pid;
    pid = getpid();

    int result = add_rust_aya(3, 4);
    printf("Process ID: %d\n", pid);
    printf("Result: %d\n", result);
    return 0;
}

 

 위 예시에서 간단하게 설명 하자면
1. add_rust_aya() 라는 제가 선언한 함수를 두고 값을 더한 다음에 값을 돌려 주게 됩니다. 
2. 이후 현재 함수를 실행하고 있는 process 의 ID 를 출력 및 결과 값을 돌려 줍니다. 

 

우리는 여기에서 add_rust_aya 함수가 출력 될때 마다 PID 를 출력 해주는 ebpf 코드를 작성할 것입니다. 

 

3. aya project 만들기 

cargo generate --name ru_uprocess_check -d program_type=uprobe https://github.com/aya-rs/aya-template

 

우선 cargo generate 을 통해서 ebpf template 을 작성해줄 것입니다. 

 

우선 target 은 임시로 test 라고 해두었습니다. 그리고 함수명만 add_rust_aya 로 해두었습니다. 

 

4. aya project 수정 

 

cargo generate 를 통해 위와 같은 repo 가 생성 됩니다. 

 

우선 2가지를 수정 할 것입니다. 

1. function 확인 이후 PID 를 출력하는 ebpf code 

2. 어떤 target 을 확인 하고 어떤 함수를 추적할지에 대한 aya main 코드 


5. ebpf code 

#![no_std]
#![no_main]

use aya_ebpf::{
    macros::uprobe,
    programs::ProbeContext, EbpfContext,
};
use aya_log_ebpf::info;

#[uprobe]
pub fn ru_uprocess_check(ctx: ProbeContext) -> u32 {
    match try_ru_uprocess_check(ctx) {
        Ok(ret) => ret,
        Err(ret) => ret,
    }
}

fn try_ru_uprocess_check(ctx: ProbeContext) -> Result<u32, u32> {
    info!(&ctx, "function add_rust_aya called");
    info!(&ctx, "pid : {} ",ctx.pid());

    Ok(0)
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    
    unsafe { core::hint::unreachable_unchecked() }
}

 

위 코드를 통해 uprobe 에서 event 발생 시 ru_uprocess_check 를 실행하고 try_ru_process_check 이 실행 되고 이후 ctx 에 저장 되어 있는 pid 를 실행하게 됩니다. 

6. main code

 이러한 ebpf 코드를 삽입 하는 코드를 작성 해볼것입니다. 
사실 cargo generate 를 통해서 작성되기 때문에 실제로는 코드를 이해 한다고 보시는게 좋을거 같습니다. 

use aya::programs::UProbe;
use aya::{include_bytes_aligned, Bpf};
use aya_log::BpfLogger;
use clap::Parser;
use log::{info, warn, debug};
use tokio::signal;
use std::borrow::Borrow;
use std::env;

#[derive(Debug, Parser)]
struct Opt {
    #[clap(short, long)]
    pid: Option<i32>,
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let opt = Opt::parse();

    env_logger::init();

    // Bump the memlock rlimit. This is needed for older kernels that don't use the
    // new memcg based accounting, see https://lwn.net/Articles/837122/
    let rlim = libc::rlimit {
        rlim_cur: libc::RLIM_INFINITY,
        rlim_max: libc::RLIM_INFINITY,
    };
    let ret = unsafe { libc::setrlimit(libc::RLIMIT_MEMLOCK, &rlim) };
    if ret != 0 {
        debug!("remove limit on locked memory failed, ret is: {}", ret);
    }

    // This will include your eBPF object file as raw bytes at compile-time and load it at
    // runtime. This approach is recommended for most real-world use cases. If you would
    // like to specify the eBPF program at runtime rather than at compile-time, you can
    // reach for `Bpf::load_file` instead.
    #[cfg(debug_assertions)]
    let mut bpf = Bpf::load(include_bytes_aligned!(
        "../../target/bpfel-unknown-none/debug/ru_uprocess_check"
    ))?;
    #[cfg(not(debug_assertions))]
    let mut bpf = Bpf::load(include_bytes_aligned!(
        "../../target/bpfel-unknown-none/release/ru_uprocess_check"
    ))?;
    if let Err(e) = BpfLogger::init(&mut bpf) {
        // This can happen if you remove all log statements from your eBPF program.
        warn!("failed to initialize eBPF logger: {}", e);
    }

    let current_dir = env::current_dir();
    let execfile : String = String::from("example");
    
    let attach = match current_dir {
        Ok(mut path) =>{
            path.push(execfile);
            path.to_str().unwrap().to_string()
        }, 
        Err(e ) => String::from("NONE"),
    };
        
    let program: &mut UProbe = bpf.program_mut("ru_uprocess_check").unwrap().try_into()?;

    program.load()?;
    program.attach(Some("add_rust_aya"), 0, &attach, opt.pid)?;

    info!("Waiting for Ctrl-C...");
    signal::ctrl_c().await?;
    info!("Exiting...");

    Ok(())
    
}

 저의 경우 C 언어를 파일을 같은 repo 에 작성 및 컴파일 해서 env::current_dir() 를 통해서 dir 값을 가지고 오고 파일이 컴파일 된 이름을 합쳐 주었습니다. 이후 

let program: &mut UProbe = bpf.program_mut("ru_uprocess_check").unwrap().try_into()?;

program.load()?;
program.attach(Some("add_rust_aya"), 0, &attach, opt.pid)?;

 

위와 같이 attach 에 있는 target ( binary file ) 을 지정하고 "add_rust_aya" 함수를 추적 하게 됩니다. 

7. Compile 및 확인

1. 우선 C 언어 파일 컴파일 하기 

gcc -o example example.c

 

이렇게 binary 파일을 컴파일 하게 됩니다. obj 파일 확인 및 링크를 확인 하려면 

gcc -c example.c

 

위 명령어를 실행하게 되면 example.o 가 생성 되고 nm 명령어를 통해 확인 하게 되면 

nm example.o

결과 

0000000000000000 T add_rust_aya
                 U getpid
0000000000000018 T main
                 U printf

 

위아 같이 결과가 나오게 됩니다. 
getpid, printf 의 경우 외부의 참조가 필요한 함수들이고 add_rust_aya, main 은 해당 소스 코드에 정의 되어 있음을 알 수 있습니다. 

고로 libc 에서 사용하는 함수를 추적하고자 하면 위에서 attach 부분을 libc 로 변경 하고 함수 명을 바꾸어주어야 합니다. 

다시 돌아와서 이제는 main 함수를 cargo xtask 로 run 하면 

 

이제 event 가 들어오기를 기다리게 됩니다. 

example 을 실행하면 

위와 같이 실행이 되고


ebpf 코드 에서는 위와 같이 PID 를 같이 출력하면서 해당 코드가 실행시 우리가 정의 해둔 function이 실행됨을 알 수 있습니다. 

8. 결론 및 아쉬움 

 처음 시작은 python 코드를 추적하기 위해서 이 코드를 작성하고자 했지만 쉽지 않다는것을 알고 우선은 uprobe 가 실행되는것을 확인하기 위해서 C 언어로 작성한 코드를 추적 했습니다. 실행 하면서 code 작성 이후 linker 에 대한 이해, uprobe 에 대한 이해를 할 수 있는 좋은 기회였지만 제가 원하는 코드를 작성하지 못한 부분에 대해서는 아쉽습니다. 추가적으로 작성하게 되면 해당 부분에 대해서 다른 글로 찾아오겠습니다. 감사합니다.

 

 

[1] uprobe 란? 

https://eunomia.dev/tutorials/5-uprobe-bashreadline/#capturing-readline-function-calls-in-bash-using-uprobe

 

 

반응형