接手过网络设备配置管理这块的开发者,应该都有一个共同的痛点:底层配置数据到处都是,接口千奇百怪,改一个参数可能要同时处理好几个关联模块,而且配置变更完全没有统一的通知机制,上层业务只能靠轮询去猜数据有没有变。
sysrepo 就是为了解决这个问题出现的。它是一个基于 C 语言实现的高性能 YANG 数据存储库,底层依赖 libyang 做 YANG 模型的解析和数据树操作,对外提供一套统一的 API,让上层应用可以用完全相同的方式读写配置数据,并且实时感知数据变化,还能通过 RPC 机制把你的业务操作暴露给外部调用方。简单说,sysrepo 是整个 YANG/NETCONF 生态里那块最关键的“数据底板”。
这篇指南适合以下几类人:刚接触 NETCONF/YANG 但被各种协议细节绕晕的新手、想在自己的设备管理框架里引入统一配置存储的架构师、以及打算直接基于 sysrepo 做二次开发的工程师。我会从设计思路一直讲到实际可用代码,尽量把“为什么这么做”说明白,而不是甩一堆 API 让你自己猜。
1. sysrepo 到底解决什么问题
1.1 没有统一数据存储时的开发噩梦
先回想一下没有 sysrepo 之前,网络设备里的配置数据是怎么存的。老派做法是设备上一个独立配置文件,格式可能接近 CLI,也可能是一坨私有 XML,还有的干脆直接写进 SQLite 表里。不同模块各自读写自己的那部分,彼此之间没有任何约束。
问题很快就出来了。第一,数据一致性没法保证,A 模块改了一个接口 IP,B 模块自己缓存的 IP 还是旧的,两边对不上。第二,没有统一的校验机制,一个 IP 地址写错了格式,直到业务真正跑起来才发现。第三,也是最疼的,上层应用想知道配置什么时候变了,只能定时去读配置文件比对,费劲而且不准时。
YANG 模型的引入把数据定义这件事正规化了,但光有模型没用,还得有人按模型去存数据、管数据。sysrepo 干的就是这个“管家”的活:它把 YANG 模型变成一棵活的数据树,所有模块都往这棵树上读写,谁改了数据,订阅过的人立刻收到通知,不存在“缓存过期”这种事。
1.2 sysrepo 的设计思路:把数据模型当共享总线
sysrepo 的核心设计思想可以概括成一句话:把 YANG 数据树当作系统的公共总线。所有业务进程不再是孤岛,而是围绕这棵树来协作。配置写入方调用 sysrepo API 更新节点,配置读取方也是通过同一套 API 查询,数据变更的实时通知走订阅机制自动推给关心的人。
这带来一个很明显的架构优势:模块之间彻底解耦。比如接口管理模块只需要关心接口相关的 YANG 节点,路由模块订阅它关心的节点,两边互不知道对方的存在,但数据是实时共享的。你再也不用维护一堆进程间的私有 IPC 协议,sysrepo 内部已经帮你处理好了。
而且 sysrepo 支持多数据存储,running 是当前生效配置,startup 是设备启动时加载的配置,candidate 是暂存区,可以改一堆后一次性提交。这个设计对实现 NETCONF 的 candidate 能力集特别关键,开发时先把改动写到 candidate 里,确认没问题再 commit 到 running,错误回滚也方便。
1.3 libyang 在其中的角色
sysrepo 本身不做 YANG 模型解析,这件事交给 libyang。libyang 负责把 YANG 模块编译成内部数据结构,把 XML/JSON 格式的数据实例解析成数据树,也能把数据树反向序列化成 XML/JSON。sysrepo 所有 API 里面出现的struct lyd_node类型就是 libyang 的数据节点,你用 libyang 构建或解析数据,用 sysrepo 做持久化与分发。
打个比方,libyang 是扳手和螺丝刀,sysrepo 是那个带抽屉的工具箱。工具箱本身不生产工具,但它把工具收纳得井井有条,你任何时候想用都能立刻拿到。
2. 开发环境准备与 sysrepo 构建指南
2.1 依赖关系全景图
动手写代码之前,先把依赖链条理清楚。sysrepo 的构建依赖主要有三块:libyang、libredblack(一个红黑树库)、protobuf 和 protobuf-c(用于内部事件消息的序列化转发)。如果你开启了一些测试选项,还会用到 cmocka 这类测试框架。
libyang 的版本要和 sysrepo 匹配。sysrepo 2.x 系列要求 libyang 2.x 版本,如果混用 1.x 的 libyang,编译直接报错。这块我踩过坑,最稳妥的方式是直接从源码构建配套版本,不要去系统仓库碰运气。
构建顺序一般是先装 libyang,再装 libredblack,然后是 protobuf 和 protobuf-c,最后才是 sysrepo 本体。如果你的系统里已经有 protobuf,注意版本别太老,sysrepo 内部对 protobuf 的版本有要求。
2.2 从源码构建 libyang 与 sysrepo
以 Ubuntu/Debian 系为例,先把基础编译工具和依赖装上:
sudo apt install build-essential cmake pkg-config libpcre2-dev libcmocka-dev然后构建 libyang。官方 GitHub 仓库CESNET/libyang,切到 v2.x 分支:
git clone https://github.com/CESNET/libyang.git cd libyang git checkout v2.1.30 mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. make -j$(nproc) sudo make install sudo ldconfig接着构建 sysrepo。仓库是sysrepo/sysrepo:
git clone https://github.com/sysrepo/sysrepo.git cd sysrepo git checkout v2.2.88 mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. make -j$(nproc) sudo make install sudo ldconfig如果 CMake 找不到 libyang,多半是 pkg-config 路径没刷新,在构建目录里手动指定:
cmake -DCMAKE_PREFIX_PATH=/usr/local -DLIBYANG_INCLUDE_DIR=/usr/local/include -DLIBYANG_LIBRARY=/usr/local/lib/libyang.so ..构建完之后,sysrepo 默认会把数据存储文件和 socket 文件放在/etc/sysrepo和/var/run/sysrepo下。如果之后想换路径,可以在 CMake 配置时用-DCMAKE_INSTALL_PREFIX统一改掉,但客户端和服务端的路径得保持一致,不然连不上。
提示:开发阶段可以先不加
-DCMAKE_BUILD_TYPE=Release,用 Debug 模式方便 gdb 调试。性能调优时再切回 Release。
3. 第一个 sysrepo 程序:连接、读取与模块管理
3.1 用 sr_connect 和 sr_session_start 建立会话
sysrepo 的编程模型里,一切操作都围绕 session 展开。session 代表一个到 sysrepo 守护进程的逻辑连接,所有读写、订阅都挂在这个 session 下。
最小可运行的一段代码长这样:
#include <stdio.h> #include <sysrepo.h> int main(void) { sr_conn_ctx_t *conn = NULL; sr_session_ctx_t *sess = NULL; int rc = sr_connect(SR_CONN_DEFAULT, &conn); if (rc != SR_ERR_OK) { fprintf(stderr, "sr_connect failed: %s\n", sr_strerror(rc)); return -1; } rc = sr_session_start(conn, SR_DS_RUNNING, &sess); if (rc != SR_ERR_OK) { fprintf(stderr, "sr_session_start failed: %s\n", sr_strerror(rc)); sr_disconnect(conn); return -1; } printf("connected to sysrepo, running datastore session ok\n"); sr_session_stop(sess); sr_disconnect(conn); return 0; }编译命令:
gcc -o first first.c -lsysrepo -lyang跑之前需要确认 sysrepo 守护进程sysrepod在运行,以及它的插件目录/usr/local/lib/sysrepo/plugins存在。没有守护进程的话,sr_connect会返回SR_ERR_INIT_FAILED,这个坑下面排查章节还会细说。
3.2 数据读写操作的核心套路
有了 session,下一步就是读写数据。sysrepo 的写操作套路是:先用 libyang 构建数据节点,然后在 session 上执行sr_set_item,最后sr_apply_changes才真正生效。这里“先构建,再设置,再提交”三步走,跟数据库事务很像。
假设我们有一个 YANG 模块example-module,里面有一个 leaf 叫hostname,写数据的代码示例:
sr_val_t val = {0}; val.xpath = "/example-module:system/hostname"; val.type = SR_STRING_T; val.data.string_val = "edge-router-01"; rc = sr_set_item(sess, val.xpath, &val, SR_EDIT_DEFAULT); if (rc != SR_ERR_OK) { printf("sr_set_item failed: %s\n", sr_strerror(rc)); return -1; } rc = sr_apply_changes(sess, 0, 0); if (rc != SR_ERR_OK) { printf("sr_apply_changes failed: %s\n", sr_strerror(rc)); return -1; }读数据有两种方式。一种还是走sr_val_t,适合读标量叶子节点;另一种是直接把整个子树拉出来,得到struct lyd_node树再遍历。官方更推荐后者,因为数据稍微复杂一点,扁平的sr_val_t列表就很难处理了。
struct lyd_node *tree = NULL; rc = sr_get_data(sess, "/example-module:system//.", 0, 0, 0, &tree); if (rc != SR_ERR_OK) { printf("sr_get_data failed: %s\n", sr_strerror(rc)); return -1; } LYD_TREE_DFS_BEGIN(tree, node) { printf("node: %s\n", LYD_NAME(node)); const char *val = lyd_get_value(node); if (val) { printf(" value: %s\n", val); } } LYD_TREE_DFS_END(tree, node); lyd_free_all(tree);注意sr_get_data的 xpath 用了//.这种写法,这是 libyang 支持的 XPath 子集,表示取这个路径下的整棵子树。如果只想知道某个叶子节点的值,用精确路径就行。
3.3 模块安装与模块实现状态
sysrepo 本身不会自动安装 YANG 模块,你写代码之前得先把模块装进去。模块来源可以是 YANG 文件,也可以是已编译的.yin文件。
rc = sr_install_module(conn, "/path/to/example-module.yang", NULL, NULL); if (rc != SR_ERR_OK) { printf("sr_install_module failed: %s\n", sr_strerror(rc)); return -1; }安装完模块还没有“实现(implemented)”。sysrepo 里一个模块如果只是被安装,它能用来做数据校验,但它不会被实际存储。要让一个模块真正参与数据管理,必须有一个运行中的进程把自己声明为该模块的实现者。
声明实现者的接口是sr_module_change_subscribe,在第六节会详细讲。这里先说一个常见困惑:我明明安装了模块,为什么sr_get_data查不到数据?大概率就是你只装了模块,没有声明实现,或者实现者进程没起来。
4. 用代码实现一个 RPC 服务端
标题里特意提到“支持 rpc 的数据库”,这其实是 sysrepo 很容易被忽略但对业务开发至关重要的能力。想象一下设备对外提供“重启接口”“清理缓存”这类操作,这些操作不属于配置数据,而是一次性动作。YANG 模型用 RPC 节点来描述这类操作,sysrepo 则负责把这个 RPC 从外部请求路由到一个实现了该处理的进程上。
4.1 在 YANG 模型里定义 RPC
先定义一个带 RPC 的 YANG 模块:
module example-rpc { yang-version 1.1; namespace "urn:example:rpc"; prefix exrpc; rpc reset-device { input { leaf confirm { type boolean; mandatory true; } } output { leaf result { type string; } } } }这个 RPC 叫reset-device,入参是confirm布尔值,出参是一个result字符串。语义很清晰:调用方必须显式确认,服务端执行后返回结果。
4.2 注册 RPC 处理器并实现回调
在业务进程里,用sr_rpc_subscribe_tree注册这个 RPC 的处理回调:
static int rpc_reset_device_cb(const char *op_path, const struct lyd_node *input, struct lyd_node **output, void *private_data) { /* 从 input 树里取 confirm 字段 */ const struct lyd_node *confirm_node = NULL; struct lyd_node_inner *parent = (struct lyd_node_inner *)input; LYD_TREE_DFS_BEGIN(input, node) { if (strcmp(LYD_NAME(node), "confirm") == 0) { confirm_node = node; break; } } LYD_TREE_DFS_END(input, node); if (confirm_node == NULL) { return SR_ERR_VALIDATION_FAILED; } const char *confirm = lyd_get_value(confirm_node); if (strcmp(confirm, "true") != 0) { return SR_ERR_VALIDATION_FAILED; } /* 这里执行真正的设备重置逻辑 */ printf("device resetting...\n"); /* 构造 output 树 */ char result[64] = "reset done"; sr_session_ctx_t *ev_sess = (sr_session_ctx_t *)private_data; struct ly_ctx *ly_ctx = sr_session_get_context(ev_sess); struct lyd_node *output_tree = NULL; lyd_new_path(ly_ctx, NULL, "/example-rpc:reset-device/result", result, 0, &output_tree); *output = output_tree; return SR_ERR_OK; }注册过程:
rc = sr_rpc_subscribe_tree(sess, "/example-rpc:reset-device", rpc_reset_device_cb, sess, 0, 0, NULL); if (rc != SR_ERR_OK) { printf("sr_rpc_subscribe_tree failed: %s\n", sr_strerror(rc)); return -1; }这个注册动作隐含了两个效果:一是把当前进程标记为example-rpc模块的实现者,二是告诉 sysrepo 守护进程,以后收到reset-device这个 RPC 请求,就往我这个进程转发。回调里我传的 private_data 直接放 session 指针,这样在回调里能用它来做上下文相关的操作。
等 RPC 处理完成,把构造好的 output 树赋值给*output返回,sysrepo 负责把这个树序列化回给 RPC 调用方。
4.3 RPC 的阻塞、超时与错误处理
RPC 回调是在 sysrepo 的事件线程里执行的,默认情况下如果回调执行时间过长,调用方会一直等着。sysrepo 有一个全局的 RPC 执行超时限制,如果你在配置文件里或者 API 参数里设置了较短超时,就会出现标题热词里提到的 “cannot finish rpc call in 30 seconds” 这种报错。
处理长耗时 RPC 的正确姿势是:回调里先把任务接住,放到自己的工作线程池,立刻返回SR_ERR_OK,等任务执行完了再通过sr_event_notif_send或者另外的机制把结果通知调用方。一步到位的同步返回只适合几毫秒内的操作。
错误处理上,回调返回值就是 RPC 的最终结果。返回SR_ERR_OK表示成功,返回SR_ERR_VALIDATION_FAILED会被调用方理解为参数校验失败,返回SR_ERR_OPERATION_FAILED表示业务执行失败。调用方通过sr_rpc_send_tree拿到的返回码就能区分不同失败语义,这在做上层错误码映射的时候特别省事。
5. 数据订阅与实时通知的开发要点
5.1 模块变更订阅还是树变更订阅
sysrepo 提供两种级别的数据变更订阅:模块变更订阅sr_module_change_subscribe和子树变更订阅sr_subtree_change_subscribe。
模块变更订阅的粒度是整个 YANG 模块,回调里给你模块名和变更的会话信息,你自己去查看到底哪个节点变了。它的优点是事件通知快、开销小,适合一个模块只有一个实现者、且模块内部节点都由同一进程处理的场景。
子树变更订阅的粒度可以精确到一个 leaf、一个 container,甚至带条件的 XPath。sysrepo 在调用回调之前会帮你算好变更集,回调里直接拿到sr_change_iter_t迭代器,挨个看操作类型(创建、修改、删除)和变更前后的值。这个 API 更适合上层应用只关心特定几个节点的情况,比如告警模块只需要监听接口状态节点,不需要关心路由配置。
举一个使用sr_subtree_change_subscribe的典型场景:监控接口 IP 变更。
static int iface_ip_change_cb(sr_session_ctx_t *session, const char *module_name, const char *xpath, sr_event_t event, uint32_t request_id, void *private_data) { sr_change_iter_t *iter = NULL; sr_change_oper_t oper; sr_val_t *old_val = NULL; sr_val_t *new_val = NULL; rc = sr_get_changes_iter(session, "/example-module:interface[name='eth0']/ip-address", &iter); if (rc != SR_ERR_OK) return rc; while (sr_get_change_next(iter, &oper, &old_val, &new_val) == SR_ERR_OK) { printf("change oper=%d\n", oper); if (old_val) printf("old=%s\n", old_val->data.string_val); if (new_val) printf("new=%s\n", new_val->data.string_val); sr_free_val(old_val); sr_free_val(new_val); } return SR_ERR_OK; }回调里不能直接调用任意阻塞型 API,因为它是跑在 sysrepo 事件处理线程上的。真要执行耗时工作,把数据拷贝回来丢给线程池。这是新手最容易犯的错:在回调里 sleep 或者做耗时 IO,结果整个 sysrepo 事件循环被卡住,其他模块的通知也跟着堵了。
5.2 线程模型与回调安全
sysrepo 的订阅回调默认是串行触发的,同一个订阅的回调不会并发进入,这能免去一部分加锁烦恼。但不同订阅之间是可能并发的,所以不同模块之间的共享数据还是得做好同步。
我在实际项目里习惯把回调里的工作压缩到最小:只解析数据、更新本地缓存、触发一个条件变量,真正的处理逻辑全部丢到自己的工作线程。这样整个 sysrepo 回调路径永远保持微秒级,不会阻塞其他事件。
另外要注意 libyang 的 context 是线程安全的,但sr_session_ctx_t在一个时刻只允许一个线程使用。如果你在回调里用到了传入的 session 去查询数据,不要在把 session 传给其他线程的持有者同时使用,否则可能出现数据竞争。
5.3 事件通知的发送与接收
除了数据变更通知,sysrepo 还能发送独立的事件通知,对应 YANG 里的 notification 定义。这个适合比如“设备温度过高”这种非配置、非数据变更的实时告警。
发送端调用sr_event_notif_send_tree:
struct lyd_node *notif_tree = NULL; lyd_new_path(ly_ctx, NULL, "/example-module:status-change/new-status", "up", 0, ¬if_tree); rc = sr_event_notif_send_tree(sess, notif_tree, 0, 0);接收端用sr_notif_subscribe_tree订阅,回调里拿到的是通知的完整数据树。这套机制特别适合做设备上报,而且它跟 NETCONF 的通知机制是打通的,底层自动帮你处理了编码和投递。
6. 常见问题与排查技巧实录
| 问题 | 可能原因 | 解决思路 |
|---|---|---|
sr_connect返回SR_ERR_INIT_FAILED | sysrepod 没启动 / socket 路径不对 / 权限不足 | 起服务、检查/var/run/sysrepo路径、用 root 跑测试程序 |
| 模块装完查不到数据 | 模块没有实现者 / 实现者进程没起来 | 用sysrepoctl -l看模块 enabled 状态和实现者列表 |
| RPC 调用报 30 秒超时 | 回调里做了耗时操作没返回 | 改成异步处理,或调大配置文件里的 timeout |
sr_apply_changes失败 | YANG 约束校验没过 / 引用的模块没装全 | 看具体返回码,先检查模块依赖是否完整 |
| 回调里查询不到最新数据 | 忘记了sr_apply_changes/ 使用了错误的 datastore | 确认 session 指向的 datastore,确认提交完成 |
调试时我最常用的工具是sysrepoctl和sysrepod带参数启动。sysrepoctl -l能列出所有已安装模块、模块状态、实现者 PID,基本一眼就能看出模块装没装对。服务端调试时先停掉系统服务,用下面命令前台起守护进程,日志直接刷终端:
sudo sysrepod -l debug -d -v2-l debug开日志级别,-d前台运行,-v2提高详细度。这样跑起来之后,再从你的程序发一个 RPC,sysrepo 内部收到什么、转发到哪个进程、回调返回什么,全都能看到。这套组合拳帮我解决过至少十几个“看不见原因”的神奇 bug。
另外推荐给正式工程加上sr_event_notif_disable之外的遥测监控:用一个独立脚本定时调用sr_get_data拉取关键节点,配合 sysrepo 自带的日志,把数据层的状态和业务层状态做交叉比对。一旦两边数值对不上,问题基本都能秒定位。
根据我的经验,在集成 sysrepo 的初期尤其要注意订阅和模块实现之间的关系。很多人只把 sysrepo 当成一个简单数据库来用,装了模块就丢程序去跑,完全不管模块是否处于 implemented 状态,结果数据写不进去或者根本拿不到通知。实际上 sysrepo 的“每个模块必须有一个实现者”这个设计,既是限制,也是保护——它保证了每个模块的配置改动一定能被某个进程感知到,不让数据躺在存储里没人管。
7. 实战经验:把 sysrepo 接入现有系统时的一些心得
最后结合几个真实项目里踩过的坑,说点文档之外的东西。
第一,不要一上来就把所有配置都塞进 sysrepo。先挑一个边界清晰、改动频率中等的模块(比如系统 hostname,NTP 配置)做试点,把订阅、落库、回滚这整条链路跑通,再逐步铺开。sysrepo 虽然是中心化存储,但它不会帮你解决模块划分的问题,模型设计得烂,后面所有代码都跟着难受。
第二,RPC 命名规范一定要提前定好。多个业务进程如果都叫reset,在统一的 YANG 命名空间里会撞车。我习惯在模块名前缀上做文章,比如edge-routing:reset、device-mgmt:reset,尽量把命名空间和业务域绑在一起,后续做 NETCONF mapping 时会顺畅很多。
第三,性能调优别过早做。sysrepo 的本地操作足够快,绝大多数场景根本到不了瓶颈。真遇到高频率数据更新的场景,优先检查是不是某个应用在疯狂轮询查询,而不是责怪 sysrepo 本身。把查询换成订阅,负载能降一个量级。
第四,用好candidate数据存储。NETCONF 的候选配置能力集在生产环境里非常有用,尤其是批量修改配置时,可以先全部写到 candidate 里做校验,确认无误再 commit。sysrepo 对candidate的支持非常完整,很多老牌设备实现反而没有这么干净的 API。
我最早接触 sysrepo 的时候,也觉得引入一个额外的数据存储层是增加复杂度,但用过一段时间之后发现,它其实帮你把最脏最乱的配置管理活给干了。数据一致性、实时通知、并发控制、模块校验,这些事情自己写很容易写崩,交给 sysrepo 之后,我只需要关心业务逻辑本身。希望你读完这篇指南后,能少走一点我当时走过的弯路。