Как незаметно запускать виртуальный Linux на QEMU

9b4d430582105a6e8d53b74b04209d95

В некоторых задачах Linux просто необходим. И самым ярким тому примером на сегодня является наличие системы WSL. Однако не везде ею можно пользоваться. Некоторые предприятия принципиально застревают на Win7. И их можно понять. Эта система не столь требовательна к железу (особенно к видео-подсистеме), не ломится чуть-что в интернет, да и в Ultimate варианте вообще не требует подключения к глобальной сети.

В большей части случаев можно обойтись родными для системы средствами разработки и сторонними инструментами. Но представьте себе, что для работы Вашего комплекса нужно собрать вместе более десятка не маленьких opensource проектов с перекрёстными зависимостями. Причём часть из них написана под python (и тут никаких проблем нет — виртуальная среда и всё ок), а часть собирается в бинарные исполняшки, от которых зависят другие модули. И тут может быть как минимум три решения:

  • собрать всё с помощью mingw-тулчейна;

  • воспользоваться msys2 или cygwin;

  • собрать всё быстро и удобно на виртуальной машине с Linux.

Однако у всех перечисленных способов есть свои недостатки:

  • Для mingw-тулчейна вам потребуется руками собирать неимоверное количество библиотек-зависимостей.

  • Среды msys2 или cygwin хороши тем, что в их репозиториях уже есть почти всё, что может пригодиться (а в вашем случае может быть и вообще всё). Но вот беда: заказчик хочет, чтобы система была монолитной и не требовала установки дополнительного ПО, а обе среды в базовой реализации не совсем портабельны. Что-то может перестать работать после переноса на новое место и в новую систему. Есть их портабелизации на portableapps.com, но тут тоже могут ожидать подводные камни: в обоих случаях при исследовании были пакеты, которые ставились как-то не так. Например binutils в portable msys2 не устанавливал исполняемые файлы. О какой сборке чего бы то ни было в таком случае может идти речь?

  • Виртуальная машине с Linux с точки зрения сборки комплекса безусловно является оптимальным решением (если конечно не требуется CUDA). Но тут возникает уже человеческий фактор. При словах «Linux» и «виртуальная машина» у довольно большого количества людей возникает примерно одинаковая реакция: «Не, не, не! Люди не умеют этим пользоваться. Учить долго и/или дорого. Делай так, чтобы было только на Винде».

Конкретно в моей задаче проблема была именно в том, что там уж слишком много всего накручено. Собрать это под Windows скорее всего можно, но вот время, которое на это придётся затратить меня не устраивало. Значит нужно прятать факт использования виртуальной машины. Благо пользователи Windows по большей части не сталкиваются с QEMU, и считают, что для работы с виртуальной машиной обязательно нужно устанавливать в систему VMWare или VirtualBox, а потом с их интерфейса запускать окошко с виртуалкой.

Я не буду здесь писать о чём-то новом. На просторах Хабра всё, что будет описано ниже уже не раз встречалось. Но вот применительно к конкретной задаче маскировки работы виртуальной машины под работу обычной консольной программы Windows, текст будет интересен.

Я буду описывать весь процесс на примере Manjaro. Во первых, я её очень Люблю. Во вторых это Arch-дистрибутив с установленным из коробки pamac и AUR. Конечно при использовании чистого Arch Linux итоговый образ получился бы меньше, но не на много.

Идея заключается в том, что создаваемый комплекс должен вести некоторую обработку файлов и выдавать файловый же результат. То есть он должен вести себя, как консольная программа. Для Windows-пользователей также будет не лишним добавить диалог открытия файлов (а, если нужно, и ввода параметров), чтобы им не пришлось параметры в командную строку вбивать.

На хосте должны быть установлены пакеты из группы qemu-full. Образ установщика гостевой системы находится на https://manjaro.org/download/. В принципе можно брать любой. Потом всё равно нужно будет удалять лишние пакеты. Но вот беда: некоторые пакеты (например tesseract) ставят себе в зависимость пакеты окружения рабочего стола. Так что лучше сразу поставить что-то полегче (xfce, например), чтобы потом не жалко было его оставлять.

Создаём диск виртуальной машины:

qemu-img create -f qcow2 image.qcow2 32G

И запускаем её установку:

qemu-system-x86_64 \
    -enable-kvm \
    -cpu host \
    -hda image.qcow2 \
    -drive file=manjaro-xfce-21.0-210318-linux510.iso,media=cdrom \
    -boot d \
    -smp 4 \
    -m 4G \
    -display gtk \
    -vga std \
    -device virtio-net,netdev=vmnic -netdev user,id=vmnic

Советую собирать на единственном разделе. Либо вообще без свопа, либо со своп-файлом. Это нужно для того, чтобы образ в итоге занял меньше места и не имел тенденции к чрезмерному росту.

Из дополнительных пакетов нам понадобится samba для получения доступа к папке Windows-хоста.

Также далее будем считать, что пользователя гостевой системы зовут user, а его пароль pass.

После установки команда запуска поменяется на:

qemu-system-x86_64 \
    -enable-kvm \
    -cpu host \
    -hda image.qcow2 \
    -smp 4 \
    -m 4G \
    -display gtk \
    -vga std \
    -device virtio-net,netdev=vmnic -netdev user,id=vmnic

Мы изъяли установочный диск:

    -drive file=manjaro-xfce-21.0-210318-linux510.iso,media=cdrom \
    -boot d \

Теперь можно устанавливать и настраивать свой программный комплекс в приятной Linux-среде. Раз уж мы работаем в Manjaro, откроем сразу GUI pamac’а и в настройках разрешим использование AUR-репозитория, также изменив сборочную директорию на ~/AUR. Теперь собирать стало ещё проще. Найти-что-то чего нет в AUR — это не так-то просто.

Пришло время маскироваться под Windows-приложение. Для этого нам потребуется portable python. Забираем любой понравившийся с https://github.com/oskaritimperi/portablepython/releases. Я буду писать на примере python3. Естественно нужен QEMU с https://qemu.weilnetz.de/w64/.

Создаём папки SystemName/workdir/qemu. В workdir распаковываем содержимое папки python’а из архива и перемещаем образ image.qcow2. A в qemu копируем содержимое установленного QEMU (установить его можно и wine’ом).

Для теста системы на поддержку виртуализации нам потребуется cpuinfo в portable python’е на Windows, а для передачи параметров (да и мало ли ещё чего) через подключаемый iso-образ pycdlib:

# Опять-таки можно и через wine запустить.
python.exe -m pip install py-cpuinfo pycdlib

В папке SystemName создаём run.bat с простеньким содержимым:

cd workdir
python.exe run.py
cd ..

А в workdir создаём скрипт run.py, который и будет заниматься получением параметров запуска, запуском виртуальной машины и выводом лога работы программы на консоль:

import cpuinfo
import os
import sys
import subprocess
import time
import getpass
from io import BytesIO
import pycdlib
from tkinter import *
from tkinter import filedialog

user = getpass.getuser()
qemu_bin = 'qemu/qemu-system-x86_64.exe'
password = getpass.win_getpass(f'{user}, введите свой пароль: ')

# Проверяем на включенность виртуализации и предупреждаем пользователя, если что.
if len(set(cpuinfo.get_cpu_info()['flags']).intersection({'vmx', 'svm'})) > 0:
    cpu = '-enable-kvm -cpu host'
else:
    cpu = '-cpu qemu64'
    print('Если включить в BIOS поддержку Intel VT-x, программа будет работать быстрее.')

# Открываем диалог выбора папки и просим пользователя показать, где лежат данные.
# При необходимости сюда и окно с параметрами можно записать.
# Но спрашивать нужно именно папку, чтобы записать туда конфигурацию для программы
# на виртуальной машине.
base_root = Tk()
base_dialog = filedialog.Directory(base_root)
base_root.withdraw()
base = base_dialog.show().strip().replace('\\', '/') # Приводим слеши в порядок.
if base[-1] != '/':
    base += '/'

# Пишем диск с конфигурацией (здесь только пользователь и пароль к разделяемой папке).
iso = pycdlib.PyCdlib()
iso.new()
conf = bytes(f'{user}\n{password}\n', 'utf-8')
iso.add_fp(BytesIO(conf), len(conf), '/CONFIG.;1')
iso.write_fp(BytesIO(conf))
iso.write('config.iso')
iso.close()

# Команда запуска виртуальной машины.
# Обратите внимание на параметр -display none.
# Это позволяет не показывать экран виртуальной машины.
run_qemu = f'{qemu_bin} {cpu} -hda ./image.qcow2 -smp {os.cpu_count()} \
-m 4G -display none -vga std -device virtio-net,netdev=vmnic \
-netdev user,id=vmnic -drive file=config.iso,media=cdrom'.replace('/', '\\')

