Привет, Хабр!

Меня зовут Данил, и я старший специалист в компании Увеон. Занимаюсь серверной частью Termidesk Assistant - это утилита для удаленных рабочих столов.

К нам в команду пришла интересная задача, нужно было собрать всю серверную часть в один исполняемый файл (.elf) и в дальнейшем на его основе сделать установочный файл (.deb), чтобы создать и запустить сервис.

Все это для того, чтобы оптимизировать наше приложение как по скорости, так и по внешним зависимостям, а также создать возможность использования Termidesk Assistant в локальных изолированных сетях.

О Nuitka мало что известно в Python-среде, особенно мало информации на русском языке, поэтому я решил взяться за написание этой статьи и расписать всё то, что успел собрать за время работы над задачей.

1.  Что же такое Nuitka?

Nuitka - транспайлер (транспилирующий компилятор), который транслирует код Python в исполняемые файлы или исходный код C/C++. То есть он переводит код Python в C++, оптимизируя его, а далее в машинный код через C++ компилятор, генерируя исполняемый файл (elf, exe и т.д.)

2. Преимущества Nuitka

При сравнении сборки Django-приложений с помощью Nuitka, Docker-контейнеров и PyInstaller, у Nuitka есть несколько потенциальных преимуществ:

  1. Производительность
    Nuitka компилирует Python-код в нативный машинный код, что может привести к повышению производительности по сравнению с интерпретируемым Python-кодом.

  2. Размер исполняемого файла
    Nuitka обычно создает меньшие по размеру исполняемые файлы по сравнению с PyInstaller, так как включает только необходимые зависимости.

  3. Защита исходного кода
    Компиляция в машинный код затрудняет обратную разработку, обеспечивая некоторую защиту вашего исходного кода.

  4. Кроссплатформенность
    Nuitka позволяет создавать исполняемые файлы для разных платформ, что может быть проще, чем управление Docker-контейнерами для разных окружений.

  5. Простота развертывания
    В отличие от Docker, не требуется устанавливать и настраивать дополнительное ПО на целевой машине.

3. Недостатки Nuitka

  1. Сложность конфигурации
    Настройка Nuitka для корректной работы с Django и всеми зависимостями может быть сложнее, чем настройка Docker или PyInstaller.

  2. Время компиляции
    Процесс компиляции с Nuitka может занимать значительно больше времени, чем создание Docker-образа или сборка с PyInstaller.

  3. Ограниченная поддержка динамических аспектов Python
    Некоторые динамические функции Python (например, динамическая загрузка модулей) могут работать некорректно или требовать дополнительной настройки.

  4. Проблемы совместимости
    Не все Python-библиотеки хорошо работают с Nuitka.

  5. Отладка
    Отлаживать скомпилированное приложение может быть сложнее (дебаг тут невозможен, только print, грубо говоря), чем интерпретируемый код или код в Docker-контейнере.

  6. Обновления
    Обновление приложения, собранного с Nuitka, требует полной пересборки, в то время как с Docker можно обновить только изменённые слои.

  7. Отсутствие изоляции
    В отличие от Docker, Nuitka не обеспечивает изоляцию окружения, что может привести к конфликтам с системными библиотеками. Для полной изоляции я использовал аргумент onefile, но оно не предоставляет изоляцию на уровне ОС и также использует системные библиотеки и драйверы, как я это обходил - чуть позже.

  8. Меньшее распространение
    Nuitka менее популярна в сообществе Django и Python вообще, чем Docker, что может затруднить поиск решений проблем и лучших практик. Поэтому я здесь ?

  9. Ограниченная масштабируемость
    Docker предоставляет более гибкие возможности для масштабирования приложений, особенно в контексте микросервисной архитектуры.

4. Резюмируем: сравнение Nuitka с Pyinstaller и Docker

Pyinstaller использует в своих пакетах питоновские скрипты и интерпретатор, в то время как Nuitka переводит код в машинный, что увеличивает скорость выполнения и повышает уровень защиты кода от чтения.

В отличие от Docker, Nuitka - открытое ПО. Это вызывает большую уверенность в безопасности у наших клиентов и меньше зависимости от политической обстановки.

