Как все уже слышали, в конце мая 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 выполняет все приготовления для старта прокси.
#!/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.8s' | grep -Po 'srcs+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__.
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).
Финальные штрихи и результат:
Буду признателен любым советам и контрибуциям.
Автор: Alex Doe