Группировка моделей телефонов Android по контейнерам Docker
Немного предыстории
Мобильное приложение Badoo существует для основных «нативных» платформ (Android, iOS и Windows Phone) и для мобильного веба. Несмотря на то, что в разработке мы не используем никаких кроссплатформенных фрэймворков, подавляющая часть бизнес-логики в приложениях схожа, и чтобы не дублировать функциональные тесты для всех платформ, мы пишем кроссплатформенные тесты с помощью Cucumber, Calabash и Appium. Это позволяет нам выносить в общую часть и переиспользовать в тестах для всех платформ код, отвечающий за проверку этой самой бизнес-логики. Различной же остается лишь реализация взаимодействия с приложением (более подробно мы рассказывали об этом здесь).
Когда кроссплатформенная автоматизация только начиналась (на iOS и Android), было принято решение использовать в качестве серверов Mac Mini. Это позволило сделать каждую из 8 билд-машин универсальной: на ней можно было собирать и запускать функциональные и юнит-тесты как для приложений на iOS, так и на Android. Такое решение устраивало нас практически всем до тех пор, пока количество функциональных тестов не перевалило за пять сотен для каждой платформы, а прогоны не стали требовать все больше времени. Для того чтобы удержать время прогона в разумных границах, мы постоянно работаем над оптимизацией тестов, а также добавляем новые Android-устройства (для iOS мы добавляем симуляторы по-другому). Со временем у нас появились Mac Mini с более чем 8 смартфонами. Важно отметить, что мы подключаем устройства одной модели к одному серверу, чтобы прогоны тестов были консистентны на одном агенте.
По существу
У себя в Badoo мы решили перенести тестирование устройств Android на Linux-хосты — необходимое оборудование стоит дешевле, а кроме того, компьютеры Mac Mini, используемые для сборки, часто прерывают USB-подключения к устройствам Android, и те внезапно исчезают во время тестирования. Для управления серверами Linux мы в основном используем контейнеры Docker, поэтому решили попробовать создать контейнер для тестирования реальных устройств Android и клонировать его для каждой модели или группы телефонов, чтобы интегрировать контейнер в существующую конфигурацию серверов.
Небольшое замечание: одно из преимуществ Linux по сравнению с Mac заключается в том, что Linux — открытая система. Она показала нам, что причина таинственного исчезновения телефонов при тестировании кроется в разрывах соединений, длящихся доли секунды. Мы исправили тесты, добавив в них повторную попытку подключения, что в значительной степени решило проблему.
По существу: Docker
Docker — это система, которая содержит в себе методы сборки и распространения конфигураций ПО с инфраструктурой операционной системы, изолирующей каждый программный контейнер от остальных компонентов компьютера. Контейнер имеет собственную файловую систему, адресное пространство и пр. Контейнеры исполняются в одном экземпляре операционной системы, но поскольку система сильнее изолирует процессы, это все работает как набор виртуальных машин.
Поясняющие диаграммы, опубликованные на сайте Docker:
На хост-компьютере используется система виртуализации, в которой запущены гостевые экземпляры ОС:
Контейнеры Docker выполняются на одной ОС:
По существу: группировка adb/adbd
Каждый контейнер должен был управлять собственным набором телефонов. Чтобы реализовать это наиболее естественным способом, нужно сопоставить группы разъемов USB разным контейнерам. Устройства, подключенные к разъемам на передней панели компьютера, появляются в каталоге /dev/bus/usb/001, который доступен контейнеру 1; устройства, подключенные к разъемам на задней панели, появляются в каталоге /dev/bus/usb/002, который доступен контейнеру 2. Чтобы увеличить количество подключаемых устройств, была заказана дополнительная плата расширения.
Все это выглядит неплохо, однако команда adb взаимодействует с телефоном через демон, который использует порт по умолчанию 5037 и работает на уровне всего компьютера. Это означает, что первый контейнер, в котором выполняется команда adb, запускает и демон adb (adbd) — в результате остальные контейнеры, подключаемые к этому демону, видят телефоны первого контейнера. Эту проблему можно было бы решить с помощью сетевых возможностей Docker (каждый контейнер получает собственный IP-адрес, а, следовательно, и собственный набор портов), однако мы воспользовались другим механизмом. Для каждого контейнера было присвоено отдельное значение переменной окружения ANDROID_ADB_SERVER_PORT. Мы выделили порт каждому контейнеру, чтобы он запускал собственный демон adb, который видит только телефоны этого контейнера.
В процессе разработки мы поняли, что нельзя выполнять команду adb на уровне хоста, не задав переменную ANDROID_ADB_SERVER_PORT, поскольку демон adbd уровня хоста, способный видеть все порты USB, «крадет» телефоны у контейнеров Docker (телефоны могут взаимодействовать только с одним демоном adbd в каждый момент времени).
Если бы мы использовали только эмуляторы, можно было бы обойтись отдельными процессами adbd, но поскольку мы работаем с реальными устройствами…
По существу: обновление контейнеров при горячем подключении устройств USB
Вторая проблема (и главная причина написания этой статьи) заключалась в том, что при перезагрузке телефона во время обычной процедуры сборки он исчезал из файловой системы и списка телефонов контейнера и больше не появлялся!
Отслеживать добавление и удаление телефонов на хост-компьютере можно по файлам в каталоге /dev/bus/usb, в котором система создает и удаляет файлы, соответствующие телефонам:
while sleep 3; do
find /dev/bus/usb > /tmp/a
diff /tmp/a /tmp/b
mv /tmp/a /tmp/b
done
К сожалению, в контейнерах Docker телефоны не только не создаются и не удаляются подобным образом; если настроить создание и удаление узлов, то они на самом деле не взаимодействуют с телефонами!
Мы решили этот вопрос «в лоб»: поместили контейнеры в режим --privileged и открыли им доступ ко всему каталогу /dev/bus/usb.
Теперь понадобился другой механизм распределения телефонов по интерфейсным шинам. Я скачал исходный код Android и внес небольшие изменения в файл platform/system/core/adb/usb_linux.cpp
std::string bus_name = base + "/" + de->d_name;
+ const char* filter = getenv("ADB_DEV_BUS_USB");
+ if (filter && *filter && strcmp(filter, bus_name.c_str())) continue;
std::unique_ptr dev_dir(opendir(bus_name.c_str()), closedir);
if (!dev_dir) continue;
Переменной ADB_DEV_BUS_USB присваивается отдельное значение для каждого контейнера, указывающее на шину, с которой должен работать контейнер.
Отступление: хотя исправление было совсем несложным, сборку adb пришлось выполнять методом проб и ошибок, поскольку большинство людей включает в сборку все подряд. Мое окончательное решение выглядело так (в чувствительной к регистру файловой системе — я работаю на Mac):
cd src/android-src
source build/envsetup.sh
lunch 6
vi system/core/adb/usb_linux.cpp
JAVA_NOT_REQUIRED=true make adb
out/host/linux-x86/bin/adb
По существу: мультиплексирование портов USB
Дела шли неплохо, но после установки платы расширения USB мы обнаружили, что на ней только одна шина USB — на компьютере теперь три шины, а у нас пять групп устройств.
Поскольку я уже влез в код adb, то решил просто добавить еще одну переменную окружения: переменная ADB_VID_PID_FILTER получает список пар идентификаторов vid: pid, и adb игнорирует любые несоответствующие устройства.
Исправление приведено ниже. При сканировании шины USB для обнаружения подключенных телефонов процессы adbd могут вступить в состояние гонки, однако на практике это не вызывает проблем.
diff --git a/adb/usb_linux.cpp b/adb/usb_linux.cpp
index 500898a..92e15ca 100644
--- a/adb/usb_linux.cpp
+++ b/adb/usb_linux.cpp
@@ -115,6 +115,71 @@ static inline bool contains_non_digit(const char* name) {
return false;
}
+static int iterate_numbers(const char* list, int* rejects) {
+ const char* p = list;
+ char* end;
+ int count = 0;
+ while(true) {
+ long value = strtol(p, &end, 16);
+//printf("%d, %p ... %p (%c) = %ld (...%s)\n", count, p, end, *end, value, p);
+ if (p == end) return count;
+ p = end + 1;
+ count++;
+ if (rejects) rejects[count] = value;
+ if (!*end || !*p) return count;
+ }
+}
+
+int* compute_reject_filter() {
+ char* filter = getenv("ADB_VID_PID_FILTER");
+ if (!filter || !*filter) {
+ filter = getenv("HOME");
+ if (filter) {
+ const char* suffix = "/.android/vidpid.filter";
+ filter = (char*) malloc(strlen(filter) + strlen(suffix) + 1);
+ *filter = 0;
+ strcat(filter, getenv("HOME"));
+ strcat(filter, suffix);
+ }
+ }
+ if (!filter || !*filter) {
+ return (int*) calloc(sizeof(int), 1);
+ }
+ if (*filter == '.' || *filter == '/') {
+ FILE *f = fopen(filter, "r");
+ if (!f) {
+ if (getenv("ADB_VID_PID_FILTER")) {
+ // Only report failure for non-default value
+ printf("Unable to open file '%s'\n", filter);
+ }
+ return (int*) calloc(sizeof(int), 1);
+ }
+ fseek(f, 0, SEEK_END);
+ long fsize = ftell(f);
+ fseek(f, 0, SEEK_SET); //same as rewind(f);
+ filter = (char*) malloc(fsize + 1); // Yes, it's a leak.
+ fsize = fread(filter, 1, fsize, f);
+ fclose(f);
+ filter[fsize] = 0;
+ }
+ int count = iterate_numbers(filter, 0);
+ if (count % 2) printf("WARNING: ADB_VID_PID_FILTER contained %d items\n", count);
+ int* rejects = (int*)malloc((count + 1) * sizeof(int));
+ *rejects = count;
+ iterate_numbers(filter, rejects);
+ return rejects;
+}
+
+static int* rejects = 0;
+static bool reject_this_device(int vid, int pid) {
+ if (!*rejects) return false;
+ for ( int len = *rejects; len > 0; len -= 2 ) {
+//printf("%4x:%4x vs %4x:%4x\n", vid, pid, rejects[len - 1], rejects[len]);
+ if ( vid == rejects[len - 1] && pid == rejects[len] ) return false;
+ }
+ return true;
+}
+
static void find_usb_device(const std::string& base,
void (*register_device_callback)
(const char*, const char*, unsigned char, unsigned char, int, int, unsigned))
@@ -127,6 +192,8 @@ static void find_usb_device(const std::string& base,
if (contains_non_digit(de->d_name)) continue;
std::string bus_name = base + "/" + de->d_name;
+ const char* filter = getenv("ADB_DEV_BUS_USB");
+ if (filter && *filter && strcmp(filter, bus_name.c_str())) continue;
std::unique_ptr dev_dir(opendir(bus_name.c_str()), closedir);
if (!dev_dir) continue;
@@ -176,6 +243,12 @@ static void find_usb_device(const std::string& base,
pid = device->idProduct;
DBGX("[ %s is V:%04x P:%04x ]\n", dev_name.c_str(), vid, pid);
+ if(reject_this_device(vid, pid)) {
+ D("usb_config_vid_pid_reject");
+ unix_close(fd);
+ continue;
+ }
+
// should have config descriptor next
config = (struct usb_config_descriptor *)bufptr;
bufptr += USB_DT_CONFIG_SIZE;
@@ -574,6 +647,7 @@ static void register_device(const char* dev_name, const char* dev_path,
static void device_poll_thread(void*) {
adb_thread_setname("device poll");
D("Created device thread");
+ rejects = compute_reject_filter();
while (true) {
// TODO: Use inotify.
find_usb_device("/dev/bus/usb", register_device);
Надеюсь, что мои идеи пригодятся вам, если вы реализуете похожий проект. Оставляйте вопросы в комментариях ниже.
Tim Baverstock,
QA automation engineer