5. Способы интеграции Nuitka в различные процессы разработки и развертывания

  1. Использование в bash-скриптах- Автоматизация сборки проекта через shell-скрипты.- Пример:```bash#!/bin/bashpython -m nuitka --follow-imports --standalone --output-dir=build myscript.py```

  2. Интеграция в CI/CD пайплайны- GitFlic CI:```yaml
    build_job:
    script:
    - pip install nuitka
    - python -m nuitka --follow-imports --standalone myapp.py
    ```
    - Jenkins:
    ```groovy
    stage('Build') {
    steps {
    sh 'pip install nuitka'
    sh 'python -m nuitka --follow-imports --standalone myapp.py'
    }
    }
    ```

  3. Использование в setup.py- Интеграция Nuitka в процесс установки пакета:
    ```python
    from setuptools import setup
    from nuitka.distutils_based_buildsystem.BuildSystem import Nuitka

    setup(
    name="MyApp",
    cmdclass={"build_exe": Nuitka},
    # другие параметры...
    )
    ```

  4. GitHub Actions workflow- Автоматизация сборки при push или pull request:
    ```yaml
    name: Build with Nuitka
    on: [push, pull_request]
    jobs:
    build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Python
    uses: actions/setup-python@v2
    - name: Install dependencies
    run: |
    pip install nuitka
    - name: Build with Nuitka
    run: python -m nuitka --follow-imports --standalone myapp.py
    ```

  5. Makefile- Упрощение процесса сборки:
    ```makefile
    build:
    python -m nuitka --follow-imports --standalone myapp.py

    clean:
    rm -rf build/
    ```

  6. Docker- Использование Nuitka внутри Docker для создания компилированных приложений:
    ```dockerfile
    FROM python:3.9
    RUN pip install nuitka
    COPY . /app
    WORKDIR /app
    RUN python -m nuitka --follow-imports --standalone myapp.py
    ```

  7. Tox- Интеграция Nuitka в процесс тестирования:
    ```ini
    [testenv:build]
    deps = nuitka
    commands = python -m nuitka --follow-imports --standalone myapp.py
    ```

  8. Pre-commit hooks- Автоматическая компиляция перед коммитом:
    ```yaml
    - repo: local
    hooks:
    - id: nuitka-build
    name: Build with Nuitka
    entry: python -m nuitka --follow-imports --standalone
    language: system
    files: ^myapp\.py$
    ```

  9. Интеграция с системами управления пакетами- Создание пакетов для систем вроде apt, yum или Homebrew с использованием Nuitka для компиляции.

Эти подходы позволяют интегрировать Nuitka в различные аспекты процесса разработки и развертывания, автоматизируя сборку и делая её частью вашего рабочего процесса. Выбор конкретного метода зависит от вашей инфраструктуры и требований проекта.

6. Переходим к практике: коротко моем опыте

6.1. Оптимизация проекта

Это было необходимо, чтобы сделать из проекта единый монолит. Если без подробностей, я заменил Memcached на python-memcached и Redis на Fakeredis. Тут подробно останавливаться не буду, потому что банально заменил зависимости проекта и добавил небольшие настройки. Далее часть чуть сложнее - прокси-сервер. У нас для этого всегда был Apache2, в связи с этой задачей я выбрал фреймворк FastAPI, если опустить эндпоинты, то вот код:
Загрузка статичных файлов:

def find_static_files():
   if getattr(sys, 'frozen', False):
       base_path = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
   else:
       base_path = os.path.dirname(os.path.abspath(__file__))


   possible_paths = [
       os.path.join(base_path, 'static'),
       os.path.join(base_path, 'django_termidesk_assistant', 'static'),
       os.path.join(base_path, 'src', 'django_termidesk_assistant', 'static'),
       os.path.join(os.path.dirname(base_path), 'static'),
       '/tmp/onefile_*/static'
   ]


   for path in possible_paths:
       if '*' in path:
           import glob
           matching_paths = glob.glob(path)
           if matching_paths:
               return matching_paths[0]
       elif os.path.exists(path) and os.path.isdir(path):
           return path


   print("Static files not found in any of the expected locations.")
   return None




def setup_static_files(app):
   static_path = find_static_files()
   if static_path:
       if getattr(sys, 'frozen', False):
           temp_dir = tempfile.mkdtemp()
           temp_static_path = os.path.join(temp_dir, 'static')
           shutil.copytree(static_path, temp_static_path)
           static_path = temp_static_path


       app.mount("/assistant/static", StaticFiles(directory=static_path), name="static")
       return app
   else:
       print("Warning: Static files not found. Static content will not be served.")
       return None




fastapi_app = setup_static_files(fastapi_app)

