5.94-метровый docker-образ с Telegram MTProxy +77


Как все уже слышали, в конце мая Telegram зарелизил официальный MTProto Proxy (aka MTProxy) сервер, написанный на сях. В 2018 году без Docker мало куда, потому он сопровождается таким же «официальным» образом в формате zero-config. Все бы хорошо, но три «но» чуток испортили впечатление от релиза: image весит >130 Mb (там достаточно толстенький Debian, а не привычный Alpine), в силу «zero-config» оно не всегда удобно конфигурируется (только параметрами среды окружения) и ребята забыли, походу, выложить Dockerfile.



TL;DR Мы сделаем практически 1-в-1 официальный alpine-based docker image размером 5.94MB и положим его сюда (а Dockerfile сюда); попутно разберемся, как иногда можно подружить софт с Alpine с помощью кусачек и напильника, и чуток поиграемся размером, исключительно фана для.

Содержимое образа


Еще раз, из-за чего весь сыр-бор? Посмотрим, что представляет собой официальный образ командой history:

$ docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" telegrammessenger/proxy

Слои читаются снизу вверху, соответственно:



Самый толстый — это Debian Jessie, от которого унаследован оригинальный образ, имеено от него нам предстоит избавиться в первую очередь (alpine:3.6, для сравнения, весит 3.97MB); следом по габаритам идут curl и свежие сертификаты. Чтобы разобраться, что означают два остальных файла и каталог, заглянем внутрь с помощью команды run, подменив CMD на bash (так можно будет погулять по запущенному образу, познакомиться внимательнее, запускать те или иные фрагменты, скопировать чего полезного):

$ docker run -it --rm telegrammessenger/proxy /bin/bash

Теперь мы с легкостью можем восстановить картину происшествия — приблизительно так выглядел утерянный официальный Dockerfile:

