Разрабатывая проект под платформу Android, даже самый небольшой, рано или поздно приходится сталкиваться с окружением для разработки. Кроме Android SDK, необходимо чтобы была последняя версия Kotlin, Gradle, platform-tools, build-tools. И если на машине разработчика все эти зависимости решаются в большей мере с помощью Android Studio IDE, то на сервере CI/CD каждое обновление может превратиться в головную боль. И если в web-разработке, решением проблемы окружения стандартом стал Docker, то почему-бы не попробовать решить с помощью него аналогичную проблему и в Android-разработке…

Для тех, кто не знает что такое Docker — если совсем просто, то это инструмент создания т.н. «контейнеров» где содержится минимальное ядро ОС и необходимый набор ПО, которые мы можем разворачивать где захотим, сохраняя при этом окружение. Что именно будет в нашем контейнере определяется в Dockerfile, который потом собирается в образ запускаемый где угодно и обладающий свойства идемпотентности.

Процесс установки и основы Docker прекрасно описаны на его официальном сайте. Поэтому, забегая немного вперед, вот такой Dockerfile у нас получился

# Т.к. основным инструментом для сборки Android-проектов является Gradle, 
# и по счастливому стечению обстоятельств есть официальный Docker-образ 
# мы решили за основу взять именно его с нужной нам версией Gradle
FROM gradle:5.4.1-jdk8

# Задаем переменные с локальной папкой для Android SDK и 
# версиями платформы и инструментария
ENV SDK_URL="https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip"     ANDROID_HOME="/usr/local/android-sdk"     ANDROID_VERSION=28     ANDROID_BUILD_TOOLS_VERSION=28.0.3

# Создаем папку, скачиваем туда SDK и распаковываем архив,
# который после сборки удаляем
RUN mkdir "$ANDROID_HOME" .android     && cd "$ANDROID_HOME"     && curl -o sdk.zip $SDK_URL     && unzip sdk.zip     && rm sdk.zip # В следующих строчках мы создаем папку и текстовые файлы 
# с лицензиями. На оф. сайте Android написано что мы 
# можем копировать эти файлы с машин где вручную эти 
# лицензии подтвердили и что автоматически 
# их сгенерировать нельзя
    && mkdir "$ANDROID_HOME/licenses" || true     && echo "24333f8a63b6825ea9c5514f83c2829b004d1" > "$ANDROID_HOME/licenses/android-sdk-license"     && echo "84831b9409646a918e30573bab4c9c91346d8" > "$ANDROID_HOME/licenses/android-sdk-preview-license"    

# Запускаем обновление SDK и установку build-tools, platform-tools
RUN $ANDROID_HOME/tools/bin/sdkmanager --update
RUN $ANDROID_HOME/tools/bin/sdkmanager "build-tools;${ANDROID_BUILD_TOOLS_VERSION}"     "platforms;android-${ANDROID_VERSION}"     "platform-tools"

Сохраняем его в папку с нашим Android-проектом и запускаем сборку контейнера командой

docker build -t android-build:5.4-28-27 .

Параметр -t задает tag или имя нашего контейнера, которое обычно состоит из его название и версии. В нашем случае мы назвали его android-build а в версии указали совокупность версий gradle, android-sdk и platform-tools. В дальнейшем нам проще будет искать нужный нам образ по имени используя такую «версию».

После того как сборка прошла мы можем использовать наш образ локально, можем загрузить его командой docker push в публичный или приватный репозиторий образов чтобы скачивать его на другие машины.

В качестве примера соберем локально проект. Для этого в папке с проектом выполним команду

docker run --rm -v "$PWD":/home/gradle/ -w /home/gradle android-build:5.4.1-28-27 gradle assembleDebug

Разберем что она означает:

docker run — сама команда запуска образа
-rm — означает что после остановки контейнера он удаляет за собой все что создавалось в процессе его жизни
-v "$PWD":/home/gradle/ — монтирует текущую папку с нашим Android-проектом во внутреннюю папку контейнера /home/gradle/
-w /home/gradle — задает рабочую директорию контейнера
android-build:5.4.1-28-27 — имя нашего контейнера, который мы собрали
gradle assembleDebug — собственно команда сборки, которая собирает наш проект

Если все сложиться удачно, то через пару секунд/минут вы увидите у себя на экране что-то вроде BUILD SUCCESSFUL in 8m 3s! А в папке app/build/output/apk будет лежать собранное приложение.

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

