Как стать автором
Обновить

KIWI Image System: атака клонов

Время на прочтение14 мин
Количество просмотров11K

⇒Введение


Когда у вас есть очень много одинаковых машин (кластер веб-серверов, куча терминалов оплаты, зал с публичными компьютерами), возникает вопрос о поддержании всех машин синхронизированными по версиям установленного ПО.
Традиционным решением является настройка репозиториев и регулярное обновление всех машин средствами пакетного менеджера. Не менее традиционным — сборка в одном месте и почти ручное раскладывание по всем машинам новой версии пакета.

Про очевидную ущербность второго способа говорить, думаю, смысла не имеет. С первым тоже не все гладко. Например, если во время массового обновления некоторые машины были недоступны, то при возвращении в бой ПО на них окажется устаревшим, что может негативно сказаться на целостности системы (например, изменения в протоколе общения между машинами без обратной совместимости). Вписывать в автозагрузку автообновление — тоже не вполне хороший вариант, так как можно получить не доконца оттестированный пакет или из-за проблем со связью не обновиться.


⇒KIWI (сайт проекта)


Если вы думаете, что киви в IT — это только сеть терминалов оплаты, то вы несколько заблуждаетесь. Ребята из OpenSUSE делают потенциально убойный набор мегаскриптов под похожим названием (KIWI против QIWI), который уже сейчас используется для создания LiveCD и LiveUSB дистрибутива OpenSUSE, а также включен в пакет SLEPOS — SUSE Linux Enterprise Point Of Service (откуда качать и как ставить платный пакет от Novell, рассказывает инструкция от IBM).

Принцип работы KIWI несложен — по специально написанным инструкциям создаем chroot-образ системы, после чего аккуратно заворачиваем полученную кучу файлов в образ (варианты описаны на сайте проекта), прикладываем к нему ядро и initrd, способные с этим образом сделать требуемый набор действий, складываем полученные 3 (на самом деле, чуть больше) файла туда, где их достанет бутлоадер, и выкатываем в бой.


⇒Что оно делает


Сначала я опишу варианты основных действий initrd, после чего подробнее опишу доступные средства для влияния на некоторые из них.

→Цель жизни рамдиска
Ядро линукса и его рамдиск запускаются, как правило, загрузчиком syslinux, за исключением вариантов с созданием образа для Xen.Созданная KIWI initrd своей единственной целью видит то, чтобы смонтировать образ настоящей системы, после чего выполнить pivot_root и с честью умереть. Но поиск этого образа она умеет делать самыми изощренными методами.

При работе с LiveCD, например, все достаточно просто — смонтировать саму болванку, найти образ большой системы, смонтировать его в loop, смонтировать поверх него UnionFS (или модный нынче AUFS) для возможности записи, после чего полученный юнион окажется годным для загрузки ОС.

→Тяжела и неказиста жизнь фрукта
Самое интересное, на мой взгляд, происходит при загрузке по PXE. В этом случае алгоритм получения образа примерно таков:
  1. сконфигурировать сетевой адаптер
  2. получить индивидуальный конфиг с сервера KIWI
  3. в зависимости от содержания конфига разбить жесткий диск на разделы
  4. при деплое на локальный диск накатить актуальный образ или удостовериться в актуальности имеющегося
  5. при бездисковой загрузке одно из:
    • скачать образ в RAM и смонтировать его
    • смонтировать удаленный корень через NFS, NBD или AoE
  6. опционально настроить union
  7. проверить наличие в образе модулей к запущенному ядру
  8. скачать дополнительные конфиги
  9. заменить некоторые конфиги на динамически созданные

→Программа мероприятия
Я укрощал KIWI для работы с Infiniband, FlexBoot (бывший Boot over IB), squashfs, aufs. Собирал SLES11, но сборка OpenSUSE принципиально не отличается. Дома у меня под виртуалкой OpenSUSE, поэтому рассмотренный пример полностью прорабатывался на ней. Я расскажу желающим, как собрать образ для сетевой загрузки далее полностью автономных систем, местами делая поправки на специфику нашего железа. Поскольку это, насколько я понял из документации, наименее поддерживаемый расклад, придется пофиксить много косяков.
Чтобы статья не превращалась в очередной Howto, несовместимый с другими версиями софта, патчей и кусков кода почти не будет


⇒Теплый старт — изучение примеров