open(f'{base}system.log', 'w') # Очищаем лог.

# Команда запуска черезчур сложна для subprocess.Popen, поэтому нужен скрипт.
open(f'./qemu.bat', 'w').write(run_qemu)
# На Windows-хосте нужно расшарить папку и только потом запускать виртуальную машину.
base_win = base.replace('/', '\\')[: -1]
os.system(f'net share vmshare={base_win} /GRANT:"{user},FULL"')
qemu = subprocess.Popen(['qemu.bat'])
    
# Начинаем читать лог.
print('Загрузка...')
lines_printed = 0
while qemu.poll() is None: # Пока виртуальная машина запущена.
    log = open(f'{base}system.log', 'rb').read()
    if b'\n' in log:
        if log[-1] != b'\n':
            log = log[: log.rfind(b'\n')]
        log = log.split(b'\n')
        if len(log[-1]) == 0:
            log = log[: -1]
        log = log[lines_printed:]
        if len(log) > 0:
            lines_printed += len(log)
            for line in log:
                try: # На случай битого вывода.
                    print(line.decode('utf-8'))
                except:
                    pass
        else:
            time.sleep(1)

# Отключаем папку.
os.system('net share /delete vmshare')
print('Завершено')

Ключевыми особенностью нового скрипта запуска QEMU (в переменной run_qemu) являются два параметра:

# Не показывать пользователю экран виртуальной машины.
-display none
# Подключить сгенерированный CD с параметрами.
-drive file=config.iso,media=cdrom

Стоит отметить, что на подключаемый CD можно складывать всё, что угодно. Например, так удобно обновлять ПО внутри системы прямо на лету.

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

#!/bin/bash
# Пример скрипта запуска комплекса run.sh
# Весь полезный вывод перенаправляется в /mnt/system.log.
# Файл конфигурации все могут прочитать в config на подключаемом CD.

# Пусть в этой папке лежат все исполняемые файлы комплекса.
cd /home/user/SystemName

# Чтение параметров.
echo "pass" | sudo -S umount /dev/sr0
mkdir /home/user/SystemName/config
echo "pass" | sudo -S mount /dev/sr0 /home/user/SystemName/config
user=`sed -n "1p" < /home/user/SystemName/config`
pass=`sed -n "2p" < /home/user/SystemName/config`
echo "pass" | sudo -S umount /dev/sr0

# Монтирование рабочей директории.
echo "pass" | sudo -S mount -t cifs -o username=$user,password=$pass,workgroup=workgroup,iocharset=utf8,uid=user //10.0.2.2/vmshare /mnt

./it_works_all_the_time.py >> /mnt/system.log &
all_time_worker=$!

# Здесь все данные, с которыми комплекс должен работать.
base="/mnt/"

# То, что нельзя распараллелить.
./1_line.py >> /mnt/system.log
./2_line.py >> /mnt/system.log
./3_line.py >> /mnt/system.log

# То, что может быть выполнено параллельно.
./4_parallel.py >> /mnt/system.log &
pid_41=$!
-S ./4_parallel.py >> /mnt/system.log &
pid_42=$!
./4_parallel.py >> /mnt/system.log &
pid_43=$!

# Ожидание распараллеленных процессов.
wait $pid_41
wait $pid_42
wait $pid_34

echo "pass" | sudo -S ./5_end.py >> /mnt/system.log

# Завершение.
kill $all_time_worker
echo "pass" | sudo -S umount /mnt
echo "pass" | sudo -S poweroff

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

После сборки всего необходимого мой образ весил 22 ГБ — неприлично много, но там точно около 13 ГБ «полезной» нагрузки. Нужно уменьшать. Опять открываем pamac на гостевой системе и в меню переходим в «Режим приложений». Удаляем всё ненужное. Теперь возвращаемся в обычный режим, в закладку «Установлены» и группу «Неиспользуемые». Итеративно удаляем всё, пока список не опустеет. И выключаем систему.

Теперь нужно по возможности уменьшить размер образа, удалив лишние файлы, дефрагментировав и обрезав его:

### В гостевой системе.

