Эльфы, гномы и осьминоги: разбираем ELF и DWARF для трассировки Сeph


Привет! Меня зовут Александр Пивкин, я ведущий SRE-инженер в MWS Cloud Platform. Сегодня я проведу вас по короткому пути через дебри подкапотного устройства бинарных файлов и покажу, какую пользу можно извлечь из этого знания применительно, например, к SDS Ceph. Сразу отмечу, что пишу я эту статью параллельно с написанием собственно трейсера. То есть даже можно сказать, что вы наблюдаете процесс исследования и создания готового решения в прямом эфире.
В предыдущей статье я затронул тему исследования производительности кластера Ceph, а также вскользь упомянул о возможности поиска проблем с производительностью с помощью BPF. Здесь я расскажу, что такое BPF и как его применить в практической задаче.
Что такое BPF
Не хочется просто повторять то, что каждый может прочитать на просторах технических сайтов, поэтому кратко перескажу своими словами. BPF (berkeley packet filter) — это технология, которая позволяет запускать небольшие программы прямо внутри ядра операционной системы. Изначально она была заточена под исследование сетевого трафика, но впоследствии адаптировалась под широкий круг задач. Сейчас BPF позволяет на низком уровне взаимодействовать с системой через то, что называется пробами (например, отслеживать системные вызовы в целях аудита безопасности), а также профилировать прикладные программы.
Однако нельзя просто так взять и запустить программу в ядре — любая ошибка роняет всю систему, поэтому программы BPF пишутся особым образом и проходят специальные стадии:
— Специально ограниченное подмножество языка С.
— Программа компилируется в специальный байт-код.
— Перед загрузкой проводится проверка на безопасность: нет ли бесконечных циклов, выходов за границы памяти и т. д.
— И только затем загрузка в ядро.
Как, судя по слухам, говаривал известный китайский мыслитель Конфуций: «Я слышу и забываю. Я вижу и запоминаю. Я делаю и понимаю», — поэтому сразу перейду к практике. Ниже приведённая небольшая программа делает очень простое, говорит «Привет» при запуске.
attribute((noinline)) void hello(char *s) { printf("%s\n",s); }
int main(void) { hello(“Привет”); return 0; }
Компилирую
clang -g -O2 test.c -o chello
и получаю
./chello
ПриветТеперь попробуем отследить запуск функции hello, а также её аргумент. Для быстрого написания и проверки гипотез я обычно использую bpftrace, потому что она хоть и обладает ограниченной функциональностью, зато дружелюбна к пользователю и делает за меня всю черновую работу и проверки. Запустив bpftrace и в другой консоли запустив скомпилированную программу, я получаю то, что и ожидал — срабатывание на каждом вызове функции hello с выводом аргумента и имени процесса для общей информации:
sudo bpftrace -e 'uprobe:./test:hello { printf("%s arg is %s\n",comm,str(arg0)); }'
Attached 1 probe
chello arg is ПриветТо есть на каждый вызов функции hello я могу узнать, какой аргумент был ей передан, что крайне полезно, если аргументы меняются во время выполнения программы и надо узнать значение в момент Х.
Приготовьтесь, начинаем погружение
Почему получился фокус? При компиляции линковщику необходимо выяснить, где функция определена и откуда её вызывают. Для этого ассемблер при создании объектного файла создаёт секцию .symtab, куда записывает имена функций, а также глобальные и статические переменные — обычные локальные переменные, живущие на стеке или в регистрах, туда не попадают. После сборки исполняемого файла эта секция больше не нужна для успешного запуска программы, но по умолчанию её никто не удаляет. Именно этот факт и позволил так легко прикрепить пробу к функции hello.
С аргументом ещё проще: arg0 — это просто содержимое регистра rdi, куда попадает первый аргумент функции. bpftrace не знает ни его имени, ни типа: то, что там строка, я сообщил сам. Для простой функции с одним простым аргументом этого хватает. Но если нужно знать, какие именно у функции аргументы, каких они типов и где внутри структуры лежит нужное поле, одной таблицы символов уже мало. И тут пора уже поговорить об отладочной информации.
Что же такое отладочная информация? Дело в том, что когда компилятор преобразует программу hello.c в машинный код, он отбрасывает почти всё, что понятно человеку, оставляя только адреса, регистры и инструкции. Отладочная информация — это то, что компилятор дополнительно кладёт в бинарник, чтобы восстановить связь между машинным кодом и кодом исходным.
DWARF — это самый распространённый формат отладочной информации на системах Linux/Unix/MacOS/BSD. Название, кстати, было придумано по приколу, просто потому, что формат исполняемых файлов на Linux и других Unix-системах называется ELF — executalbe and linkabe format. Эльф и гном — отличная и узнаваемая пара для любителей фэнтези. Скучную расшифровку, Debugging With Attributed Record Formats, придумали значительно позже, чтобы придать официоз шутке.
Для просмотра отладочной информации используются различные утилиты, но я в основном использую readelf и objdump. Обе они заглядывают внутрь ELF-файла, но имеют немного разный взгляд на содержимое. Readelf смотрит на файл как на структуру ELF, objdump же — как на код и данные. Но прежде чем запускать их, стоит понять, что на самом деле лежит внутри ELF-файла: структура, которая есть у каждого бинарника, хотя мы о ней почти не задумываемся.
Краткий обзор внутреннего устройства бинарного файла
Когда я только начинал разбираться в том, как устроен ELF-файл, сложность показалась ошеломляющей. Но в действительности всё не так сложно, как выглядело поначалу. ELF-файл состоит из компонентов всего четырёх типов: заголовка бинарника, заголовков программы, секций и заголовков секций (по одному на каждую секцию).
Заголовок файла (readelf -h test). Сообщает сводную информацию о файле и том, где искать остальное его содержимое.
Секции и заголовки секций. Код и данные в бинарнике логически разделены на неперекрывающиеся блоки, называемые секциями. У секций нет определённой общей структуры, структура каждой секции зависит от её содержимого. Каждая секция описывается своим заголовком, который перечисляет её свойства и позволяет найти принадлежащие ей байты. Строго говоря, разделение на секции призвано обеспечить удобную организацию для работы компоновщика. Это значит, что не каждая секция необходима для подготовки процесса и виртуальной памяти к выполнению исполняемого файла. И поскольку это нужно только на этапе линковки, то заголовков секций может вообще не быть.
Выше я уже упоминал секцию .symtab, а вообще секций 37 (при сборке с -g), и посмотреть список можно командой readelf -S test. В общем и целом их можно поделить на две группы: загружаются в память и не загружаются в память. Секции с флагом «A» загружаются в память при запуске, остальные (.symtab, .strtab, все .debug_*) нужны только инструментам: отладчикам, профилировщикам и тому же bpftrace.
Заголовки программы. Чтобы загрузить и выполнить файл, данные должны быть логически организованы особым образом. Поэтому в бинарнике есть ещё одна логическая структура, которая называется сегменты, используемые на этапе выполнения. Таблица заголовков программы даёт сегментное представление, в отличие от секционного, нужного только для компоновки. Сегментное представление помогает находить релевантный код и данные и решать, что следует загрузить в виртуальную память. Для исполняемого файла заголовки программы обязательны, в отличие от заголовков секций.
Easy mode
Как упоминалось выше, имена функций в исполняемом файле хранятся в специальной секции .symtab. Поэтому нет ничего проще, как поискать в ней функцию по имени и выяснить её адрес в виртуальной памяти.
readelf -s test
24: 0000000000001140 36 FUNC GLOBAL DEFAULT 12 helloЛибо можно просмотреть все символы, к которым можно прикрепить пробу напрямую:
bpftrace -lv 'uprobe:./test:*'К каждой из функций в выводе можно прицепиться и зажигаться во время её исполнения.
Однако есть проблема, которая заключается в том, что отладочная информация сильно раздувает размер исполняемого файла. Наш тестовый файл содержит всё необходимое и весит 17КБ. Теперь вырезаю всё, что не требуется для запуска файла с помощю llvm-strip –strip-all test, и размер становится 15КБ. На таком маленьком примере это кажется незначительным, но на огромных проектах, типа Ceph, это сотни мегабайт: ceph-osd с DWARF инфой весит 600МБ, а без неё — всего лишь 200. Поэтому подавляющее большинство бинарников «в бою» поставляется именно в обрезанном виде.
Но удаление отладочных символов означает, что теперь больше нет никаких символов, к которым можно было бы прикрепить пробу, просто указав человекочитаемое имя функции. Убедиться в этом просто — команда file скажет, что бинарник stripped, то есть лишён всего излишнего. Остались только адреса в виртуальной памяти, регистры ЦПУ и смещения. К счастью, bpftrace умеет цеплять пробы не только по имени, но и по адресу. Смещение функции hello мы уже видели в .symtab, пока файл не был обрезан — 0х1140.
bpftrace -e 'uprobe:./test:0x1140 { printf("%s arg is %s\n",comm,str(arg0)); }'
Attached 1 probe
test arg is ПриветВыходит, что, зная адрес нужной функции, можно обойтись и без отладочных символов. Команда strip удаляет только метаданные (.symtab, .strtab и все .debug_*), а байты секции .text остаются теми же, равно как и адреса. Значит, узнав смещение один раз, можно его использовать повторно для одной и той же сборки, потому что сам код «не двигается», выбрасываются только метаданные.
Hard mode
Наверное, терминатор смог бы найти нужные адреса в голом бинаре, но я человек. Однако я помню о том, что плоть слаба, а дух силён, а значит, и эта задача будет решена. Правда, для начала нам всё равно необходима отладочная инфа. Ceph вместе с пакетом ceph-osd также поставляет пакет ceph-osd-dbgsym. Сишная библиотека libdwfl умеет по build-id находить, загружать и работать с дебаг-инфой, вынесенной в отдельный пакет, поэтому отладочная информация должна быть ровно от той же сборки. А автоматизация поисковых действий в самих дварф-структурах содержится в библиотеке libdw. Примеры того, как с ними работать, можно найти в сети, поэтому я не буду на этом заострять внимание. Вместо этого я расскажу принцип поиска необходимой информации.
Предлагаю выбрать какую-нибудь функцию из кода Ceph, чтобы исследовать её подробнее. Но функций в коде ceph сотни, если не тысячи, и просто так выбрать какую-нибудь не очень ценно с практической точки зрения. Поэтому предлагаю приступить к решению прикладной задачи, например, оценить сколько времени операция стоит в очереди OSD (object storage daemon). То есть временная разница между функциями OSD::enqueue_op и OSD::dequeue_op.
Итак, берём полный бинарник, который я собрал именно для этих целей, и исследуем его на предмет адреса функции OSD::enqueue_op:
nm -C ceph-osd-notstripped | grep enqueue_op
000000000053d860 T OSD::enqueue_op(spg_t, boost::intrusive_ptr&&, unsigned int)
0000000000347b5e t OSD::enqueue_op(spg_t, boost::intrusive_ptr&&, unsigned int) [clone .cold].Имеем две записи, T — значит, что это глобальный символ из секции .text, t — локальный символ. Наш адрес — 0х53d860. В процессе написания я внезапно обнаружил, что даже на обрезанном файле эта функция вполне себе находится в секции .dynsym — динамических символов.
nm -C -D ceph-osd-stripped | grep enqueue_op
000000000053d860 T OSD::enqueue_op(spg_t, boost::intrusive_ptr&&, unsigned int)Предполагаю, что это необходимо для динамического связывания на этапе выполнения кода.
Итак, у нас есть адрес пролога функции OSD::enqueue_op. Её пара, dequeue_op находится по адресу:
nm -C ceph-osd-notstripped | grep dequeue_op
000000000053dd20 T OSD::dequeue_op(boost::intrusive_ptr, boost::intrusive_ptr, ThreadPool::TPHandle&)
0000000000347bac t OSD::dequeue_op(boost::intrusive_ptr, boost::intrusive_ptr, ThreadPool::TPHandle&) [clone .cold]То, что отмечено как clone.cold, — это оптимизация компилятора, который делит функцию на условные «горячий» и «холодный» пути. «Горячий» путь — это то, что компилятор считает обычным ходом исполнения, а «холодный» — обработка исключений и вообще всё то, что считается редкой веткой. Холодные куски функций, как правило, группируются в отдельной области бинарника.
Таким образом, я получил адреса для прикрепления к двум функциям, начальной и конечной точки измеряемого интервала. Сигнатуры обеих функций не одинаковы:
void OSD::enqueue_op(spg_t pg, OpRequestRef&& op, epoch_t epoch)
void OSD::dequeue_op(PGRef pg, OpRequestRef op, ThreadPool::TPHandle &handle)Но нас интересует оба аргумента op типа OpRequestRef и pg (в enqueue_op это spg_t, в dequeue_op — PGRef), которые содержат нужное, а именно:
1) поле request.header.type — тип операции (чтение/запись)
2) reqid.name._num — номер клиента
3) reqid.tid — номер транзакции, порядковый номер
4) pg_id.pgid.m_pool — номер пула
5) pg_id.pgid.m_seed — номерPG (placement group) в пуле
И здесь в полный рост встаёт тот факт, что вручную, просто читая дварф-инфу глазами, достать мы можем в лучшем случае номера регистров, куда попадут аргументы. Но для структур, которыми щедро одарён Ceph, ручной поиск в отладочной инфе малоперспективен. Для этого есть отличная утилита pahole, которая в человекочитаемом виде показывает внутреннее устройство классов/структур, а также размерность и смещение её составляющих.
Начнём с enqueue_op. По соглашению System V ABI, первые 6 целочисленных аргументов кладутся в регистры rdi,rsi,rdx,rcx,r8 и r9. Числа с плавающей точкой идут в xmm. Однако если передаваемый аргумент больше 16 байт, то он автоматом едет в стек. Первый регистр rdi занят невидимым нулевым аргументом this, который добавляется не статическим методам С++. Первый аргумент spg_t равен 24 байтам — едет на стек. Второй аргумент — ссылка, 8 байт, в регистр rsi. Получается, что теперь регистр нам известен. Теперь необходимо найти смещения нужных нам полей в OpRequest. Заглянув в исходный код цефа, видим, что для поиска request.header.type нам нужно проделать такой путь OpRequest::request → Message::header → ceph_msg_header::type:
pahole -C OpRequest ceph-osd-notstripped 2>/dev/null
class Message * request; /* 264 8 */
pahole -C Message ceph-osd-notstripped 2>/dev/null
struct ceph_msg_header header; /* 24 53 */
pahole -C ceph_msg_header ceph-osd-notstripped 2>/dev/null
ceph_le16 type; /* 16 2 */По шагам,
1) Читаем значение из rsi и разыменовываем его, ибо указатель. P1 = (rsi)
2) Прибавляем 264 к шагу 1 и разыменовываем, получаем request. P2 = (P1 + 264)
3) Прибавляем 24 без разыменовывания P3 = P2 + 24, потому что header — это не указатель
4) type тоже не указатель, поэтому также плюс 16. P4 = P3 + 16
То есть для bpftrace рабочий варик чтения type выглядит вот так:
bpftrace -e ' uprobe:/path/to/ceph-osd:0x53d860 {
$p1 = *(uint64 *)(arg1); // deref rsi -> px, т.е. OpRequest
$p2 = *(uint64 *)($p1 + 264); // deref (+request) -> Message
$type = *(uint16 *)($p2 + 24 + 16); // header (+24), type (+16) — без deref
printf("op type = %d\n", $type); }'Для функции dequeue_op и её первого аргумента принцип действия такой же. Регистр rdi — this. Регистр rsi содержит аргумент pg типа PGRef. По C++ ABI объекты с нетривиальным копированием или деструктором, а intrusive_ptr именно такой, передаются по скрытой ссылке: вызывающая сторона кладёт копию в свою память и передаёт её адрес. Поэтому в rsi лежит адрес intrusive_ptr, а сам PG* получается разыменованием. А дальше по той же схеме:
pahole -C PG ceph-osd-notstripped 2>/dev/null
const struct spg_t pg_id; /* 32 24 */
pahole -C spg_t ceph-osd-notstripped 2>/dev/null
struct pg_t pgid; /* 0 16 */
pahole -C pg_t ceph-osd-notstripped 2>/dev/null
uint64_t m_pool; /* 0 8 /
uint32_t m_seed; / 8 4 */Снова по шагам:
1) Читаем из rsi. P1 = *(rsi)
2) Прибавляем P2 = P1 + 32
3) m_pool P3 = P2
4) m_seed P4 = P2 + 8.
Теперь для того, чтобы найти задержки в очереди для каждой операции, нужно просто засечь время между вхождением в enqueue_op и dequeue_op, связывая каждую конкретную операция с tid, чтобы отличать операции друг от друга для сквозной трассировки.
Примерно вот так выглядит принципиальная схема выяснения адресов, регистров и смещений для того, чтобы прикрепить BPF-пробы при отсутствии дебаг-инфы в исследуемом бинарном файле.
А где же DWARF
Через буреломы регистров, смещений и прочих страшных слов наконец-то я дошёл до самого DWARF-формата. Для облегчения понимания я намеренно буду рассматривать всё ту же тестовую программу из начала статьи.
DWARF — это не просто один блок данных, а целое семейство из отдельных секций ELF-файла, каждая из которых отвечает ровно за одну узкую задачу: одна хранит соответствие адресов строкам исходника, другая — описания типов, третья — правила раскрутки стека. Главная секция — .debug_info, посмотреть её можно командой readelf -wi <FILENAME>. В ней находится дерево записей, которые называются DIE — Debug Information Entry. У каждой DIE есть тег, указывающий вид DIE, а также набор атрибутов вида «ключ-значение». Корень дерева — DW_TAG_compile_unit, единица компиляции — .c-файл со всем, что в него включено.
Contents of the .debug_info section:
Compilation Unit @ offset 0:
Length: 0x86 (32-bit)
Version: 5 Unit Type: DW_UT_compile (1)
Abbrev Offset: 0
Pointer Size: 8
<0>:<c> Abbrev Number: 1 (DW_TAG_compile_unit)
<d> DW_AT_producer : (indexed string: 0): clang version 22.1.8
<e> DW_AT_language : 29 (C11)
<10> DW_AT_name : (indexed string: 0x1): chello.c
<11> DW_AT_str_offsets_base: 0x8
<15> DW_AT_stmt_list : 0
<19> DW_AT_comp_dir : (indexed string: 0x2): /tmp/article
<1a> DW_AT_low_pc : (index: 0x1): 0x1140
<1b> DW_AT_high_pc : 0x21
<1f> DW_AT_addr_base : 0x8Здесь указана общая инфа — имя файла с исходным кодом, директория, какой компилятор использовался и т. д. Чуть далее находится функция hello с её аргументом:
<1><54>: Abbrev Number: 8 (DW_TAG_subprogram)
<55> DW_AT_low_pc : (index: 0x1): 0x1140
<56> DW_AT_high_pc : 0x5
<5a> DW_AT_frame_base : 1 byte block: 57 (DW_OP_reg7 (rsp))
<5c> DW_AT_call_all_calls: 1
<5c> DW_AT_name : (indexed string: 0x5): hello
<5d> DW_AT_decl_file : 0
<5e> DW_AT_decl_line : 4
<5f> DW_AT_prototyped : 1
<5f> DW_AT_external : 1
<2><5f>: Abbrev Number: 9 (DW_TAG_formal_parameter)
<60> DW_AT_location : 1 byte block: 55 (DW_OP_reg5 (rdi))
<62> DW_AT_name : (indexed string: 0x8): s
<63> DW_AT_decl_file : 0
<64> DW_AT_decl_line : 4
<65> DW_AT_type : <0x84>
<2><69>: Abbrev Number: 0У функции имеется тег DW_TAG_subprogram, а также ряд атрибутов, сообщающих, что точкой входа является адрес 0х1140, имя hello, база кадра находится в регистре rsp, а также, что у неё имеется 1 аргумент s, который находится в регистре rdi. Аргумент s имеет тип, который можно узнать в DIE по смещению 0х84. На указанном смещении находится информация, что аргумент s — это указатель:
<1><84>: Abbrev Number: 12 (DW_TAG_pointer_type)
<85> DW_AT_type : <0x36>А базовый тип указателя следует узнавать по смещению 0х36, где и выясняется, что это тип char:
<1><36>: Abbrev Number: 5 (DW_TAG_base_type)
<37> DW_AT_name : (indexed string: 0x3): char
<38> DW_AT_encoding : 6 (signed char)
<39> DW_AT_byte_size : 1Возможно, что вы заметили некое Abbrev Number возле каждого тега. Это не мусор, а ссылка на отдельную таблицу .debug_abbrev. Идея простая: у тысяч функций в большом проекте набор атрибутов почти всегда одинаковый (имя, файл, строка, тип возврата, флаги и т. д.). Вместо того чтобы для каждой DIE заново перечислять «у меня будут вот эти атрибуты вот в таком порядке», компилятор один раз описывает «форму» (abbreviation) и потом в .debug_info просто пишет «запись формы номер 8», а следом — только сами значения, без имён атрибутов. Экономия огромная: на десятках тысяч функций одинаковой формы можно не повторять описание структуры каждый раз.
То же самое происходит со строками. Имя переменной вроде i или this встречается в коде сотни раз — хранить его текстом на каждом месте применения расточительно. Поэтому все строки один раз лежат в .debug_str, а в DIE хранится только индекс (indexed string: 0x5), который через .debug_str_offsets указывает на нужную строку.
Похожая история с адресами: они лежат в .debug_addr. Помните index: 0x1 в описании hello? Это буквальная ссылка на запись номер 1 в секции .debug_addr:
<55> DW_AT_low_pc : (index: 0x1): 0x1140
readelf -wA test
Contents of the .debug_addr section:
For compilation unit at offset 0:
Index Address
0: 0000000000002004
1: 0000000000001140
2: 0000000000001150
3: 000000000000115dОднако ручками копаться в дварф-инфе в поисках нужного весьма утомительно. Найти пять смещений для пары функций — уже вечер работы, а в Ceph кандидатов на трассировку — десятки. Поэтому...
…Даёшь автоматизацию производства!
И в этом поможет библиотека libdw. Идея в четыре шага, один в один повторяющая то, что я показывал ручными операциями.
1) Обходим все CU (compilation unit). dwarf_nextcu () перебирает все CU, а dwarf_getfuncs () для каждого CU вызывает колбек для каждой DIE с тегом DW_TAG_subprogram — без единой строчки самописного обхода дерева.
2) Вычисляем адреса с фолбеком на диапазоны. dwarf_entrypc(), а если пусто (функция расщеплена на hot/cold, как, например, dequeue_op) — dwarf_ranges() берёт начало первого диапазона.
3) Ищем параметры. Для каждого DW_TAG_formal_parameter читаем DW_AT_location: в каком регистре (или где на стеке) лежит аргумент.
4) Вычленяем нужные поля — тот же рецепт, что и вручную через pahole. Идём по цепочке имён (op.px.request), на каждом шаге смотрим тег DIE: указатель/ссылка — отмечаем «нужен deref» и ныряем дальше в тип; структура/класс — ищем член по имени и прибавляем его DW_AT_data_member_location. На выходе тот же список, который я выписывал вручную.
./parser ceph-osd-notstripped -F enqueue_op -m op.px.request.header.type
OSD::enqueue_op at raw dwarf offset 0x45677f
low_pc 0x53d860
param this of type OSD * const @ rdi
param pg of type struct spg_t @ (no location)
.pgid @ +0
.shard @ +16
param op of type OpRequestRef @ rsi
param epoch of type epoch_t @ rdx
field op.px.request.header.type @ [deref] +0 [deref] +264 [deref] +24 +16 (3 derefs)Итого
Итак, мы прошли путь от простого hello до реальной функции в ceph-osd. Таблица символов даёт адреса функций, соглашение о вызовах подсказывает, в каком регистре искать аргумент, а DWARF раскрывает устройство структур и смещения нужных полей. Узнав всё это один раз на полной сборке, можно трассировать обрезанный бинарник в проде. Детальное описание формата DWARF раздуло бы и так не малый объём статьи до совсем неприличных размеров. Да и в целом зачем дублировать то, о чём можно прочитать в первоисточнике.
Кстати, в настоящее время, повсеместно применяется пятая версия стандарта, но уже в процессе обсуждения следующая, шестая версия. На пути исследователя поджидает множество ловушек, например, связанных с С++ и хитростями агрессивной оптимизации компилятора, такие как:
— Неявный аргумент this
— Наследование и виртуальность
— Разделение функций на «горячую» и «холодную» части
— Встраивание (inlining) вызываемых функций прямо в тело вызывающей
— Identical code folding: одинаковые адреса у разных функций
— Переупорядочивание или вообще выброс аргумента
Это только небольшая часть приколов, которые встретятся на непростом пути исследования внутрянки скомпилированных файлов больших проектов. Но, как писал Луций Анней Сенека в диалоге «О стойкости мудреца»: «При беглом взгляде на крутую дорогу человек сначала воспринимает её как непреодолимую, но, пройдя по ней, видит, что это не так и то, что на расстоянии представлялось обрывом, оказывается пологим склоном».
В следующей статье наконец-то перейдём от приготовлений к самому интересному — к написанию работающего bpf-трейсера для Ceph со схемой хранения EC.
Если вы тоже интересуетесь этим, пишите в комментариях свои мысли или приходите в сообщество облака в Телеграме. С большим удовольствием обсудим все тонкости и сложности этой не самой лёгкой темы.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.