Редирект:

@fastapi_app.exception_handler(404)
async def custom_404_handler(request: Request, exc: Exception):
   return RedirectResponse(url="/assistant/")

Генерация сертификатов для https:

def generate_ssl_certificates():
   try:
       subprocess.run(['openssl', 'genrsa', '-out', 'privkey.pem', '2048'], check=True)
       subprocess.run(['openssl', 'req', '-new', '-x509', '-key', 'privkey.pem',
                       '-out', 'fullchain.pem', '-days', '365', '-subj', "/CN=localhost"], check=True)
   except subprocess.CalledProcessError as e:
       print(f"Failed to generate SSL certificates: {e}")
       raise

Запуск http сервера:

def run_http_server():
   config = Config(fastapi_app, host="0.0.0.0", port=80)
   server = Server(config=config)
   try:
       server.run()
   except Exception as e:
       print(f"Server error on port 80: {e}")

Запуск https сервера:

def run_https_server():
   generate_ssl_certificates()


   certfile = "fullchain.pem"
   keyfile = "privkey.pem"


   config = Config(fastapi_app, host="0.0.0.0", port=443,
                   ssl_keyfile=keyfile, ssl_certfile=certfile)
   server = Server(config=config)
   try:
       server.run()
   except Exception as e:
       print(f"Server error on port 443: {e}")

Дальше запускаем в отдельных процессах, чтобы не было конфликтов (пробовал асинхрон - не вышло):

multiprocessing.freeze_support()
http_process = multiprocessing.Process(target=run_http_server)
https_process = multiprocessing.Process(target=run_https_server)
daphne_process = multiprocessing.Process(target=run_daphne)


http_process.start()
https_process.start()
daphne_process.start()

run_daphne - скрипт запуска внутреннего сервера соответственно.

6.2. GitFlic-пайплайны, связанные со сборкой в Nuitka

Далее идет процесс сборки пакетов на CI/CD сервере.

6.2.1. Сборка elf-файла

build_nuitka_elf:
 image: "python:3.8-buster"
 stage: build
 tags:
   - at-docker
 before_script:
   - python -m venv venv
   - source venv/bin/activate
   - pip install --upgrade pip
   - pip install --upgrade setuptools wheel
   - apt -y update >/dev/null
   - apt install -y gettext patchelf >/dev/null
   - export PATH=$PATH:/usr/sbin
   - pip install -r ${CI_PROJECT_DIR}/src/django_termidesk_assistant/requirements.txt
   - pip install uvicorn==0.17.6 fastapi==0.78.0 starlette==0.19.1
   - cd ${CI_PROJECT_DIR}/src/django_termidesk_assistant
   - python manage.py makemigrations >/dev/null
   - python manage.py migrate >/dev/null
   - python manage.py createcachetable >/dev/null
   - python manage.py collectstatic --noinput >/dev/null
   - python manage.py compilemessages >/dev/null
 script:
   - cd ${CI_PROJECT_DIR}
   - export PYTHONPATH=$PYTHONPATH:${CI_PROJECT_DIR}/src/django_termidesk_assistant
   - export DJANGO_SETTINGS_MODULE=django_termidesk_assistant.settings
   - pip install nuitka==1.5.4
   - python -m nuitka
     --remove-output
     --follow-imports
     --include-module=django_termidesk_assistant.settings
     --include-package=django_termidesk_assistant
     --include-package=termidesk_assistant
     --include-package=signalling
     --include-module=termidesk_assistant.middleware
     --include-package=django
     --include-module=django.core.management
     --include-package=channels
     --include-package=channels_redis
     --include-package=fakeredis
     --include-package=asgiref
     --include-package=django.templatetags.i18n
     --include-package=daphne
     --include-package=rest_framework
     --include-module=django_termidesk_assistant.asgi
     --include-package=django_structlog
     --include-package=gettext
     --include-package=uvicorn
     --include-package=fastapi
     --include-package=starlette
     --include-data-dir=${CI_PROJECT_DIR}/src/django_termidesk_assistant/termidesk_assistant=termidesk_assistant
     --include-data-dir=${CI_PROJECT_DIR}/src/django_termidesk_assistant/static=static
     --include-data-dir=${CI_PROJECT_DIR}/src/django_termidesk_assistant/locale=locale
     --include-data-dir=${CI_PROJECT_DIR}/src/django_termidesk_assistant/var/db=var/db
     --include-data-dir=${CI_PROJECT_DIR}/src/django_termidesk_assistant/var/log=var/log
     --output-dir=${CI_PROJECT_DIR}/dist
     --onefile
     --onefile-tempdir-spec=/tmp/onefile_%PID%_%TIME%
     --output-filename=termidesk_assistant_server.bin
     ${CI_PROJECT_DIR}/src/django_termidesk_assistant/run_servers.py
 artifacts:
   expire_in: 1 day
   paths:
     - dist/

