Если вам случалось работать над очень крупным Android проектом, с большим количеством модулей, кода, или наоборот не самой удачной декомпозицией на модули и наличием легаси, то вы наверняка знаете что сборка подобного проекта может занимать довольно много времени. К тому же, этапы сборки множатся и усложняются, да и средние размеры проектов серьёзных мобильных приложений продолжают увеличиваться. По моим субъективным ощущениям, с каждым годом среднее время сборки проекта растёт и не похоже что эта тенденция будет меняться.

Бывает довольно обидно, когда в работу поступает задача, решение которой очевидно, фикс проблемы делается за пару минут, но чтобы проверить что всё исправлено нужно переключать ветки, пересобирать проект, потом переключаться обратно и пересобирать исходную ветку. Это может занимать очень много времени. Или бывает так, что находясь “в потоке” нужно что-то быстро проверить, но увидеть вступили ли изменения в силу можно только пересобрав и перезапустив проект, а если что-то пойдёт не по плану, или по каким-то причинам кэш сборки не отработает и проект будет собираться почти "на холодную"? Подобные, казалось бы, мелочи могут занимать огромное количество времени в течение дня, отвлекают, мешают сосредоточиться и продуктивно использовать рабочее время. Особенно много времени тратится если рабочий компьютер далеко не самый производительный, или если вы пользуетесь ноутбуком, не ориентированным на выполнение тяжёлых сборок. 

В этой статье я хочу показать вам как можно собирать огромные Android проекты на далеко не самых производительных компьютерах или ноутбуках, утилизируя для этого вычислительные мощности удалённого сервера или отдельного домашнего настольного компьютера, не потеряв при этом ни капли удобства инструментов Android Studio - графического интерфейса, отладчика, и т.д.

Вступление

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

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

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

Описание проблемы

Как и многие из читателей, последние пару лет я работаю удалённо. Некоторое время назад я устроился в крупную финтех компанию, в команду работающую над очень крупным и известным в РФ продуктом.

Я работаю android разработчиком и оказалось, что мой рабочий проект большой. Нет, лучше так - он очень большой, огромный. Речь идёт о нескольких сотнях gradle-модулей (gradle в документации просит называть их “проектами”, но я по привычке буду использовать слово “модуль”), нескольких десятках тысяч файлов, далеко за миллион строк кода и сотни тысяч строк xml. До android разработки я пару лет занимался бэкенд разработкой на Java и к большим проектам я привык, но с такого размера проектом на андроид я столкнулся впервые. 

Ожидаемо что первая холодная сборка проекта произвела на меня неизгладимое впечатление. Мой ноутбук - макбук pro 2015 года с процессором Intel i7-4870HQ (4 ядра / 8 потоков) и 16 ГБ ОЗУ. Сборка на нём заняла более чем час (!) и весь этот час показатели температуры процессора были пиковыми, а система практически ни на что не могла реагировать. Мне просто было больно смотреть на свой надрывающийся от нагрузки ноутбук.

Компания, спасибо ей огромное, предоставляла рабочее оборудование. Первоначально, я от него отказался, потому что, если честно, не очень люблю брать что-то у работодателя. Мне намного комфортнее работать на своём ноутбуке, однако после нескольких рабочих дней я написал им и попросил выслать рабочий ноутбук. Мне прислали очень хороший, намного превосходящий по характеристикам мой личный компьютер макбук pro 2019 года с процессором i7-9750H (6 ядер / 12 потоков), причём в специальном варианте с расширенной до 32 ГБ ОЗУ (во время моего трудоустройства в компании это был один из топовых по производительности макбуков).

С новым ноутбуком дела стали значительно лучше. Сборка проекта стала занимать какое-то входящее в границы адекватности, в моём понимании, время, но по прежнему была слишком долгой для android проекта. Холодная сборка теперь укладывалась в 30-35 минут, сборка проекта после подтягивания свежих изменений из гита - около 20 минут, после переключения веток - чуть больше 10 минут, а горячая, с небольшими изменениями, в которой большинство тасок gradle пропускаются - от 2 до 4 минут. Это всё равно очень много, особенно для такого производительного ноутбука. Впрочем, по рассказам коллеги, на более слабом ноутбуке, на котором было установлено только 8 ГБ ОЗУ проект не собирался вообще, ни за какое время. 

Я начал работу в компании, подстроился под особенности того что проект действительно очень большой, привык параллельно сборке делать какие-то дела - отвечать на рабочие письма, делать код-ревью и т.д., но мысль о том что сборку необходимо как-то ускорять не покидала голову.

Постепенно погружаясь в проект я выявил в нём проблемные места влиявшие на время сборки. Сказывалось несколько архитектурных решений применённых в проекте. Один из модулей замыкал на себе очень много всего, в нём содержалось огромное количество всего объёма кода проекта и пересобирался он практически каждый раз при запуске проекта. Проект исторически содержал груз большого количества легаси кода, от которого было не так просто избавиться. Он постепенно рефакторился в сторону более грамотного разнесения логики по модулям и оптимизации сборки в сторону эффективного параллельного выполнения, но задачей это было сложной, трудозатратной, прогресс шёл медленно, а промежуточный результат, к сожалению, эффективности практически не прибавлял.

Модулей было очень много, несколько сотен, как я упомянул ранее. В процессе поиска причин проблемы и попыток её исправить я понял что увеличение количества gradle модулей в проекте очень значительно увеличивает затраченное время на некоторых её этапах. В принципе, это известная проблема, о которой пишут практически в каждом материале по ускорению сборки проекта. Бесконечно “параллелить” проект нельзя, нужна очень грамотная декомпозиция кода и золотая середина между количеством модулей и количеством кода в каждом из них. В этом проекте, в процессе разделения на модули маятник качнулся скорее в сторону того что их стало слишком много и каждый из них содержал слишком мало кода. В целом, декомпозиция кода в проекте была выполнена достойно и, по моим наблюдениям, максимум из того что можно было достичь в плане распараллеливания сборки было достигнуто, но для комфортной работы этого всё равно было недостаточно.

Я начал пробовать как-то сам оптимизировать в мелочах скрипты сборки, но с удивлением обнаружил что практически все конфигурации были уже настроены очень хорошо, параметры JVM довольно неплохо выставлены и подогнаны под размер проекта, многие экспериментальные флаги и фичи были включены. Казалось, из того что можно было посильно сделать было сделано абсолютно всё, а я реально не мог выжать никакой значительный результат.

Такая сборка хорошо выполнялась бы на полноценном стационарном рабочем компьютере с многоядерным процессором, но на ноутбуках часто количество ядер не такое уж большое, и сам процессор установлен из “энергосберегающей” серии, которая рассчитана на энергоэффективность и на то чтобы меньше выделять тепла при работе, но производительность ядра такого процессора имеют ниже средней. Это был абсолютно логичный и очевидный звоночек о том, что работать нужно на полноценном компьютере, а не на ноутбуке. Но, к сожалению, я такой возможностью не располагал.

В процессе сборки проекта htop большую часть времени показывает загрузку по всем потокам на 100%, что очень круто и говорит о том что вычислительный ресурс компьютера используется максимально эффективно, однако полностью загруженные ядра влекут за собой новую проблему - нагрев. А с нагревом приходит тротлинг и/или сильный шум вентиляторов. 

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

Тут хочется отдельно “поблагодарить” Apple. Современные ноутбуки очень сильно гонятся за тонкостью и красотой, достижение которой в полной мере невозможно не принеся в жертву качество охлаждения. А Apple здесь впереди планеты всей потому, что, во-первых, гонится за этим сильно больше других производителей, а, во-вторых, изготавливает корпуса полностью из очень хорошо проводящего тепло металла. При сборке проекта ноутбук разогревается до такой температуры что его неприятно держать например на коленях, к металлической части над клавиатурой невозможно надолго прислонить палец, а руки лежащие на корпусе потеют и измазывают тачпад и клавиатуру. Поэтому в шутке про приготовление шашлыков над макбуком на котором идёт сборка проекта на Java есть доля правды.

Ещё, я обратил внимание что, в отличие от других производителей ноутбуков, политика охлаждения в MacOS для макбуков устроена следующим образом - если речь идёт о небольших кратковременных нагрузках, то отведение тепла производится полностью пассивно на корпус, затем, если нагрузка продолжается и увеличивается, то после определённого порога процессор начинает тротлить и сбрасывать тактовую частоту, но всё равно отводит тепло только пассивно, и только в самую последнюю очередь, если нет никакой другой возможности, в системе охлаждения включаются активные вентиляторы, и, как только нагрузка немного падает, MacOS пытается тут же по максимуму работу этих самых вентиляторов ограничить. Из-за этого процессор очень сильно притормаживает на тяжёлых и, особенно, средних нагрузках, сильно греется, и без того медленная сборка становится ещё процентов на 30-50 медленнее. Как я также потом вычитал в интернете, процессоры именно той модели, экземпляр которой стоит в моём рабочем макбуке, считаются одними из самых “горячих” камней Intel для ноутбуков на рынке. А может быть это именно мне не повезло и именно мне достался какой-то особенно горячий экземпляр.

Я уже давненько и в личных проектах, и, разумеется, с новым рабочим проектом особенно, использую программу Mac Fan Control которая позволяет принудительно установить скорость вращения вентиляторов на 100%. Я всегда перед запуском сборки, а часто и просто на весь рабочий день принудительно включаю вентиляторы на максимум, иначе температура ноутбука и скорость работы становятся просто недопустимыми. Это создаёт постоянный шум, который отвлекает и работать становится неприятно.

Кстати, все замеры, включая те что были указаны выше, были сделаны с принудительно включёнными на 100% возможной скорости вращения вентиляторами, иначе стабильность воспроизводства результатов теряется, а время выполнения сборки становится ещё больше.

Разумеется, что и эта проблема не возникла бы в таком серьёзном объёме, если бы я работал не на ноутбуке, а за стационарным компьютером, на процессор которого установлена огромная башня радиатора и мощные кулеры, или система жидкостного охлаждения.

Итак, вот такие получаются исходные данные. Есть очень медленно выполняющаяся сборка. Усилия для её ускорения регулярно прилагаются, на рефакторинг легаси кода заложено время и он медленно, верно идёт по плану, но, всё же, такие проблемы быстро не решаются, а работать надо прямо здесь и сейчас. К сожалению, я нахожусь далеко от дома, где у меня есть хороший и производительный стационарный компьютер, поэтому вынужден ограничиваться рабочим ноутбуком. 

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

Постановка задачи

Итак. Собственно, проблем которые мне бы хотелось решить ровно две:

  1. Сборка проходит слишком долго.

  2. Ноутбук греется и шумит.

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

1) Во-первых, разумеется, я не мог пользоваться никакими публичными сервисами и ресурсами для подобных случаев - VPS, облаками и прочим. Конфиденциальность исходного кода очень важна, и абсолютно недопустимо чтобы для целей сборки он попадал на какие-то непонятные машины в сети которые не принадлежат ни компании, ни мне, и которые я никак не контролирую.

К счастью, так получилось, что личный компьютер у меня был, хороший и производительный, он отлично подходит для решения моих задач, но физически он находился в другом городе, зато, что очень важно, поскольку я периодически пользовался им как сервером для своих нужд, то у меня был организован к нему доступ по ssh из внешней сети. Причём, доступ к нему был через белый IP-адрес и проброс портов на роутере. Роутер надёжный, не от провайдера, очень добротно и секурно настроенный OpenWrt. Это давало необходимые гарантии безопасности и главное - не было необходимости использовать никакие туннели или VPN, которые могли бы заметно снизить скорость работы с компьютером. На деле скорость и пропускная способность соединения были ограничены только наименьшим значением доступной скорости либо провайдера у меня дома, либо провайдера в месте из которого я подключаюсь.

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

3) В-третьих, очень важна возможность отладки. Её необходимо обеспечить обязательно. Не будет отладки - не будет смысла вообще устраивать всю эту схему. Плюс, важный момент, отладка нужна именно на реальном устройстве. Это всё таки мобильная разработка, т.е. мне необходимо в процессе работы производить проверку некоторых вещей строго на реальном девайсе. Да, есть эмуляторы, но они менее удобны и не способны полностью покрыть мои потенциальные задачи. Поэтому нужно искать способ безопасно прокидывать adb по сети или делать что-то аналогичное.

4) Для сборки проекта необходим доступ во внутреннюю сеть компании, т.к. там, логично, располагаются git сервер и maven репозиторий с частью пакетов необходимых для сборки проекта.

5) К сожалению, для доступа в эту самую сеть используется проприетарная технология VPN, под которую нет open source клиента, есть только клиенты от производителя под Windows и MacOS, но не под Linux. А на удалённой машине, конечно, Linux

Тут важное отступление - некоторые из моих коллег всё-таки пользовались Linux, поднимая виртуальную машину с Windows, в которой запускали VPN клиент, и пускали через эту виртуальную машину трафик с компьютера. По словам одного из коллег, у которого на жёстком диске компьютера был дуалбут с Windows и Arch, при прочих равных условиях сборка проекта на Arch проходила ЗНАЧИТЕЛЬНО быстрее чем на Windows. А по словам другого коллеги, который пробовал собирать проект на практически идентичных по железу конфигурациях на MacOS и на Windows, сборка на Windows также производилась НЕМНОГО, но быстрее чем на MacOS. Это вселяло надежду и настраивало на необходимость копать в эту тему дальше.

6) Приватный ключ необходимый для подключения к VPN компании хранится на физическом USB токене, который выдавался лично в руки каждому удалённо работающему сотруднику. 

Это очень большая проблема применительно к моей задаче, т.к. в теории, это позволяет мне подключиться к ресурсам компании ровно с одного компьютера. Этот токен требовался мне для доступа не только к git и maven с машины, на которой производится сборка, но и к Jira, Confluence и т.д. Получается что мне необходимо будет подключаться к одним ресурсам с одной машины, а к другим - с другой - не очень хорошо. При этом, критически важно чтобы токен обязательно физически остался при мне. Слишком рискованно было бы оставлять его в настольном компьютере, на котором бы шла сборка. Что делать если бы в квартире с компьютером выключили свет? Любой форс мажор привёл бы к полной потере доступа к ресурсам компании. 

7) Я и мой стационарный компьютер проживали достаточно далеко друг от друга, и службе безопасности компании не очень нравилось если я периодически вместо привычного адреса заходил в сеть компании через роутер, находящийся в квартире со стационарным компьютером. У меня менялся IP адрес подключения, и по геолокации он мог в течение короткого времени показаться то в одном городе, то в другом, в совершенно другом конце страны. 

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

8) Дополнительно, я бы хотел минимизировать взаимодействие с терминалом. По возможности я хотел бы работать только с UI составляющей, нажимать кнопочки “Run”, “Debug”, а не вводить в терминал команды, по сложности похожие на заклинания способные призывать сатану.

Звучит как непростой и довольно интересный вызов, за который будет интересно взяться.

Поиск решения

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

Первая мысль, которая приходит в голову (не будь проблемы с токеном) - воспользоваться каким-нибудь софтом для удалённого рабочего стола. Было бы логично поставить Teamviewer, Anydesk или какие-то похожие инструменты и пользоваться ими. Но тогда сразу возникает ряд вопросов.

Во-первых, лично я не очень люблю работать по RDP и похожим технологиям и стараюсь избегать этого когда возможно. Мне нравится работать в родной среде ОС, а не в “окошечке-в-окошечке”, и я бы хотел чтобы весь UI остался на рабочем ноутбуке.

Во-вторых, на удалённой машине нет доступа к VPN сети компании. Сами по себе эти инструменты либо, обычно, не могут решить проблему с тем что токен доступа к VPN находится только у меня, либо, даже если могут пробрасывать его каким-то образом, то это заставит меня отключаться на основной машине от VPN на время сборки. Учитывая, что для использования ключа из токена требуется каждый раз вводить пароль, то делать этого однозначно не хочется. И, кроме того, появляется вышеописанная проблема с засветом параллельных подключений с двух IP адресов.

Я подумывал о том, чтобы настроить USBIP - сервис для пробрасывания подключений к USB устройствам через сеть, и разделять доступ к USB токену одновременно и на домашний ПК где будет проходить сборка, и на рабочий ноутбук где буду сидеть я. Настроить сервер с ним можно было например на том же домашнем роутере с OpenWrt. 