После установки пакетов kiwi, kiwi-desc-netboot, kiwi-doc можно сразу собрать образ OpenSUSE, который сможет грузиться по PXE с некоторых Ethernet-адаптеров и монтироваться через NFS, например. Другой функционал поддерживается крайне слабо. Но, надо заметить, это уже хоть что-то.

→Теория
Для сборки образа вам понадобится следующее:
  • каталог с описанием самого образа — копируется из /usr/share/doc/packages/kiwi/examples
  • каталог с описанием процесса сборки initrd — лежит в /usr/share/kiwi/images/netboot
  • пакет squashfs для заворачивания образа в маленький файл (В OpenSuSE используется схожая ClicFS, но этого пакета нет в SLES)
  • два каталога под создаваемые файлы — корень будущего образа и место для складирования результатов
  • права root

→Тестовое окружение
Для определенности работать будем в /tmp/kiwi/, процесс подготовки будет состоять примерно в следующем:
susetest:/tmp/kiwi # cp -a /usr/share/doc/packages/kiwi/examples/suse-11.2/suse-pxe-client /tmp/kiwi/mysystem
susetest:/tmp/kiwi # mkdir /tmp/kiwi/image
susetest:/tmp/kiwi # ls
image  mysystem

→Собираем!
Сборка образа состоит из двух этапов — подготовки (prepare) и непосредсвенно сборки (create). В совокупности с остальными параметрами выглядеть это будет примерно так:
kiwi --prepare /tmp/kiwi/mysystem --root /tmp/kiwi/mysystem-chroot --force-new-root
kiwi --create /tmp/kiwi/mysystem-chroot/ -d /tmp/kiwi/image
Обратите внимание на опцию force-new-root. Она позволит создавать новый корень, даже если каталог mysystem-chroot не пуст. Очень полезно для многократной сборки.
Для полного понимания того, что происходит, можно смотреть в файл лога, об имени которого KIWI сообщает при запуске, или отдать длинную опцию --logfile terminal

Первое, что заметно — долгое время установки пакетов, обусловленное скоростью доступа к репозиториям SuSE. Поэтому я настоятельно советую обзавестись зеркалом или хотя бы использовать в качестве репозитория установочный диск (смонтированный локально или расшаренный по HTTP).

На выходе мы получим много файлов в каталоге образа /tmp/kiwi/image, среди которых внимание надо обратить на следующие:
  • initrd-netboot-suse-11.2.x86_64-2.1.1.splash.gz — шибко умный initrd
  • initrd-netboot-suse-11.2.x86_64-2.1.1.kernel — симлинк на ядро, с которым собраны образ и инитрда
  • suse-11.2-pxe-client.x86_64-1.2.8 — образ системы
  • suse-11.2-pxe-client.x86_64-1.2.8.md5 — MD5-сумма предыдущего пункта

→Ключ на старт!
Для загрузки клиента нам нужны DHCP-сервер, TFTP-сервер, syslinux, конфиги pxelinux и KIWI. С первыми двумя ролями отлично справляется dnsmasq.

〉Дерево на сервере
TFTP root. У меня получилась вот такая структура:
susetest:~ # find /srv/tftp/
/srv/tftp/
/srv/tftp/KIWI
/srv/tftp/KIWI/config.default
/srv/tftp/kiwi
/srv/tftp/kiwi/initrd-netboot-suse-11.2.x86_64-2.1.1.kernel
/srv/tftp/kiwi/initrd-netboot-suse-11.2.x86_64-2.1.1.splash.gz
/srv/tftp/pxelinux.cfg
/srv/tftp/pxelinux.cfg/default
/srv/tftp/pxelinux.0
/srv/tftp/image
/srv/tftp/image/suse-11.2-pxe-client.x86_64-1.2.8
/srv/tftp/image/suse-11.2-pxe-client.x86_64-1.2.8.md5

Конфиг KIWI должен обязательно лежать в /KIWI, а образы — обязательно под /image

〉Простейшие конфиги
susetest:~ # cat /srv/tftp/pxelinux.cfg/default
# pxelinux config
default kiwi
prompt 0
LABEL kiwi
   KERNEL kiwi/initrd-netboot-suse-11.2.x86_64-2.1.1.kernel
   APPEND initrd=kiwi/initrd-netboot-suse-11.2.x86_64-2.1.1.splash.gz kiwiserver=10.0.0.1
   IPAPPEND 2