Давайте разберем построчно (ну, почти).

Выбираем образ докера python:3.8-buster, так как версии выше вызывают ошибку glibc на Astra Linux: glibc_2.33' not found. К тому же наша кодовая база написана для python 3.7, потому что в официальных репозиториях Astra Linux SE последняя доступная версия Python это 3.7.3, как и включенный в ОС интерпретатор.

Далее мы активируем виртуальное окружение и ставим patchelf, необходимый для сборки elf-файлов.

Можно заметить, что uvicorn, fastapi и starlette ставятся отдельно, я специально их вынес из requirements.txt, потому что они нужны исключительно для сборки в Nuitka и кастомизированного прокси-сервера, а у нас еще есть другие версии Termidesk Assistant.

Потом мы проходимся по стандартным Django-командам, которые нужны перед запуском любого Django-приложения.

Давайте перейдем к сборке. Мы ставим Nuitka версии 1.5.4, поскольку эмпирически было выяснено, что она оптимально подходит для python 3.8 и тех библиотек, что у нас поставлены для кодовой базы python 3.7. Пройдемся по самим аргументам Nuitka:
--remove-output - удаление C-файлов из python-модуля после сборки.

--follow-imports - компиляция зависимостей вместе с проектом.

--include-module и --include-package - если есть ошибка во время сборки, то указываем нужный нам импорт. В моем случае через --include-package я включал также внутренние приложения проекта.

--include-data-dir - все нужные для приложения папки, в основном это статические файлы.

--output-dir - папка, в которой будет лежать наш билд-файл внутри докер-контейнера.

--onefile - формат билда, в нашем случае это единый файл без зависимых папок и файлов.

--onefile-tempdir-spec - путь до папки, в которой будут храниться временные файлы во время запуска билда, например, файлы кэша.

--output-filename - имя билд-файла, ограничений по названию нет.

Далее ставим путь до скрипта входа, в моем случае это скрипт запуска процессов серверов.

Далее в artifacts указываем хранение файла сборки на CI/CD - сервере и папку, в которой будет лежать файл внутри zip-файла.

6.2.2. Сборка deb-файла