Идея неплохая, и я был бы очень рад избавиться от необходимости постоянно носить токен, воткнутый в макбук, в котором только USB type-C разъёмы, потому что для этого приходилось использовать неудобный переходник. И даже в старом макбуке 2015 года, где ещё были стандартные USB разъёмы, этот токен тоже было неудобно носить, он постоянно норовил за что-нибудь зацепиться.

Но с этим подходом также есть свои проблемы. Во-первых, USBIP, судя по многим отзывам в интернете, не очень стабильно работает. Во-вторых, насколько я понимаю, нацелен он в основном на Linux, и не понятно как он будет вести себя на MacOS. В-третьих, проблема с засветом IP-адресов никуда не уходит, будет слишком заметно что я с одним токеном подключаюсь с разных концов нашей необъятной. В-четвёртых, на домашнем ПК стоит Linux, для которого VPN клиента нет, и для решения этой проблемы придётся колхозить схему с пробросом трафика через виртуальную машину с Windows. Неудобно. 

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

Выбор инструментов

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

Придётся поднимать подключение к VPN на рабочем ноутбуке, а доступ к ресурсам компании обеспечивать домашнему ПК транзитом через ноутбук. Как именно это сделать - есть несколько вариантов, но главное - обеспечить достаточную транспортную безопасность. Т.е. туннель между компьютером и ноутбуком обязан быть зашифрованным, а тут я бы хотел полагаться только на открытые, проверенные и надёжные инструменты.

В конечном счёте, для сборки, по существу, нужно только два ресурса компании - git и maven. А поскольку они оба сетевые, то это значительно упрощает дело. Собственно, тут для решения проблем, на сцену выходят nginx c его возможностью проксирования трафика, и ssh с его возможностью создания реверсивных туннелей (reverse port forwarding).

На удалённом компьютере я поднял отдельную виртуальную машину с Ubuntu server 20.04.5. В целом, я буду расписывать все шаги так, будто бы мы настраиваем абсолютно новую систему. В вашем случае это может быть свежая арендованная VPS например.

По операционной системе на локальном компьютере - пока что здесь будет только инструкция для MacOS. Для Linux она будет отличаться только обращением к пакетному менеджеру используемом в вашем дистрибутиве и командами по запуску и остановке сервисов. Для Windows же в плане настройки локальной машины всё также будет похоже и логика команд будет такой же, но, конечно, установить и работать с ПО придётся способами специфическими именно для этой операционной системы.

Поскольку мой ноутбук на MacOS, я использую brew для того чтобы устанавливать ПО. На ноутбуке нам понадобятся некоторые инструменты. Скачаем их с помощью brew:

brew install coreutils curl nginx rsync

Настраиваем ssh

SSH - удивительный и многофункциональный инструмент с широким набором возможностей. Реверсивные туннели - одна из многих, и именно она нас сегодня выручит. Реверсивный туннель позволяет удалённому компьютеру обращаться к своему локальному порту, а все обращения пробрасываются к локальному порту на той машине с которой производится ssh подключение. Подробнее об этой и многих других возможностях ssh можно почитать в документации или на хабре, например тут или тут.

Сначала на ноутбуке сгенерируем новые ключи для ssh клиента. Генерировать будем ключевую пару ed25519. Ed25519 - это современный и очень быстрый алгоритм аутентификации использующий очень эффективную эллиптическую кривую curve25519, созданную известным криптографом Даниелем Бернштейном. Она детально исследована экспертами по криптографии и имеет наибольшее доверие со стороны сообщества. Ed25519 использует очень короткие ключи, сохраняя необходимую криптостойкость, превосходит по производительности схемы аутентификации использующие стандартные эллиптические кривые рекомендованные NIST, и на добрый порядок производительнее схем с аутентификацией по RSA. Скорость установки соединения для нас будет очень важна, поэтому используем её:

ssh-keygen -o -t ed25519 -f ~/.ssh/id_ed25519_android_builds_server -C "key_for_android_build_server" -P ""

Теперь заранее подготовим алиас для будущего подключения в ssh конфиге. Алиас я назову android_builds_server. Открываем файл ~/.ssh/config, и дописываем в конец новую запись:

Host android_builds_server
HostName ЗДЕСЬ_ВАШ_IP_АДРЕС
Port 34567
User builder
IdentityFile ~/.ssh/id_ed25519_android_builds_server
IdentitiesOnly yes
Compression yes

Теперь идём наш удалённый ПК и заходим в терминал от root. В моём случае это виртуальная машина на компьютере, у вас это может быть консоль в VPS. Самым первым делом, как и на любой машине, нам потребуется правильно сконфигурировать ssh сервер.

Я хочу создать отдельного пользователя на компьютере для выполнения моих android сборок, назову его builder. Именно им будет осуществляться вход в систему по ssh. Для этого на сервере сначала создадим этого пользователя, обязательно с домашней директорией, затем назначим ему пароль и добавим в группу sudo:

useradd -m builder
passwd builder
usermod -aG sudo builder

Конфигурация ssh будет играть большое значение в будущем. Нам потребуется максимальная скорость установки соединения и как можно более быстрая скорость передачи данных. Вот так выглядит мой конфиг.

/etc/ssh/sshd_config
AddressFamily inet
ListenAddress 0.0.0.0
Port 34567
HostKey /etc/ssh/ssh_host_ed25519_key
AllowUsers builder
SyslogFacility AUTH
LogLevel INFO
Protocol 2
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes128-gcm@openssh.com,aes256-gcm@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
LoginGraceTime 45
PermitRootLogin no
StrictModes yes
MaxAuthTries 5
MaxSessions 5
MaxStartups 2:50:10
PubkeyAuthentication yes
PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
UsePAM no
X11Forwarding no
PrintMotd no
GatewayPorts yes
Compression yes
AcceptEnv LANG LC_*
Subsystem	sftp	/usr/lib/openssh/sftp-server

Самое важное для нас здесь:

  • Port 34567 - меняю порт на нестандартный чтобы боты не беспокоили и не засоряли логи;

  • HostKey /etc/ssh/ssh_host_ed25519_key - удаляю использование всех ключей сервера кроме ed25519;

  • AllowUsers builder - разрешаем внешние подключения по ssh только для свежесозданного пользователя builder;

  • Protocol 2 - чтобы исключить использование старых небезопасных версий ssh;

  • Для HostKeyAlgorithms ставим значения ssh-ed25519,ssh-ed25519-cert-v01@openssh.com - принимаем подключения только от клиентов с ключами ed25519 и отказываем в подключении по другим алгоритмам, например RSA;

  • Для Ciphers ставим в самом приоритетном порядке использование chacha20-poly1305@openssh.com (сверхбыстрый алгоритм симмертичного шифрования, основа TLS 1.3), во вторую - aes128-gcm@openssh.com (самый шустрый алгоритм из классических);

  • "PermitRootLogin no", "PubkeyAuthentication yes", "PasswordAuthentication no", "PermitEmptyPasswords no", "ChallengeResponseAuthentication no" - оставляем аутентификацию только по публичным ключам и запрещаем всё остальное;

  • GatewayPorts yes - важный параметр, имеющий по умолчанию значение "no". Позволяет использовать ssh туннели, которые нам потребуются;

  • Compression yes - опционально. Я не делал точных замеров, но субъективно мне показалось, что использование этого параметра ускоряет передачу файлов через ssh

  • Subsystem sftp /usr/lib/openssh/sftp-server - обязательно оставляем, т.к. нам потребуется передача файлов.

Сохраним конфиг и проверим что всё сделано без ошибок:

sshd -t

Если всё в порядке, то перезапускаем ssh:

systemctl restart sshd

Также я устанавливаю и включаю NTP, чтобы в будущем не было проблем при TLS соединениях связанных с рассинхронизацией времени:

timedatectl set-timezone Europe/Moscow
apt install ntp
systemctl enable ntp

Отлично. Теперь с ноутбука можно будет подключаться к удалённому ПК по алиасу, вот так:

ssh android_builds_server

Разберёмся с запуском ssh туннеля. 

