Хабрахабр

[Из песочницы] 5.94-метровый docker-образ с Telegram MTProxy

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

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

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

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

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

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

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

$ 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.

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

Собирать будем тоже в 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 (поясню далее), чтоб избежать головной боли и использовать оригинальный код как есть, но это до безобразия раздует наш миниатюрный образ, потому будем растить настоящий, мать его, бонсай.

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

Во-первых, он не понимает выражения {,15}, придется явно указать {0,15}. Теперь нас ждут разборки с grep'ом, который в busybox поставке немного отличается от привычного (и, кстати, значительно медленнее, имейте в виду в своих проектах). Во-вторых, не поддерживает флаг -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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть