BPF CO-RE的探索與落地【轉(zhuǎn)載】

本文作者:strickland
本文鏈接:https://www.strickland.cloud/post/1
版權(quán)聲明:本博客所有文章除特別聲明外鼠哥,均采用 BY-NC-SA 許可協(xié)議。轉(zhuǎn)載請注明出處呈础!

在最近這一段時(shí)間粗恢,我們使用 eBPF 實(shí)現(xiàn)了一些監(jiān)控組件,在這過程中因?yàn)?eBPF 的一些兼容性遇到不少問題在最近這一段時(shí)間,我們使用 eBPF 實(shí)現(xiàn)了一些監(jiān)控組件,在這過程中因?yàn)?eBPF 的一些兼容性遇到不少問題考蕾。包括頭文件的引入、低版本內(nèi)核使用 BTF 等棵癣,同時(shí)我們沒用使用 bcc 來作為 eBPF 前端辕翰,而是使用了cilium/ebpf(下文簡稱ebpf-go),它提供了一層 Go 的接口來操作 eBPF 程序,不過因?yàn)樵擁?xiàng)目和 repo 內(nèi)示例程序較為簡單狈谊,在實(shí)際開發(fā)中還是遇到了一些編程上的問題。本篇的主要是圍繞著 BPF CO-RE的相關(guān)介紹,并且將我們在實(shí)際開發(fā)中使用 BPF CO-RE 和 ebpf-go相關(guān)的問題以及解決辦法分享出來河劝。

限于篇幅壁榕,本篇文章并不是 eBPF 的入門文章,為了能夠徹底理解文章的內(nèi)容赎瞎,讀者最好有 eBPF牌里、bcc、ebpf-go 的基本使用經(jīng)驗(yàn)务甥。

為什么需要 BPF CO-RE

雖然本篇的內(nèi)容是介紹 BPF CO-RE牡辽,但是不對ebpf-go的使用方式做簡要介紹,就不容易理解為什么需要使用 CO-RE來解決一些痛點(diǎn)問題敞临。ebpf-go的作用就是提供一個(gè)可編程的Go 語言接口态辛,提供操作 eBPF 程序的基本函數(shù): 掛載、釋放挺尿,對 eBPF map進(jìn)行增刪改查等奏黑。

它的基本使用方法是讓程序員編寫好一段純的 eBPF 程序,ebpf-go會負(fù)責(zé)對這段程序的翻譯编矾,能夠讓我們使用 Go 進(jìn)行 eBPF 程序的掛載和 map 數(shù)據(jù)的處理熟史。下面是一個(gè)簡單的示例,參考自ebpf-go-exmaple:

#include "common.h"

char __license[] SEC("license") = "Dual MIT/GPL";

struct bpf_map_def SEC("maps") kprobe_map = {
    .type        = BPF_MAP_TYPE_ARRAY,
    .key_size    = sizeof(u32),
    .value_size  = sizeof(u64),
    .max_entries = 1,
};

SEC("kprobe/sys_execve")
int kprobe_execve() {
    u32 key     = 0;
    u64 initval = 1, *valp;

    valp = bpf_map_lookup_elem(&kprobe_map, &key);
    if (!valp) {
        bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
        return 0;
    }
    __sync_fetch_and_add(valp, 1);

    return 0;
}

上面這段程序的插樁點(diǎn)在sys_execve窄俏,作用是記錄sys_execve被調(diào)用的次數(shù)蹂匹。不過SEC("kprobe/sys_execve")只是一個(gè)提示性的宏,實(shí)際的函數(shù)掛載位于下面這段程序凹蜈。實(shí)際使用中需要將上面這段ebpf程序編譯為.o文件怒详,然后被下面程序中的loadBpfObjects函數(shù)加載到內(nèi)核,臟活累活 ebpf-go都做完了踪区。//go:generate這行就就是預(yù)處理指令昆烁,將 C 語言編寫的 ebpf 程序編譯為.o文件。

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf kprobe.c -- -I../headers

const mapKey uint32 = 0

func main() {
    objs := bpfObjects{}
    if err := loadBpfObjects(&objs, nil); err != nil {
        log.Fatalf("loading objects: %v", err)
    }
    defer objs.Close()
    // 使用kprobe掛載
    kp, err := link.Kprobe(fn, objs.KprobeExecve, nil)
    if err != nil {
        log.Fatalf("opening kprobe: %s", err)
    }
    defer kp.Close()

    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    log.Println("Waiting for events..")

    for range ticker.C {
        var value uint64
        if err := objs.KprobeMap.Lookup(mapKey, &value); err != nil {
            log.Fatalf("reading map: %v", err)
        }
        log.Printf("%s called %d times\n", fn, value)
    }
}

從這個(gè)例子來看缎岗,ebpf-go無法動(dòng)態(tài)的在程序運(yùn)行時(shí)進(jìn)行判斷某些條件是否成立静尼。比如說struct task_struct結(jié)構(gòu)體內(nèi)部的state在這個(gè)commit被重命名為了__state,我們很難根據(jù)內(nèi)核版本去判斷是否有某個(gè)結(jié)構(gòu)體(不過確實(shí)libbpf后來也提供了判斷內(nèi)核版本的功能传泊,還有一些別的奇技淫巧也可以做鼠渺,不過使用BPF CO-RE有更好的實(shí)現(xiàn)方式)。甚至如果一個(gè)結(jié)構(gòu)體的成員被重命名眷细、被移除那么就會導(dǎo)致在本地開發(fā)的 eBPF 程序在線上環(huán)境無法使用拦盹。ebpf-go的實(shí)現(xiàn)是靜態(tài)的,而不像bcc那樣可以在運(yùn)行時(shí)期間進(jìn)行字符替換溪椎。

BPF CO-RE