FROM debian:jessie-20180312
RUN set -eux   && apt-get update   && apt-get install -y --no-install-recommends curl ca-certificates   && rm -rf /var/lib/apt/lists/*
COPY ./mtproto-proxy /usr/local/bin
RUN mkdir /data
COPY ./secret/ /etc/telegram/
COPY ./run.sh /run.sh
CMD ["/bin/sh", "-c", "/bin/bash /run.sh"]

Где mtproto-proxy — скомпилированный сервер, папка secret содержит лишь файл hello-explorers-how-are-you-doing с ключом шифрования AES (см. команды сервера, там, кстати, официальная рекомендация получать ключ через API, но положили его таким макаром, вероятно, чтоб избежать ситуации, когда API тоже заблокирован), а run.sh выполняет все приготовления для старта прокси.

Содержимое оригинального run.sh
#!/bin/bash
if [ ! -z "$DEBUG" ]; then set -x; fi
mkdir /data 2>/dev/null >/dev/null
RANDOM=$(printf "%d" "0x$(head -c4 /dev/urandom | od -t x1 -An | tr -d ' ')")

if [ -z "$WORKERS" ]; then
  WORKERS=2
fi

echo "####"
echo "#### Telegram Proxy"
echo "####"
echo
SECRET_CMD=""
if [ ! -z "$SECRET" ]; then
  echo "[+] Using the explicitly passed secret: '$SECRET'."
elif [ -f /data/secret ]; then
  SECRET="$(cat /data/secret)"
  echo "[+] Using the secret in /data/secret: '$SECRET'."
else
  if [[ ! -z "$SECRET_COUNT" ]]; then
    if [[ ! ( "$SECRET_COUNT" -ge 1 &&  "$SECRET_COUNT" -le 16 ) ]]; then
      echo "[F] Can generate between 1 and 16 secrets."
      exit 5
    fi
  else
    SECRET_COUNT="1"
  fi

  echo "[+] No secret passed. Will generate $SECRET_COUNT random ones."
  SECRET="$(dd if=/dev/urandom bs=16 count=1 2>&1 | od -tx1  | head -n1 | tail -c +9 | tr -d ' ')"
  for pass in $(seq 2 $SECRET_COUNT); do
    SECRET="$SECRET,$(dd if=/dev/urandom bs=16 count=1 2>&1 | od -tx1  | head -n1 | tail -c +9 | tr -d ' ')"
  done
fi

if echo "$SECRET" | grep -qE '^[0-9a-fA-F]{32}(,[0-9a-fA-F]{32}){,15}$'; then
  SECRET="$(echo "$SECRET" | tr '[:upper:]' '[:lower:]')"
  SECRET_CMD="-S $(echo "$SECRET" | sed 's/,/ -S /g')"
  echo -- "$SECRET_CMD" > /data/secret_cmd
  echo "$SECRET" > /data/secret
else
  echo '[F] Bad secret format: should be 32 hex chars (for 16 bytes) for every secret; secrets should be comma-separated.'
  exit 1
fi

if [ ! -z "$TAG" ]; then
  echo "[+] Using the explicitly passed tag: '$TAG'."
fi

TAG_CMD=""
if [[ ! -z "$TAG" ]]; then
  if echo "$TAG" | grep -qE '^[0-9a-fA-F]{32}$'; then
    TAG="$(echo "$TAG" | tr '[:upper:]' '[:lower:]')"
    TAG_CMD="-P $TAG"
  else
    echo '[!] Bad tag format: should be 32 hex chars (for 16 bytes).'
    echo '[!] Continuing.'
  fi
fi

curl -s https://core.telegram.org/getProxyConfig -o /etc/telegram/backend.conf || {
  echo '[F] Cannot download proxy configuration from Telegram servers.'
  exit 2
}
CONFIG=/etc/telegram/backend.conf

IP="$(curl -s -4 "https://digitalresistance.dog/myIp")"
INTERNAL_IP="$(ip -4 route get 8.8.8.8 | grep '^8\.8\.8\.8\s' | grep -Po 'src\s+\d+\.\d+\.\d+\.\d+' | awk '{print $2}')"

if [[ -z "$IP" ]]; then
  echo "[F] Cannot determine external IP address."
  exit 3
fi

if [[ -z "$INTERNAL_IP" ]]; then
  echo "[F] Cannot determine internal IP address."
  exit 4
fi

echo "[*] Final configuration:"
I=1
echo "$SECRET" | tr ',' '\n' | while read S; do
  echo "[*]   Secret $I: $S"
  echo "[*]   tg:// link for secret $I auto configuration: tg://proxy?server=${IP}&port=443&secret=${S}"
  echo "[*]   t.me link for secret $I: https://t.me/proxy?server=${IP}&port=443&secret=${S}"
  I=$(($I+1))
done

[ ! -z "$TAG" ] && echo "[*]   Tag: $TAG" || echo "[*]   Tag: no tag"
echo "[*]   External IP: $IP"
echo "[*]   Make sure to fix the links in case you run the proxy on a different port."
echo
echo '[+] Starting proxy...'
sleep 1
exec /usr/local/bin/mtproto-proxy -p 2398 -H 443 -M "$WORKERS" -C 60000 --aes-pwd /etc/telegram/hello-explorers-how-are-you-doing -u root $CONFIG --allow-skip-dh --nat-info "$INTERNAL_IP:$IP" $SECRET_CMD $TAG_CMD

Сборка


Под CentOS 7 MTProxy на Хабре уже собирали, попробуем собрать образ под Alpine и сэкономить мегабайтов, эдак, 130 в результирующем docker image.

Отличительная особенность Alpine Linux — использование musl вместо glibc. Обе представляют из себя стандартные C библиотеки. Musl миниатюрен (в нем нет и пятой части «стандарта»), но объем и производительность (обещанная, по крайней мере), решают, когда речь идет о Docker. Да и ставить glibc на Alpine не является расово верным, дядька Jakub Jirutka не поймет, к примеру.

Собирать будем тоже в docker'е, чтоб изолировать зависимости и получить свободу для экспериментов, так что создадим новый Dockerfile:

FROM alpine:3.6

RUN apk add --no-cache git make gcc musl-dev linux-headers openssl-dev
RUN git clone --single-branch --depth 1 https://github.com/TelegramMessenger/MTProxy.git /mtproxy/sources
RUN cd /mtproxy/sources     && make -j$(getconf _NPROCESSORS_ONLN)

Из зависимостей нам пригодится git (и не только для клонирования официального репозитория, make файл зашьет sha коммита в версию), make, gcc и заголовочные файлы (минимальный набор получен опытным путем). Клонируем только master branch глубиной в 1 коммит (нам точно не понадобится история). Ну и попробуем утилизировать все ресурсы хоста при компиляции с -j ключом. Умышленно разбил на большее количество слоев, чтоб получить удобное кэширование при пересборках (обычно их бывает немало).

Запускать будем командой build (находясь в директории с Dockerfile):

$ docker build -t mtproxy:test .

А вот и первая проблема:

In file included from ./net/net-connections.h:34:0,
                 from mtproto/mtproto-config.c:44:
./jobs/jobs.h:234:23: error: field 'rand_data' has incomplete type
   struct drand48_data rand_data;
                       ^~~~~~~~~

Собственно, все последующие будут связаны с ней же. Во-первых, для тех, кто не знаком с сями, компилятор на самом деле ругается на отсутствие декларации структуры drand48_data. Во-вторых, разработчики musl забили на потокобезопасные random-функции (с постфиксом _r) и на все что с ними связано (включая структуры). А разработчики Telegram, в свою очередь, не стали заморачиваться с компиляцией под системы, где random_r и его собратья не реализованы (во многих OS библиотеках можно встретить флаг HAVE_RANDOM_R или его произвольную + наличие или отсутствие этой группы функций обычно учитывается в автоконфигураторе).

Ну, теперь то точно будем ставить glibc? Нет! Мы скопируем из glibc по минимуму то, что нам нужно, и сделаем патч для исходников MTProxy.

Помимо проблем с random_r, мы хапнем проблему с функцией backtrace (execinfo.h), которая используется для вывода stack backtrace в случае исключительной ситуации: можно было бы попробовать заменить на имплементацию из libunwind, но оно того не стоит, потому вызов был обрамлен проверкой на __GLIBC__.

Содержимое патча random_compat.patch
Index: jobs/jobs.h
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- jobs/jobs.h	(revision cdd348294d86e74442bb29bd6767e48321259bec)
+++ jobs/jobs.h	(date 1527996954000)
@@ -28,6 +28,8 @@
 #include "net/net-msg.h"
 #include "net/net-timers.h"
 
+#include "common/randr_compat.h"
+
 #define __joblocked
 #define __jobref
 
Index: common/server-functions.c
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- common/server-functions.c	(revision cdd348294d86e74442bb29bd6767e48321259bec)
+++ common/server-functions.c	(date 1527998325000)
@@ -35,7 +35,9 @@
 #include <arpa/inet.h>
 #include <assert.h>
 #include <errno.h>
+#ifdef __GLIBC__
 #include <execinfo.h>
+#endif
 #include <fcntl.h>
 #include <getopt.h>
 #include <grp.h>
@@ -168,6 +170,7 @@
 }
 
 void print_backtrace (void) {
+#ifdef __GLIBC__
   void *buffer[64];
   int nptrs = backtrace (buffer, 64);
   kwrite (2, "\n------- Stack Backtrace -------\n", 33);
@@ -178,6 +181,7 @@
     kwrite (2, s, strlen (s));
     kwrite (2, "\n", 1);
   }
+#endif
 }
 
 pthread_t debug_main_pthread_id;
Index: common/randr_compat.h
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- common/randr_compat.h	(date 1527998264000)
+++ common/randr_compat.h	(date 1527998264000)
@@ -0,0 +1,72 @@
+/*
+    The GNU C Library is free software.  See the file COPYING.LIB for copying
+    conditions, and LICENSES for notices about a few contributions that require
+    these additional notices to be distributed.  License copyright years may be
+    listed using range notation, e.g., 2000-2011, indicating that every year in
+    the range, inclusive, is a copyrightable year that would otherwise be listed
+    individually.
+*/
+
+#pragma once
+
+#include <endian.h>
+#include <pthread.h>
+
+struct drand48_data {
+    unsigned short int __x[3];	/* Current state.  */
+    unsigned short int __old_x[3]; /* Old state.  */
+    unsigned short int __c;	/* Additive const. in congruential formula.  */
+    unsigned short int __init;	/* Flag for initializing.  */
+    unsigned long long int __a;	/* Factor in congruential formula.  */
+};
+
+union ieee754_double
+{
+    double d;
+
+    /* This is the IEEE 754 double-precision format.  */
+    struct
+    {
+#if	__BYTE_ORDER == __BIG_ENDIAN
+        unsigned int negative:1;
+        unsigned int exponent:11;
+        /* Together these comprise the mantissa.  */
+        unsigned int mantissa0:20;
+        unsigned int mantissa1:32;
+#endif				/* Big endian.  */
+#if	__BYTE_ORDER == __LITTLE_ENDIAN
+        /* Together these comprise the mantissa.  */
+        unsigned int mantissa1:32;
+        unsigned int mantissa0:20;
+        unsigned int exponent:11;
+        unsigned int negative:1;
+#endif				/* Little endian.  */
+    } ieee;
+
+    /* This format makes it easier to see if a NaN is a signalling NaN.  */
+    struct
+    {
+#if	__BYTE_ORDER == __BIG_ENDIAN
+        unsigned int negative:1;
+        unsigned int exponent:11;
+        unsigned int quiet_nan:1;
+        /* Together these comprise the mantissa.  */
+        unsigned int mantissa0:19;
+        unsigned int mantissa1:32;
+#else
+        /* Together these comprise the mantissa.  */
+        unsigned int mantissa1:32;
+        unsigned int mantissa0:19;
+        unsigned int quiet_nan:1;
+        unsigned int exponent:11;
+        unsigned int negative:1;
+#endif
+    } ieee_nan;
+};
+
+#define IEEE754_DOUBLE_BIAS	0x3ff /* Added to exponent.  */
+
+int drand48_r (struct drand48_data *buffer, double *result);
+int lrand48_r (struct drand48_data *buffer, long int *result);
+int mrand48_r (struct drand48_data *buffer, long int *result);
+int srand48_r (long int seedval, struct drand48_data *buffer);
\ No newline at end of file
Index: Makefile
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- Makefile	(revision cdd348294d86e74442bb29bd6767e48321259bec)
+++ Makefile	(date 1527998107000)
@@ -40,6 +40,7 @@
 DEPENDENCE_NORM	:=	$(subst ${OBJ}/,${DEP}/,$(patsubst %.o,%.d,${OBJECTS}))
 
 LIB_OBJS_NORMAL := +	${OBJ}/common/randr_compat.o  	${OBJ}/common/crc32c.o  	${OBJ}/common/pid.o  	${OBJ}/common/sha1.o Index: common/randr_compat.c
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- common/randr_compat.c	(date 1527998213000)
+++ common/randr_compat.c	(date 1527998213000)
@@ -0,0 +1,120 @@
+/*
+    The GNU C Library is free software.  See the file COPYING.LIB for copying
+    conditions, and LICENSES for notices about a few contributions that require
+    these additional notices to be distributed.  License copyright years may be
+    listed using range notation, e.g., 2000-2011, indicating that every year in
+    the range, inclusive, is a copyrightable year that would otherwise be listed
+    individually.
+*/
+
+#include <stddef.h>
+#include "common/randr_compat.h"
+
+int __drand48_iterate (unsigned short int xsubi[3], struct drand48_data *buffer) {
+    uint64_t X;
+    uint64_t result;
+
+    /* Initialize buffer, if not yet done.  */
+    if (!buffer->__init == 0)
+    {
+        buffer->__a = 0x5deece66dull;
+        buffer->__c = 0xb;
+        buffer->__init = 1;
+    }
+
+    /* Do the real work.  We choose a data type which contains at least
+       48 bits.  Because we compute the modulus it does not care how
+       many bits really are computed.  */
+
+    X = (uint64_t) xsubi[2] << 32 | (uint32_t) xsubi[1] << 16 | xsubi[0];
+
+    result = X * buffer->__a + buffer->__c;
+
+    xsubi[0] = result & 0xffff;
+    xsubi[1] = (result >> 16) & 0xffff;
+    xsubi[2] = (result >> 32) & 0xffff;
+
+    return 0;
+}
+
+int __erand48_r (unsigned short int xsubi[3], struct drand48_data *buffer, double *result) {
+    union ieee754_double temp;
+
+    /* Compute next state.  */
+    if (__drand48_iterate (xsubi, buffer) < 0)
+        return -1;
+
+    /* Construct a positive double with the 48 random bits distributed over
+       its fractional part so the resulting FP number is [0.0,1.0).  */
+
+    temp.ieee.negative = 0;
+    temp.ieee.exponent = IEEE754_DOUBLE_BIAS;
+    temp.ieee.mantissa0 = (xsubi[2] << 4) | (xsubi[1] >> 12);
+    temp.ieee.mantissa1 = ((xsubi[1] & 0xfff) << 20) | (xsubi[0] << 4);
+
+    /* Please note the lower 4 bits of mantissa1 are always 0.  */
+    *result = temp.d - 1.0;
+
+    return 0;
+}
+
+int __nrand48_r (unsigned short int xsubi[3], struct drand48_data *buffer, long int *result) {
+    /* Compute next state.  */
+    if (__drand48_iterate (xsubi, buffer) < 0)
+        return -1;
+
+    /* Store the result.  */
+    if (sizeof (unsigned short int) == 2)
+        *result = xsubi[2] << 15 | xsubi[1] >> 1;
+    else
+        *result = xsubi[2] >> 1;
+
+    return 0;
+}
+
+int __jrand48_r (unsigned short int xsubi[3], struct drand48_data *buffer, long int *result) {
+    /* Compute next state.  */
+    if (__drand48_iterate (xsubi, buffer) < 0)
+        return -1;
+
+    /* Store the result.  */
+    *result = (int32_t) ((xsubi[2] << 16) | xsubi[1]);
+
+    return 0;
+}
+
+int drand48_r (struct drand48_data *buffer, double *result) {
+    return __erand48_r (buffer->__x, buffer, result);
+}
+
+int lrand48_r (struct drand48_data *buffer, long int *result) {
+    /* Be generous for the arguments, detect some errors.  */
+    if (buffer == NULL)
+        return -1;
+
+    return __nrand48_r (buffer->__x, buffer, result);
+}
+
+int mrand48_r (struct drand48_data *buffer, long int *result) {
+    /* Be generous for the arguments, detect some errors.  */
+    if (buffer == NULL)
+        return -1;
+
+    return __jrand48_r (buffer->__x, buffer, result);
+}
+
+int srand48_r (long int seedval, struct drand48_data *buffer) {
+    /* The standards say we only have 32 bits.  */
+    if (sizeof (long int) > 4)
+        seedval &= 0xffffffffl;
+
+    buffer->__x[2] = seedval >> 16;
+    buffer->__x[1] = seedval & 0xffffl;
+    buffer->__x[0] = 0x330e;
+
+    buffer->__a = 0x5deece66dull;
+    buffer->__c = 0xb;
+    buffer->__init = 1;
+
+    return 0;
+}
\ No newline at end of file

