- PVSM.RU - https://www.pvsm.ru -
Однажды коллега поделился размышлениями об API для распределённых вычислительных кластеров, а я в шутку ответил: «Очевидно, что идеальным API был бы простой вызов telefork()
, чтобы твой процесс очнулся на каждой машине кластера, возвращая значение ID инстанса». Но в итоге эта идея овладела мной. Я не мог понять, почему она такая глупая и простая, намного проще, чем любой API для удалённой работы, и почему компьютерные системы, кажется, не способны на такое. Я также вроде бы понимал, как это можно реализовать, и у меня уже было хорошее название, что является самой трудной частью любого проекта. Поэтому я приступил к работе.
За первые выходные сделал базовый прототип, а второй уикенд принёс демку, которая могла телефоркнуть
процесс на гигантскую виртуальную машину в облаке, прогнать рендеринг трассировки путей на множестве ядер, а затем телефоркнуть процесс обратно. Всё это завёрнуто в простой API.
На видео показано, что рендеринг на 64-ядерной VM в облаке завершается за 8 секунд (плюс 6 секунд на телефорк туда и обратно). Тот же рендеринг локально в контейнере на моём ноутбуке занимает 40 секунд:
Как возможно телепортировать процесс? Вот что должна объяснить эта статья! Основная идея заключается в том, что на низком уровне у процесса Linux всего несколько составляющих. Просто нужен способ восстановить каждую из них от донора, передать по сети и скопировать в клонированный процесс.
Вы можете подумать: «Но как реплицировать [что-то трудное, например, TCP-соединение]?» Действительно. На самом деле мы не переносим такие сложные вещи, чтобы сохранить код простым. То есть это просто забавная техническая демонстрация, которую, вероятно, не следует использовать в продакшне. Но она всё равно умеет телепортировать широкий класс, в основном, вычислительных задач!
Я реализовал код в виде библиотеки Rust, но теоретически вы можете обернуть программу в C API, а затем запустить через привязки FFI для телепортации даже питоновского процесса. Реализация составляет всего около 500 строк кода (плюс 200 строк комментариев):
use telefork::{telefork, TeleforkLocation};
fn main() {
let args: Vec<String> = std::env::args().collect();
let destination = args.get(1).expect("expected arg: address of teleserver");
let mut stream = std::net::TcpStream::connect(destination).unwrap();
match telefork(&mut stream).unwrap() {
TeleforkLocation::Child(val) => {
println!("I teleported to another computer and was passed {}!", val);
}
TeleforkLocation::Parent => println!("Done sending!"),
};
}
Я также написал хелпер под названием yoyo
, который телефоркается на сервер, выполняет переданное замыкание, а затем телефоркается обратно. Это создает иллюзию, что можно легко запустить фрагмент кода на удалённом сервере, например, с гораздо большей вычислительной мощностью.
// load the scene locally, this might require loading local scene files to memory
let scene = create_scene();
let mut backbuffer = vec![Vec3::new(0.0, 0.0, 0.0); width * height];
telefork::yoyo(destination, || {
// do a big ray tracing job on the remote server with many cores!
render_scene(&scene, width, height, &mut backbuffer);
});
// write out the result to the local file system
save_png_file(width, height, &backbuffer);
Посмотрим, как выглядит процесс в Linux (на котором работает ОС материнского хоста telefork
):
/proc/<pid>/maps
. Они содержат как весь исполняемый код нашей программы, так и данные, с которыми она работает.
Таким образом, базовую реализацию telefork
можно сделать с помощью простого сопоставления памяти и регистров основных потоков. Этого должно хватить для простых программ, которые в основном выполняют вычисления, не взаимодействуя с ресурсами ОС, такими как файлы (в принципе, для телепортации достаточно открыть файл в системе и закрыть его перед вызовом telefork
).
Я не первым задумался о воссоздании процессов на другой машине. Так, очень похожие вещи делает отладчик записи и воспроизведения rr [4]. Я отправил автору этой программы @rocallahan [5] несколько вопросов, а он рассказал мне о системе CRIU [6] для «горячей» миграции контейнеров между хостами. CRIU умеет передавать процесс Linux в другую систему, поддерживает восстановление всех видов файловых дескрипторов и других состояний, однако код действительно сложный и использует множество системных вызовов, которые требуют специальных сборок ядра и рутовых разрешений. По ссылке с вики-страницы CRIU я нашёл DMTCP [7], созданный для снапшотов распределённых заданий на суперкомпьютерах, чтобы их можно было перезапустить позже, и у этой программы код оказался проще [8].
Эти примеры не заставили меня отказаться от попыток реализовать собственную систему, поскольку это чрезвычайно сложные программы, которые требуют специальных раннеров и инфраструктуры, а я хотел реализовать максимально простую телепортацию процессов как вызов библиотеки. Поэтому я изучил фрагменты исходного кода rr
, CRIU, DMTCP и некоторых примеров ptrace — и собрал собственную процедуру telefork
. Мой метод работает по-своему, это мешанина различных техник.
Чтобы телепортировать процесс, нужно выполнить определённую работу в исходном процессе, который вызывает telefork
, и определённую работу на стороне вызова функции, которая получает потоковый процесс на сервере и воссоздаёт его из потока (функция telepad
). Они могут происходить одновременно, но всю сериализацию также можно выполнить перед загрузкой, например, сбросив её в файл, а позже загрузив.
Ниже приведён упрощённый обзор обоих процессов. Если хотите детально разобраться, предлагаю прочитать исходный код [9]. Он содержится в одном файле и плотно закомментирован, чтобы читать по порядку и разбираться, как всё работает.
telefork
Функция telefork
получает поток с возможностью записи, по которому она передаёт всё состояние своего процесса.
/proc/<pid>/maps
показывает, где находятся все сопоставления. Для этого я использовал proc_maps crate [11].
[vdso]
, используются для создания определённых системных вызовов, например, более быстрого получения времени.
PTRACE_GETREGS
для системного вызова ptrace [13]. Она позволяет получить все значения регистра дочернего процесса. Затем просто записываю их в сообщение по каналу.Чтобы превратить целевой процесс в копию входящего процесса, нужно будет заставить процесс выполнить кучу системных вызовов на самом себе, не имея доступа к какому-либо коду, потому что мы всё удалили. Мы выполняем удалённые системные вызовы с помощью ptrace [13], универсального системного вызова для манипулирования и проверки других процессов:
process_vm_readv
для чтения первой страницы сопоставления [vdso]
ядра, которое, насколько мне известно, содержит по крайней мере один syscall во всех версиях Linux, а затем ищу по байтам его смещение. Я делаю это только один раз и обновляю его, когда перемещаю сопоставление [vdso]
.
PTRACE_SETREGS
. Указатель инструкции указывает на инструкцию syscall, регистр rax
хранит номер системного вызова в Linux [14], а аргументы хранятся в регистрах rdi, rsi, rdx, r10, r8, r9
.
PTRACE_SINGLESTEP
для выполнения команды syscall.
PTRACE_GETREGS
, чтобы восстановить возвращаемое значение syscall, и посмотреть, удалось ли это сделать.telepad
Используя этот и уже описанные примитивы, мы можем воссоздать процесс:
munmap
.
mremap
, чтобы переназначить их на целевые назначения.
mmap
для создания маппингов, а затем process_vm_writev
для потоковой передачи туда страниц памяти.
PTRACE_SETREGS
для восстановления регистров основного потока, которые были отправлены, за исключением rax
. Это значение возвращается для raise(SIGSTOP)
, где остановился процесс снапшота. Перезаписываем его произвольным целым числом, которое передаётся в telepad
.
yoyo
, провести телепортацию обратно по тому же соединению.PTRACE_DETACH
.Некоторые части моей реализации telefork спроектированы неидеально. Я знаю, как их исправить, но и в нынешнем виде система мне нравится, а иногда их действительно сложно исправить. Вот несколько интересных примеров:
mremap
для vDSO точно так же, как это делает DMTCP [15], но оказалось, что это работает только при восстановлении на точно такой же сборке ядра. Если же осуществлять копирование содержимого vDSO, то такой способ работает в разных сборках одной и той же версии. Именно так я запустил телефорк в своей демо-версии трассировки путей, поскольку процедура получения количества ядер CPU в glibc проверяет текущее время с помощью vDSO для кэширования количества. Но чтобы действительно правильно всё реализовать, нужно либо исправить все функции vDSO, чтобы просто выполнить инструкции syscall, как это делает rr
, либо исправить каждую функцию vDSO на переход к функции vDSO из процесса донора.
brk
и других состояний. Я попытался использовать метод из DMTCP, но он работает только в том случае, если целевой brk
больше, чем brk
донора. Правильный способ, который восстанавливает ещё и другие вещи, — это PR_SET_MM_MAP
, но для него нужно повышение привилегий и флаг сборки ядра.
glibc
для pid и tid, который не совместим с другими типами локального хранилища потоков. Как вариант из CRIU, можно рассмотреть восстановление процесса с теми же PID и TID через нестандартное пространство имён.
perf_event_open
.
fork()
в Unix этого не делает, но он должен просто остановить все потоки перед потоковой передачей памяти, затем скопировать их регистры и восстановить их в потоках клонированного процесса.Думаю, вы уже поняли, что при наличии правильных низкоуровневых интерфейсов можно реализовать некоторые сумасшедшие вещи, которые кому-то казались невозможными. Вот некоторые мысли, как развить основные идеи telefork. Хотя многое из перечисленного, наверное, полностью можно реализовать только на полностью новом или исправленном ядре:
userfaultfd
. Эта функция умеет ловить ошибки страниц и более эффективно выполняет сопоставление на новые страницы, чем обработчики SIGSEGV
и mmap
. Она позволит передавать новые страницы памяти только тогда, когда они доступны программе, что даёт возможность телепортировать процессы с меньшей задержкой — они начинают работать практически сразу.
userfaultfd
плюс набор патчей для защиты от записи userfaultfd, который как раз смерджили в начале апреля [16], чтобы реализовать алгоритм когерентности кэша, такой как MESI [17], чтобы эффективно реплицировать память процесса через кластер машин. В этом случае память нужно будет передавать только тогда, когда одна машина прочитает страницу, в которую другая машина записала информацию с момента последнего чтения. Тогда потоки — это просто наборы регистров, которые можно очень дёшево распределить между машинами через регистры пулов потоков ядра. Разумно перераспределить потоки так, чтобы они работали на той же машине, что и другие потоки, с которыми они взаимодействуют. Вы даже можете запускать системные вызовы: для этого приостанавливаем выполнение syscall, переносим поток на исходную хост-машину, выполняем там syscall, а затем переносим его обратно. Примерно так работает ваш многоядерный процессор, только мы используем страницы вместо линий кэша и сеть вместо шины. Те же самые методы, как минимизация совместного использования между потоками, которые работают в параллельном программировании, будут эффективными и здесь. Я думаю, что это действительно может быть очень круто, хотя для бесперебойной работы может потребоваться дополнительная поддержка ядра. Но в итоге вы будете программировать распределённый кластер так же, как вы программируете многоядерную машину, и (с кучей оптимизационных трюков, о которых я ещё не писал) она вполне сможет сравниться с обычной распределённой системой, которую пришлось бы строить в противном случае.Мне это действительно очень нравится, потому что здесь пример одной из моих любимых техник — нырнуть на менее известный слой абстракции, который относительно легко выполняет то, что мы считали почти невозможным. Телепортация вычислений может показаться невозможной или очень сложной. Вы могли подумать, что она потребует таких методов, как сериализация всего состояния, копирование двоичного исполняемого файла на удалённую машину и запуск его там со специальными флагами командной строки для перезагрузки состояния. Но нет, всё гораздо проще. Под вашим любимым языком программирования лежит слой абстракции, где вы можете выбрать довольно простое подмножество функций — и за выходные реализовать телепортацию большинства чистых вычислений на любом языке программирования в 500 строчках кода. Думаю, что такие погружения на другой уровень абстракции часто приводит более простым и универсальным решениям. Ещё один из моих проектов, похожих на этот, — Numderline [18].
На первый взгляд такие проекты кажутся экстремальными хаками, и в значительной степени так оно и есть. Они делают вещи так, как никто не ожидает, а когда ломаются, то делают это на уровне абстракции, на котором вроде как не должны работать подобные программы — например, таинственно исчезают ваши файловые дескрипторы. Но иногда вы можете правильно установить уровень абстракции и закодировать любые возможные ситуации, так что в итоге всё будет работать плавно и волшебно. Думаю, что хорошими примерами здесь являются rr [4] (хотя telefork умудрился его засегфолтить) и облачная миграция виртуальных машин в реальном режиме времени (по сути, телефорк на уровне гипервизора).
Мне также нравится представлять эти вещи как идеи альтернативных способов работы компьютерных систем. Почему наши API для кластерных вычислений намного сложнее, чем простая программа, которая транслирует функции в кластер? Почему сетевое системное программирование намного сложнее многопоточного? Конечно, вы можете привести всевозможные веские причины, но они обычно основаны на том, насколько трудно это сделать, на примере существующих систем. А может при правильной абстракции или при достаточных усилиях всё будет работать легко и незаметно? Принципиально тут нет ничего невозможного.
Автор: Дата-центр "Миран"
Источник [19]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/virtualizatsiya/352552
Ссылки в тексте:
[1] системные вызовы: http://man7.org/linux/man-pages/man2/syscalls.2.html
[2] brk: http://man7.org/linux/man-pages/man2/brk.2.html
[3] PR_SET_MM_MAP: https://lore.kernel.org/patchwork/patch/494297/
[4] отладчик записи и воспроизведения rr: https://github.com/mozilla/rr
[5] @rocallahan: https://robert.ocallahan.org/
[6] CRIU: https://criu.org/Main_Page
[7] DMTCP: http://dmtcp.sourceforge.net/
[8] код оказался проще: https://github.com/dmtcp/dmtcp/blob/7d02a2e063a8e70cc4d836d0b658124614666f44/src/mtcp/mtcp_restart.c
[9] исходный код: https://github.com/trishume/telefork/blob/master/src/lib.rs
[10] fork: http://man7.org/linux/man-pages/man2/fork.2.html
[11] proc_maps crate: https://github.com/rbspy/proc-maps
[12] process_vm_readv: http://man7.org/linux/man-pages/man2/process_vm_readv.2.html
[13] ptrace: http://man7.org/linux/man-pages/man2/ptrace.2.html
[14] номер системного вызова в Linux: https://filippo.io/linux-syscall-table/
[15] делает DMTCP: https://github.com/dmtcp/dmtcp/blob/7d02a2e063a8e70cc4d836d0b658124614666f44/src/mtcp/mtcp_restart.c#L813
[16] набор патчей для защиты от записи userfaultfd, который как раз смерджили в начале апреля: https://patchwork.kernel.org/cover/11005675/
[17] MESI: https://en.wikipedia.org/wiki/MESI_protocol
[18] Numderline: https://blog.janestreet.com/commas-in-big-numbers-everywhere/
[19] Источник: https://habr.com/ru/post/499422/?utm_source=habrahabr&utm_medium=rss&utm_campaign=499422
Нажмите здесь для печати.