現(xiàn)如今 eBPF 在很多方便都有它的身影,cilium普舆、skywalking恬口、bpftrace、pixie等等在各個(gè)方面上都發(fā)揮著作用沼侣,不過 eBPF 存在的缺陷是功能隨著內(nèi)核版本的更新而更新祖能,老版本內(nèi)核無法使用相當(dāng)多基于 eBPF 的組件,另外一個(gè)問題是 eBPF 程序的移植性較不好蛾洛,這一點(diǎn)已經(jīng)從上一小節(jié)得到了驗(yàn)證养铸,結(jié)構(gòu)體成員的更新、重命名等操作都會讓一個(gè) eBPF 到一個(gè)新的內(nèi)核版無法在使用轧膘。

BPF CO-RE(Compile Once – Run Everywhere)的目的是能夠讓 eBPF 程序有更好的可移植性钞螟,在多個(gè)不同版本的內(nèi)核之間可以做兼容。除了前面的示例以外谎碍,還可能存在結(jié)構(gòu)體成員的偏移量改變鳞滨、結(jié)構(gòu)體成員被移除、類型被改變等等兼容問題椿浓。那么太援,盡可能的讓bpf 程序有更好的兼容性,可以用tracepoint來替代kprobe扳碍,不過tracepoint缺陷是可以插樁的函數(shù)點(diǎn)相當(dāng)少提岔,另外一個(gè)可行的方法根據(jù)目標(biāo)內(nèi)核版本在運(yùn)行時(shí)做一些工作,這就是BCC所做的事情笋敞,不過BCC依賴于內(nèi)核的頭文件碱蒙,它需要機(jī)器上內(nèi)核版本所對應(yīng)的內(nèi)核頭文件是安裝的,而且bcc內(nèi)置了clang/llvm夯巷,這無疑也會增加一個(gè)程序所需的內(nèi)存(這一點(diǎn)是十分顯而易見的赛惩,使用bcc做好的鏡像比ebpf-core要大很多),bcc在運(yùn)行時(shí)進(jìn)行程序的編譯趁餐,加載bpf程序到內(nèi)核喷兼,所以程序的一點(diǎn)小問題都只能到運(yùn)行時(shí)才能發(fā)覺。

CO-RE的目的就是解決上述的問題后雷,CO-RE的實(shí)現(xiàn)依賴于BTF(BPF type formation) 它可以認(rèn)為是面向 eBPF 程序的 debuginfo,文檔開宗明義地介紹了它的作用:

BTF (BPF Type Format) is the metadata format which encodes the debug info related to BPF program/map. The name BTF was used initially to describe data types. The BTF was later extended to include function info for defined subroutines, and line info for source/line information.

BTF 在較新的內(nèi)核版本是默認(rèn)自帶的季惯,否則的話需要手動(dòng)的指定CONFIG_DEBUG_INFO_BTF=y,在某些線上的低版本內(nèi)核當(dāng)中沒有BTF支持那么使用CO-RE就相當(dāng)棘手臀突,在后文我們將會介紹如何在低版本內(nèi)核使用BTF勉抓。除此以外,CO-RE的實(shí)現(xiàn)來依賴于Clang提供的一些結(jié)構(gòu)體重定位等輔助信息候学,libbpf會將btf與clang所提供的信息相結(jié)合完成整個(gè)重定位的過程藕筋。限于知識水平,對于這些更加底層的內(nèi)容了解的很少梳码,不過多展開隐圾。

本小節(jié)的不少例子都來自于這篇博客伍掀,作者是CO-RE項(xiàng)目的核心開發(fā)者,這里只是挑選了部分內(nèi)容作為示例翎承。

遠(yuǎn)離頭文件

如果有過編寫bcc腳本的經(jīng)驗(yàn)硕盹,就會有我要使用的結(jié)構(gòu)體到底在哪個(gè)頭文件這種問題符匾。如果內(nèi)核有 BTF 的支持1,那么可以根據(jù)當(dāng)前內(nèi)核版本生成一個(gè)囊括了所有結(jié)構(gòu)體的頭文件叨咖,不過它沒有所需要的宏,大部分宏的取值直接參考內(nèi)核頭文件手動(dòng)編寫。生成頭文件的命令如下2:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

1: BTF 應(yīng)該是從linux 5.4開始默認(rèn)支持的啊胶,不過可以要驗(yàn)證當(dāng)前內(nèi)核是否具有BTF支持只需要查看/sys/kernel/btf路徑是否存在甸各。

2: 該命令對 bpftool 的版本有要求,通過 apt 下載的 bpftool 可能版本較低焰坪,會提示 failed to load BTF from /sys/kernel/btf/vmlinux: Unknown error -4001這樣類似的錯(cuò)誤趣倾,最好的方法是手動(dòng)編譯,參考bpftool repo某饰。

3: 對于沒有 BTF 支持的內(nèi)核如果使用vmlinux.h程序?qū)o法通過編譯儒恋。

讀取結(jié)構(gòu)體成員

在bcc當(dāng)中訪問結(jié)構(gòu)體的方法就如同普通的C語言程序那樣,如下示例程序是bcc程序訪問結(jié)構(gòu)體的方法:

pid_t pid = task->pid;

實(shí)際上黔漂,bcc對這段程序進(jìn)行改寫诫尽,設(shè)置BPF(debug=4)可以看到被bcc重寫過后的代碼,這會給人一種誤導(dǎo)炬守,誤以為實(shí)際的這種形式就是正確的結(jié)構(gòu)訪問牧嫉。bcc使用了bpf_probe_read函數(shù)對上面這行代碼進(jìn)行了改寫,示例程序改寫過后的代碼如下:

pid_t pid = ({
    typeof(pid_t) _val;
    __builtin_memset(&_val, 0, sizeof(_val));
    bpf_probe_read(&_val, sizeof(_val), (u64) &task->pid);
    _val;
});

Note: 對于bcc其他相關(guān) debug 參數(shù)參考bcc reference-guide

BPF CO-RE引入了一個(gè)新的函數(shù)bpf_core_read,用法與bpf_probe_read减途,只不過它會針對內(nèi)核的版本做兼容性的調(diào)整(比如說pid在不同的內(nèi)核版本所處的偏移量不同)酣藻。該函數(shù)只是一個(gè)宏,它的內(nèi)部還是調(diào)用了bpf_probe_read,下面是示例:

pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);

該函數(shù)的源碼如下,位于bpf_core_read.h如果本機(jī)上沒有該頭文件鳍置,需要手動(dòng)安裝libbpf辽剧。

#define bpf_core_read(dst, sz, src)                     \
    bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))

不過無論是使用原生的bpf helper系列函數(shù),還是使用bpf_core_read税产,面對鏈?zhǔn)阶x取的場景就顯得十分麻煩怕轿。如將 eBPF 程序掛載到vfs_open函數(shù),讀取被打開的文件名砖第,使用原生的 API 程序如下:

SEC("kprobe/vfs_open")
int kprobe_func(struct pt_regs *ctx)
{
    u64 pid = bpf_get_current_pid_tgid();
    struct path *p = (struct path*)PT_REGS_PARM1(ctx);
    struct dentry *de;
    bpf_probe_read_kernel(&de,sizeof(void*),&p->dentry);
    struct qstr d_name;
    bpf_probe_read_kernel(&d_name,sizeof(d_name),&de->d_name);
    char filename[32];
    bpf_probe_read_kernel(&filename,sizeof(filename),d_name.name);
    if (d_name.len == 0)
        return 0;
    char fmt_str[] = "path:%s";
    bpf_trace_printk(fmt_str,sizeof(fmt_str),filename);
    return 0;
};

可以看到大部分的程序篇幅都在調(diào)用bpf_probe_read_kernel撤卢,CO-RE 提供了一個(gè)宏,對這個(gè)過程進(jìn)一步地封裝,那么這個(gè)程序就可以簡化為:

SEC("kprobe/vfs_open")
int BPF_KPROBE(vfs_open, const struct path *path, struct file *file)
{
    pid_t pid;
    pid = bpf_get_current_pid_tgid() >> 32;
    const unsigned char *filename;
    // 一行語句就實(shí)現(xiàn)了鏈?zhǔn)降淖x取
    filename = BPF_CORE_READ(path,dentry,d_name.name);
    bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
    return 0;
}

相類似的還有一些宏BPF_CORE_READ_STR_INTO,BPF_CORE_READ_INTO大差不差梧兼,看博客里邊的講解就行放吩。值得一提的是,最近的內(nèi)核版本中新增的

一種 eBPF程序類型:BPF_PROG_TYPE_TRACING羽杰,它支持如同c語言語法那樣直接訪問結(jié)構(gòu)體成員渡紫。

處理結(jié)構(gòu)體問題

回到最開始的例子到推,我們提到過struct task_struct內(nèi)的state成員新一點(diǎn)的內(nèi)核版本已經(jīng)被重命名為__state(在21年被合并進(jìn)了內(nèi)核),那么如何編寫一個(gè)使用到該成員的程序并且能夠在不同的內(nèi)核版本兼容就是一個(gè)值得關(guān)注的問題惕澎。BPF CO-RE提供了ignored suffix rule的功能莉测,它的作用是對于任何的符號只要包含著三下劃線,那么下劃線以及它所有的字符都會被忽略唧喉。我們定義一個(gè)struct task_struct___my_own對于 BPF CO-RE而言捣卤,這完全地等價(jià)于struct task_struct。這是一個(gè)十分重要的特性意味著我們可以通過定義不同類型的結(jié)構(gòu)體來處理不同版本的內(nèi)核兼容問題八孝,示例如下:

struct task_struct {
    pid_t pid;
    int __state;
} __attribute__((preserve_access_index));

// 兼容老版本內(nèi)核
struct task_struct___old {
    pid_t pid;
    long state;
} __attribute__((preserve_access_index));

Note: 在后文會對__attribute__((preserve_access_index))介紹董朝。

我們定義了兩個(gè)結(jié)構(gòu)體分別作用于新版本與老版本的內(nèi)核,在結(jié)合BPF CO-RE提供的另外一個(gè)函數(shù)bpf_core_field_exists用于判斷某個(gè)結(jié)構(gòu)體內(nèi)是否具有某個(gè)成員干跛,如下語句就就很好的處理了兼容問題:

struct task_struct *prev = (struct task_struct*)PT_REGS_PARM1(ctx);
unsigned int state;
if (bpf_core_field_exists(prev->__state)) {
    state = BPF_CORE_READ(prev, __state);
} else {
    // ___old 這幾個(gè)字符會被 libbpf 忽略子姜,它實(shí)際等同于struct task_struct
    // 這就解決了不同版本的內(nèi)核兼容性問題。
    struct task_struct___old *t_old = (void *)prev;
    state = BPF_CORE_READ(t_old, state);
}

我們使用ebpf-go重寫了bcc-runqlat就用到了該特性楼入,很好地解決了我們線上集群當(dāng)中內(nèi)核版本較低不兼容問題哥捕。

判斷內(nèi)核版本

另外一種解決辦法是手動(dòng)判斷內(nèi)核版本,針對不同的內(nèi)核版本做處理嘉熊。在BPF CO-RE 之前據(jù)我所知沒有這方便的支持遥赚,不過該功能沒有實(shí)際的在項(xiàng)目中使用,在此只是拋磚引玉地做介紹记舆。BPF CO-RE 提供了extern Kconfig variables, 能夠讀取一些位于/proc/config.gz內(nèi)的kconfig 配置并且在ebpf程序當(dāng)中使用鸽捻,如下的示例讀取了內(nèi)核版本信息,那么也可以根據(jù)不同的內(nèi)核版本做對應(yīng)的處理泽腮,遺憾的是目前 ebpf-go 不支持這一功能御蒲。

extern int LINUX_KERNEL_VERSION __kconfig;
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(5, 15, 0)) {
    /* we are on v5.15+ */
}

Note:

該功能與ebpf-go的結(jié)合并不是很好,會出現(xiàn)如下錯(cuò)誤:

