使用 Lua 的动态对象接口

noah

在这篇文章中,我将演示如何使用 Lua 脚本语言动态扩展 RADOS 中对象的接口,然后构建一个示例服务,用于图像缩略图生成和存储,该服务在目标对象存储设备 (OSD) 内部执行远程图像处理。我们将玩得很开心。

在开始之前,由于这是我在 ceph.com 上的第一篇文章,我想先介绍一下我自己。我是 Noah Watkins,一名博士生,偶尔也会为 Ceph 项目做贡献。我曾在 Inktank 实习,并且我还维护 Ceph Hadoop 绑定。

.

RADOS 对象类

RADOS 对象存储的一个不太为人所知的功能是,通过编写 C/C++ 插件来扩展对象接口的能力,这些插件可以添加新的远程执行目标,从而对对象数据执行任意操作。能够将用户定义的函数添加到 OSD 是一项非常强大的功能,它允许应用程序减少网络往返次数和数据移动,利用远程资源,并通过利用远程操作执行的事务上下文来简化否则复杂的接口。但营销就到此为止——这是一个简单的示例,它计算对象的 MD5 哈希值,而无需通过网络传输对象负载。

示例:对象的 MD5 哈希值

客户端计算对象 MD5 哈希值的直接方法是首先检索整个对象,然后将 MD5 哈希函数应用于本地数据。使用 librados 和 crypotpp 库,这可能如下所示

bufferlist data; size_t size;

ioctx.read("my_obj", data, 0, 0);

byte digest[AES::BLOCKSIZE]; MD5().CalculateDigest(digest, (byte*)data.c_str(), data.length());

在这里,客户端首先通过网络读取整个对象,然后计算对象数据的 MD5 哈希值。但是,可以通过引入一个用于在存储系统中计算 MD5 哈希值的自定义对象接口来避免传输整个对象。以下代码片段说明了如何使用对象类设施计算 MD5 哈希值的基本方法。请注意,以下代码实际上将被编译成共享库并动态加载到正在运行的 OSD 进程中,但我们省略了部署细节以保持简单(在本文末尾提供了有关开始使用对象类的更多信息的链接)。

int compute_md5(cls_method_context_t hctx, bufferlist *in, bufferlist *out) { size_t size; int ret = cls_cxx_stat(hctx, &size, NULL); if (ret < 0) return ret;

bufferlist data; ret = cls_cxx_read(hctx, 0, size, data); if (ret < 0) return ret;

byte digest[AES::BLOCKSIZE]; MD5().CalculateDigest(digest, (byte*)data.c_str(), data.length());

out->append(digest, sizeof(digest)); return 0; }

在解释 compute_md5 函数之前,让我们看看客户端如何远程调用 compute_md5 来计算哈希值

bufferlist input, output; ioctx.exec("my_obj", "my_hash_class", "compute_md5", input, output);

在这里,客户端运行 librados exec 方法,以在名为“my_obj”的对象上远程调用 compute_md5 函数。请注意,“my_hash_class”是一个标识插件的名称(在本教程中未显示),并且可能包含许多可以远程调用的函数。现在,通过网络的力量,以及大量的挥手动作,客户端可以调用上面的 compute_md5 函数,该函数将在存储目标对象的 OSD 上远程运行(这些是关于这实际上如何发生的许多令人厌烦的细节,超出了本文档的范围)。当远程方法执行时,它会执行一个事务,该事务以原子方式读取对象负载并计算 MD5 哈希值,所有这些都在 OSD 进程内进行,避免了任何对象数据的网络传输。在 compute_md5 函数的末尾,摘要被写入 out 参数,该参数将被编组回客户端。

这真是太神奇了。但是,在某些情况下,将 C/C++ 编译成共享库(可能针对多个目标架构)的开销太大了。如果我们能够动态地注入和更改对象接口,那就太好了。为了满足这种需求,我们创建了一种使用 Lua 脚本语言定义新对象类的机制,我将在下面描述。

其他资源:对象类开发

虽然有必要介绍对象类的概念,但不幸的是,在本篇文章中无法提供关于该主题的完整教程。位于 github 上的“Hello, World”示例对象类包含广泛的文档:https://github.com/ceph/ceph/blob/master/src/cls/hello/cls_hello.cc。这是一个很好的起点,如果您有任何问题,请随时在 Ceph 邮件列表或 IRC 频道上提问。

使用 Lua 的动态对象类