build_nuitka_deb_package:
 image: "python:3.8-buster"
 stage: package
 tags:
   - at-docker
 dependencies:
   - build_nuitka_elf
 before_script:
   - apt-get update
   - apt-get install -y dpkg-dev
 script:
   - |
     if [ -z "${CI_COMMIT_TAG}" ]; then
       export PACKAGE_VERSION=$(date +%Y%m%d%H%M%S)
     else
       export PACKAGE_VERSION=${CI_COMMIT_TAG}
     fi
   - export PACKAGE_NAME="termidesk-assistant-server${PACKAGE_VERSION}"
   - mkdir -p ${PACKAGE_NAME}/DEBIAN
   - mkdir -p ${PACKAGE_NAME}/usr/local/bin
   - mkdir -p ${PACKAGE_NAME}/etc/systemd/system
   - mkdir -p ${PACKAGE_NAME}/var/lib/termidesk-assistant
   - mkdir -p ${PACKAGE_NAME}/var/log/termidesk-assistant
   - cp dist/termidesk_assistant_server.bin ${PACKAGE_NAME}/usr/local/bin/
   - chmod +x ${PACKAGE_NAME}/usr/local/bin/termidesk_assistant_server.bin


   - |
     cat << EOF > ${PACKAGE_NAME}/DEBIAN/control
     Package: termidesk-assistant-server
     Version: ${PACKAGE_VERSION}
     Maintainer: Release Team <release@uveon.ru>
     Architecture: amd64
     Section: non-free/admin
     Priority: optional
     Description: Termidesk Assistant Server
     Homepage: http://uveon.ru/
     EOF


   - |
     cat << EOF > ${PACKAGE_NAME}/etc/systemd/system/termidesk-assistant.service
     [Unit]
     Description=Termidesk Assistant Server
     After=network.target


     [Service]
     ExecStart=/usr/local/bin/termidesk_assistant_server.bin
     Restart=on-failure
     RestartSec=5
    
     User=root
     Group=root
     Environment=PATH=/usr/bin:/usr/local/bin
     WorkingDirectory=/var/lib/termidesk-assistant
    
     StandardOutput=append:/var/log/termidesk-assistant/service.log
     StandardError=append:/var/log/termidesk-assistant/service.log


     [Install]
     WantedBy=multi-user.target
     EOF


   - |
     cat << EOF > ${PACKAGE_NAME}/DEBIAN/postinst
     #!/bin/bash
     set -e


     # Log file for installation
     LOG_FILE="/var/lib/termidesk-assistant/install.log"


     log() {
         echo "\$(date): \$1" >> \$LOG_FILE
     }


     log "Starting postinst script"


     # Create necessary directories
     mkdir -p /var/lib/termidesk-assistant /var/log/termidesk-assistant
     chown root:root /var/lib/termidesk-assistant /var/log/termidesk-assistant
     chmod 755 /var/lib/termidesk-assistant /var/log/termidesk-assistant
     log "Created necessary directories"


     # Reload systemd to recognize the new service
     systemctl daemon-reload
     log "Systemd reloaded"


     # Enable the service to start on boot
     systemctl enable termidesk-assistant.service
     log "Service enabled"


     # Start the service
     systemctl start termidesk-assistant.service
     log "Service start command issued"


     # Check if the service is running
     sleep 5  # Give the service a moment to start
     if systemctl is-active --quiet termidesk-assistant.service; then
         log "Service is running successfully"
     else
         log "Service failed to start. Check systemctl status termidesk-assistant.service for more info"
         systemctl status termidesk-assistant.service >> \$LOG_FILE 2>&1
         journalctl -u termidesk-assistant.service --no-pager >> \$LOG_FILE
     fi


     # Check if the service is enabled
     if systemctl is-enabled --quiet termidesk-assistant.service; then
         log "Service is enabled for autostart"
     else
         log "WARNING: Service is not enabled for autostart"
     fi


     log "Postinst script completed"


     exit 0
     EOF


   - chmod 755 ${PACKAGE_NAME}/DEBIAN/postinst


   - dpkg-deb --build ${PACKAGE_NAME}
 artifacts:
   expire_in: 1 day
   paths:
     - ./*.deb

Начинается все также, но учитываем, что stage должен быть следующим, иначе не выйдет поставить предыдущий пайплайн в зависимость этому:

dependencies:
   - build_nuitka_elf

Кратко, так выглядит зависимость второго пайплайна от первого:

Далее ставим необходимый пакет dpkg-dev для сборки deb-файла.
Перейдем к части script пайплайна:

Вводим переменную PACKAGE_NAME, основанную на имени приложения, тега коммита и времени. Создаем необходимые для deb-файла директории, в том числе и логи. Копируем наш elf-файл из предыдущего пайплайна.

А теперь пишем файлы, без которых наш результат будет нежизнеспособен:

сontrol - описание deb-файла.

service - окружение, файл, на котором будет основан deb-файл, пользователь и группы пользователей, которым дан доступ, рабочая папка и т.д.

postinst - команды, которые должны быть исполнены после установки, в моем случае это создание папки логов, запуск самого сервиса приложения, создание и запись в лог сборки файла.

В artifacts указываем аналогично хранение в один день и сохранение файла с расширением .deb.

7. Резюмируем

В этой статье мы разобрали преимущества и недостатки Nuitka перед ее аналогами, такими как Pyinstaller и Docker. Выяснили, какие существуют варианты интеграции Nuitka в ваше приложение. Пошагово рассмотрели, как собрать исполняемый и установочный файлы на моем примере с Termidesk Assistant.

Если у вас остались какие-то вопросы, пишите в комментариях, с радостью отвечу!

Комментарии (2)


  1. Akorabelnikov
    09.01.2025 21:00

    Был ли замерен прирост производительности?


    1. DanilKomyshev Автор
      09.01.2025 21:00

      Примерно от 10% до 76% в зависимости от обращений к бд, потому что операции уже происходят на стороне бд, что понижает ускорение. Замеры также были помодульно, поэтому где больше логики на питоне, тем ускорение выше. Вообще по-хорошему сделать тесты на базовых операциях, если и когда этим займемся, обязательно результаты в ответе запишу)