Обратите внимание! ssh туннель и nginx-прокси нужны вам только в случае если в вашем проекте используется ресурс во внутренней сети компании. В моём случае это maven репозиторий. Если в вашем проекте такой проблемы нет, то смело пропускайте команды по настройке ssh туннелей, а с ними и всю последующую секцию по настройке nginx, и сразу переходите к разделу "Настраиваем android build-tools".

Запуск ssh туннеля делается одной несложной командой, она выполняется с ноутбука:

ssh -f -N android_builds_server -R 33333:localhost:33333

  • -R - этот флаг указывает на необходимость пробросить порт с удалённой машины на локальную. Вариацией передаваемых параметров у него несколько но нас интересует только такой ПОРТ_НА_УДАЛЁННОЙ_МАШИНЕ:ХОСТ_НА_КОТОРЫЙ_ПРОБРАСЫВАЕТСЯ_ТУННЕЛЬ:ПОРТ_НА_ХОСТЕ. В нашем случае номера портов будут совпадать на локальной и удалённой машине, а хостом будет сам ноутбук

  • -f - флаг указывающий на необходимость сразу перейти в бэкграунд а не открывать собственно шелл

  • -N - этот флаг указывает не выполнять никаких команд на удалённом хосте. В man-е по ssh он как раз описан как флаг используемый при операции проброса портов

К сожалению, туннели ssh имеют неприятное свойство терять соединение и повисать в таком состоянии в системе. Если порт уже занят процессом, то новый ssh туннель открыться не сможет, поэтому перед их запуском всегда желательно проверять и, при необходимости, закрывать текущие открытые туннели. Как назло, никакой удобной команды для этого не существует, только выцеплять их в списке запущенных процессов и посылать им kill. Я делаю это вот так:

kill $(ps -ef | grep -v grep | grep 33333:localhost:33333 | awk '{print $2}') 2>/dev/null

Запомним это. На этом настройка ssh на обеих сторонах закончена. Теперь настроим часть касающуюся nginx.

Настраиваем nginx

На локальной машине настроим nginx в режиме прокси. Мы его уже установили ранее через brew. Находим в MacOS файл с конфигурацией nginx по пути /usr/local/etc/nginx. Необходимо добавить в него блок для создания прокси. Редактируем файл конфигурации следующим образом:

/usr/local/etc/nginx
worker_processes 1;

events {
    worker_connections 1024;
}

http {
    include mime.types;
    default_type application/octet-stream; sendfile on;
    proxy_send_timeout 120; proxy_read_timeout 300; proxy_buffering off; proxy_request_buffering off; keepalive_timeout 5 5;
    tcp_nodelay on;
    server {
        listen 127.0.0.1:33333;
        server_name localhost;
        charset utf-8;
        client_max_body_size 1G;
        proxy_set_header Host privaterepo.nexus.company.com; 
        location / {
            proxy_pass https://privaterepo.nexus.company.com/; 
        }
    }
}

Мы будем использовать локальный порт 33333 для того чтобы принимать запросы и отправлять их в maven репозиторий во внутренней сети компании через VPN туннель.

Самое важное здесь это: 

  • listen 127.0.0.1:33333 - принимаем только локальные подключения и указываем номер порта;

  • proxy_set_header Host privaterepo.nexus.company.com - поскольку репозиторий используеn TLS и может делить адрес с другим ресурсом, мы обязаны установить заголовок Host со значением доменного имени репозитория;

  • блок location / и параметр proxy_pass - проксируем любые пришедшие на локальный сервер на настоящий мавен репозиторий.

Проверим что конфигурация создана без ошибок и nginx будет работать:

nginx -t

Обратите внимание, что в этот момент вы уже должны быть подключены к VPN сети компании, иначе nginx может не запуститься по причине недоступности хоста указанного в блоке конфигурации прокси сервера.

Теперь перезапускаем nginx:

brew services stop nginx
brew services start nginx

Отлично. На этом настройка nginx закончена. Прокси сервер запущен.

Попробуем убедиться что запросы к maven смогут корректно выполниться. 

В моём случае maven репозиторием был Nexus, располагающийся во внутренней сети, обратиться к нему можно только по HTTPS протоколу, и для соединения используется самоподписанный сертификат. Для подтверждения доступа используется basic-auth, который поддерживает curl.

Для начала попробуем взять curl и получить артефакт из maven репозитория напрямую, также как это делает во время сборки gradle:

curl -u USERNAME:PASSWORD --insecure https://privaterepo.nexus.company.com/repository/main/com/packagename/artifact/1.2.3/artifact-1.2.3.aar --output artifact.aar

где:

  • флаг -u используется для передачи в виде basic-auth логина и пароля разделённых двоеточием;

  • --insecure требуется чтобы curl выполнил запрос к серверу с самоподписанным сертификатом.

Видим что запрос успешно выполнился и библиотека была выкачана. Теперь попробуем сделать тоже самое, но на этот раз обратимся не напрямую на maven репозиторий, а на наш свеженастроенный локальный nginx-прокси:

curl -u USERNAME:PASSWORD --insecure https://localhost:33333/repository/main/com/packagename/artifact/1.2.3/artifact-1.2.3.aar --output artifact.aar

Работает! Файл также был успешно загружен. Теперь осталось только убедиться в том что файл сможет загрузиться через реверсивный туннель.

Запустим туннель, предварительно отправив команду на прибитие уже запущенных процессов:

kill $(ps -ef | grep -v grep | grep 33333:localhost:33333 | awk '{print $2}') 2>/dev/null
ssh -f -N android_builds_server -R 33333:localhost:33333 2>/dev/null

Заходим на сервер:

ssh android_builds_server

Теперь с удалённого компьютера через ssh туннель мы должны иметь возможность обратиться к запущенному на MacOS nginx и поэтому запросы к maven репозиторию должны успешно выполниться. Номер порта на обоих машинах одинаковый, поэтому пробуем:

curl -u USERNAME:PASSWORD --insecure https://localhost:33333/repository/main/com/packagename/artifact/1.2.3/artifact-1.2.3.aar --output artifact.aar

Файл успешно загружен. Замечательно. Самая сложная часть работы сделана.

Осталось только немного поправить скрипт сборки проекта. К сожалению, без этого никак, но зато поменять нужно всего несколько значений, а работать всё будет одинаково вне зависимости производим мы локальную сборку или сборку на удалённом компьютере. Главное откиньте эти изменения в отдельный changelist в git плагине в Android Studio чтобы случайно не запушить это дело в ремоут.

Нас интересует корневой скрипт сборки - это файл build.gradle в корне проекта. Там есть два блока - buildscript/repositories и allprojects/repositories в каждом из них перечислены используемые в проекте maven репозитории.

Возьмём блок с репозиторием который находится во внутренней сети компании. Сейчас он выглядит так:

maven {
    name "PrivateNexus"
    url "https://privaterepo.nexus.company.com/repository/main"
    credentials {
        username 'USERNAME'
        password 'PASSWORD'
    }
}

Заменим настоящий адрес репозитория на адрес nginx-прокси, и обязательно добавим параметр сигнализирующий о том, чтобы игнорировать ошибки SSL соединения связанные с самоподписанным сертификатом, т.к. на удалённой машине установленных корневых сертификатов компании не будет! 

Должно получиться вот так:

maven {
    name "PrivateNexus"
    url "https://localhost:33333/repository/main"
    allowInsecureProtocol = true
    credentials {
        username 'USERNAME'
        password 'PASSWORD'
    }
}

С такой конфигурацией все нужные артефакты смогут подтянуться при сборке как на локальной машине, так и на удалённой. Отлично! Это успех и доказательство того что схема абсолютно реальна. Теперь можно вернуться к шагам настройки удалённого компьютера, которые необходимо выполнить вне зависимости от того требуется нам проброс доступа к maven репозиторию во внутренней сети или нет - загрузить необходимые для выполнения сборки android проектов зависимости.

Настраиваем android build-tools

Для сборки android проекта на удалённой машине нам потребуется подготовить все необходимые инструменты.

Зайдём на удалённый ПК по ssh. Для начала нам потребуется JDK. Установим его через apt:

sudo apt install openjdk-11-jdk -y