Контейнер не хранит никаких изменений, и каждая сборка запускается с нуля, что с одной стороны гарантирует идентичность сборки независимо от места ее запуска, с другой стороны каждый раз приходиться скачивать все зависимости и компилировать весь код заново, а это иногда может занимать существенное время. Поэтому кроме обычного «холодного» запуска у нас есть вариант запуска сборки с сохранением т.н. «кэша», где мы сохраняем папку ~/.gradle просто копируя ее в рабочую папку проекта, а в начале следующей сборки возвращаем ее обратно. Все процедуры копирования мы вынесли в отдельные скрипты и сама команда запуска у нас стала выглядеть так

docker run --rm -v "$PWD":/home/gradle/ -w /home/gradle android-build:5.4.1-28-27 /bin/bash -c "./pre.sh; gradle assembleDebug; ./post.sh"

В итоге, среднее время сборки проекта у нас сократилось в несколько раз (в зависимости от числа зависимостей на проекте, но средний проект таким образом стал собираться за 1 минуту вместо 5 минут).

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

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


  1. alexkuzko
    29.08.2019 19:48

    Такое бы, да для сборки яблочных приложений. Но ничего кроме специально подготовленных виртуалок ещё не видел. Может кто сталкивался?


  1. aol-nnov
    29.08.2019 08:57

    `gradle` можно было поставить в `ENTRYPOINT`, например

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

    Скажете «это тривиально, посмотри в интернетах»? Так тут вся статья тривиальная, если уж на то пошло


    1. gecube
      29.08.2019 09:28

      Согласен — проблема с правами пользователя имеет место быть.
      Я знаю как минимум три или четыре решения, но нет ни одного прям красивого. Могу поделиться идеями, если интересно.
      С другой стороны — в ci/cd процессе, суть не на машине разработчика, проблемы с правами скорее всего нет, тем более, если использовать dind.


      1. aol-nnov
        29.08.2019 09:34

        > суть не на машине разработчика

        Да, в сиае много вопросов отпадает, но как же «единство сборочного окружения»? :)

        А идеями поделиться никогда не лишне! Я, например, колдую небольшой скрипт, который на старте контейнера создает пользователя с нужным uid и только после этого выполняет команду. А что делают ваши три или четыре варианта? :)


        1. gecube
          29.08.2019 11:05

          Я идей накидаю, а Вы там соберите в кучу.


          Идея 1. Наивно можно запускать контейнер с ключом -u (--uid). Тогда исполняемый файл будет запускаться под юзером с указанным UID. Прокинуть айди текущего юзера можно через $(id -u). Проблема в том, что это работает, если все каталоги внутри образа с правами 777 и программа должна быть собрана и настроена портабельно. Это далеко не всегда так


          Идея 2. Раз предыдущее не работает, то давайте мы сделаем docker-entrypoint.sh скрипт, будем в нем свитчится в нужного юзера и там же заchown'им и chmod'им все необходимые для работы каталоги. В принципе это работает, но выглядит не красиво: приходится передавать айди текущего юзера через переменную окружения (docker run ... -e UID=$(id -u)) внутрь контейнера. А потом через тот же gosu свитчиться в нужного юзера. При этом это не работает с ключом --uid из п.1, т.к. нам надо стартовать из-под рута. Дополнительно — это не решает проблемы с правами на корневой каталог, куда будут смонтированы файлы докера (он все равно будет под рутом). И в опеншифте такой контейнер не запустится.


          Идея 3. Нам вообще не нравится писать колбасу вида
          docker run --rm -v "$PWD":/home/gradle/ -w /home/gradle android-build:5.4.1-28-27 /bin/bash -c "./pre.sh; gradle assembleDebug; ./post.sh" Итого — приходим к необходимости написания какой-то внешней обвязки в виде баш скрипта, алиаса или мейкфайла. Запомним это.


          Идея 4.а Все проблемы с правами на каталог, который создает докер из-за ключа -v. Если перейти на полный синтаксис bind mount, то целевой каталог НЕ СОЗДАЕТСЯ автоматически, а это значит, что если мы создадим его в скрипте из Идея 3 руками, то с правами на каталог будет все ок, а права на файлы будут нормальные — см. Идея 1 и Идея 2


          Идея 4.b Мы можем вообще не париться с вольюмами, а тупо писать файлы в volume, а вытаскивать их из них либо через временный контейнер с нужным юзером, либо через docker cp


          Идея 4. c Ах, еще можно запустить в докер-контейнере тот же тар, а наружу прокинуть файл через пайп (что-то типа docker exec -it blablabla cat FILE | cat > FILE — тот кат, что справа уже выполняется на хосте с привилегиями текущего юзера, т.е. получается нам вообще без разницы, что внутри контейнера происходит)


          1. aol-nnov
            29.08.2019 11:13

            Годно. Теперь, если кто-то будет использовать эту статью по назначению, обязательно наткнется на эти залежи и улучшит свой пайплайн ;)

            Сам я же уже давольно давно использую «идею 2» с некоторыми вариациями — всё хорошо. (что до опеншифта, он мне не надыть, так что, проблем не возникает)


            1. gecube
              29.08.2019 16:17

              antaresm по поводу прав на каталоги и файлы артефактов есть что добавить?


              1. aol-nnov
                29.08.2019 16:20

                Я просто на старте контейнера в нем создаю пользователя с uid равным идентификатору с хоста, и дальше выполняю под ним. Естественно, проверяю, запускается ли контейнер в ci окружении или у разработчика на хосте.

                YMMV ;)


                1. gecube
                  29.08.2019 17:09

                  Т.е. если на хосте два юзера с ID 1000 и 1001, то один из них будет в обломе )
                  Хотя для линукс машин, в общем-то, это не очень типичный сценарий, а на маке с докером обычно нет проблем с правами на файлы )))))


                  1. aol-nnov
                    29.08.2019 17:12

                    > если на хосте два юзера с ID 1000 и 1001, то один из них будет в обломе

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


                    1. gecube
                      29.08.2019 17:50

                      Сорри, я не Вам писал, Вы ответили, а я невпопад повторно ответил )
                      Да, Вы правы — если пробрасывать uid в контейнер (мы рассмотрели способы как это сделать), но в статье автора — id user'а в контейнере зафиксирован. Соответственно, как мы выше определили — это норм для ci/cd, но не очень круто, когда разраб хочет "сборочный" контейнер запустить на своем ПК


              1. antaresm Автор
                29.08.2019 18:53

                Если нам нужны непосредственно артефакты как файлы, то у нас сам контейнер запускается под root, поэтому после сборки проблем достать артефакты не возникает;
                В большинстве случаев же, gradle заливает полученный apk в HockeyApp или GooglePlay.


                1. gecube
                  29.08.2019 18:55

                  то у нас сам контейнер запускается под root

                  Ну, Вы имеете в виду, что docker run выполняется от root'а? Тогда да, проблем нет.
                  Как насчет работы на машине разработчика? Или в опеншифте (где рут внутри контейнера запрещен по вполне понятным причинам)


                  1. antaresm Автор
                    29.08.2019 19:00

                    Да, docker run выполняется root'ом
                    Для разработчика локально запустить root тоже не проблема.
                    А для опеншифт как я уже писал мы не храним артефакт, а заливаем его на внешний ресурс


                    1. gecube
                      29.08.2019 19:12

                      Для разработчика локально запустить root тоже не проблема.

                      Как минимум — лишнее действие. Как максимум — в компании может быть политика ИБ (хотя, конечно, может выглядеть глупо — рута не даем, а докер даем)))


                      А для опеншифт как я уже писал мы не храним артефакт, а заливаем его на внешний ресурс

                      Вы им вообще пользовались, извините? Речь про запуск сборщика в кластере.


                      1. antaresm Автор
                        29.08.2019 19:14

                        не пользовались. поэтому я просто вам описал решение которое у нас на сервере CI используется. основная то проблема в правах, как я понял; Или, извините, я не правильно понял проблему


                        1. gecube
                          29.08.2019 19:38

                          Да, основная проблема в правах.
                          Но как я выше подчеркнул — ее можно игнорировать, в случае, если вся история происходит в CI/CD конвейере на выделенном инстансе. Но ведь хочется больше!!! Масштабировать процесс на все ) и на машины разраба, и в кластере собирать (чтобы не платить за выделенную машину, а платить за фактические потребленное время сборок) и т.п.


  1. gecube
    29.08.2019 09:30

    Хотел добавить, что курлить артефакт из публичного репо без проверки sha — так себе идея.
    Пример — собираете Вы такой свой образ, сидите в Корп сети, а там Корп прокси. Помимо того, что он курочит хттпс, он легко может вместо артефакта zip выдать страницу с ошибкой. Но курл ее честно скачает и положит во временный образ.
    Другой вопрос, что процесс сборки скорее всего свалится на распаковке, но вот такой вектор атаки все равно остаётся...


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


    1. antaresm Автор
      29.08.2019 09:41

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


      1. gecube
        29.08.2019 11:06

        Ссылка так-то на оф.сайт — https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip


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

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


  1. ooki2day
    29.08.2019 10:38

    yes | /path_to_sdk/sdk/tools/bin/sdkmanager«build-tools;29.0.0» — принимаем лицензию для build-tools
    yes | /path_to_sdk/sdk/tools/bin/sdkmanager «platforms;android-28» — принимаем лицензию для платформы.
    таким образом, можно принимать лицензию автоматически для всех компонентов.


  1. magic_goop
    29.08.2019 18:53

    Есть ли у вас опыт запуска espresso тестов в docker контейнере (т.е. запуск эмулятора)?


    1. antaresm Автор
      29.08.2019 18:54

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