为了支持动态生成对象接口,我们将 LuaJIT VM 嵌入到 OSD 进程中。你可能会问,为什么选择 Lua?Lua 语言及其运行时专门设计为嵌入式语言,并且与 LuaJIT 虚拟机结合使用时,可以实现接近本地性能。简而言之,当前实现期望将包含任何数量的函数处理程序的 Lua 脚本发送到 OSD,以及客户端请求,该请求指定要执行脚本中的哪个特定函数。现在让我们深入了解细节。

Lua 对象类是一个任意的 Lua 脚本,其中包含至少一个导出的函数处理程序,客户端可以远程调用它。通过构建处理程序集合,可以构造新的有趣的接口到对象,并动态地加载到正在运行的 RADOS 集群中。以下 Lua 脚本的基本结构如下所示

-- 辅助模块 -- 辅助函数 -- 等...

function helper() end

function handler1(input, output) helper() end

function handler2(input, output) end

cls.register(handler1) cls.register(handler2)

在上面的 Lua 脚本中,可以使用任意数量的函数和模块来支持由函数 handler1handler2 导出的行为。客户端可以远程执行任何已注册的函数,提供任意输入,并接收任意输出。

处理程序注册

用 Lua 编写的对象类可能有许多函数,其中只有一部分函数可供客户端直接调用。为了使 Lua 函数可用,必须通过注册它来导出该函数。这是使用 cls.register 函数完成的。以下代码片段说明了它的工作原理。

function helper() -- 帮助处理事情 end

function thehandler(input, output) helper() end

cls.register(thehandler)

在上面的示例中,cls.register(thehandler) 导出函数 thehandler,使其可供客户端调用。如果客户端尝试调用未注册的函数 helper,则会收到返回值 -ENOTSUPP

错误处理语义

在上一节中,我们展示了一个用 C++ 编写的对象类方法,该方法计算对象的 MD5 哈希值。回到这个例子,请注意,对象上的每个操作都经过仔细检查是否有错误,如果任何操作失败,则返回错误代码。当从对象类处理程序返回负值时,当前事务将被中止,并且返回值将传递回客户端。当处理程序成功完成时,返回零将提交事务。虽然在 C++ 中我们必须显式地执行这些检查,但在 Lua 中,这种处理错误的常见模式可以完全管理。例如,以下 C++ 对象类处理程序

int handle1(cls_method_context_t hctx, bufferlist *in, bufferlist *out) { int ret = cls_cxx_create(hctx, true); if (ret < 0) return ret; ... return 0; }

处理程序 handle1 将返回 -EEXIST(如果对象已存在,或者在运行 cls_cxx_create 时遇到任何其他错误),如果处理程序成功完成,则返回零。相同的函数可以在 Lua 中构造,但是当错误处理符合这种自动中止的常见模式时,Lua 对象类运行时将自动选择正确的返回值。例如,在以下示例中,handle2handle3 具有与上面用 C++ 定义的 handle1 相同的语义。

function handle2(input, output) cls.create(true); return 0; end

function handle3(input, output) cls.create(true); end

cls.register(handle2) cls.register(handle3)

有些操作返回我们可能想要直接处理的错误代码。例如,从对象映射中检索值时,使用 -ENOENT 来指示给定的键未找到。如果处理程序代码可以处理这种情况(例如,创建和初始化新的键),那么简单地返回所有其他错误代码就足够了。以下 C++ 处理程序说明了这种情况,其中我们在任何不是 -ENOENT 的错误代码上中止。

int handle(cls_method_context_t hctx, bufferlist *in, bufferlist *out) { string key; ::decode(key, *in); int ret = cls_cxx_map_get_val(hctx, key, &bl); if (ret < 0 && ret != -ENOENT) return ret; if (ret == -ENOENT) { /* 初始化新键 */ } ... return 0; }

相同的处理程序可以用 Lua 如下构造

function handle(input, output) key = input:str() ok, ret_or_val = pcall(cls.map_get_val, key) if not ok then if ret_or_val ~= -cls.ENOENT then return ret_or_val else -- 初始化新键 end end val = ret_or_val ... return 0 end

这里的技巧是通过 Lua pcall 函数以受保护模式调用 cls.map_get_val,这可以防止任何错误自动传播到调用者,从而允许我们的处理程序检查返回值。

日志记录

对象类可以将调试信息写入 OSD 日志(例如,/var/log/ceph/osd-0.log)使用 cls.log 函数。该函数接受任意数量的参数,这些参数将转换为字符串并在最终输出中用空格分隔。如果第一个参数是数字,则将其解释为日志级别。如果未指定日志级别,则使用默认日志级别。