can't load BPF from ELF: load BTF: reference to .kconfig: not supported

issue有人反饋了該問題诊赊,不過社區(qū)一直沒有支持厚满,有一個(gè)最近的PR提交對LINUX_KERNEL_VERSION支持,cilium社區(qū)對應(yīng)支持kconfig的意見是它的移植性不好碧磅,因?yàn)樵赿ebian系列的內(nèi)核中沒有/proc/config.gz文件碘箍,而是位于/boot/config-$(uname-r),可以參考這issue鲸郊。

自定義結(jié)構(gòu)體

在前文-遠(yuǎn)離頭文件-一小節(jié)介紹過有BTF支持下的內(nèi)核可以產(chǎn)生一個(gè)大型頭文件囊括了所有內(nèi)核數(shù)據(jù)結(jié)構(gòu)丰榴,問題是在低版本機(jī)器上都沒有 BTF 支持,那么如何使用各種頭文件呢秆撮? 最笨的方法是從所需的內(nèi)核源碼中逐個(gè)復(fù)制結(jié)構(gòu)體四濒,然而內(nèi)核結(jié)構(gòu)體往往層級嵌套很深,這種方法不切實(shí)際。另外一種方法是直接下載當(dāng)前內(nèi)核版本所對應(yīng)的頭文件然后手動(dòng)的鏈接(bcc就是這種做法)盗蟆,不過在實(shí)踐中我們發(fā)現(xiàn)手動(dòng)的鏈接無法通過編譯戈二。

這一切有了 BPF CO-RE都迎刃而解,無論我們需要什么結(jié)構(gòu)體喳资,只要秉持著我要什么觉吭,就定義什么,BPF CO-RE 會處理好各個(gè)結(jié)構(gòu)體的重定位仆邓。以獲取vfs_read的文件名為例鲜滩,vfs_read源碼第一個(gè)參數(shù)struct file包含著文件名,路徑為struct file -> struct path -> struct dentry -> struct qstr 宏赘,只需要使用一個(gè)編譯器的 attribute 就可以實(shí)現(xiàn)要啥就定義啥绒北,結(jié)構(gòu)體的源碼定義如下:

struct qstr {
    union {
        struct {
            u32 hash;
            u32 len;
        };
        u64 hash_len;
    };
    const unsigned char *name;
}

struct dentry {
    struct qstr d_name;
}__attribute__((preserve_access_index));

struct path {
    struct dentry *dentry;
}__attribute__((preserve_access_index));

struct file {
    struct path f_path;
}__attribute__((preserve_access_index));

這一切所有的魔法都在于__attribute__((preserve_access_index))黎侈,該attribute解釋可以參考文檔察署,簡單來說它讓編譯器期間保留了被修飾的結(jié)構(gòu)體的debuginfo,讓 BPF CO-RE 可以完成對所需結(jié)構(gòu)體的重定位峻汉,這也依賴于 BTF 的支持贴汪。實(shí)際上對于BPF_CORE_READ這些宏調(diào)用來說,自己定義的結(jié)構(gòu)體也不需要使用這個(gè)attribute休吠,這個(gè)宏的源碼實(shí)際調(diào)用的是bpf_core_read函數(shù)扳埂,而它的源碼中使用這個(gè)特性,如下:

#define bpf_core_read(dst, sz, src)                     \
    bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))

一個(gè)十分hack的技巧:

我們知道CPU訪問結(jié)構(gòu)體成員的方式就是簡單的首地址+offset瘤礁,而BPF CO-RE 的實(shí)現(xiàn)上也是記錄所需結(jié)構(gòu)體成員偏移量是多少阳懂,也因此只需定義我們關(guān)注的結(jié)構(gòu)體成員加上__attribute__((preserve_access_index));就可以工作。那么很自然的柜思,如果沒有這個(gè)attribuet岩调,我們認(rèn)為地在結(jié)構(gòu)體內(nèi)進(jìn)行字節(jié)的填充,也可以達(dá)到類似的效果赡盘,上一小節(jié)的struct qstr 我們其實(shí)只關(guān)注的它的*name号枕,前面8個(gè)字節(jié)并不重要,搖身一變可以使用手工地填充8個(gè)字節(jié)達(dá)到相同的效果陨享,如下:

struct qstr {
    char padding[8]; // 人為填充 8 個(gè)字節(jié)
    const unsigned char *name;
};

BTFhub

歸根到底葱淳,BPF CO-RE 十分依賴于 BTF,而老版本的內(nèi)核甚至一些5.x版本內(nèi)核在build的時(shí)候如果沒有指定CONFIG_DEBUG_BTF=y內(nèi)核都沒有 BTF 的支持抛姑。BTFhub的出現(xiàn)解決了這個(gè)問題赞厕,除此以外它還能夠?qū)?BTF 文件進(jìn)行剪裁,讓它只包含我們所需要的結(jié)構(gòu)體所相關(guān)的debuginfo定硝。btfhub的使用也相當(dāng)簡單皿桑,參考文檔。它還依賴于BTF archive,該 repo 就是將很多低版本內(nèi)核的 BTF 文件制作好了,使用btfhub/tools/btfgen.sh 腳本將這些 BTF 文件按需裁剪唁毒,命令如下:

./tools/btfgen.sh -a x86_64 -o foo.o

foo.o就是C語言編寫的 eBPF 程序經(jīng)過//go genrate預(yù)處理指令編譯過后的.o文件蒜茴,裁剪過后的btf文件會位于btfhub/custom-archive目錄內(nèi)。值得一提的是浆西,repo中的btfgen.sh會將btfarchive倉庫內(nèi)所有的 BTF 都進(jìn)行裁剪粉私,這個(gè)過程十分耗時(shí),我們對 btfgen.sh 做了一點(diǎn)修改近零,可以指定發(fā)行版來裁剪 BTF 文件加快這個(gè)過程诺核,實(shí)現(xiàn)思路就是在腳本內(nèi)判斷此時(shí)被裁剪的BTF路徑是否包含我們所期望的發(fā)行版,關(guān)鍵代碼如下:

# 使用方法: /tools/btfgen.sh -a x86_64 -r debian -o foo.o
while getopts ":a:o:r:" opt; do
    case "${opt}" in
        a)
            a=${OPTARG}
            [[ "${a}" != "x86_64" && "${a}" != "arm64" ]] && usage
        ;;
        o)
            [[ ! -f ${OPTARG} ]] && { echo "error: could not find bpf object: ${OPTARG}"; usage; }
            o+=("${OPTARG}")
         ;;
         r) # 給 btfgen.sh 新增加了一個(gè)
           echo "target release: ${OPTARG}"
           r=${OPTARG} 
        ;;
        *)
            usage
        ;;
    esac
done
# 省略一些代碼

# $file 是 btf 全文件名久信,只要判斷它是否包含所期望的發(fā)行版名稱即可
if [[ ! $file =~ "${r}"  ]];then
    continue
fi

# 省略部分代碼

值得一提的是窖杀,實(shí)際開發(fā)中制作 BTF 文件可以一次性的傳入多個(gè).o文件,也就是:btfgen.sh -a x86_64 -r debian -o foo.o -o bar.o裙士。對于btfgen的原理參考這篇文檔入客。

ebpf-go 的使用心得

雖然本篇文章的主要目的是介紹如何使用 BPF CO-RE,不過我仍然認(rèn)為在開發(fā) eBPF 監(jiān)控組件過程中遇到的一些問題是有借鑒意義腿椎,另外由于 ebpf-go 的文檔缺失也不可避免的帶來一些學(xué)習(xí)上的成本桌硫。

如何判讀 BTF 文件是否有效

使用 BPF hub 生成的 btf 文件驗(yàn)證,可以使用bpftool btf dump 命令來驗(yàn)證啃炸,示例如下:

$  bpftool btf dump file  4.19.0-21-amd64.btf 
# 省略一些內(nèi)容
[7] STRUCT 'task_struct' size=7104 vlen=3
        'state' type_id=4 bits_offset=128
        'pid' type_id=6 bits_offset=9792
        'nsproxy' type_id=8 bits_offset=13824
[8] PTR '(anon)' type_id=9
[9] STRUCT 'nsproxy' size=56 vlen=1
        'mnt_ns' type_id=10 bits_offset=192
[10] PTR '(anon)' type_id=12
[11] STRUCT 'ns_common' size=24 vlen=1
        'inum' type_id=1 bits_offset=128
[12] STRUCT 'mnt_namespace' size=120 vlen=1
        'ns' type_id=11 bits_offset=64

我實(shí)際的 eBPF 程序當(dāng)中所定義的 struct task_struct只包含了state,pid,nsproxy三個(gè)結(jié)構(gòu)體這也在上面的數(shù)據(jù)得到了反映铆隘。

使用自定的 BTF

前文介紹的 BPF hub 能為低版本的內(nèi)核生成對應(yīng)的 BTF 文件,不過對于如何在 ebpf-go 當(dāng)中使用自定的 BTF 文件文檔中并沒有提及南用。在ebpfgo源碼的prog.go內(nèi)有相關(guān)的信息膀钠,ProgramOptions結(jié)構(gòu)體內(nèi)的KernelTypes就是用于傳遞自定 BTF 相關(guān)的內(nèi)容。測試代碼prog_test.go展示了用法裹虫≈壮埃總結(jié)一下在實(shí)際開發(fā)中,如下這段代碼就是標(biāo)準(zhǔn)的加載自定的 BTF 過程:

spec, err := loadBpf()
if err != nil {
    log.Fatalf("loading BPF error %v", err)
}
var options *ebpf.CollectionOptions
// 自定的 BTF 文件路徑嗎恒界,由 BTFHub 內(nèi)的工具制作而成
btfSpec, err := btf.LoadSpec("/root/4.19.0-18-amd64.btf")
if err != nil {
    log.Errorln(err)
    return
}
options = &ebpf.CollectionOptions{Programs: ebpf.ProgramOptions{KernelTypes: btfSpec}}
if err = spec.LoadAndAssign(&objs, options); err != nil {
    log.Fatalf("loading objects error %v", err)
}
defer objs.Close()

Note: 雖然未找到官方的文檔描述睦刃,不過實(shí)際開發(fā)中發(fā)現(xiàn)可以使用vmlinux.h+自定義 BTF 文件的實(shí)現(xiàn)所有的結(jié)構(gòu)結(jié)構(gòu)體引用,不在需要自己手動(dòng)的定義結(jié)構(gòu)體十酣。

變量替換

對于所監(jiān)控的指標(biāo)涩拙,我們往往期望它是更加動(dòng)態(tài)的,可以在運(yùn)行時(shí)指定的或者是以配置文件的形式傳入耸采。在bcc當(dāng)中有相當(dāng)多的例子都是在進(jìn)行時(shí)進(jìn)行字符替換兴泥,如runqslower.py,它用于發(fā)現(xiàn)那些處于調(diào)度隊(duì)列中太久的進(jìn)程虾宇,變量min_us是等待時(shí)間的閾值搓彻,其中關(guān)鍵代碼如下:

# ebpf 程序, 省略了一些代碼
delta_us = (bpf_ktime_get_ns() - *tsp) / 1000;
if (FILTER_US)
    return 0;
# 省略了一些代碼

# FILTER_US 將會被替換
if min_us == 0:
    bpf_text = bpf_text.replace('FILTER_US', '0')
else:
    bpf_text = bpf_text.replace('FILTER_US', 'delta_us <= %s' % str(min_us))

因?yàn)?ebpf-go 本身的實(shí)現(xiàn)方式(eBPF 程序需要經(jīng)過靜態(tài)編譯再被加載),進(jìn)行變量替換不能像bcc那樣簡便旭贬,這里介紹兩種方式的變量替換:

一怔接、使用RewriteConst函數(shù)

