Как я делаю защищенное файловое хранилище — 5: Безальтернативный SQLite

Часть 1: Рождение идеи
Часть 2: Ограничения и возможности
Часть 3: PostgreSQL против MongoDB
Часть 4: Пришествие gocryptfs
Часть 5: Безальтернативный SQLite (вы здесь)
Ссылка на репозиторий в конце статьи.
1. Когда в гости приходят гости, которых никто не приглашал
Первый раз я увидел скорпиона весной. Он выползал из моего дома на улицу. Из моего дома. На улицу.
Скорпион показался мне просто огромным. В тот момент я стоял у входа и эта встреча застала нас обоих врасплох.
Скорпион остановился на пороге, и мы молча смотрели друг на друга. Полагаю, он прикидывал, насколько я опасен. Я же прикидывал, достаточно ли хорошо обут.
А потом скорпион побежал. Обратно ко мне домой. Хотя я уже не был уверен в том, чей это дом.
Вы видели, как бежит скорпион, если чувствует опасность? Во-первых, очень быстро. Я не ожидал от него такой прыти. Во-вторых, он разгибает хвост горизонтально и использует его для балансировки. Почему-то в тот момент мне подумалось, что так бегают динозавры.
Я догнал его через пару метров. Сомневаться было некогда (простите). При контакте с подошвой скорпион громко хрустнул и бежать перестал. Но все равно пытался куда-то ползти. Такой живучести можно было только позавидовать. В любом случае, надеюсь, он не очень мучался.
Поскольку я не был уверен в том, насколько скорпионы социальные животные, то весь остаток дня искал его друзей. Я передвигал по дому все, что можно было передвинуть. Я заглядывал подо все, подо что можно было заглянуть. Но в тот раз больше никого не нашел.
Зато у меня мгновенно появилась привычка внимательно осматривать место, куда планирую присесть, прилечь или обуться. И еще я перестал оставлять открытой дверь на улицу.
2. И снова база данных
После перехода на gocryptfs снова возник вопрос: какую базу данных использовать? Как PostgreSQL, так и MySQL были предназначены для работы с локальной файловой системой. Их работа опирается на файловые блокировки, кэширование и синхронизацию данных с диском. При этом файловые системы, реализованные через FUSE, могут отличаться по поведению файловых операций от ext4 или XFS. Для большинства приложений это не выглядело серьезной проблемой, но в случае с MySQL или PostgreSQL все было гораздо сложнее.
Разумеется, я решил проверить все на практике. По очереди разместил данные PostgreSQL и MySQL внутри gocryptfs и попробовал запустить их в такой конфигурации.
Очень быстро стало понятно, что хорошей идеей это не было. Обе базы создавали совсем не ту нагрузку, при которой дополнительный слой FUSE остается незаметным: множество небольших чтений и записей, синхронизация данных с диском, работа с журналами и постоянные изменения одних и тех же файлов. Все эти операции теперь проходили через gocryptfs, а затем через FUSE к реальной файловой системе.
Но основные проблемы возникали при попытках размонтировать gocryptfs во время работы баз данных. В зависимости от способа размонтирования операция либо завершалась ошибкой, либо приводила к сбоям в работе баз. При этом самостоятельно проверять crash consistency и корректность восстановления после таких ситуаций было не той задачей, на которую мне хотелось бы тратить время. Некоторое время я всё же экспериментировал с настройками, но с каждой новой итерацией надежность всей этой конструкции стремительно падала в моих глазах.
В конечном счете я пришел к выводу, что даже если и получится собрать рабочую конфигурацию, доверия к ее надежности у меня не будет. И после нескольких неудачных экспериментов я отказался от этой затеи и стал искать вариант с более простой моделью хранения.
3. SQLite вырывается вперед
Единственной реалистичной альтернативой оказался SQLite. Он представляет собой обычный файл, не требует отдельного серверного процесса и официально допускает работу через FUSE. Но и здесь не обошлось без ограничений.
SQLite поддерживает несколько режимов журналирования. Во время изменения данных база сначала записывает служебную информацию в специальный журнал, чтобы в случае сбоя можно было откатить незавершенную транзакцию и восстановить согласованное состояние базы данных. Разные режимы отличаются тем, как именно ведется этот журнал, а также требованиями к файловой системе, производительностью и поведением при конкурентном доступе:
WAL (Write-Ahead Logging) — самый распространенный. Изменения сначала записываются в отдельный журнал, а затем переносятся в основной файл базы данных. Это повышает производительность и позволяет читать базу данных, пока другая транзакция выполняет запись.
DELETE — классический режим. Перед изменением базы исходные данные записываются в отдельный журнал, который удаляется после завершения транзакции.
TRUNCATE — работает почти так же, как DELETE, но после завершения транзакции журнал не удаляется, а очищается до нулевого размера. Это позволяет избежать части операций с файловой системой.
PERSIST — файл журнала также сохраняется на диске, но вместо удаления или очистки его заголовок помечается как недействительный. При следующих транзакциях файл используется повторно.
MEMORY — журнал хранится только в оперативной памяти. Это сокращает количество операций с диском, но лишает базу возможности восстановиться после аварийного завершения процесса.
OFF — журналирование полностью отключается. Это уменьшает накладные расходы при записи, но база перестает быть защищена от повреждения в случае сбоя посреди транзакции.
Разумеется, первым делом я попробовал режим WAL. Первый же тест закончился печально. Стоило принудительно размонтировать gocryptfs при открытом соединении с базой данных, как после повторного монтирования SQLite уже не мог открыть базу.
Причина была довольно простой. В режиме WAL база данных состоит уже не из одного файла, а как минимум из трех: основного файла базы, журнала -wal и файла общей памяти -shm. Во время работы SQLite постоянно поддерживает согласованность между ними. Если файловая система исчезает прямо во время работы, этот процесс прерывается, а согласованность файлов нарушается. После повторного монтирования база данных оказывается поврежденной.
В конечном счете из всех режимов меня заинтересовали только классические rollback-режимы: DELETE, TRUNCATE и PERSIST. Они полностью совместимы с FUSE, а различия между ними сводятся главным образом к тому, что происходит с файлом журнала после завершения транзакции. Поскольку в моем случае это не имело принципиального значения, я остановился на стандартном режиме DELETE. И на практике этот вариант оказался полностью стабильным.
Из этого следовало еще одно важное решение. Несмотря на то что приложение должно использовать асинхронный доступ к базе данных, запускать несколько Uvicorn-воркеров не имеет смысла. SQLite все равно допускает только одну транзакцию записи одновременно, поэтому дополнительные процессы не увеличат производительность, а лишь усложнят синхронизацию между ними. В итоге приложение должно будет работать с одним воркером, а асинхронность позволит эффективно обслуживать множество одновременно ожидающих запросов.
4. Установка и первый запуск
Теперь можно было наконец довести до конца запуск приложения. Получилась довольно простая цепочка, которая запускается одной командой:
Makefile > Dockerfile > entrypoint > application
Установка и Создание Подготовка Исключительно
служебные переносимого контейнера бизнес-логика
команды окружения к запуску приложения
Всё начинается с Makefile. По большому счету это лишь удобная оболочка над командами сборки и запуска, которая избавляет от необходимости каждый раз вручную вводить длинные команды docker. Для установки достаточно выполнить make install.
Дальше в работу вступает Dockerfile. Он собирает полностью готовый образ. В него устанавливаются Python, fuse3, gocryptfs и стандартные зависимости самого приложения. В результате получается переносимое окружение, которое можно запустить на любой машине с установленным Docker.
После старта контейнера управление сначала получает небольшой entrypoint-скрипт. Он подготавливает окружение к работе: при необходимости создает конфигурационный файл, загружает параметры окружения и передает управление самому приложению.
Последним шагом запускается Uvicorn. К этому моменту уже готовы конфигурация и окружение, поэтому FastAPI остается только начать принимать запросы.
Если приложение запускается впервые, после старта остается выполнить еще один дополнительный шаг — задать мастер-пароль от хранилища. После этого приложение генерирует случайную gocryptfs passphrase, шифрует ее этим мастер-паролем и сохраняет в томе с секретами. В открытом виде на диск не попадают ни gocryptfs passphrase, ни мастер-пароль.
5. Базовые правила работы хранилища
Теперь предстояло разобраться с тем, как именно приложение должно хранить пользовательские данные. Общая архитектура уже была определена: само приложение работает внутри Docker-контейнера, а всё ценное находится снаружи, в подключаемых томах.
Таких томов два. Первый предназначен для зашифрованных данных, второй — для секретов, необходимых для их расшифровки. Разделение было принципиальным: если данные и ключи окажутся в одном месте, вся идея защиты потеряет значительную часть смысла.
Первый том я назвал cipherdir. Это обычный каталог на хостовой файловой системе, в котором хранятся зашифрованные файлы и каталоги. Их содержимое и имена зашифрованы, но сама структура каталогов остается видимой (это особенность работы самого gocryptfs). При этом ни само приложение, ни пользователи не должны обращаться к содержимому cipherdir напрямую.
Для работы с файлами предназначен другой каталог — mountpoint. Он находится внутри контейнера и служит точкой монтирования gocryptfs. Когда хранилище открыто, в этом каталоге появляется расшифрованное представление содержимого cipherdir. Для приложения это все выглядит как обычная файловая система с обычными данными.
Таким образом, одни и те же данные существуют в двух представлениях: зашифрованном — во внешнем томе, и расшифрованном — только внутри контейнера и только во время монтирования. При любых файловых операциях gocryptfs на лету выполняет шифрование и расшифровку полностью прозрачно для самого приложения.
Второй внешний том предназначен для секретов. В нем должен храниться зашифрованный пароль gocryptfs, который, в свою очередь, защищен мастер-паролем владельца. Такой том можно разместить на съемном носителе или подключать из другого источника. Главное, чтобы приложение могло определить его отсутствие.
Теперь получалось установить несколько простых правил.
Во-первых, контейнер не должен содержать никаких незаменимых данных. Должна быть возможность его остановить, удалить и создать заново. Если оба внешних тома сохранились и доступен мастер-пароль, состояние приложения можно полностью восстановить.
Во-вторых, расшифрованные данные должны быть доступны только при открытом хранилище. Пока gocryptfs не смонтирован, приложение не должно обращаться к пользовательским файлам и базе данных. После размонтирования доступ к ним должен прекращаться.
В-третьих, секреты должны оставаться отделенными от данных. Наличие одного только cipherdir не должно позволять открыть хранилище. Для этого потребуются и секреты из второго тома, и мастер-пароль владельца.
И наконец, приложение должно самостоятельно управлять жизненным циклом файловой системы. Открытие хранилища означает проверку мастер-пароля и монтирование gocryptfs. Закрытие — прекращение операций с данными и размонтирование. Причем это должно работать не только по команде владельца, но и при возникновении обстоятельств, требующих немедленного закрытия хранилища.
В результате получалась довольно простая конструкция. Контейнер можно пересоздать. Том с зашифрованными данными можно скопировать или перенести. Том с секретами можно физически отключить. А расшифрованное представление файлов доступно только тогда, когда приложение открывает хранилище.
Оставалось научить приложение соблюдать эти правила. Особенно последнее.
6. Конец пятой части
Дальше предстояло подумать над жизненным циклом хранилища: монтированием, размонтированием и контролем секретов. И самое главное — пришло время Dead Man's Switch.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.