cls.log('hi') -- 将记录 'hi' cls.log(0, 'ouch') -- 在日志级别 = 0 记录 'ouch' cls.log('foo', 'bar') -- 记录 'foo bar' cls.log(1) -- 将在默认日志级别记录 '1'

日志记录对于调试脚本执行很有用,并且还可以用于提供更详细的错误信息。

对象负载 I/O

可以使用 cls.readcls.write 函数读取和写入对象的负载数据。每个函数都接受一个偏移量和一个长度参数。

size, mtime = cls.stat() data = cls.read(0, size) -- 从偏移量 0 读取 size 字节 cls.write(0, data:length(), data) -- 在偏移量 0 处写入 data 的长度

索引访问

支持范围查询的键/值存储(基于 Google 的 LevelDB)可以使用 cls.map_set_valcls.map_get_val 函数访问。键可以是任何字符串,值是任何大小的标准 blob。

function handler(input, output) cls.map_set_val("foo", input) data = cls.map_get_val("foo") assert(data == input) end

其他资源

Lua 对象类设施尚未位于主 Ceph 树中。该功能位于 cls-lua 分支上,可以从 github 上签出

git://github.com/ceph/ceph.git cls-lua

适用于从源代码构建和安装 Ceph 的常规过程适用,并且唯一的依赖项是必须安装 LuaJIT 开发库。这些依赖项在 Ubuntu 上可用。此外,已实现了比本文中列出的更多功能,并且一组单元测试可在源代码树中找到,演示了所有功能的范围。

Lua 客户端库

在进入示例应用程序之前,我将介绍另外两个组件,它们将使我们的生活更轻松。第一个是 librados 的 Lua 绑定,第二个是一个隐藏将 Lua 脚本序列化以在 OSD 内执行的细节的 Lua 库。

lua-rados

Lua 绑定 librados 客户端库在 https://github.com/noahdesu/lua-rados/ 上可用。在这里,我们将提供一个简要概述以供参考。请参阅 完整文档以获取更多信息。好的,让我们直接开始。以下代码片段显示了如何连接到 RADOS 集群

local rados = require "rados"

local cluster = rados.create() cluster:conf_read_file() cluster:connect()

接下来,为特定的池打开客户端 I/O 上下文

local ioctx = cluster:open_ioctx('data')

现在 Lua 客户端可以与对象交互,例如设置扩展属性