Положим его в папку ./patches и немного модифицируем наш Dockerfile, чтоб применять патч «на лету»:

FROM alpine:3.6

COPY ./patches /mtproxy/patches

RUN apk add --no-cache --virtual .build-deps       git make gcc musl-dev linux-headers openssl-dev     && git clone --single-branch --depth 1 https://github.com/TelegramMessenger/MTProxy.git /mtproxy/sources     && cd /mtproxy/sources     && patch -p0 -i /mtproxy/patches/randr_compat.patch     && make -j$(getconf _NPROCESSORS_ONLN)     && cp /mtproxy/sources/objs/bin/mtproto-proxy /mtproxy/     && rm -rf /mtproxy/{sources,patches}     && apk add --no-cache --virtual .rundeps libcrypto1.0     && apk del .build-deps

Теперь собранный бинарник mtproto-proxy как минимум запускается, и мы можем двинуться дальше.

Оформление


Пришло время превратить оригинальный run.sh в docker-entrypoint.sh. На мой взгляд, это логично, когда «обязательная обвязка» уходит в ENTRYPOINT (его всегда можно перегрузить снаружи), а аргументы запуска докеризованного приложения по максимуму укладываются в CMD (+переменные среды окружения в качестве дублера).

Мы могли бы установить в наш альпийский образ bash и полноценный grep (поясню далее), чтоб избежать головной боли и использовать оригинальный код как есть, но это до безобразия раздует наш миниатюрный образ, потому будем растить настоящий, мать его, бонсай.