Далее нам нужны сами инструменты для сборки - build-tools, platform-tools и т.д. Обычно они скачиваются через SDK manager в Android Studio, но в случае машины на которой нет графической оболочки, придётся поступить немного по другому. Нам потребуется набор CLI инструментов для работы с Android от Google. Найти их можно там же где располагаются ссылки на скачивание Android Studio. Вот по этому адресу:
https://developer.android.com/studio/index.html

Прямо под ссылками на скачивание Android Studio есть блок "Command line tools only". Нас интересует версия под Linux. Выцепим её прямо из терминала чтобы не скачивать через браузер и не передавать с ноутбука отдельно.

Для начала создадим структуру папок под них.Нам необходимо чтобы конечный путь к исполняемому файлу sdkmanager был вот таким:

~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager

Выполняем команду:

mkdir -p Android/Sdk/cmdline-tools/latest

Переходим в папку с SDK:

cd Android/Sdk

Загружаем страницу через curl и грепаем с неё ссылку на архив:

curl -sSL https://developer.android.com/studio/index.html#command-tools | grep commandlinetools-linux | egrep -o 'https?://[^ ]+' | sed 's/'\"'/'/g | head -1

Полученную ссылку загружаем через wget, а затем распаковываем через unzip так чтобы содержимое оказалось в папке ~/Android/Sdk/cmdline-tools/latest

После этого откроем ~/.bashrc и пропишем назначение переменных окружения, а также пропишем все необходимые пути в PATH:

JAVA_HOME="/usr/lib/jvm/java-11-openjdk-amd64"
ANDROID_HOME="/home/builder/Android/Sdk"
ANDROID_SDK_ROOT="/home/builder/Android/Sdk"
export JAVA_HOME
export ANDROID_HOME
export ANDROID_SDK_ROOT
PATH="$JAVA_HOME/bin:$ANDROID_HOME:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"

Сохраняем ~/.bashrc, готово. 

Теперь в переменной окружения PATH есть путь к скачанному sdkmanager, и мы можем установить всё что нужно для сборки. Но sdkmanager ничего не даст делать пока не принять лицензионные соглашения. Обычно это также как и управление зависимостями производится в диалоговом окне в Android Studio, но предусмотрен и CLI способ. Выполняем:

sdkmanager --sdk_root=/home/builder/Android/Sdk --licenses

Нас попросит подвердить то что мы ознакомлены с условиями лицензионного соглашения. Пишем "yes".

Затем скачиваем необходимые для сборки зависимости:

sdkmanager "platform-tools"
sdkmanager "platforms;android-30"
sdkmanager "build-tools;30.0.3"

Обратите внимание! Версии platforms и build-tools должны соответствовать версиям в проекте, который планируется собирать на этой удалённой машине. В моём случае эти значения такие, но в вашем случае могут отличаться. 

В принципе, если вам не жалко времени и места на диске, то можете скачать несколько самых популярных версий платформы android и build-tools чтобы не докачивать их под каждый конкретный проект, но я обойдусь только ими.

Вот и всё, с этого момента мы можем клонировать на нашу удалённую машину проект из git репозитория и вызывать заветный ./gradlew assembleDebug, сборка будет работать.

На этом настройка удалённого ПК закончена.

Настраиваем mirakle

Пришло время вернуться непосредственно к самому коду проекта.

Для начала проговорим очевидное. Что такое сборка? Это когда мы берём файлы с исходным кодом, компилируем их предусмотренным образом, и получаем на выходе результат. Довольно просто и логично.

Изначально я планировал сначала либо настроить на удалённом компьютере проброс порта для git, таким же способом, каким пробросил его для доступа к maven репозиторию, и пользоваться им вызывая команды оттуда, либо же копировать исходный код на удалённую машину через rsync или scp, чтобы просто получить доказательство реализуемости такого подхода - редактировать код на локальной машине, а собирать код на удалённой. Мне стало интересно, а не реализовал ли кто-то уже подобную схему в виде готового инструмента делающего именно это, и, когда я поискал нечто похожее на github, то с удивлением обнаружил, что, во-первых, такое решение действительно уже существует, а, во-вторых, оно почему-то не очень популярно, не особо находится в поиске в интернете, и о нём почему-то не рассказывают публично.

Это gradle плагин mirakle

Суть его работы примерно в следующем:

  • Он использует rsync и ssh для безопасной передачи файлов проекта с локальной машины на удалённую. 

  • Передаёт через ssh на удалённую машину команду которая вызывает сборку проекта из переданных исходников проекта.

  • По завершению процесса сборки копирует получившийся результат с удалённой машины на локальную, опять же через связку rsync ssh

Никакой магии, всё просто и гениально.

На самом деле плагин mirakle - это форк другого, намного более популярного проекта - mainframer. Mainframer - более универсальный инструмент и предназначен как раз для реализации вот такой вот схемы удалённой сборки. Им можно собирать например проекты бэкенд приложений Java, и не только. Вызывается mainframer вот так (в целом, по команде как раз можно предположить то как он работает):

mainframer ./gradlew build

mainframer - это standalone инструмент, в то время как mirakle предлагает интеграцию в виде gradle плагина, что очень удобно, однако если вызов mainframer-а прямой, не сложный, и для mainframer существует, в том числе, интеграция в виде плагина для Intellij Idea, позволяющего включать/выключать сборку на удалённой машине прямо из интерфейса IDE, то для mirakle придётся немного поколдовать руками и заготовить пару скриптов для включения и выключения режима удалённой сборки, но это не сложно и делается один раз.

На самом деле, всё что нам потребуется - иметь готовый настроенный ssh доступ на удалённую машину, подготовить на ней все необходимые инструменты для сборки андроид проектов (всё это уже готово), и грамотно прописать конфигурацию mirakle в части параметров для вызова rsync, и исключений, чтобы не передавать ничего лишнего через ssh туннель на удалённую машину и обратно.

Пожалуй, самая интересная часть в том, что mirakle делает всё это с помощью init скрипта gradle. Gradle позволяет описывать логику скриптов сборки не только для каждого отдельного проекта, но и для всей локальной машины целиком. Например применять при запуске gralde какие-то опции или добавлять логику глобально, для всех проектов сразу, не важно в каком именно месте он был вызван. При этом, там доступен весь тот же самый синтаксис, как и в случаях с обычными проектами. Например, можно применить gradle плагин, да, ко всем проектам сразу. Именно этой возможностью и пользуется mirakle.

Если представить очень грубо, то init скрипт требуется для того чтобы при получении команды сначала можно было предварительно и заблаговременно правильным образом вызвать rsync, а затем к команде с вызываемой таской gradle добавить префикс ssh ALIAS и таким вот образом вызвать её на удалённой машине вместо локальной.

Плагину mirakle необходимо проинициализироваться именно в init скрипте, а не на уровне проекта, чтобы всё было осуществимо, но это не проблема. Мы подготовим небольшой скрипт который будет использовать плагин только с текущим проектом и не оказывать влияния на остальные.

По умолчанию в системе нет ни одного init скрипта gradle, потому что в большинстве случаев все необходимые параметры сборки настраиваются на уровне проекта, а рядовому пользователю в них обычно нет никакой острой необходимости. Случаи использования чего-то подобного - достаточно редки. Лично, я с ними ни разу не сталкивался до этой ситуации.

Init скрипты располагаются в директории ~/.gradle. Перейдём в неё и создадим внутри директорию init.d. После этого внутри ~/.gradle/init.d/ создадим файл с init скриптом gradle. Назовем его mirakle.gradle. В моём случае конфигурация выглядит вот так:

def projectToBuildRemotely = "$ROOT_PROJECT_NAME"

initscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'io.github.adambl4:mirakle:1.6.0'
    }
}

apply plugin: Mirakle