susetest:~ # cat /srv/tftp/KIWI/config.default
IMAGE=/dev/ram1;suse-11.2-pxe-client.x86_64;1.2.8;10.0.0.1
UNIONFS_CONFIG=/dev/ram2,/dev/ram1,clicfs

Обратите внимание на параметр IPAPPEND 2. Он позволит KIWI понять, каким сетевым интерфейсом пользоваться для продолжения загрузки. Параметр kiwiserver указывает, откуда тащить конфиг. Если его не указать, то initrd, собранные старыми версиями KIWI, не найдут KIWI-сервер, а собранные новыми — предположат, что он совпадает с DHCP-сервером.

В конфиге KIWI написано, что образ мы раскатываем в память (блочный рамдиск /dev/ram1), а запись перенаправляем в /dev/ram2 средствами clicfs (можно использовать AUFS). Диск /dev/ram0 использовать нельзя, поскольку это сам initrd.

→Полетели!
После настройки DHCP- и TFTP-серверов, а также написания конфигов pxelinux и KIWI PXE-клиент должен нормально загрузиться. По дефолту рутовый пароль «linux». Можно сделать rm -rf /* и понаблюдать за процессом :)

〉Если не взлетело
Есть несколько типичных проблем при загрузке. Одна из них — Network module: Failed to load network module !, вторая — ошибки при загрузке образа. Решения обеих описаны в разделе Хакинг.


⇒Тюнинг


→Содержимое образа
Теперь, когда все, кажется, начало как-то работать, настало время наполнить наш образ нужным софтом. Поэтому с помощью любимого редактора открываем mysystem/config.xml и начинаем неистово редактировать.

〉Заголовки
Про поля типа name, author и т.п. все понятно.
В группе preferences можно отредактировать поле type, убрав оттуда поле pxedeploy. Надпись filesystem="clicfs" говорит о том, в какую ФС завоачивать систему. Можно написать сюда ext3, например. boot="netboot/suse-11.2" указвает на описание процесса сборки initrd, которое ищется под /usr/share/kiwi/image. Можно использовать полный путь, если нужное описание не там. Еще есть возможность вписать атрибут bootprofile="something", чтобы выбрать один из вариантов сборки initrd.

〉Репозитории и юзеры
Как создаются группы и юзеры с нужными паролями, из примера понятно. Остановлюсь на репозиториях.
Репозитории должны поддерживаться выбранным в preferences packagemanager-ом, табличка с возможными вариантами есть в документации. Для zypper можно использовать репозитории типа yast2 и rpm-md. Установочный диск SLES имеет тип rpm-md.
Протоколы репозиториев тоже бывают разными, в частности, opensuse:// использует официальное зеркало, obs:// работает только при интеграции с OBS и ссылается на проект в том же OBS-е.

〉Устанавливаемый софт
У тега <packages> может быть три типа: bootstrap, image и delete. Под первым перечислены пакеты, необходимые для создания минимального рабочего chroot-окружения, пакеты из image ставятся после выполнения chroot. Тип delete описывает пакеты, которые KIWI удалит без соблюдения зависимостей после установки пакетов из image.
В свежих KIWI можно указать параметры bootinclude и bootdelete к тегу package, что позволит без изменения XML описания initrd добавить и удалить пакеты из последней.

〉скелет корневой ФС
В каталог root, что находится возле xml-ки, можно сложить файлы, с содержимым которых вы точно определились, но коробочные варианты которых не устраивают. Например, я там держу inittab, скрипт автологина, инит-скрипты, ключи SSH и еще что-то. Дерево этого каталога накладывается на образ системы после установки пакетов, что обеспечивает непереписываемость файлов из него в процессе установки софта.

〉config.sh
После установки пакетов во время выполнения стадии prepare KIWI запускает скрипт config.sh, который можно положить рядом с config.xml. В этом скрипте принято описываь команды настройки образа, например, прописывание sysconfig-параметров или включение/выключение сервисов. Я в нем выставляю права на некоторые файлы в /etc, ибо они (права) теряются, если хранить все в git.

〉images.sh
Лежит там же. Скрипт, выполняемый непосредственно перед сборкой образа из chroot-окружения. Это самая страшная часть KIWI. Деструктивные действия, выполняемые тут, приводят к диким ошибкам при запуске бинарей. Здесь самоуничтожается пакетный менеджер, удаляются каталоги с документацией, многие бинарники, некоторые библиотеки, etc. Если хотите образ, близкий к нормальной системе, мой совет: напишите exit 0 в самом начале.

→Протокол выкачки образа
Когда ваш clicfs- или squashfs-образ начнет весить 130 МБ (или все 500 — зависит от целей), у вас непременно возникнет проблема при выкачке его через TFTP. Никакая физическая сеть не способна дать гарантий по доставке каждого пакета, поэтому протокол, основанный на UDP, не справляется с большими файлами. В KIWI есть возможность использовать HTTP для этой цели.
Для переключения на использование HTTP при выкачке конфига и образа ядру нужно передать параметр kiwiservertype=http. Естественно, понадобится HTTP-сервер, отдающий файлы из /KIWI и /image. Попробуйте.

На tty1 KIWI напишет о невозможности найти образ, но не надо сразу лезть в конфиг nginx. Перейдите на tty3 и почитайте stderr при вызове curl. Ага? libsasl не найден. Нарушилась линковка внутри initrd, поэтому придется править описание netboot-образа. Об этом следующий раздел.


⇒Хакинг KIWI


→Цели и средства
Как и почти любой opensource-продукт, KIWI далек от совершенства. Всвязи с этим для достижения желаемого результата надо вонзаться в содержимое пакета, что в случае с KIWI не так уж сложно — сам KIWI написан на перле, а скрипты, заворачиваемые в initrd — на баше. Поэтому при помощи текстового редактора, пользуясь методом внимательного всматривания в чужой код, мы будем вносить необходимые изменения в нашу initrd. Я рекомендую для экспериментов скопировать каталог описания initrd (для OpenSuSE 11.2 это /usr/share/kiwi/image/netboot/suse-11.2) во что-то лежащее рядом, например, /usr/share/kiwi/image/netboot/custom и удалить оттуда файл с чексуммами — .checksum.md5

→Восстанавливаем линковку curl для работы с HTTP
Поскольку при сборке initrd логика работы KIWI та же, что и при сборке образа системы, сразу имеем подозрительные места, в которых может произойти удаление нужных библиотек: <packages type=delete> в XML-конфиге и images.sh.

〉XML
Сразу бросается в глаза тег drivers, в котором перечислены модули ядра, упаковываемые в initrd. К нему вернемся потом.
У каждого относящегося к содержимому initrd тега есть атрибут profiles, который позволяет в одной XML определить несколько «профилей» Выбор профиля производится на основании атрибута bootprofile тега type в XML системы. По умолчанию используются профили default и std (в старых KIWI — только std). Не забудьте, что править надо разделы, относящиеся к используемому профилю. Оказывается, профиль с таким прикольным названием как diskless не включает в себя пакет curl. Исправляем, если надо.

Как и ожидалось, в <packages type=delete> указаны krb5 и cyrus-sasl, содержащие либы, с которыми слинкован curl. Удаляем, выполняем стадию create, пробуем, в некоторых случаях — негодуем и переходим к следующему пункту. В самой свежей версии KIWI после правки XML все работает как надо.

〉images.sh
Сам скрипт в моем случае оказался небольшим. Деструкция происходит при выполнении функции suseStripInitrd, которая подсасывается из файла /usr/share/kiwi/modules/KIWIConfig.sh (можно найти при помощи grep по каталогу /usr/share/kiwi). Там находим шаблон в списке на удаление, под который попадает ненайденная либа (в случае с KIWI 3.01 это было чем-то типа /usr/lib*/libkrb*), вырезаем зловредный кусок, пересобираем образ. Повторять, пока не заработает.