它的主要用法是在 eBPF C 程序中以 volatile const定義變量,在包含著 eBPF 程序被加載以后進(jìn)行運(yùn)行時(shí)的重寫稀轨。下面是監(jiān)控vfd_read函數(shù)的調(diào)用時(shí)長是否超過了預(yù)設(shè)的閾值示例代碼:

// 這個(gè)值會被重寫
const volatile u64 latency_thresh;

SEC("kprobe/vfs_read")
static int kprobe_vfs_read(struct pt_regs *ctx) {
    u64 ts = bpf_ktime_get_ns();
    u64 pid = bpf_get_current_pid_tgid();
    bpf_map_update_elem(&start_map,&pid,&ts,BPF_ANY);
    return 0;
}

SEC("kretprobe/vfs_read")
int kretprobe_vfs_read(struct pt_regs *ctx) {
    // 從 map 當(dāng)中取得進(jìn)入到 vfs_read 的時(shí)間戳
    u64 pid = bpf_get_current_pid_tgid();
    u64 *tsp = bpf_map_lookup_elem(&start_map,&pid);
    if (tsp == 0) {
        return 0;
    }
    // tsp 是進(jìn)入到 vfs_read 的時(shí)間戳扼脐,使用 kprobe/vfs_read 記錄到 map
    u64 latency = bpf_ktime_get_ns() - *tsp;
    if (latency > latency_thresh) {
        // 進(jìn)行數(shù)據(jù)的采集
    }
}

在 ebpf-go 的程序中對latency_thresh進(jìn)行重寫,下面的示例程序?qū)㈤撝翟O(shè)置為了 1ms。

// 內(nèi)核時(shí)間以 ns 為單位奋刽,將 ms 轉(zhuǎn)為 ns
thresh :=   time.Millisecond.Nanoseconds()
spec, err := loadBpf()
if err != nil {
    log.Fatalf("load bpf error %v", err)
}
consts := map[string]interface{}{
    "latency_thresh": thresh.String(),
}
if err = spec.RewriteConstants(consts); err != nil {
    log.Fatalf("RewriteConstants error:%v", err)
}
var objs = bpfObjects{}
if err = spec.LoadAndAssign(&objs, nil); err != nil {
    log.Fatalf("loading objects error %v", err)
}

對于該函數(shù)的使用可以參考文檔issue瓦侮。注意,該方法只能在5.2+的內(nèi)核可以使用佣谐,對于低版本內(nèi)核使用該操作會提示如下類似的錯(cuò)誤信息:

map .rodata: map create: read- and write-only maps not supported (requires >= v5.2)

確實(shí)肚吏,大部分的 eBPF 程序的錯(cuò)誤信息都很不直觀。該錯(cuò)誤的原因是對于全局變量(global constant)clang會在編譯期間將這些變量放到.rodata這個(gè)節(jié)(ELF section)狭魂,libbpf 會將這個(gè)節(jié)的數(shù)據(jù)寫入到一個(gè).rodata的map當(dāng)中并且在正式被加載到內(nèi)核之前被重寫罚攀,不過這個(gè)功能在 linux 5.2 內(nèi)核被引入。對于該問題的討論參考以下三個(gè)鏈接:

https://github.com/cilium/ebpf/discussions/592

https://arthurchiao.art/blog/bpf-advanced-notes-4-zh/

https://nakryiko.com/posts/bpf-tips-printk/

二趁蕊、通過內(nèi)聯(lián)匯編

這種方式比較 hack坞生,通過內(nèi)聯(lián)匯編在 ELF 文件的符號表內(nèi)寫入了一個(gè)符號,然后在 ebpf-go 程序內(nèi)進(jìn)行變量重寫掷伙。示例代碼如下:

u64 latency_thresh;
asm("%0 = thresh ll" : "=r"(latency_thresh));

該代碼會在 ELF 文件的符號表內(nèi)插入一個(gè)名為thresh的符號,這行代碼的意思是又兵,將thresh的賦值給latency_thresh任柜,即相當(dāng)于latency_tresh=thresh,那么我們要做的就是重寫thresh的值即可沛厨。

查看 ELF 文件的符號表宙地,確實(shí)內(nèi)聯(lián)匯編插入了一個(gè)新的符號。

# 省略了一些輸出
13: 0000000000000000   504 FUNC    GLOBAL DEFAULT    7 kprobe_finish_ta[...]
14: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND thresh # 內(nèi)聯(lián)匯編插入的符號
15: 0000000000000014    20 OBJECT  GLOBAL DEFAULT   10 latency_map
16: 0000000000000000    13 OBJECT  GLOBAL DEFAULT    9 LICENSE
17: 0000000000000000     8 OBJECT  GLOBAL DEFAULT   11 unused_data_t

下面要做的事情就是變量替換逆皮,ebpf-go 會將所要掛載的 eBPF 程序抽象為 ProgramSpec結(jié)構(gòu)體宅粥,它內(nèi)部有一個(gè)Name成員對應(yīng)于 eBPF 程序的函數(shù)名,所以我們只需要遍歷所有要掛載的 eBPF 程序电谣,按需重寫變量。還是以前面的 vfs_read函數(shù)為例,需要對kretprobe_vfs_read函數(shù)內(nèi)的latency_thresh進(jìn)行重寫丰涉,ebpf-go中關(guān)鍵部分程序如下颜价。

for _, prog := range spec.Programs {
    if prog.Name == "kretprobe_vfs_read" { // 選擇在哪個(gè)程序內(nèi)進(jìn)行變量重寫
        for i, ins := range spec.Programs[prog.Name].Instructions {
            if ins.Reference() == "thresh" {
                // 內(nèi)核時(shí)間是 ns,閾值為 1ms晒来,要將 ms 轉(zhuǎn)為 ns
                spec.Programs[prog.Name].Instructions[i].Constant = time.Millisecond.Nanoseconds()
                spec.Programs[prog.Name].Instructions[i].Offset = 0
            }
        }
    }
}

eBPF 程序的debug