rootProject {
    if (projectToBuildRemotely.equals(name)) {
        project.logger.lifecycle('Remote builds mode activated for this project. Going to start remote build now.')
        mirakle {
            host '$SERVER_SSH_ALIAS'
            remoteFolder ".mirakle"
            excludeCommon += ["*.DS_Store"]
            excludeCommon += ["*.hprof"]
            excludeCommon += [".idea"]
            excludeCommon += [".gradle"]
            excludeCommon += ["**/.git/"]
            excludeCommon += ["**/.gitignore"]
            excludeCommon += ["**/local.properties"]
            excludeCommon += ["**/backup_*.gradle"]
            excludeCommon += ["remote_android_*.sh"]
            excludeCommon += ["remote_android_*.properties"]
            excludeCommon += ["remote_android_*.config"]
            excludeLocal += ["**/build/"]
            excludeLocal += ["*.keystore"]
            excludeLocal += ["*.apk"]
            excludeRemote += ["**/src/"]
            excludeRemote += ["**/build/.transforms/**"]
            excludeRemote += ["**/build/kotlin/**"]
            excludeRemote += ["**/build/intermediates/**"]
            excludeRemote += ["**/build/tmp/**"]
            rsyncToRemoteArgs += ["-avAXEWSlHh"]
            rsyncToRemoteArgs += ["--info=progress2"]
            rsyncToRemoteArgs += ["--compress-level=9"]
            rsyncFromRemoteArgs += ["-avAXEWSlHh"]
            rsyncFromRemoteArgs += ["--info=progress2"]
            rsyncFromRemoteArgs += ["--compress-level=9"]
            fallback false
            downloadInParallel false
            downloadInterval 3000
            breakOnTasks = ["install", "package"]
        }
    } else {
        project.logger.lifecycle("Remote builds mode activated but for different project. Stop now.")
    }
}

Как видим, самым первым делом, перед инициализацией проекта подключается плагин mirakle, который подтягивается из репозитория mavenCentral.

Далее идёт блок rootProject в котором уже располагается блок mirakle. В нём указываются все необходимые настройки.

  • Проверка projectToBuildRemotely.equals(name) необходима потому что init-скрипты gradle применяются ко всем проектам gradle, и таким образом можно случайно начать удалённо собирать то что вы не планируете. Поэтому я добавляю в скрипт проверку на имя корневого проекта. Именем корневого проекта в mirakle считается имя директории в которой располагается проект. Это позволяет в должной степени обезопасить себя. Если запускается сборка проекта, который мы действительно хотим собрать на удалённой машине, то всё пойдёт как положено. Если запускается сборка какого-то другого проекта, с которым мы прямо сейчас не работаем, то она упадёт с ошибкой. Управлять включением и выключением удалённой сборки мы будем явно, для каждого из проектов отдельно.

  • Первое что указывается - параметр host - в нём необходимо указать алиас из ssh конфигов который используется для подключения к удалённому ПК - android_builds_server.

  • Далее - remoteFolder - в этом параметре указывается относительный путь до папки в которую будут передаваться с помощью rsync файлы проектов.

  • Далее, самая важная часть - это правильно настроенные параметры для передачи в rsync. Директория с проектом android проекта содержит много того, что передавать непосредственно для сборки не потребуется, или даже прямо противопоказано, как например локальные кэши. Очень важно исключить толстые и ненужные директории для передачи, потому что ssh соединение - не самая быстрая штука и важно минимизировать количество данных которые нужно передать. В небольшом проекте эти директории могут занимать не так много места, однако в моём случае это было несколько гигабайт.

    Плагин mirakle поддерживает три вида исключений файлов: excludeLocal (исключить передачу файлов с ЛОКАЛЬНОЙ машины на УДАЛЁННУЮ), excludeRemote (с УДАЛЁННОЙ машины на ЛОКАЛЬНУЮ) excludeCommon (в обе стороны)

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

    В первую очередь определим то что точно не нужно передавать ни в одну из сторон:

    • excludeCommon += ["*.DS_Store"]  - служебные файлы MacOS

    • excludeCommon += ["*.hprof"] - файлы профайлинга JVM, могут занимать очень много места

    • excludeCommon += [".idea"] - файлы IDE, также могут много весить и абсолютно не нужны при сборке

    • excludeCommon += [".gradle"]  - локальные конфигурации и кэш gradle. Много весит и на каждой из машин они должны быть свои

    • excludeCommon += ["**/.git/"] - файлы git, не требуются при сборке и также могут быть очень тяжёлыми в крупном проекте

    • excludeCommon += ["**/.gitignore"]

    • excludeCommon += ["**/local.properties"]  -  обязательно необходимо исключить local.properties чтобы не сломать сборку на стороне удалённой машины, т.к. путь к android sdk там будет свой, мы будем создавать его руками для копии на той стороне

    • excludeCommon += ["**/backup_.gradle"]  - бэкап скрипта сборки который создаёт mirakle. На стороне удалённой машины он не нужен

    • excludeCommon += ["remote_android_.*sh"]  - это и ниже - локальные скрипты и другие файлы нужные для управления включением/выключением сборки на удалённой машине, которые мы создадим позже

    • excludeCommon += ["remote_android_*.properties"]

    • excludeCommon += ["remote_android_*.config"]

  • Далее определим конкретно то, что не должно передаться с локальной машины на удалённую, но не наоборот:

    • excludeLocal += ["**/build/"] - обязательно полностью исключить локальные файлы билда

    • excludeLocal += ["*.keystore"] - опционально, добавил для себя это правило чтобы даже случайно никогда и никуда не отправить кейстор с ключами для подписи релизных сборок

    • excludeLocal += ["*.apk"] - опционально, я часто храню в корне проекта множество ранее собранных apk разных версий/флейворов, весят они очень много поэтому добавил для себя правило исключающее их передачу

  • После этого определим правила по передаче с удалённой машины на локальную. Здесь, логично необходимо исключить исходный код, и по сути передать назад только apk, т.е. build/output, но тут есть нюансы. Результатом могут быть не только apk, но и aab бандлы, а ещё, если применяется proguard/r8, то нужны и файлы маппингов для crashlytics. А ещё есть классы полученные кодогенерацией, которые тоже хранятся в build/generated, без них IDE сойдёт с ума и будет подсвечивать красным имена многих классов и сигналить что класс не найден. При этом сгенерированные классы, как ни странно, занимают не так много места, даже в очень большом проекте, поэтому я предпочитаю их передать на локальную машину. В общем, нужно осторожно руками прописать исключения только для самых тяжёлых директорий с промежуточными результатами сборки. Основная из них, самая жирная - это build/intermediates, обычно в моём проекте она весит много гигабайт и нужна она только там где производится сборка, то есть на удалённой машине, но и несклько других тоже исключим. Получится вот так:

    • excludeRemote += ["/src/"]

    • excludeRemote += ["/build/intermediates/"]

    • excludeRemote += ["/build/kotlin/"]

    • excludeRemote += ["/build/.transforms/"]

    • excludeRemote += ["/build/tmp/"]

  • Далее идут параметры для rsync. Их можно установить отдельно для команд передачи файлов с локальной машины на удалённую и наоборот, с удалённой на локальную. Я для обоих случаев выставляю один набор параметров. Самые важные из них:

    • -W - для передачи целых файлов а не из diff-ов. У меня нормально завёлся только этот режим

    • --compress-level=9 - этот параметр может принимать значения от 0 до 9 включительно. Экспериментальным путём я пришёл к тому что выставляю здесь максимальный уровень потому что это, по сути, trade-off между процессорным временем и скоростью сети. В моём случае передать один тяжёлый apk было значительно быстрее, если сильнее его пережать. Сжатие на стационарном ПК практически не занимало времени, и даже передача директории src/ с исходного кодом на удалённую машину с этим параметром выполнялась быстрее

    • -v, -h и --info==progress2 позволяют наблюдать в терминале прогресс передачи файлов в удобном виде. Без этого некомфортно, потому что передача больших файлов может занимать время и хочется наблюдать текущий прогресс

  • Далее идут общие параметры самого mirakle:

    • Я ставлю fallback false потому что если его выставить true, то сборка будет запускаться локально вместо показа ошибки о невозможности выполниться на удалённой машине. Я предпочитаю явно разграничить эти два режима - либо сборка только локально, либо только удалённо, с явным переключением мной этого в конфигурации руками

    • downloadInParallel false - обязательно. Этот параметр можно было бы выставить в true, если бы результатом сборки было много разных файлов. В обычном же случае это заставит mirakle раз в несколько секунд запускать rsync для передачи файлов с удалённой машины на локальную и у меня это почему-то приводило к зависанию сборки. Когда параметр установлен в false то сборка пройдёт именно так как это ожидается интуитивно - сначала полностью закончится, а потом отработает rsync чтобы передать собранный apk на локальную машину. Вообще-то, параллельный режим, в теории, очень здорово ускоряет сборку проекта, но почему-то я с ним получал либо худший результат, либо ошибки

    • breakOnTasks = ["install", "package"] - здесь указываются названия gradle тасок перед которыми mirakle должен прервать выполнение сборки на удалённой машине, передать файлы с удалённой машины на локальную и продолжить на локальной. Логично что это должны быть таски по установке apk на устройство или эмулятор. Т.е. например, я нажимаю кнопку "Run" в Android Studio, тогда выполняется gradle таск installDebug. С подключенным mirakle плагином файлы исходного кода буду сначала отправлены на удалённую машину, там начинается сборка, и производится она до того времени пока не случится переход к команде install. В этот момент она прервётся, будет вызван rsync чтобы передать apk с удалённой машины на локальную, после чего команда install будет отработана уже локально на ноутбуке - скачанный apk файл будет отправлен на устройство