→Правим список модулей и пакетов, включенных в initrd
Включенные модули — очень важный аспект сборки initrd. Если собирается универсальный initrd для разных машин, туда нужно включить как моно больше сетевых дров для совместимости. Если же все загружаемые машины имеют одинаковые (или два-три вида) сетевые карты, имеет смысл убрать ненужные дрова для уменьшения размера получаемого initrd. То же самое касается и пакетов — нет необходимости включать parted в initrd к системе, не работающей с жестким диском. Также ненужными могут оказаться nfs-client, nbd, bootsplash, etc.

Всвязи с тем, что дефолтный расклад не удовлетворяет требованиям минимальности в конкретном случае, вырезаем ненужные модули и пакеты. Следует почитать /var/log/boot.kiwi на тему того, какие команды запускаются, чтобы не выбросить что-то нужное.
Среди модулей, важность которых может быть неочевидна, я упомяну следующие:
  • drivers/hid/*
    drivers/usb/* — нужны для работы USB-клавиатуры, которая поможет переключаться на tty3 и читать дебаг.
  • drivers/block/loop.ko — нужен в некоторых случаях для монтирования образа корневой ФС (clicfs содержит fsdata.ext3 с данными)
  • drivers/block/brd.ko — без него не будет девайсов /dev/ram*, соответственно, некуда будет раскатывать образ и нечего накладывать как read-write ветку корневой ФС
Также следует напомнить о зависимостях модулей. Старые версии KIWI их не отслеживают, поэтому в случае ошибок при подгрузке какого-либо модуля следует при помощи modinfo изучить все дерево его зависимостей и указать зависимости в конфиге.

→Добавление необходимых драйверов
Следует прочитать, если видите сообщение о невозможности загрузить networkModule
У некоторых KIWI может не найти нужный сетевой драйвер. Причины этому может быть две: отсутствие модуля в initrd и неправильное определение драйвера для конкретной сетевухи.

Диагностика состоит в запуске команды hwinfo --netcard на проблемной машине из любой живой суськи той же версии. В выводе нужно отыскать интерфейс, с которого происходит загрузка, и найти строчку с предлагаемым модулем.
После этого следует понять, какой драйвер считает «родным» сам интерфейс:
# for i in /sys/class/net/*; do echo -n "`basename $i`: "; [ `basename $i` == lo ] && echo || basename `readlink $i/device/driver`; done
Далее нужно понять, включен ли драйвер в initrd (zcat initrd.gz | cpio -vt | grep modulename.ko) и включить его при необходимости. Например, по умолчанию отсутствует драйвер e1000e.
Если же модуль есть, но не загружается, следует указать в параметрах к ядру разделенный двоеточиями список драйверов для нужной сетевой карты. Например, для загрузки через Infiniband-карту от Mellanox нужно отдать параметр networkModule=mlx4_ib:ib_ipoib

→Правка параметров dhcpcd
Может так случиться, что драйвер сетевухи инициализируется в течение времени, большего чем таймаут DHCP-клиента. Актуально, например, для драйвера IP over IB. В этом случае следует в файле /usr/share/kiwi/modules/KIWILinuxRC.sh дописать в строке с запуском dhcpcd таймаут, например -t 120 (в зависимости от времени реального подъема конкретной карточки).

→Поддержка USB-клавиатур
Так как в некоторых современных дистрибутивах драйвера к USB-устройствам вынесены в модули, те, кто мучается с неработающим KIWI-клиентом, могут остаться без управления и, следовательно, без возможности читать дебаг-лог. Для выхода из этой ситуации, очевидно, надо убедиться в наличии нужных дров в initrd и дописать своевременную их подгрузку.
Набор модулей таков:
  • usbhid — драйвер к USB-устройствам ввода
  • *-hcd — драйверы для контроллеров USB. Для того, чтобы понять, что нужно, можно выполнить на живой системе
    dmesg | grep -i usb | grep hcd
А подгружать их будем в хук-скрипте на начальной стадии инициализации (init). Хук init.sh должен включить в себя строчки с подгрузкой недостающих модулей. О том, где это писать, далее.

→Хуки initrd
Если внимательно вчитаться в linuxrc в каталоге root описания initrd, то местами можно увидеть строчки вида
runHook preprobe
Это запуск хук-скриптов, располагающихся в каталоге /hooks активного initrd. Соответственно, для вонзания своего кода в нужное место инита нужно найти, какой хук запускается в нужном месте (для примера, в указанной строке хук называется preprobe) и создать соответствующий файл в скелете initrd — root/hooks/hookname.sh

→Отказ от bootsplash
Если вы хотите сэкономить место и удалить из initrd излишние красивости (которые еще и не показываются, гы-гы), вы удалите из описаний образов ахинею типа bootsplash-branding-openSUSE и gfxboot-branding-openSUSE. В результате initrd может перестать сжиматься — при сборке будет обновляться initrd-netboot-suse-11.2.x86_64-2.1.1, а его сжатая версия — нет.
Причину такого поведения можно найти в файле /usr/share/kiwi/modules/KIWIBoot.pm в функции setupSplashForGrub. Не найдя файла с картинкой, функция вываливается с ошибкой, и дальнейшего сжатия образа не происходит. Если найти то место, откуда вызывается эта функция, можно заметить, что при наличии исполняемого файла /usr/sbin/splashy вместо setupSplashForGrub вызывается функция setupSplashy, которая почти ничего не делает и завершается успешно. Поэтому создаем пустой исполняемый файл в нужном месте и радуемся результату.

→Скачиваем индивидуальный конфиг для машины
Может возникнуть необходимость в раздаче каких-либо файлов клиентам. В KIWI есть встроенная функция для этого — параметр CONF в файле конфигурации, выкачиваемом при загрузке. Происходит это после комментария «Import fixed configuration files» в root/linuxrc.
Но вот беда — для работы кода нужно, чтобы переменная systemIntegrity содержала слово «clean», а попадает оно туда только когда образ корневой ФС на диске, NFS, NBD или AOE. Остается только скопировать объявление правильного значения в специфичный для RAM-image кусок кода. Я это сделал в разделе, озаглавленном «Download network client image», сразу после сообщения об успешном скачивании образа:
   if test $sum1 = $sum2;then
       Echo "Image checksum test: fine :-)"
       [ -z $DISK ] && systemIntegrity="clean"
       break
   fi
Теперь можно создать для конкретного клиента (или группы клиентов на основании начала IP-адреса) файл вида /KIWI/config.0A000017 (для клиента с адресом 10.0.0.23 в данном случае), в котором к старым двум строчкам добавится новая:
susetest:/srv/tftp/KIWI # cat config.0A000017 
IMAGE=/dev/ram1;suse-11.2-pxe-client.x86_64;1.2.8;10.0.0.1
UNIONFS_CONFIG=/dev/ram2,/dev/ram1,clicfs
CONF=/configs/sshd;/etc/ssh/sshd_config;10.0.0.1
Загружаемся, проверяем. В самом деле,
linux:~ # head -1 /etc/ssh/sshd_config
#  This file was downloaded from KIWI server according to CONF line in client config



⇒Результаты усилий


Если все получилось, имеем BOOT-сервер, раздающий клиентам по DHCP IP-адреса, хостнеймы, роутинг, etc., далее отдающий каждому клиенту индивидуальный конфиг, конфиг для группы машин или конфиг по умолчанию, а затем — эффективно сжатые образы в соответствии с конфигами.

От клиента при этом требуется только умение грузиться по PXE и небольшой запас оперативной памяти.

Вся прелесть в том, что куча клиентов могут иметь абсолютно одинаковые корневые ФС (как клонированные), с точностью до конфигурационных файлов. После загрузки клиент становится полностью самостоятельным, то есть отсутствует точка отказа в виде, например, NFS-сервера. Жесткий диск иметь необязательно, вместо него лучше купить дополнительные пару гигабайт оперативной памяти для вмещения образа и локальных изменений. Для массового обновления машин достаточно пересобрать образ, а потом их все просто перезагрузить по очереди.

→Рожденные проблемы
У каждого решения есть как плюсы, так и минусы. В случае с «клонами» встает вопрос о большом количестве конфигурационных файлов — поле CONF много не вместит, а если вместит — смотреться будет дико. Кроме этого хранить на сервере n*m файлов (m конфигов на n клиентах) не всегда удобно. Тогда на помощь приходит Chef, уже слегка освещенный на Хабре.

Вторая проблема — сохранение файлов. Есть возможность извернуться и смонтировать, например, в /var/log локальный жесткий диск. Но, по-моему, для динамических общих данных лучше использовать распределенную ФС, а для логов — syslog-сервер.

Третья (незначительная) проблема — необходимость поддержания в работающем состоянии сервер загрузки. Легко решается дублированием оборудования.


⇒Резюме


KIWI — отличный инструмент для создания идентичных по назначению машин. Кроме того он является отличным инструментом для создания флешки «мой говнолинукс всегда со мной» (два раздела: один с обновляемым ro-образом, другой — с изменяемыми данными). Но при пользовании приходит осознание, что KIWI — тот еще фрукт. На любителя, так сказать. И следует заметить, что, будучи правильно приготовленным, он способен восхищать.


Есть вопросы? Задавайте в комментариях, я постараюсь ответить.
Теги:
Хабы:
+28
Комментарии24

Публикации

Изменить настройки темы

Истории

Ближайшие события