local name = 'xattr key' local data = 'i am some important data' ioctx:setxattr('my_obj', name, data, #data)

这些是编写 RADOS Lua 客户端的基础知识。现在,让我们从 Lua 客户端运行一些远程脚本。

cls-lua-client

将脚本发送到 OSD 的协议非常简单,但很容易封装在一个便利库中。位于 github 上的 cls-lua-client 在 https://github.com/noahdesu/cls-lua-client/ 上,正是这样做的,它构建在上一节中描述的 lua-rados 库之上。假设我们已经连接到 RADOS 集群并构建了一个 I/O 上下文对象,则可以像以下示例中那样执行远程 Lua 脚本。首先,让我们创建一个包含我们要执行的脚本的 Lua 字符串。

local script = [[ function say_hello(input, output) output:append("Hello, ") if #input == 0 then output:append("world") else output:append(input:str()) end output:append("!") end cls.register(say_hello) ]]

上面的脚本将在其输出中发送字符串“Hello, world!”(如果输入长度为零)。否则,它将回复“Hello,!”,其中将被客户端发送的输入所取代。可以使用 cls-lua-client 库如下远程执行

local ret, outdata = clslua.exec(ioctx, "oid", script, "say_hello", "") print(outdata)

local ret, outdata = clslua.exec(ioctx, "oid", script, "say_hello", "John") print(outdata)

执行此操作将产生以下输出

Hello, world! Hello, John!

太好了,现在我们拥有构建示例应用程序的所有组件!

示例应用程序:图像缩略图服务

作为一个驱动示例,我们将构建一个基于 RADOS 的服务,用于存储和生成图像缩略图。该服务非常简单,具有以下属性。

  1. 将图像写入对象会设置“base”或“original”图像数据。
  2. 可以从 OSD 内部远程生成从基本图像计算得出的缩略图。
  3. 可以检索原始图像和任何生成的缩略图。

在以下示例中,我将演示该服务的核心。在实践中,这些例程将被添加到更大的项目或可执行文件中,并且当然会针对错误和不同的边缘情况进行更强大的处理。该服务的完整示例可以在 cls-lua-client github 项目中找到,网址为 https://github.com/noahdesu/cls-lua-client/blob/master/examples/imgserv.lua

存储图像

要在 RADOS 中存储图像,我们首先从本地文件读取它,然后将其写入对象。为了支持存储和检索不同的缩略图,我们将图像 blob 的位置和大小记录在对象索引中,记录在描述它的键下。在这个简单的示例中,写入图像会将其设置为基本图像,因此我们将其存储在键“original”下。

function put(object, filename) -- 从文件读取图像 blob local file = io.open(filename, "rb") local img = file:read("*all")

-- 将 blob 写入对象 local size, offset = #img, 0 ioctx:write(object, img, size, offset)

-- 在对象索引中记录大小/偏移量 local loc_spec = size .. "@" .. offset ioctx:omapset(object, { original = loc_spec, }) end

减少往返次数

在前面的示例中,需要两个往返才能 1) 设置对象数据和 2) 更新索引。可以通过使用协同设计的接口,在单个往返中原子地完成这些操作,如下面的脚本所示

function put_smart(object, filename) -- 定义要远程执行的脚本 local script = [[ function put(img) -- 写入输入 blob local size, offset = #img, 0 cls.write(offset, size, img)

-- 更新 leveldb 索引 local loc_spec_bl = bufferlist.new() local loc_spec = size .. "@" .. offset loc_spec_bl:append(spec) cls.map_set_val("original", loc_spec_bl) end cls.register(store) ]]

-- 从文件读取输入图像 blob local file = io.open(filename, "rb") local img = file:read("*all")

-- 远程执行脚本,将图像作为输入 clslua.exec(ioctx, object, script, "put", img) end

该脚本从文件中读取图像,并将图像作为输入发送到在 OSD 上执行的脚本,同时处理写入和索引更新。真棒!

检索图像

要读取图像的特定版本,我们需要查找目标图像 blob 的偏移量和长度,这些信息存储在对象索引中。在下面的示例中,索引查找和对象读取是在远程执行的,如果存在该图像,则将其返回给客户端。在下一节中,我将展示如何存储 *spec* 字符串,但为了便于理解,它描述了创建缩略图的规范(例如,500×400 像素)。

function get(object, filename, spec) local script = [[ function get(input, output) -- 根据规范查找图像的位置 local loc_spec_bl = cls.map_get_val(input:str()) local size, offset = string.match(loc_spec_bl:str(), "(%d+)@(%d+)")

-- 从对象读取并返回图像 blob out_bl = cls.read(offset, size) output:append(out_bl:str()) end cls.register(get) ]]

-- 远程执行脚本 ret, img = clslua.exec(ioctx, object, script, "get", spec)

-- 将图像写入输出文件 local file = io.open(filename, "wb") file:write(img) end

脚本返回的图像随后被写入输出文件。

生成缩略图

缩略图使用 Lua 包装器生成到 ImageMagick ,可在 github 上找到,网址为 https://github.com/leafo/magick。使用 *magick.thumb* 函数生成缩略图,传入图像 blob 和缩略图规范字符串(例如,500×300 像素)。远程运行的脚本首先读取原始图像,计算缩略图,将缩略图附加到对象负载,然后在对象索引中记录缩略图的偏移量和大小,记录在等于规范字符串的键下。

function thumb(object, spec_string) local script = [[ (*local magick = require "magick"

function get_orig_img() -- 查找原始图像的位置 local loc_spec_bl = cls.map_get_val("original") local size, offset = string.match(loc_spec_bl:str(), "(%d+)@(%d+)")

-- 将图像读入内存 return cls.read(offset, size) end

function thumb(input, output) -- 将缩略图规范应用于原始图像 local spec_string = input:str() local blob = get_orig_img() local img = assert(magick.load_image_from_blob(blob:str())) img = magick.thumb(img, spec_string)

-- 将缩略图附加到对象 local obj_size = cls.stat() local img_bl = bufferlist.new() img_bl:append(img) cls.write(obj_size, #img_bl, img_bl)

-- 在 leveldb 中保存位置 local loc_spec = #img_bl .. "@" .. obj_size local loc_spec_bl = bufferlist.new() loc_spec_bl:append(loc_spec) cls.map_set_val(spec_string, loc_spec_bl) end

cls.register(thumb)*) ]]

clslua.exec(ioctx, object, script, "thumb", spec_string) end

就这样了,伙计们……动态 RADOS 对象接口!想贡献吗?我们不断改进 Lua 绑定和内部 Lua 对象类 API,并始终欢迎反馈。感谢您的光临!