對于 eBPF 程序的 debug 是相當(dāng)麻煩的钞诡,在這里我們演示一個(gè)訪存錯(cuò)誤的例子,在 eBPF 內(nèi)使用未初始化來作為 map 的 value 是不允許的,還是以 vfs_read 為例荧降,我們記錄每一個(gè)執(zhí)行vfs_read的時(shí)間戳并且記錄到map,如下:

static int trace_enter(struct pt_regs *ctx) {
    u64 pid = bpf_get_current_pid_tgid();
    u64 ts = bpf_ktime_get_ns();
    struct data_t data; // 沒有初始化接箫,只是聲明,這種寫法是不被允許的
    // struct data_t data = {}; 正確的寫法
    data.pid = pid >> 32;
    data.ts = ts;
    bpf_get_current_comm(&data.comm,sizeof(data.comm));
    bpf_map_update_elem(&start_map, &pid, &data, BPF_ANY);
    return 0;
}

運(yùn)行后程序提示如下的錯(cuò)誤信息:

invalid indirect read from stack R3 off -40+29 size 32

至于為什么內(nèi)核不允許這樣做朵诫,參考這里列牺。顯然這樣的提示信息對于如何發(fā)覺程序問題出在哪毫無用處,好在 ebpf-go 提供了方法能夠輸出 eBPF 的校驗(yàn)日志拗窃,代碼如下:

objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
    var ve *ebpf.VerifierError
    // 輸出校驗(yàn)日志瞎领,
    if errors.As(err, &ve) {
        fmt.Printf("Verifier error: %+v\n", ve)
    }
    log.Fatalf("loading objects: %v", err)
}
defer objs.Close()

運(yùn)行帶有bug的程序,terminal 會輸出校驗(yàn)日志:

; bpf_map_update_elem(&start_map, &pid, &data, BPF_ANY);
17: (18) r1 = 0xffff8948118b5800
19: (b7) r4 = 0
20: (85) call bpf_map_update_elem#2
invalid indirect read from stack R3 off -40+29 size 32

結(jié)果表明該bug的問題出現(xiàn)在bpf_map_update_elem這行代碼中随夸,結(jié)合前面的描述 eBPF 不允許 map 的 value 是未初始化的結(jié)構(gòu)體九默,這一切恰好對應(yīng)。相類似的問題還會出現(xiàn)在結(jié)構(gòu)體字節(jié)對齊的問題當(dāng)中宾毒,這個(gè)可以參考cilium文檔描述驼修。

對于 Debug 的討論: https://github.com/cilium/ebpf/discussions/838

官方文檔: https://pkg.go.dev/github.com/cilium/ebpf#example-Program-VerifierError

如何正確的操作 Map

參考了一些開源項(xiàng)目的寫法,他們使用 ebpf-go 讀取 eBPF map 的偽代碼如下诈铛,使用定時(shí)器來以某個(gè)時(shí)間間隔的讀取乙各。

for {
    select {
    case <- timer expier:
        return
    case <- ticker:
        readMapData()
    }
}

因此每一輪間隔結(jié)束后都會對整個(gè) map 重新遍歷,那么就會讀取到重復(fù)的數(shù)據(jù)幢竹。當(dāng)然會想到在遍歷的過程中耳峦,然而直接在遍歷過程中刪除數(shù)據(jù)是不安全的,這一點(diǎn)不同于go原生的map。所以可行的方法是焕毫,遍歷過程中記錄本輪所迭代的key蹲坷,遍歷完了再刪除。示例代碼如下:

var iter = objs.TsMap.Iterate()
var keys []uint64 // 記錄已經(jīng)遍歷過的entry的key
var key uint64
var val Data      // 結(jié)構(gòu)體邑飒,用于
for iter.Next(&key, &val) {
    // 注意: 不能再Iterator當(dāng)中刪除entry循签,這是不安全的
    keys = append(keys, key)
    fmt.Println(val.Pid, val.Latency, string(val.Comm[:]))
}

// 在本輪迭代中,將遍歷過的數(shù)據(jù)從map中刪除疙咸。
for _, k := range keys {
    err = objs.TsMap.Delete(&k)
    if err != nil {
        log.Errorln(err)
    }
}

對于遍歷 map 的過程中刪除元素是不安全的描述查看文檔: https://pkg.go.dev/github.com/cilium/ebpf#MapIterator.Next

從 C 結(jié)構(gòu)體到 Go 結(jié)構(gòu)體

在 ebpf-go 的底層使用的是反射來將 eBPF map內(nèi)的數(shù)據(jù)轉(zhuǎn)為 go 結(jié)構(gòu)體(所以如果手動(dòng)定義結(jié)構(gòu)體務(wù)必要保證Go結(jié)構(gòu)體的成員是大寫字母開頭)县匠。因此我們在定義的時(shí)候也必須保證 Go 結(jié)構(gòu)體定義順序、成員字節(jié)數(shù)與 eBPF 程序內(nèi)結(jié)構(gòu)體是完全一致的撒轮。但是手動(dòng)定義結(jié)構(gòu)體存在一些問題乞旦,會導(dǎo)致結(jié)構(gòu)體成員所反射出來的結(jié)果是錯(cuò)誤的,為此我向 ebpf-go的作者提了相關(guān)issue腔召,具體的整個(gè)過程可以查看 issue杆查,限于篇幅只闡述這個(gè)問題的基本表現(xiàn):

// c 結(jié)構(gòu)體 
struct data_t {
    u32 pid;
    u64 latency; 
    char comm[16];
};

// go 結(jié)構(gòu)體,會導(dǎo)致有bug
type Data struct {
    Pid     uint32
    Latency uint64
    Comm    [16]uint8
}