Начнем с шебанга, заменим #!/bin/bash на #!/bin/sh. Дефолтный для alpine ash способен переварить практически весь синтаксис bash'а «as is», но с одной проблемой мы все же столкнемся — по неведомым причинам он отказался принять parenthesis в одном из условий, потому развернем его, инвертируя логику сравнения:



Теперь нас ждут разборки с grep'ом, который в busybox поставке немного отличается от привычного (и, кстати, значительно медленнее, имейте в виду в своих проектах). Во-первых, он не понимает выражения {,15}, придется явно указать {0,15}. Во-вторых, не поддерживает флаг -P (perl style), но спокойно переваривает выражение при включенном extended (-E).

У нас в зависимостях остается лишь curl (нет никакого смысла заменять его на wget из busybox'а) и libcrypto (его достаточно, непосредственно openssl в этой сборке совсем не требуется).

Пару лет назад в Docker появился прекрасный multi-stage build, он идеально подходит, к примеру, для Go приложений или для ситуаций, когда сборка сложна и проще передавать артефакты от образа к образу, чем делать финальную очистку. Для посадки нашего бонсая мы им воспользуемся, это позволит немного сэкономить.

FROM alpine:3.6
# Этот образ будет будет использован только для сборки (кэш сохранится)

RUN apk add --no-cache --virtual .build-deps     # ... пропустим, покажем последний аккорд
    && make -j$(getconf _NPROCESSORS_ONLN)

FROM alpine:3.6
# А это финальный, чистенький образ, ничего лишнего

WORKDIR /mtproxy
COPY --from=0 /mtproxy/sources/objs/bin/mtproto-proxy .
# Просто скопируем артефакт из первого образа в чистую среду
# Ну и выполним дальше специфичные для финального образа процедуры

Бонсай должен быть бонсаем — избавимся от установки libcrypto. При сборке нам нужны были заголовочные файлы из пакета openssl-dev, который в зависимостях подтянет libcrypto и наш исполняемый файл окажется сориентирован на использование libcrypto.so.1.0.0. А ведь это единственная зависимость, к тому же, она предустановлена в Alpine (в версии 3.6 это libcrypto.so.41, 3.7 — libcrypto.so.42, лежит в /lib/). Меня сейчас отругают, это не самый надежный способ, но оно того стоит и мы все-таки добавим симлинк на имеющуюся версию (если у вас есть способ лучше, с удовольствием приму PR).

Финальные штрихи и результат:

Docker Hub
GitHub

Буду признателен любым советам и контрибуциям.




К сожалению, не доступен сервер mySQL