На этом с конфигурацией mirakle всё. Можно запускать сборку проекта, и, если вы всё сделали правильно, то файлы проекта отправятся на удалённую машину, там будет вызвана сборка, и: когда она закончится, результирующий apk будет передан на локальную машину и с неё отправлен на устройство. При этом можно использовать все стандартные вещи из UI Android Studio - отладку и т.д. Поскольку apk файл находится локально, то никаких пробросов adb на удалённый хост не потребуется. Смело нажимайте кнопку Run или Debug и увидите в логе вызываемых в процессе сборки gradle тасок новые :uploadToRemote, :executeOnRemote, :downloadFromRemote. Первая сборка займёт некоторое время, потому что удалённой машине необходимо будет выкачать дистрибутив gradle, и подготовить кэши, зато последующие будут проходить без задержек.

Если вы настраивали всё на удалённой машине руками по этой инструкции, а не с помощью приложенного скрипта из репозитория, то не забудьте зайти на удалённую машину, перейти в директорию с проектом, находящуюся в /home/builder/.mirakle/PROJECT_NAME руками создать файл local.properties. В этот файл нужно руками поместить строку содержащую путь к android sdk:

sdk.dir=/home/builder/Android/Sdk

Ещё одна ремарка. Авторы плагина предлагают использовать ssh в режиме shared socket. Т.е. когда соединение устанавливается один раз и затем переиспользуется при последующих обращениях, из-за чего не тратится время на повторное соединение. Звучит это логично и правильно, однако, именно в моём случае, такой способ не работал, потому что использование shared socket-ов не позволяло нормально пользоваться ssh reverse port forwarding-ом, поэтому я решил использовать ssh как есть, но при этом настроить обмен ключами на ed25519 вместо RSA. На ed25519 обмен ключами и установление соединения у меня занимает времени в несколько раз меньше чем на RSA. "На глаз" подключение происходит настолько быстро что даже нет визуально наблюдаемой задержки после ввода команды, поэтому считаю что shared socket режимом можно пренебречь.

Автоматизация

Прописывать все эти параметры каждый раз, создавать или изменять init-скрипт gradle вручную - неудобно, поэтому я подготовил репозиторий со скриптами, которые позволяют быстро настроить и локальную машину, и удалённую машину, и одной командой включать сборку на удалённой машине и также одной командой возвращаться в режим обычной локальной сборки. Вот ссылка:

https://github.com/LuigiVampa92/RemoteAndroidBuilds

Скачайте репозиторий и перенесите все файлы со скриптами в корень android проекта, который хотите собирать на удалённой машине. 

Советую сразу добавить строку в файл .gitignore, которая исключит эти файлы для добавления и случайной отправки в remote git репозиторий:

remote_android_*

Все основные параметры сборки на удалённой машине необходимо править в  файле remote_android_config.properties.

Для включения и выключения режима сборки на удалённой машине требуется выполнять remote_android_build_on.sh и remote_android_build_off.sh соответсвенно.

Но прежде чем их выполнять необходимо подготовить удалённую машину.

Разберём два отдельных случая:

  1. Первый случай. У вас есть уже готовая настроенная удалённая машина, к которой уже настроен доступ по ssh, на ней уже есть какой-либо не-root пользователь, и на неё требуется только установить всё необходимое для сборки android проектов

В первом случае порядок работы такой:

открываем файл remote_android_config.properties и изменяем значения в этих полях на те которые соответствуют вашим:

server.ssh.alias=android_builds_server
server.ssh.set.user.name=builder
server.ssh.set.user.password=builder
server.ssh.set.port=34567
server.ssh.set.key="~/.ssh/id_ed25519_android_builds_server"

Заполняем значение параметра server.sdk.dependencies по аналогии со значением по умолчанию значениями для вашего проекта.

Если вам требуется прокинуть доступ в частный maven репозиторий, то установите nexus.proxy.required в true и настройте nginx и build.gradle из инструкции выше, в разделе про настройку nginx. Порт можно оставить 33333, если только вы не используете этот порт для каких-то своих задач.

Запустите remote_android_server_setup.sh для того чтобы убедиться что все необходимые зависимости установлены. Зависимости, которых не хватает на сервере будут установлены во время работы скрипта.

Всё готово. Теперь вы можете запускать remote_android_build_on.sh для включения удалённой сборки и remote_android_build_off.sh для возврата к сборке на локальной машине.

  1. Второй случай. У вас есть новая ненастроенная удалённая машина, например вы только что арендовали VPS и у вас есть её IP адрес, пароль root и доступ по ssh и больше вы пока что ничего не подготавливали и не настраивали.

В этом случае порядок действий такой:

У вас должен быть IP адрес сервера. Открываем файл remote_android_config.properties и изменяем значения в этих полях на те которые соответствуют вашим:

server.ssh.ip=127.0.0.1
server.ssh.port=22

Также как и в первом случае заполните значение параметра server.sdk.dependencies значением для вашего конкретного проекта. Блок с параметрами содержащий алиас, имя, пароль, порт и ключ заполнять в вашем случае не нужно. Эти значения будут установлены во время работы скриптов настраивающих окружение.

Сначала необходимо подготовить локальную часть ssh. Запускайте remote_android_local_prepare.sh. Этот скрипт сгенерирует новые ed25519 ключи и конфигурацию для ssh на локальной машине. В дальнейшем повторять это действие не потребуется.

Затем выполняйте remote_android_server_prepare.sh. Этот скрипт настроит ssh на удалённой машине. Обратите внимание что ssh попросит у вас дважды ввести пароль root - один раз чтобы отправить скрипт настройки ssh на удалённый ПК, второй раз - чтобы его выполнить. Это делается один раз для удалённого ПК. Далее доступ к машине будет осуществляться по алиасу, без ввода пароля, с использованием авторизации на публичных ключах.

После этого выполняйте remote_android_server_setup.sh. Этот скрипт установит все необходимые зависимости и полностью подготовит систему к сборке проектов. Его также достаточно выполнить один раз, однако, если в будущем вы будете добавлять в properties файл с конфигурацией новые версии android или build-tools, то этот скрипт будет необходимо вызвать заново, чтобы на удалённой машине установились новые зависимости.

Всё готово. Теперь вы можете запускать remote_android_build_on.sh для включения удалённой сборки и remote_android_build_off.sh для возврата к сборке на локальной машине.

Результат

ПЛЮСЫ РЕШЕНИЯ:

  • В результате холодная сборка моего проекта сократилась с 30-40 минут на рабочем макбуке до 8-10 минут на удалённом ПК. А горячая - с 2-4 минут до 30-40 секунд. При этом ноутбук не надрывается от нагрузки. От неё надрывается удалённый ПК, но он намного более приспособлен для таких тяжёлых вычислительных задач. 

htop:

Сборка идёт полным ходом. Любо дорого смотреть:

htop на рабочем ноутбуке
htop на рабочем ноутбуке
htop на удалённом компьютере
htop на удалённом компьютере

  • Ноутбук практически не греется и не шумит, для меня только одно это стоило всей проделанной работы, но, к сожалению, только почти. Даже такой подход не разгружает машину разработчика на 100%, т.к. помимо gradle есть ещё сама Android Studio которая тоже будет требовать немало ресурсов на индексацию проекта и т.д. Во время индексации проекта ноутбук кратковременно подогревается и пыхтит вентиляторами, но по сравнению с тем как обстояли дела ранее, это совсем не страшно.

  • Удалённый ПК может без проблем собирать проект, несмотря на то что на нём отсутствует физический USB токен доступа с ключами для подключения к VPN. 

  • Система настраивается из нулевого до готового состояния за две команды и пару минут времени. Это очень удобно, не требует зарываться в настройку удалённой машины на Linux и открывает довольно интересные возможности. Если вам позволяют условия и конфиденциальность вашего кода не является проблемой, то можно арендовать вычислительные мощности под ваши сборки в дата-центрах. Вообще-то, очень производительные машины арендовать под такие цели обычно не выгодно, т.к. оплата там почасовая, и платите вы в том числе и за то время что машина НЕ работает, например ночью или на выходных, однако, подготовка инстанса за пару минут позволяет арендовать машину только на время рабочего дня, заплатив за использование компьютера, который при аналогичной аппаратной конфигурации в покупке стоил бы огромных денег, всего по 10-20-30 рублей в час, или 100-250 рублей за рабочий день, вполне приемлемо если на кону часы ежедневного бесцельного дзен-созерцания монитора в ожидании сборки под шум вентилятора и ощущение медленно плавящегося процессора в ноутбуке.

  • Я абсолютно ничего не потерял в удобстве. Привычные инструменты привычно работают. Режим сборки переключается одной командой. В любой момент есть возможность вернуться на локальную сборку

  • Не создаётся никаких проблем для службы безопасности компании, я подключаюсь к рабочему VPN компании и всем рабочим ресурсам только с одной точки.

МИНУСЫ РЕШЕНИЯ:

К сожалению, в этой схеме появляется новое бутылочное горлышко в виде пропускной способности сети. 

Здесь против меня снова играет особенность именно моего проекта. Дело в том что собранный apk файл получается очень тяжёлый - несколько сотен мегабайт, это даже с учётом того что студия собирает apk файл только под конкретную архитектуру, локаль, размер экрана, и его передача по ssh занимает определённое время. Отказаться от передачи по ssh нельзя, иначе пострадает безопасность. Впрочем, если находиться дома, в одной локальной сети с компьютером, то можно сконфигурировать ssh без использования шифрования вообще и передавать файл очень быстро по гигабитной витой паре и 5 ГГц WiFi. 

В интернете рекомендуют прописать в конфигурации сервера ssh использование шифра blowfish для симметричного шифрования. Он очень быстрый и должен значительно улучшить пропускную способность сети. Можно попробовать. Несмотря на то что этот шифр считается устаревшим, он всё ещё надёжен. Впрочем, меня устраивает производительность chacha20.

Особенно эта проблема чувствуется не в продолжительных холодных, а в горячих сборках, т.к. они выполняются относительно быстро, даже на локальной машине, а время которое тратилось на работу gradle и было выиграно за счёт сборки на удалённой машине теперь тратится на передачу apk файла с удалённой машины на ноутбук. Да, это всё равно быстрее чем локальная сборка, но не так быстро как хотелось бы. Впрочем за отсутствие нагрева и шума я готов всё простить

Что ещё можно сделать

Сама по себе идея вызывать скрипты для включения и выключения каких-либо настроек мне не очень нравится. Лично меня это не напрягает, но, всё таки, для человека со стороны решение будет ожидаемо попахивать лёгким кустарным душком, из-за необходимости что-то вводить в терминале. Было бы идеально сделать плагин для Android Studio, в котором можно было бы ввести все необходимые параметры в графическом интерфейсе и просто переключать режимы сборки одной кнопкой. Думаю, в будущем так и стоит попробовать сделать.

Есть проблема со стабильностью ssh туннелей, которые будут нужны для сборки если она включает в себя обращение к maven репозиториям к которым нет прямого доступа с удалённой машины. Я вычитал про инструмент autossh, суть которого как раз в том чтобы следить за состоянием постоянно открытых ssh соединений и восстанавливать их, в случае если они не отвечают, но autossh я пока что не пробовал пользоваться. Опять же, лично для меня проблема не критичная, потому что выкачанные из maven библиотеки кэшируются, и если только не обновились их версии, что случается не часто, то сборка будет отлично проходить даже без доступности репозитория. Впрочем, если претендовать на звание полноценного решения, то эту проблему придётся решить

Мне кажется что это не лучшая идея, но можно попробовать заморочиться и сделать опцию с копированием исходников и хранением их на удалённой машине в tmpfs. В теории это должно ускорить обращение к файлам в хранилище, т.к. файлов много и обращений к ним тоже много, но я не уверен что это хорошая идея. Дело в том что запущенная gradle сборка проекта в активной фазе когда каждое ядро процессора занято сборкой модулей отжирает у компьютера несколько десятков гигабайт памяти. Сколько же тогда её нужно будет установить чтобы полностью покрыть и потребность gradle, и хранение файлов? Да и не думаю что выигрыш в производительности будет огромным

Что можно было сделать по другому

В процессе поиска решения проблемы я попробовал ещё как минимум один хороший рабочий подход, которым даже некоторое время пользовался, но всё таки вернулся к методу описанному в этой статье: это запуск не только процесса сборки, но и самой Android Studio на удалённой машине. Для этого я использовал X11 forwarding который поддерживается в MacOS, т.к. для MacOS существует своя реализация Х11 - XQuartz. Вообще использование Х11 через интернет в чистом виде - очень плохая идея, т.к. будут задержки и они будут очень значительными, работать будет невозможно, но есть проект X2GO, который эту проблему решает. Причём MacOS позволяет сделать это очень красиво. Можно сделать кастомный скрипт в Apple Automator который будет через x2go запускать студию на удалённом ПК, вынести его в Dock и назначить ему иконку от Android Studio - с точки зрения UI этот скрипт будет запускаться как родное приложение на MacOS, только на самом деле это запускается приложение на удалённом компьютере, а только его окно рисуется в MacOS. У меня получилось таким образом запускать студию через x2go и она работала практически вообще без видимой сетевой задержки, я даже мог скроллить содержимое окна и это выглядело ровно как в окне программы запущенной прямо на ноутбуке.

Из очевидных плюсов - ноутбук остаётся вообще абсолютно холодным, потому что даже индексация проекта в IDE происходит на удалённой машине. Очень непривычное, но приятное ощущение когда работаешь с таким огромным проектом.

Но и минусы появляются существенные - во-первых, в такой схеме без проблем будет возможен запуск собранных приложений только на эмуляторе, а это не всем и не всегда подходит. Во-вторых, если вдруг на какое-то время оборвётся связь, то придётся либо ждать, либо выкачивать проект на локальную машину, собирать и запускаться с неё, что будет дольше чем случай когда исходники находятся только в одном месте, к тому же можно временно потерять незалитые в git изменения. В добавок усложняется логистика, мне кажется, что нарваться на проблемы выхода в сеть с разных адресов одновременно становится намного проще. Ну и, конечно, на машине теперь не получится подготовить окружение двумя скриптами за полторы минуты, теперь придётся устанавливать и DE, и иксы, и Android Studio, и много чего ещё.

Мне кажется что такой способ хорош только в одном случае - когда у вас есть хороший домашний ПК, на нём установлен Linux, к нему есть доступ по ssh и вы большую часть времени работаете именно за ним, а только иногда временно переходите на ноутбук. Тогда, конечно, чертовски приятно запустить с ноутбука студию на привычной машине, да ещё и в нативном окне.

В целом, такой способ только в некоторых деталях отличается от использования TeamViewer и его аналогов, и наверняка это не то о чём стоит всерьёз писать на Хабре.

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

P.S. Кстати, судя по именам разработчиков gradle плагина mirakle на github, это русскоговорящие ребята. Если вдруг вы читаете это, то примите от меня тысячу благодарностей. Спасибо вам огромное за то, что сэкономили мне огромное количество рабочих часов. Мне кажется что ваш проект здорово недооценён аудиторией, а может быть просто не так много ситуаций в которых его можно эффективно применить. Успехов вам!

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