這兩個(gè)結(jié)構(gòu)體定義會讓 Go 程序所得到的 Latency成員是不正確的值臀蛛。該問題歸根到底的原因是編譯器對c結(jié)構(gòu)體的字節(jié)填充以及ebpf-go將c結(jié)構(gòu)體反射為Go結(jié)構(gòu)體并不是簡單的memcpy亲桦,它使用的是go-binary庫崖蜜。為了徹底地避免這個(gè)問題,可以使用 ebpf-go 的-type參數(shù)客峭,它的作用就是根據(jù) c 結(jié)構(gòu)體自動(dòng)地生成一個(gè) Go 結(jié)構(gòu)體豫领,它會負(fù)責(zé)處理好必要的字節(jié)填充問題,這一用法參考這個(gè)例子舔琅。注意等恐,該例子內(nèi)的這行語句是必須的:

struct rtt_event *unused_event __attribute__((unused));

否則不能生成所需的 Go 結(jié)構(gòu)體。此外备蚓,相類似的用例還可以參考這里课蔬。

參考資料

eBPF 的系統(tǒng)性資料相對零散、復(fù)雜郊尝,下面是我個(gè)人在學(xué)習(xí)當(dāng)中認(rèn)為相當(dāng)不錯(cuò)的參考文檔二跋。

https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md eBPF 的某些特性都是隨著內(nèi)核的變化而變化的,這個(gè)文檔列出了各種特性在哪個(gè)版本被加入到內(nèi)核流昏。

https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md bcc 倉庫的 API 文檔扎即,相較于 man page來說好懂一點(diǎn)。

https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md 入門 eBPF 很好的一個(gè)教程

https://arthurchiao.art/index.html 個(gè)人博客况凉,有相當(dāng)多的 eBPF 文章谚鄙,寫的很不錯(cuò)

https://nakryiko.com/ BPF CO-RE 核心開發(fā)者的個(gè)人博客

https://docs.cilium.io/en/latest/bpf/ cilium文檔,詳盡的對bpf做了描述

https://elixir.bootlin.com/linux/latest/source/samples/bpf Linux 內(nèi)核中 ebpf 程序的示例代碼

https://www.brendangregg.com/ bcc 的核心開發(fā)者之一 Brendan Gregg的博客

https://www.ebpf.top/ 一個(gè)關(guān)于 ebpf 的中文網(wǎng)站

https://github.com/cilium/ebpf ebpf-go刁绒,相較于其他的go實(shí)現(xiàn)闷营,這個(gè)做的最好,社區(qū)活躍度也高

https://man7.org/linux/man-pages/man2/bpf.2.html man page,內(nèi)容大而全就是有些晦澀膛锭,擇需參考

https://libbpf.readthedocs.io/en/latest/api.html libbpf 的 API 文檔

總結(jié)

本文先對 BPF CO-RE 做了基本介紹粮坞,描述了什么是 BPF CO-RE 和 它的一些使用樣例以及簡單地描述了背后的原理。還介紹了如何使用 BTFHub 解決低版本內(nèi)核不支持 BTF 的方案初狰。雖然的出發(fā)點(diǎn)是關(guān)注于 BPF CO-RE,但是我們使用 ebpf-go 開發(fā)監(jiān)控組件的過程中還是遇到了一些開發(fā)上的問題互例,我們將所遇到的問題奢入、解決辦法都一并地分享了出來。

最后媳叨,eBPF 是一個(gè)相對較新并且仍在快速變化的技術(shù)腥光,限于我的個(gè)人知識面未能完全的將所有的內(nèi)容都一一解釋清楚,對于文中相關(guān)內(nèi)容所存在的任何問題糊秆、技術(shù)上的誤解武福,歡迎大家反饋、討論痘番。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末捉片,一起剝皮案震驚了整個(gè)濱河市平痰,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌伍纫,老刑警劉巖宗雇,帶你破解...
    沈念sama閱讀 212,332評論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異莹规,居然都是意外死亡赔蒲,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,508評論 3 385
  • 文/潘曉璐 我一進(jìn)店門良漱,熙熙樓的掌柜王于貴愁眉苦臉地迎上來舞虱,“玉大人,你說我怎么就攤上這事母市》担” “怎么了?”我有些...
    開封第一講書人閱讀 157,812評論 0 348
  • 文/不壞的土叔 我叫張陵窒篱,是天一觀的道長焕刮。 經(jīng)常有香客問我,道長墙杯,這世上最難降的妖魔是什么配并? 我笑而不...
    開封第一講書人閱讀 56,607評論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮高镐,結(jié)果婚禮上溉旋,老公的妹妹穿的比我還像新娘。我一直安慰自己嫉髓,他們只是感情好观腊,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,728評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著算行,像睡著了一般梧油。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上州邢,一...
    開封第一講書人閱讀 49,919評論 1 290
  • 那天儡陨,我揣著相機(jī)與錄音,去河邊找鬼量淌。 笑死骗村,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的呀枢。 我是一名探鬼主播胚股,決...
    沈念sama閱讀 39,071評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼裙秋!你這毒婦竟也來了琅拌?” 一聲冷哼從身側(cè)響起缨伊,我...
    開封第一講書人閱讀 37,802評論 0 268
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎财忽,沒想到半個(gè)月后倘核,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,256評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡即彪,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,576評論 2 327
  • 正文 我和宋清朗相戀三年紧唱,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片隶校。...
    茶點(diǎn)故事閱讀 38,712評論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡漏益,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出深胳,到底是詐尸還是另有隱情绰疤,我是刑警寧澤,帶...
    沈念sama閱讀 34,389評論 4 332
  • 正文 年R本政府宣布舞终,位于F島的核電站轻庆,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏敛劝。R本人自食惡果不足惜余爆,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,032評論 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望夸盟。 院中可真熱鬧蛾方,春花似錦、人聲如沸上陕。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,798評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽释簿。三九已至亚隅,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間庶溶,已是汗流浹背枢步。 一陣腳步聲響...
    開封第一講書人閱讀 32,026評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留渐尿,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,473評論 2 360
  • 正文 我出身青樓矾瑰,卻偏偏與公主長得像砖茸,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子殴穴,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,606評論 2 350

推薦閱讀更多精彩內(nèi)容