# Удаляем кэш пакетов. Устанавливать нам больше нечего.
pacman -Sc # Дважды соглашаемся.
rm -f /var/cache/pacman/pkg/*
rm -rf ~/AUR


### На хосте.

# Подключаем модуль ядра.
modprobe nbd max_part=8

# Монтируем файловую систему.
qemu-nbd --connect=/dev/nbd0 image.qcow2
mkdir /mnt/qcow2
mount /dev/nbd0p1 /mnt/qcow2

# Дефрагментируем. Всё свободное место скапливается в конце диска.
e4defrag /dev/nbd0p1 /mnt/qcow2

# Заполняем свободное место нулями.
# Будьте внимательны. В процесс диск раздуется до предельного разера.
dd if=/dev/zero of=/mnt/qcow2/tempfile
rm -f /mnt/qcow2/tempfile

# Отключаем файловую систему.
umount /mnt/qcow2
qemu-nbd --disconnect /dev/nbd0
rmmod nbd

# Пересобираем диск, чтобы он занимал минимум места.
mv image.qcow2 image.qcow2.old
qemu-img convert -O qcow2 image.qcow2.old image.qcow2
rm -f image.qcow2.old

Вот теперь 13 ГБ. Точно, как и ожидалось.

Пара слов о том, как понять, какой объём системы является «полезным»:

import pacman
import locale
from ipywidgets import IntProgress
from IPython.display import display
from datetime import datetime

locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8')

print('Получение списка установленных пакетов.')
installed = pacman.get_installed()
installed = {pkg['id'] for pkg in installed}

print('Получение информации о пакетах.')
progress = IntProgress(min=0, max=len(installed))
display(progress)
specially_installed = []
all_installed = []
for pkg in installed:
    info = pacman.get_info(pkg)
    if info['Причина установки'] != 'Установлен как зависимость другого пакета':
        specially_installed.append(info)
    all_installed.append(info)
    progress.value = progress.value + 1
installed_dict = {pkg['Название']: pkg for pkg in all_installed}

print('Получение пакетов, зависящих от пакета.')
progress = IntProgress(min=0, max=len(installed))
display(progress)
dependensies = dict()
for pkg in installed:
    dependensies[pkg] = set()
    try:
        for depend in pacman.depends_for(pkg):
            if depend != pkg and depend in installed:
                dependensies[pkg].add(depend)
    except:
        dependensies.pop(pkg)
    progress.value = progress.value + 1
    
    
# Ищем нужные пакеты и их зависимости.
needed_words = {
    'В', 'этом', 'списке', 'должны', 'быть', 'солва', ',', 'являющиеся', 
    'базовыми', 'формами', 'имён', 'пакетов', ',', 'необходимых', 'Вашему', 
    'комплексу', '.', 
    'То', 'есть', ',', 'если', 'пакет', 'называется', 'python-pip', ',', 
    'то', 'в', 'списке', 'должен', 'быть', 'просто', 'pip', '.'}
needed = set()
for name in needed_words:
    for pkg in installed:
        if name in pkg:
            needed.add(pkg)
new_needed = set()
for pkg in list(needed):
    if pkg in dependensies:
        for dep in dependensies:
            if dep not in needed:
                needed.add(dep)
                new_needed.add(dep)
print(f'Добавлено {len(new_needed)} пакетов. Всего {len(needed)} пакетов.')


# Вычисляем "полезный" объём.
sizes = {'B': 1, 'K': 1024, 'M': 1048576, 'G': 1073741824}
size_needed = 0
size_not_needed = 0
for pkg in installed:
    size = float(installed_dict[pkg]['Установленный размер'].split()[0].replace(',', '.')) * sizes[installed_dict[pkg]['Установленный размер'].split()[1][0]]
    if pkg in needed:
        size_needed += size
    else:
        size_not_needed += size
print(f'Размер необходимых пакетов {int(size_needed / 1048576)} MB')
print(f'Размер пакетов для удаления {int(size_not_needed / 1048576)} MB')
    
print('Завершено.')

В итоге, получен универсальный способ сборки громоздких opensource комплексов с кучей зависимостей под Windows. По производительности, конечно, не ах. Всё-таки виртуализация (а-то и без VT-x может запуститься). Но при ограничениях, описанных в начале статьи, лучшего добиться можно, но неимоверно сложно. Напомню, что это должно было выглядеть, как программа, собранная исключительно под Windows.

Всем, дочитавшим до этого места, спасибо! Буду рад комментариям.

© Habrahabr.ru