Running students' code in isolation. The hard way [ukr]
Talk presentation
We'll talk about running code that cannot be trusted and the additional difficulties in testing it. We will touch on both application and infrastructure solutions.
And, of course, I will share real-life stories of situations where things went awry and about fire-fighting.
Talk transcription
Всім привіт. Мене звати Юрій. Я радий бути сьогодні на цій сцені. Після такої паузи, хто не задонатив, зробіть це ще раз. Я думаю, воно того вартує. Трішки про себе. Я інженер, Fullstack-розробник з понад 6 років досвіду в веб-розробці. За цей період я пробував різноманітні технології та стикаюся з численними викликами. Сьогодні я розповім про один з таких викликів – запуск ненадійного коду та створення власного тест-транера. Всі приклади, які ми обговорюватимемо, походять з продукту, що розробляється в Mate academy. Тому корисно мати контекст щодо компанії та продукту. Mate academy– це EdTech-стартап, який спеціалізується на навчанні людей програмуванню та іншим технічним спеціальностям. Ми пропонуємо навчання Frontend, Fullstack JavaScript, Java, Python, QA та іншим не менш важливим технічним напрямкам. Наша мета – не лише навчити людей, але й допомогти їм побудувати кар'єру та успішно влаштуватися на роботу.
Майже 10% з тих, кого ми навчили, особисто отримали знання від мене, і я пишаюся своїм внеском у їхні кар'єри. Щоб люди могли навчатися онлайн та отримувати професійні навички, нам потрібна навчальна платформа. Ми успішно імплементували її, наділяючи функціоналом, який дозволяє студентам взаємодіяти та залишатися мотивованими. Від простого MVP (Minimum Viable Product) ми просунулися до повноцінної платформи, де студенти можуть не лише спілкуватися, а й писати код у власному онлайн-редакторі та перевіряти його на тестах.
Початково ми стикалися з завданням – забезпечити можливість писати код на нашому сайті, уникнувши використання зовнішніх ресурсів, таких як CodePen для JavaScript. Наш редактор коду повинен бути багатофункціональним, підтримувати різні мови програмування та забезпечувати безпеку. Безпека полягала в тому, щоб уникнути використання хакерських методів через виконання ненадійного коду, а також у тому, щоб учні не могли обманювати тестування, змінюючи JavaScript-скрипти в браузері. Тестування, запуск та валідація коду повинні бути реалізовані на серверній стороні для надійності та безпеки. З низькою затримкою студенти можуть миттєво отримувати результати тестів, що є критичним для простих завдань та швидкого навчання. Взагалі запуск коду, якому ми не довіряємо, це доволі непроста задача. І є декілька груп варіантів рішень, які можна розглядати.
Перша група – це інфраструктурні рішення, коли ми повністю закриваємо інстанс, на якому запускається код, за допомогою інфраструктури. Другий варіант – це рішення на рівні коду, де ми запускаємо код в якомусь сендбоксі. Третє – це можливість комбінувати різні рішення та поєднувати їх плюси. Щодо інфраструктурних рішень, вони мають великий потенціал безпеки. Наприклад, в AWS чи інших клауд-провайдерах можна орендувати віртуальні машини, які ізольовані від інших. Це забезпечує надійну роботу. Аналогічно, використання Docker також може забезпечити високий рівень ізоляції. Однак, недоліками цього підходу є тривалий час запуску контейнерів та можливість погіршення користувацького досвіду.
Альтернативою є рішення на рівні коду, використовуючи пісочницю. Node.js надає модуль VM, який дозволяє запускати код в ізольованому контексті. Також існують бібліотеки, наприклад VM2, яка забезпечує додатковий рівень безпеки. Однак, ці підходи можуть бути обмеженими в безпеці та ефективності. Іншим варіантом є використання ізольованих VM, які дозволяють запускати код в окремому ізольованому середовищі. Деякі новіші модулі, такі як Isolated VM, пропонують високий рівень безпеки та можливість запуску коду ізольовано. У своєму виборі рішення я врахував плюси та мінуси обох підходів і вирішив скористатися гібридним рішенням. Це включає запуск коду на сервісі Lambda, який надає певний рівень ізоляції за замовчуванням. Це поєднання з підняттям безпеки через використання IP-соціалі в середовищі Navi-M2.
Отже, це гібридне рішення має свої переваги, такі як вже вбудована ізоляція Lambda та додатковий рівень безпеки через IP-соціалі, що робить його оптимальним вибором для даного контексту. Є API, який отримує додаткові метадані з бази даних і надсилає запит через SDK на AWS Lambda. Зрештою, якщо ви працюєте з іншим клауд-провайдером, це не так важливо. Це може бути будь-яка хмарна функція. Важливий момент - наявність проксі Lambda. Тут ідеться про підтримку різних мов.
Проксі Lambda приймає набір файлів та мову програмування і вибирає, на який робочий процес Lambda надіслати цей запит. І, умовно, вона пропускає через себе всі ці запити. Плюс проксі Lambda надає сумарний сервіс, дозволяючи нам не знати, що саме відбувається всередині. Ми завжди надсилаємо запит на один і той самий ресурс. Вона повертає відповідь, що дозволяє обробляти помилки, якщо робочий процес Lambda зазнає неполадок, і відповідати зручним для API-сервера способом.
Тепер перейдемо до пісочниці. Ось приклад того, як працює вбудований модуль VM. Ми його реквайримо. Можна, звісно, імпортувати з VM-бібліотек. Далі створюємо об'єкт, названий "sandbox". Це глобальний об'єкт для пісочниці. Код, який ми запускаємо в пісочниці, розглядає цей об'єкт як глобальний. Все, що він має в доступі, обмежується тим, що ми передали у цьому пісочному об'єкті. Маємо скрипт. Літерально в рядку ми пишемо JS-код, компілюємо його для ефективнішого запуску, і потім виконуємо його в новому контексті - в контексті цього глобального об'єкта. Скрипт може змінювати параметри та ключі в цьому глобальному об'єкті, що видно на слайді. Це один з методів комунікації з пісочницею. Однак, оскільки це рішення не є безпечним, я використовував модуль VM2 та вклав у нього додатковий код.
Перше - це допоміжна функція "Init VM". Також, аналогічно до попереднього, тут ініціалізується пісочниця. VM2 надає зручний інтерфейс, дозволяючи налаштовувати тайм-аут, включати або виключати функціонал та визначати можливість використовувати його в пісочниці. Приклади подані нижче. І ще важливий момент - функція "safe expect". Замість звичайного "expect" в юніт тестах ми використовуємо цю функцію, яка вміє запускати код в пісочниці. Внутрішньо вона має пісочницю в змінній EVM та приймає код, що представляє рядок для виклику функції студента, та очікуване значення. Замість традиційного "expect" ми виконуємо його в цьому контексті, отримуючи результат виконання коду в пісочниці. Це ще один метод взаємодії з пісочницею, де передаємо вираз, а отримуємо результат виконання цього виразу. Все досить просто: запускаємо його в 3КЧ.
Другий спосіб взаємодії з пісочницею - передача виразу, такого як "2 плюс 3", який використовується для отримання результату виразу в пісочниці. В цьому випадку ми викликаємо функцію в цьому коді, отримуємо результат і порівнюємо його із сподіваним значенням за допомогою "expect". Ймовірно, найважливіше - вигляд тесту. Тест включає багато важливих елементів. Ми імпортуємо NITVM, виводимо текст коду студента, готуємо все в "before", створюємо функцію "safe expect" та пишемо код тесту всередині рядка. Це дозволяє нам описати скрипт в простий спосіб, і це ефективно використовувалося для понад 50 завдань.
Все було класно, але ніхто не хотів цим користуватись. Умовно, у людей не було вибору, особливо в тих, хто пише контент та створює задачі. Попереднє використання потребувало значного контексту, документації та процесу вступу, щоб зрозуміти, як користуватися цими "хелперами". Тести виглядали відмінно, але їхнє використання вимагало особливого розуміння, оскільки вони відрізнялись від звичайних юніт-тестів. Додатково, існувала ймовірність, що користувачі могли просто забути використовувати це, запускаючи свій код напряму і викликаючи проблеми з безпекою. Бажано було навчити студентів розуміти такі юніт-тести, але це було непрактично через їхню складність.
У зв'язку з цим було прийнято рішення повністю вилучити синтаксичне оточення (синтбокс) та переписати тести на звичайні юніт-тести за допомогою тестранера Mocha. Це спростило використання та усунуло проблему неправильного розуміння тестів. Однак, в ході подальшого розвитку, виявилося, що AWS Lambda має обмеження, такі як перезапускання Lambda, що може впливати на надійність та продуктивність. Деякі зміни, такі як переозначення прототипу масива, призводили до непередбачених проблем.
Для вирішення цих питань, було введено автоматизоване керування параметрами Lambda через AWS SDK, дозволяючи ефективно перезапускати та оптимізувати лямбду з мінімальним втручанням. Такий підхід забезпечив більшу стабільність системи та уникнення проблем, пов'язаних з ручним перезапуском. Проксі Lambda в даному контексті відповідає за обробку помилок, які виникають у JS Worker, зокрема при непроходженні тестів. У звичайних умовах, коли тести пройшли успішно, JS Worker повертає очікуваний результат, і жодних помилок не виникає. Проте, якщо помилка виникає на рівні Worker, проксі лямбда здійснює її обробку, перезапускає Worker і вирішує проблему.
Не зважаючи на функціональність проксі Lambda, виявилося, що це рішення є не ідеальним, особливо з точки зору комфорту та емоційного стану. Таким чином, за вихідні був розроблений та випущений новий варіант, який ефективно вирішив дану проблему. У певний момент обговорення було висловлено концепцію "овертаймів", що дозволило зрозуміти, що необхідно використовувати синбокс. Без його впровадження не було вибору, альтернативи також не були прийнятними, зокрема інфраструктурні рішення чи мокання модулів OS.
Таким чином, було вирішено рухатись у напрямку написання власного тестранера, який має здатність запускатися в пісочниці або вже містить самостійну пісочницю для виконання тестів. Цей підхід дозволяє зберігати контроль над глобальним об'єктом, інжектувати необхідні глобальні хуки та ефективно управляти виконанням тестів. Запропонована бібліотека отримала назву "Matcha", і вона була випущена наступного понеділка після вихідних. Назва бібліотеки виникла з ідеї, що Mocha є чаєм чи кавою, а Matcha — це щось нове та цікаве, хоч і певним чином пов'язане із світом напоїв.
Тут відбуваються схожі речі, як і раніше. Ми ініціалізуємо з якимось гелпером ту саму VM2-пісочницю, але тут великий нахил на те, який глобальний об'єкт ми туди передаємо. Тобто замість коду студента чи функції ми туди передаємо ось ці хуки. Зміна hooks — це наші всі describe, eat, before, before, each. Вони разом з функцією run всі створюються функції getglobals. І вони разом в замиканні мають структуру даних з колбеками з тестів. Тобто хуки можуть туди ці колбеки поставити, а run може вже про них проітеруватись і запустити, відповідно, тести.
Це основна частина, тут є ще моменти з віртуальною файловою системою, з моками Node.js функціоналу, щоб можна було дозволити його використовувати в тестах, в безпечних системах. Але це основна штука. Друга частина цього класу, коли ми власне запускаємо тести. У нас є, десь там, код, який додає ці файли, це умовно тестові файли. І ось тут власне видно ці два етапи. Перший етап — ми проходимо з циклом по всіх наших тестових файлах, requireмо їх. Коли ми requireмо, запускається їхній код, там вже запускається describe. Ми одразу запускаємо колбеки всередині describe, і вже тоді ці дочірні колбеки з it-before-hook'ів, ми їх додаємо в наш мовний масивчик. Там далі буде цей масивчик. І тоді другим етапом ми викликаємо функцію, яка теж доступна в Пісочниці — виконати ці тести.
Там все доволі просто. Кожен колбек теста, перед ним всі before- і після нього after-hook'и запускаються. В try-catch-блокі, якщо там все проходить, то все ок. Ми там знаємо title теста. Кажемо, він пройшов. Якщо не проходить, ми ловимо помилку і формуємо правильний об'єкт з результатом тесту. Додаємо його в якийсь масивчик. Стосовно структури даних, насправді тести ми пишемо досить рідко, але можна так писати. Ми пишемо їх в деревоподібній структурі. Тобто ми можемо вкладати describe, щоб мати прикольний title і не дублювати якісь before-hook'и. Ми можемо мати різну кількість hook'ів в різних штуках. Коли ви маєте дочірній describe, вам треба запустити before-each свій, а перед тим before-each parent-ового describe, чи ще якийсь глобальний. Тому, так як в нас є тут деревовидна структура тесту, в нас теж деревовидна структура даних з цими кулбеками. У нас є якийсь root-овий describe, в ньому можуть бути children, це можуть бути тести, можуть бути інші describe-блоки, які по рекурсивній системі виглядають так само. Ну і аналогічно hook'и. Це все виливається в поточну платформу. Ми маємо тест, ми маємо код, який пишемо, запускаємо. Ця вся історія на Lambda проганяється, і в сайтбарі ми бачимо результат.
Загалом, рідко коли хто пише ідеальний код, всюди є якісь баги, всюди потрібні покращення і я не виняток, часто щось ламається. Але дуже кльово, що це рішення про підписи. Ми працювали три роки без якихось змін. При тому, що ми заскилились на інші країни, виросла кількість людей, кількість студентів, кількість контенту, різноманітність контенту. Все воно далі працює на річній періоді, про яку я вам розказував. І що ще, наприклад, найважливіше, на момент, коли ця вся проблемка з падаючим JavaScript runtime сталося, на той момент в нас вже було порядка 150 задач з їхніми тестами. Вони всі вже були на звичайній MoCA-тестранері, звичайній юніт-тесті, який всі звикли бачити, без якогось особливого там хелпер-коду. І вдалося зробити так, щоб вони всі продовжили без зовсім змін в контенті працювати на своєму кастомному тестранері. Тобто тестранер ми підмінули без шовно, без змін контенту. Кльово, що в нас були референс-рішення для всіх задач, і можна було це протестувати, тобто запустити кожну задачу з початковим кодом. Тести мають фейлитись, і з референс-кодом тести всі мають пройти. Ця перевірка пройшла, і вдалося без шовно замінити тестранер. На тому в мене все. Радий буду поспілкуватись тут в офлайні. І якщо у когось є запитання, теж залюбки на них відповім.