[Системное администрирование, *nix, Оболочки, DevOps, Микросервисы] Blue-Green Deployment на минималках
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
В этой статье мы с помощью bash, ssh, docker и nginx организуем бесшовную выкладку веб-приложения. Blue-green deployment — это техника, позволяющая мгновенно обновлять приложение, не отклоняя ни одного запроса. Она является одной из стратегий zero downtime deployment и лучше всего подходит для приложений с одним инстансом, но возможностью загрузить рядом второй, готовый к работе инстанс.
Допустим, у Вас есть веб-приложение, с которым активно работает множество клиентов, и ему совершенно никак нельзя на пару секунд прилечь. А Вам очень нужно выкатить обновление библиотеки, фикс бага или новую крутую фичу. В обычной ситуации, потребуется остановить приложение, заменить его и снова запустить. В случае докера, можно сначала заменить, потом перезапустить, но всё равно будет период, в котором запросы к приложению не обработаются, ведь обычно приложению требуется некоторое время на первоначальную загрузку. А если оно запустится, но окажется неработоспособным? Вот такая задача, давайте её решать минимальными средствами и максимально элегантно.
DISCLAIMER: Большая часть статьи представлена в экспериментальном формате — в виде записи консольной сессии. Надеюсь, это будет не очень сложно воспринимать, и этот код сам себя документирует в достаточном объёме. Для атмосферности, представьте, что это не просто кодсниппеты, а бумага из "железного" телетайпа.
Извините, данный ресурс не поддреживается. :(
Интересные техники, которые сложно нагуглить просто читая код описаны в начале каждого раздела. Если будет непонятно что-то ещё — гуглите и проверяйте в explainshell (благо, он снова работает, в связи с разблокировкой телеграма). Что не гуглится — спрашивайте в комментах. С удовольствием дополню соответствующий раздел "Интересные техники".
Приступим.
$ mkdir blue-green-deployment && cd $_
Сервис
Сделаем подопытный сервис и поместим его в контейнер.
Интересные техники
- cat << EOF > file-name (Here Document + I/O Redirection) — способ создать многострочный файл одной командой.
- wget -qO- URL (explainshell) — вывести полученный по HTTP документ в /dev/stdout (аналог curl URL).
Распечатка
Я специально разрываю сниппет, чтобы включить подсветку для Python. В конце будет ещё один такой кусок. Считайте, что в этих местах бумага порвалась и была склеена.
$ cat << EOF > uptimer.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from time import monotonic
app_version = 1
app_name = f'Uptimer v{app_version}.0'
loading_seconds = 15 - app_version * 5
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/':
try:
t = monotonic() - server_start
if t < loading_seconds:
self.send_error(503)
else:
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
response = f'<h2>{app_name} is running for {t:3.1f} seconds.</h2>\n'
self.wfile.write(response.encode('utf-8'))
except Exception:
self.send_error(500)
else:
self.send_error(404)
httpd = HTTPServer(('', 8080), Handler)
server_start = monotonic()
print(f'{app_name} (loads in {loading_seconds} sec.) started.')
httpd.serve_forever()
EOF
$ cat << EOF > Dockerfile
FROM python:alpine
EXPOSE 8080
COPY uptimer.py app.py
CMD [ "python", "-u", "./app.py" ]
EOF
$ docker build --tag uptimer .
Sending build context to Docker daemon 39.42kB
Step 1/4 : FROM python:alpine
---> 8ecf5a48c789
Step 2/4 : EXPOSE 8080
---> Using cache
---> cf92d174c9d3
Step 3/4 : COPY uptimer.py app.py
---> a7fbb33d6b7e
Step 4/4 : CMD [ "python", "-u", "./app.py" ]
---> Running in 1906b4bd9fdf
Removing intermediate container 1906b4bd9fdf
---> c1655b996fe8
Successfully built c1655b996fe8
Successfully tagged uptimer:latest
$ docker run --rm --detach --name uptimer --publish 8080:8080 uptimer
8f88c944b8bf78974a5727070a94c76aa0b9bb2b3ecf6324b784e782614b2fbf
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8f88c944b8bf uptimer "python -u ./app.py" 3 seconds ago Up 5 seconds 0.0.0.0:8080->8080/tcp uptimer
$ docker logs uptimer
Uptimer v1.0 (loads in 10 sec.) started.
$ wget -qSO- http://localhost:8080
HTTP/1.0 503 Service Unavailable
Server: BaseHTTP/0.6 Python/3.8.3
Date: Sat, 22 Aug 2020 19:52:40 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 484
$ wget -qSO- http://localhost:8080
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.8.3
Date: Sat, 22 Aug 2020 19:52:45 GMT
Content-Type: text/html
<h2>Uptimer v1.0 is running for 15.4 seconds.</h2>
$ docker rm --force uptimer
uptimer
Реверс-прокси
Чтобы наше приложение имело возможность незаметно поменяться, необходимо, чтобы перед ним была ещё какая-то сущность, которая скроет его подмену. Это может быть веб-сервер nginx в режиме реверс-прокси. Реверс-прокси устанавливается между клиентом и приложением. Он принимает запросы от клиентов и перенаправляет их в приложение а ответы приложения направляет клиентам.
Приложение и реверс-прокси можно связать внутри докера с помощью docker network. Таким образом, контейнеру с приложением можно даже не пробрасывать порт в хост-системе, это позволяет максимально изолировать приложение от угроз из внешки.
Если реверс-прокси будет жить на другом хосте, придётся отказаться от docker network и связать приложение с реверс-прокси через сеть хоста, пробросив порт приложения параметром --publish, как при первом запуске и как у реверс-прокси.
Реверс-прокси будем запускать на порту 80, ибо это именно та сущность, которой следует слушать внешку. Если 80-й порт у Вас на тестовом хосте занят, поменяйте параметр --publish 80:80 на --publish ANY_FREE_PORT:80.
Интересные техники
- "В docker-сетях, созданных пользователем, с контейнерами можно связываться не только по IP адресу. Имя контейнера также резолвится в его айпишник" (статья "Networking with standalone containers", часть "Use user-defined bridge networks", пункт 5 докер-кодекса).
Распечатка
$ docker network create web-gateway
5dba128fb3b255b02ac012ded1906b7b4970b728fb7db3dbbeccc9a77a5dd7bd
$ docker run --detach --rm --name uptimer --network web-gateway uptimer
a1105f1b583dead9415e99864718cc807cc1db1c763870f40ea38bc026e2d67f
$ docker run --rm --network web-gateway alpine wget -qO- http://uptimer:8080
<h2>Uptimer v1.0 is running for 11.5 seconds.</h2>
$ docker run --detach --publish 80:80 --network web-gateway --name reverse-proxy nginx:alpine
80695a822c19051260c66bf60605dcb4ea66802c754037704968bc42527bf120
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
80695a822c19 nginx:alpine "/docker-entrypoint.…" 27 seconds ago Up 25 seconds 0.0.0.0:80->80/tcp reverse-proxy
a1105f1b583d uptimer "python -u ./app.py" About a minute ago Up About a minute 8080/tcp uptimer
$ cat << EOF > uptimer.conf
server {
listen 80;
location / {
proxy_pass http://uptimer:8080;
}
}
EOF
$ docker cp ./uptimer.conf reverse-proxy:/etc/nginx/conf.d/default.conf
$ docker exec reverse-proxy nginx -s reload
2020/06/23 20:51:03 [notice] 31#31: signal process started
$ wget -qSO- http://localhost
HTTP/1.1 200 OK
Server: nginx/1.19.0
Date: Sat, 22 Aug 2020 19:56:24 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
<h2>Uptimer v1.0 is running for 104.1 seconds.</h2>
Бесшовный деплоймент
Выкатим новую версию приложения (с двухкратным бустом startup performance) и попробуем бесшовно её задеплоить.
Интересные техники
- echo 'my text' | docker exec -i my-container sh -c 'cat > /my-file.txt' — Записать текст my text в файл /my-file.txt внутри контейнера my-container.
- cat > /my-file.txt — Записать в файл содержимое стандартного входа /dev/stdin.
Распечатка
$ sed -i "s/app_version = 1/app_version = 2/" uptimer.py
$ docker build --tag uptimer .
Sending build context to Docker daemon 39.94kB
Step 1/4 : FROM python:alpine
---> 8ecf5a48c789
Step 2/4 : EXPOSE 8080
---> Using cache
---> cf92d174c9d3
Step 3/4 : COPY uptimer.py app.py
---> 3eca6a51cb2d
Step 4/4 : CMD [ "python", "-u", "./app.py" ]
---> Running in 8f13c6d3d9e7
Removing intermediate container 8f13c6d3d9e7
---> 1d56897841ec
Successfully built 1d56897841ec
Successfully tagged uptimer:latest
$ docker run --detach --rm --name uptimer_BLUE --network web-gateway uptimer
96932d4ca97a25b1b42d1b5f0ede993b43f95fac3c064262c5c527e16c119e02
$ docker logs uptimer_BLUE
Uptimer v2.0 (loads in 5 sec.) started.
$ docker run --rm --network web-gateway alpine wget -qO- http://uptimer_BLUE:8080
<h2>Uptimer v2.0 is running for 23.9 seconds.</h2>
$ sed s/uptimer/uptimer_BLUE/ uptimer.conf | docker exec --interactive reverse-proxy sh -c 'cat > /etc/nginx/conf.d/default.conf'
$ docker exec reverse-proxy cat /etc/nginx/conf.d/default.conf
server {
listen 80;
location / {
proxy_pass http://uptimer_BLUE:8080;
}
}
$ docker exec reverse-proxy nginx -s reload
2020/06/25 21:22:23 [notice] 68#68: signal process started
$ wget -qO- http://localhost
<h2>Uptimer v2.0 is running for 63.4 seconds.</h2>
$ docker rm -f uptimer
uptimer
$ wget -qO- http://localhost
<h2>Uptimer v2.0 is running for 84.8 seconds.</h2>
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
96932d4ca97a uptimer "python -u ./app.py" About a minute ago Up About a minute 8080/tcp uptimer_BLUE
80695a822c19 nginx:alpine "/docker-entrypoint.…" 8 minutes ago Up 8 minutes 0.0.0.0:80->80/tcp reverse-proxy
На данном этапе образ билдится прямо на сервере, что требует наличия там исходников приложения, а также нагружает сервер лишней работой. Следующим шагом будет выделение сборки образа на отдельную машину (например, в CI-систему) с последующей передачей его на сервер.
Перекачка образов
К сожалению, перекачивать образа с localhost на localhost не имеет смысла, так что этот раздел можно пощупать только имея под рукой два хоста с докером. На минималках это выглядит примерно так:
$ ssh production-server docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
$ docker image save uptimer | ssh production-server 'docker image load'
Loaded image: uptimer:latest
$ ssh production-server docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
uptimer latest 1d56897841ec 5 minutes ago 78.9MB
Команда docker save сохраняет данные образа в .tar архив, то есть он весит примерно в 1.5 раза больше, чем мог бы весить в сжатом виде. Так пожмём же его во имя экономии времени и трафика:
$ docker image save uptimer | gzip | ssh production-server 'zcat | docker image load'
Loaded image: uptimer:latest
А ещё, можно наблюдать за процессом перекачки (правда, для этого нужна сторонняя утилита):
$ docker image save uptimer | gzip | pv | ssh production-server 'zcat | docker image load'
25,7MiB 0:01:01 [ 425KiB/s] [ <=> ]
Loaded image: uptimer:latest
Совет: Если Вам для соединения с сервером по SSH требуется куча параметров, возможно вы не используете файл ~/.ssh/config.
Передача образа через docker image save/load — это наиболее минималистичный метод, но не единственный. Есть и другие:
- Container Registry (стандарт отрасли).
- Подключиться к docker daemon сервера с другого хоста:
- Переменная среды DOCKER_HOST.
- Параметр командной строки -H или --host инструмента docker-compose.
- docker context
Второй способ (с тремя вариантами его реализации) хорошо описан в статье How to deploy on remote Docker hosts with docker-compose.
deploy.sh
Теперь соберём всё, что мы делали вручную в один скрипт. Начнём с top-level функции, а потом посмотрим на остальные, используемые в ней.
Интересные техники
- ${parameter?err_msg} — одно из заклинаний bash-магии (aka parameter substitution). Если parameter не задан, вывести err_msg и выйти с кодом 1.
- docker --log-driver journald — по-умолчанию, драйвером логирования докера является текстовый файл без какой-либо ротации. С таким подходом логи быстро забивают весь диск, поэтому для production-окружения необходимо менять драйвер на более умный.
Скрипт деплоймента
deploy() {
local usage_msg="Usage: ${FUNCNAME[0]} image_name"
local image_name=${1?$usage_msg}
ensure-reverse-proxy || return 2
if get-active-slot $image_name
then
local OLD=${image_name}_BLUE
local new_slot=GREEN
else
local OLD=${image_name}_GREEN
local new_slot=BLUE
fi
local NEW=${image_name}_${new_slot}
echo "Deploying '$NEW' in place of '$OLD'..."
docker run \
--detach \
--restart always \
--log-driver journald \
--name $NEW \
--network web-gateway \
$image_name || return 3
echo "Container started. Checking health..."
for i in {1..20}
do
sleep 1
if get-service-status $image_name $new_slot
then
echo "New '$NEW' service seems OK. Switching heads..."
sleep 2 # Ensure service is ready
set-active-slot $image_name $new_slot || return 4
echo "'$NEW' service is live!"
sleep 2 # Ensure all requests were processed
echo "Killing '$OLD'..."
docker rm -f $OLD
docker image prune -f
echo "Deployment successful!"
return 0
fi
echo "New '$NEW' service is not ready yet. Waiting ($i)..."
done
echo "New '$NEW' service did not raise, killing it. Failed to deploy T_T"
docker rm -f $NEW
return 5
}
Использованные функции:
- ensure-reverse-proxy — Убеждается, что реверс-прокси работает (полезно для первого деплоя)
- get-active-slot service_name — Определяет какой сейчас слот активен для заданного сервиса (BLUE или GREEN)
- get-service-status service_name deployment_slot — Определяет готов ли сервис к обработке входящих запросов
- set-active-slot service_name deployment_slot — Меняет конфиг nginx в контейнере реверс-прокси
По порядку:
ensure-reverse-proxy() {
is-container-up reverse-proxy && return 0
echo "Deploying reverse-proxy..."
docker network create web-gateway
docker run \
--detach \
--restart always \
--log-driver journald \
--name reverse-proxy \
--network web-gateway \
--publish 80:80 \
nginx:alpine || return 1
docker exec --interactive reverse-proxy sh -c "> /etc/nginx/conf.d/default.conf"
docker exec reverse-proxy nginx -s reload
}
is-container-up() {
local container=${1?"Usage: ${FUNCNAME[0]} container_name"}
[ -n "$(docker ps -f name=${container} -q)" ]
return $?
}
get-active-slot() {
local service=${1?"Usage: ${FUNCNAME[0]} service_name"}
if is-container-up ${service}_BLUE && is-container-up ${service}_GREEN; then
echo "Collision detected! Stopping ${service}_GREEN..."
docker rm -f ${service}_GREEN
return 0 # BLUE
fi
if is-container-up ${service}_BLUE && ! is-container-up ${service}_GREEN; then
return 0 # BLUE
fi
if ! is-container-up ${service}_BLUE; then
return 1 # GREEN
fi
}
get-service-status() {
local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot"
local service=${1?usage_msg}
local slot=${2?$usage_msg}
case $service in
# Add specific healthcheck paths for your services here
*) local health_check_port_path=":8080/" ;;
esac
local health_check_address="http://${service}_${slot}${health_check_port_path}"
echo "Requesting '$health_check_address' within the 'web-gateway' docker network:"
docker run --rm --network web-gateway alpine \
wget --timeout=1 --quiet --server-response $health_check_address
return $?
}
set-active-slot() {
local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot"
local service=${1?$usage_msg}
local slot=${2?$usage_msg}
[ "$slot" == BLUE ] || [ "$slot" == GREEN ] || return 1
get-nginx-config $service $slot | docker exec --interactive reverse-proxy sh -c "cat > /etc/nginx/conf.d/$service.conf"
docker exec reverse-proxy nginx -t || return 2
docker exec reverse-proxy nginx -s reload
}
Функция get-active-slot требует небольших пояснений:
Почему она возвращает число, а не выводит строку?
Всё равно в вызывающей функции мы проверяем результат её работы, а проверять exit code средствами bash намного проще, чем строку. К тому же, получить из неё строку очень просто:
get-active-slot service && echo BLUE || echo GREEN.
А трёх условий точно хватает, чтобы различить все состояния?
оригинал
Даже двух хватит, последнее тут просто для полноты, чтобы не писать else.
Осталась неопределённой только функция, возвращающая конфиги nginx: get-nginx-config service_name deployment_slot. По аналогии с хелсчеком, тут можно задать любой конфиг для любого сервиса. Из интересного — только cat <<- EOF, что позволяет убрать все табы в начале. Правда, цена благовидного форматирования — смешанные табы с пробелами, что сегодня считается очень дурным тоном. Но bash форсит табы, а в конфиге nginx тоже было бы неплохо иметь нормальное форматирование. Короче, тут смешение табов с пробелами кажется действительно лучшим решением из худших. Однако, в сниппете ниже Вы этого не увидите, так как хабр "делает хорошо", меняя все табы на 4 пробела и делая невалидным EOF. А вот тут заметно.
Чтоб два раза не вставать, сразу расскажу про cat << 'EOF', который ещё встретится далее. Если писать просто cat << EOF, то внутри heredoc производится интерполяция строки (раскрываются переменные ($foo), вызовы команд ($(bar)) и т.д.), а если заключить признак конца документа в одинарные ковычки, то интерполяция отключается и символ $ выводится как есть. То что надо для вставки скрипта внутрь другого скрипта.
get-nginx-config() {
local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot"
local service=${1?$usage_msg}
local slot=${2?$usage_msg}
[ "$slot" == BLUE ] || [ "$slot" == GREEN ] || return 1
local container_name=${service}_${slot}
case $service in
# Add specific nginx configs for your services here
*) nginx-config-simple-service $container_name:8080 ;;
esac
}
nginx-config-simple-service() {
local usage_msg="Usage: ${FUNCNAME[0]} proxy_pass"
local proxy_pass=${1?$usage_msg}
cat << EOF
server {
listen 80;
location / {
proxy_pass http://$proxy_pass;
}
}
EOF
}
Это и есть весь скрипт. И вот гист с этим скриптом для скачки через wget или curl.
Выполнение параметризированных скриптов на удалённом сервере
Пришло время стучаться на целевой сервер. В этот раз localhost вполне подойдёт:
$ ssh-copy-id localhost
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
himura@localhost's password:
Number of key(s) added: 1
Now try logging into the machine, with: "ssh 'localhost'"
and check to make sure that only the key(s) you wanted were added.
Мы написали скрипт деплоймента, который перекачивает предварительно собранный образ на целевой сервер и бесшовно подменяет контейнер сервиса, но как его выполнить на удалённой машине? У скрипта есть аргументы, так как он универсален и может деплоить сразу несколько сервисов под один реверс-прокси (конфигами nginx можно разрулить по какому url какой будет сервис). Скрипт нельзя хранить на сервере, так как в этом случае мы не сможем его автоматически обновлять (с целью багфиксов и добавления новых сервисоы), да и вообще, стэйт = зло.
Решение 1: Таки хранить скрипт на сервере, но копировать его каждый раз через scp. Затем подключиться по ssh и выполнить скрипт с необходимыми аргументами.
Минусы:
- Два действия вместо одного
- Места куда вы копируете может не быть, или не быть к нему доступа, или скрипт может выполняться в момент подмены.
- Желательно убрать за собой (удалить скрипт).
- Уже три действия.
Решение 2:
- В скрипте держать только определения функций и вообще ничего запускать
- С помощью sed дописывать в конец вызов функции
- Отправлять всё это прямо в shh через pipe (|)
Плюсы:
- Truely stateless
- No boilerplate entities
- Feeling cool
Вот давайте только без Ansible. Да, всё уже придумано. Да, велосипед. Смотрите, какой простой, элегантный и минималистичный велосипед:
$ cat << 'EOF' > deploy.sh
#!/bin/bash
usage_msg="Usage: ${FUNCNAME[0]} ssh_address image_name"
ssh_address=${1?$usage_msg}
image_name=${2?$usage_msg}
echo "Connecting to '$ssh_address' via ssh to seamlessly deploy '$image_name'..."
( sed "\$a deploy $image_name" | ssh -T $ssh_address ) << 'END_OF_SCRIPT'
deploy() {
echo "Yay! The '${FUNCNAME[0]}' function is executing on '$(hostname)' with argument '$1'"
}
END_OF_SCRIPT
EOF
$ chmod +x deploy.sh
$ ./deploy.sh localhost magic-porridge-pot
Connecting to localhost...
Yay! The 'deploy' function is executing on 'hut' with argument 'magic-porridge-pot'
Однако, мы не можем быть уверены, что на удалённом хосте есть адекватный bash, так что добавим в начало небольшую проверочку (это вместо shellbang):
if [ "$SHELL" != "/bin/bash" ]
then
echo "The '$SHELL' shell is not supported by 'deploy.sh'. Set a '/bin/bash' shell for '$USER@$HOSTNAME'."
exit 1
fi
А теперь всё по-настоящему:
$ docker exec reverse-proxy rm /etc/nginx/conf.d/default.conf
$ wget -qO deploy.sh https://git.io/JUJq1
$ chmod +x deploy.sh
$ ./deploy.sh localhost uptimer
Sending gzipped image 'uptimer' to 'localhost' via ssh...
Loaded image: uptimer:latest
Connecting to 'localhost' via ssh to seamlessly deploy 'uptimer'...
Deploying 'uptimer_GREEN' in place of 'uptimer_BLUE'...
06f5bc70e9c4f930e7b1f826ae2ca2f536023cc01e82c2b97b2c84d68048b18a
Container started. Checking health...
Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:
HTTP/1.0 503 Service Unavailable
wget: server returned error: HTTP/1.0 503 Service Unavailable
New 'uptimer_GREEN' service is not ready yet. Waiting (1)...
Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:
HTTP/1.0 503 Service Unavailable
wget: server returned error: HTTP/1.0 503 Service Unavailable
New 'uptimer_GREEN' service is not ready yet. Waiting (2)...
Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.8.3
Date: Sat, 22 Aug 2020 20:15:50 GMT
Content-Type: text/html
New 'uptimer_GREEN' service seems OK. Switching heads...
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2020/08/22 20:15:54 [notice] 97#97: signal process started
'uptimer_GREEN' service is live!
Killing 'uptimer_BLUE'...
uptimer_BLUE
Total reclaimed space: 0B
Deployment successful!
Теперь можно открыть http://localhost/ в браузере, запустить деплоймент ещё раз и убедиться, что он проходит бесшовно путём обновления страницы по КД во время выкладки.
Не забываем убираться после работы :3
$ docker rm -f uptimer_GREEN reverse-proxy
uptimer_GREEN
reverse-proxy
$ docker network rm web-gateway
web-gateway
$ cd ..
$ rm -r blue-green-deployment
===========
Источник:
habr.com
===========
Похожие новости:
- [Системное администрирование, Виртуализация, Конференции, DIY или Сделай сам] Now you see us. Раскрываем секреты успеха международной IT-конференции в эпоху пандемии
- [PostgreSQL, Kotlin, Микросервисы, Kubernetes] Как мы в 2020 году изобретали процесс разработки, отладки и доставки в прод изменений базы данных
- [IT-инфраструктура, Серверное администрирование, Big Data, Визуализация данных, DevOps] ELK SIEM Open Distro: Интеграция с WAZUH (перевод)
- [Разработка веб-сайтов, PHP] Xdebug через Windows Subsystem For Linux 2 (WSL2)
- [Open source, *nix] FOSS News №30 – дайджест новостей свободного и открытого ПО за 17–23 августа 2020 года
- [*nix, Информационная безопасность, Облачные сервисы, Серверное администрирование, Сетевые технологии] Как мы выбирали VPN-протокол и сервер настраивали
- [Amazon Web Services, DevOps] AWS CLI через MFA
- [*nix, Сетевые технологии, Компьютерное железо, Сетевое оборудование] В RouterOS 7 добавили поддержку WireGuard
- [Информационная безопасность, Виртуализация, Финансы в IT] Как подружить ГОСТ Р 57580 и контейнерную виртуализацию. Ответ Центробанка (и наши соображения на этот счет)
- [Настройка Linux, Разработка под Windows] Подсистема Windows для Linux 2 (WSL2) стала доступна на ОС Windows 10 версии 1903 и версии 1909
Теги для поиска: #_sistemnoe_administrirovanie (Системное администрирование), #_*nix, #_obolochki (Оболочки), #_devops, #_mikroservisy (Микросервисы), #_bash, #_nginx, #_docker, #_deployment, #_bluegreen, #_zero_downtime, #_devops, #_linux, #_sistemnoe_administrirovanie (
Системное администрирование
), #_*nix, #_obolochki (
Оболочки
), #_devops, #_mikroservisy (
Микросервисы
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 22:07
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
В этой статье мы с помощью bash, ssh, docker и nginx организуем бесшовную выкладку веб-приложения. Blue-green deployment — это техника, позволяющая мгновенно обновлять приложение, не отклоняя ни одного запроса. Она является одной из стратегий zero downtime deployment и лучше всего подходит для приложений с одним инстансом, но возможностью загрузить рядом второй, готовый к работе инстанс. Допустим, у Вас есть веб-приложение, с которым активно работает множество клиентов, и ему совершенно никак нельзя на пару секунд прилечь. А Вам очень нужно выкатить обновление библиотеки, фикс бага или новую крутую фичу. В обычной ситуации, потребуется остановить приложение, заменить его и снова запустить. В случае докера, можно сначала заменить, потом перезапустить, но всё равно будет период, в котором запросы к приложению не обработаются, ведь обычно приложению требуется некоторое время на первоначальную загрузку. А если оно запустится, но окажется неработоспособным? Вот такая задача, давайте её решать минимальными средствами и максимально элегантно. DISCLAIMER: Большая часть статьи представлена в экспериментальном формате — в виде записи консольной сессии. Надеюсь, это будет не очень сложно воспринимать, и этот код сам себя документирует в достаточном объёме. Для атмосферности, представьте, что это не просто кодсниппеты, а бумага из "железного" телетайпа. Извините, данный ресурс не поддреживается. :( Интересные техники, которые сложно нагуглить просто читая код описаны в начале каждого раздела. Если будет непонятно что-то ещё — гуглите и проверяйте в explainshell (благо, он снова работает, в связи с разблокировкой телеграма). Что не гуглится — спрашивайте в комментах. С удовольствием дополню соответствующий раздел "Интересные техники". Приступим. $ mkdir blue-green-deployment && cd $_
Сервис Сделаем подопытный сервис и поместим его в контейнер. Интересные техники
Распечатка Я специально разрываю сниппет, чтобы включить подсветку для Python. В конце будет ещё один такой кусок. Считайте, что в этих местах бумага порвалась и была склеена.
$ cat << EOF > uptimer.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from time import monotonic app_version = 1 app_name = f'Uptimer v{app_version}.0' loading_seconds = 15 - app_version * 5 class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/': try: t = monotonic() - server_start if t < loading_seconds: self.send_error(503) else: self.send_response(200) self.send_header('Content-Type', 'text/html') self.end_headers() response = f'<h2>{app_name} is running for {t:3.1f} seconds.</h2>\n' self.wfile.write(response.encode('utf-8')) except Exception: self.send_error(500) else: self.send_error(404) httpd = HTTPServer(('', 8080), Handler) server_start = monotonic() print(f'{app_name} (loads in {loading_seconds} sec.) started.') httpd.serve_forever() EOF
$ cat << EOF > Dockerfile FROM python:alpine EXPOSE 8080 COPY uptimer.py app.py CMD [ "python", "-u", "./app.py" ] EOF $ docker build --tag uptimer . Sending build context to Docker daemon 39.42kB Step 1/4 : FROM python:alpine ---> 8ecf5a48c789 Step 2/4 : EXPOSE 8080 ---> Using cache ---> cf92d174c9d3 Step 3/4 : COPY uptimer.py app.py ---> a7fbb33d6b7e Step 4/4 : CMD [ "python", "-u", "./app.py" ] ---> Running in 1906b4bd9fdf Removing intermediate container 1906b4bd9fdf ---> c1655b996fe8 Successfully built c1655b996fe8 Successfully tagged uptimer:latest $ docker run --rm --detach --name uptimer --publish 8080:8080 uptimer 8f88c944b8bf78974a5727070a94c76aa0b9bb2b3ecf6324b784e782614b2fbf $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 8f88c944b8bf uptimer "python -u ./app.py" 3 seconds ago Up 5 seconds 0.0.0.0:8080->8080/tcp uptimer $ docker logs uptimer Uptimer v1.0 (loads in 10 sec.) started. $ wget -qSO- http://localhost:8080 HTTP/1.0 503 Service Unavailable Server: BaseHTTP/0.6 Python/3.8.3 Date: Sat, 22 Aug 2020 19:52:40 GMT Connection: close Content-Type: text/html;charset=utf-8 Content-Length: 484 $ wget -qSO- http://localhost:8080 HTTP/1.0 200 OK Server: BaseHTTP/0.6 Python/3.8.3 Date: Sat, 22 Aug 2020 19:52:45 GMT Content-Type: text/html <h2>Uptimer v1.0 is running for 15.4 seconds.</h2> $ docker rm --force uptimer uptimer Реверс-прокси Чтобы наше приложение имело возможность незаметно поменяться, необходимо, чтобы перед ним была ещё какая-то сущность, которая скроет его подмену. Это может быть веб-сервер nginx в режиме реверс-прокси. Реверс-прокси устанавливается между клиентом и приложением. Он принимает запросы от клиентов и перенаправляет их в приложение а ответы приложения направляет клиентам. Приложение и реверс-прокси можно связать внутри докера с помощью docker network. Таким образом, контейнеру с приложением можно даже не пробрасывать порт в хост-системе, это позволяет максимально изолировать приложение от угроз из внешки. Если реверс-прокси будет жить на другом хосте, придётся отказаться от docker network и связать приложение с реверс-прокси через сеть хоста, пробросив порт приложения параметром --publish, как при первом запуске и как у реверс-прокси. Реверс-прокси будем запускать на порту 80, ибо это именно та сущность, которой следует слушать внешку. Если 80-й порт у Вас на тестовом хосте занят, поменяйте параметр --publish 80:80 на --publish ANY_FREE_PORT:80. Интересные техники
Распечатка $ docker network create web-gateway
5dba128fb3b255b02ac012ded1906b7b4970b728fb7db3dbbeccc9a77a5dd7bd $ docker run --detach --rm --name uptimer --network web-gateway uptimer a1105f1b583dead9415e99864718cc807cc1db1c763870f40ea38bc026e2d67f $ docker run --rm --network web-gateway alpine wget -qO- http://uptimer:8080 <h2>Uptimer v1.0 is running for 11.5 seconds.</h2> $ docker run --detach --publish 80:80 --network web-gateway --name reverse-proxy nginx:alpine 80695a822c19051260c66bf60605dcb4ea66802c754037704968bc42527bf120 $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 80695a822c19 nginx:alpine "/docker-entrypoint.…" 27 seconds ago Up 25 seconds 0.0.0.0:80->80/tcp reverse-proxy a1105f1b583d uptimer "python -u ./app.py" About a minute ago Up About a minute 8080/tcp uptimer $ cat << EOF > uptimer.conf server { listen 80; location / { proxy_pass http://uptimer:8080; } } EOF $ docker cp ./uptimer.conf reverse-proxy:/etc/nginx/conf.d/default.conf $ docker exec reverse-proxy nginx -s reload 2020/06/23 20:51:03 [notice] 31#31: signal process started $ wget -qSO- http://localhost HTTP/1.1 200 OK Server: nginx/1.19.0 Date: Sat, 22 Aug 2020 19:56:24 GMT Content-Type: text/html Transfer-Encoding: chunked Connection: keep-alive <h2>Uptimer v1.0 is running for 104.1 seconds.</h2> Бесшовный деплоймент Выкатим новую версию приложения (с двухкратным бустом startup performance) и попробуем бесшовно её задеплоить. Интересные техники
Распечатка $ sed -i "s/app_version = 1/app_version = 2/" uptimer.py
$ docker build --tag uptimer . Sending build context to Docker daemon 39.94kB Step 1/4 : FROM python:alpine ---> 8ecf5a48c789 Step 2/4 : EXPOSE 8080 ---> Using cache ---> cf92d174c9d3 Step 3/4 : COPY uptimer.py app.py ---> 3eca6a51cb2d Step 4/4 : CMD [ "python", "-u", "./app.py" ] ---> Running in 8f13c6d3d9e7 Removing intermediate container 8f13c6d3d9e7 ---> 1d56897841ec Successfully built 1d56897841ec Successfully tagged uptimer:latest $ docker run --detach --rm --name uptimer_BLUE --network web-gateway uptimer 96932d4ca97a25b1b42d1b5f0ede993b43f95fac3c064262c5c527e16c119e02 $ docker logs uptimer_BLUE Uptimer v2.0 (loads in 5 sec.) started. $ docker run --rm --network web-gateway alpine wget -qO- http://uptimer_BLUE:8080 <h2>Uptimer v2.0 is running for 23.9 seconds.</h2> $ sed s/uptimer/uptimer_BLUE/ uptimer.conf | docker exec --interactive reverse-proxy sh -c 'cat > /etc/nginx/conf.d/default.conf' $ docker exec reverse-proxy cat /etc/nginx/conf.d/default.conf server { listen 80; location / { proxy_pass http://uptimer_BLUE:8080; } } $ docker exec reverse-proxy nginx -s reload 2020/06/25 21:22:23 [notice] 68#68: signal process started $ wget -qO- http://localhost <h2>Uptimer v2.0 is running for 63.4 seconds.</h2> $ docker rm -f uptimer uptimer $ wget -qO- http://localhost <h2>Uptimer v2.0 is running for 84.8 seconds.</h2> $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 96932d4ca97a uptimer "python -u ./app.py" About a minute ago Up About a minute 8080/tcp uptimer_BLUE 80695a822c19 nginx:alpine "/docker-entrypoint.…" 8 minutes ago Up 8 minutes 0.0.0.0:80->80/tcp reverse-proxy На данном этапе образ билдится прямо на сервере, что требует наличия там исходников приложения, а также нагружает сервер лишней работой. Следующим шагом будет выделение сборки образа на отдельную машину (например, в CI-систему) с последующей передачей его на сервер. Перекачка образов К сожалению, перекачивать образа с localhost на localhost не имеет смысла, так что этот раздел можно пощупать только имея под рукой два хоста с докером. На минималках это выглядит примерно так: $ ssh production-server docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE $ docker image save uptimer | ssh production-server 'docker image load' Loaded image: uptimer:latest $ ssh production-server docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE uptimer latest 1d56897841ec 5 minutes ago 78.9MB Команда docker save сохраняет данные образа в .tar архив, то есть он весит примерно в 1.5 раза больше, чем мог бы весить в сжатом виде. Так пожмём же его во имя экономии времени и трафика: $ docker image save uptimer | gzip | ssh production-server 'zcat | docker image load'
Loaded image: uptimer:latest А ещё, можно наблюдать за процессом перекачки (правда, для этого нужна сторонняя утилита): $ docker image save uptimer | gzip | pv | ssh production-server 'zcat | docker image load'
25,7MiB 0:01:01 [ 425KiB/s] [ <=> ] Loaded image: uptimer:latest Совет: Если Вам для соединения с сервером по SSH требуется куча параметров, возможно вы не используете файл ~/.ssh/config.
Второй способ (с тремя вариантами его реализации) хорошо описан в статье How to deploy on remote Docker hosts with docker-compose. deploy.sh Теперь соберём всё, что мы делали вручную в один скрипт. Начнём с top-level функции, а потом посмотрим на остальные, используемые в ней. Интересные техники
Скрипт деплоймента deploy() {
local usage_msg="Usage: ${FUNCNAME[0]} image_name" local image_name=${1?$usage_msg} ensure-reverse-proxy || return 2 if get-active-slot $image_name then local OLD=${image_name}_BLUE local new_slot=GREEN else local OLD=${image_name}_GREEN local new_slot=BLUE fi local NEW=${image_name}_${new_slot} echo "Deploying '$NEW' in place of '$OLD'..." docker run \ --detach \ --restart always \ --log-driver journald \ --name $NEW \ --network web-gateway \ $image_name || return 3 echo "Container started. Checking health..." for i in {1..20} do sleep 1 if get-service-status $image_name $new_slot then echo "New '$NEW' service seems OK. Switching heads..." sleep 2 # Ensure service is ready set-active-slot $image_name $new_slot || return 4 echo "'$NEW' service is live!" sleep 2 # Ensure all requests were processed echo "Killing '$OLD'..." docker rm -f $OLD docker image prune -f echo "Deployment successful!" return 0 fi echo "New '$NEW' service is not ready yet. Waiting ($i)..." done echo "New '$NEW' service did not raise, killing it. Failed to deploy T_T" docker rm -f $NEW return 5 } Использованные функции:
По порядку: ensure-reverse-proxy() {
is-container-up reverse-proxy && return 0 echo "Deploying reverse-proxy..." docker network create web-gateway docker run \ --detach \ --restart always \ --log-driver journald \ --name reverse-proxy \ --network web-gateway \ --publish 80:80 \ nginx:alpine || return 1 docker exec --interactive reverse-proxy sh -c "> /etc/nginx/conf.d/default.conf" docker exec reverse-proxy nginx -s reload } is-container-up() { local container=${1?"Usage: ${FUNCNAME[0]} container_name"} [ -n "$(docker ps -f name=${container} -q)" ] return $? } get-active-slot() { local service=${1?"Usage: ${FUNCNAME[0]} service_name"} if is-container-up ${service}_BLUE && is-container-up ${service}_GREEN; then echo "Collision detected! Stopping ${service}_GREEN..." docker rm -f ${service}_GREEN return 0 # BLUE fi if is-container-up ${service}_BLUE && ! is-container-up ${service}_GREEN; then return 0 # BLUE fi if ! is-container-up ${service}_BLUE; then return 1 # GREEN fi } get-service-status() { local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot" local service=${1?usage_msg} local slot=${2?$usage_msg} case $service in # Add specific healthcheck paths for your services here *) local health_check_port_path=":8080/" ;; esac local health_check_address="http://${service}_${slot}${health_check_port_path}" echo "Requesting '$health_check_address' within the 'web-gateway' docker network:" docker run --rm --network web-gateway alpine \ wget --timeout=1 --quiet --server-response $health_check_address return $? } set-active-slot() { local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot" local service=${1?$usage_msg} local slot=${2?$usage_msg} [ "$slot" == BLUE ] || [ "$slot" == GREEN ] || return 1 get-nginx-config $service $slot | docker exec --interactive reverse-proxy sh -c "cat > /etc/nginx/conf.d/$service.conf" docker exec reverse-proxy nginx -t || return 2 docker exec reverse-proxy nginx -s reload } Функция get-active-slot требует небольших пояснений: Почему она возвращает число, а не выводит строку?
get-active-slot service && echo BLUE || echo GREEN. А трёх условий точно хватает, чтобы различить все состояния?
оригинал Даже двух хватит, последнее тут просто для полноты, чтобы не писать else. Осталась неопределённой только функция, возвращающая конфиги nginx: get-nginx-config service_name deployment_slot. По аналогии с хелсчеком, тут можно задать любой конфиг для любого сервиса. Из интересного — только cat <<- EOF, что позволяет убрать все табы в начале. Правда, цена благовидного форматирования — смешанные табы с пробелами, что сегодня считается очень дурным тоном. Но bash форсит табы, а в конфиге nginx тоже было бы неплохо иметь нормальное форматирование. Короче, тут смешение табов с пробелами кажется действительно лучшим решением из худших. Однако, в сниппете ниже Вы этого не увидите, так как хабр "делает хорошо", меняя все табы на 4 пробела и делая невалидным EOF. А вот тут заметно. Чтоб два раза не вставать, сразу расскажу про cat << 'EOF', который ещё встретится далее. Если писать просто cat << EOF, то внутри heredoc производится интерполяция строки (раскрываются переменные ($foo), вызовы команд ($(bar)) и т.д.), а если заключить признак конца документа в одинарные ковычки, то интерполяция отключается и символ $ выводится как есть. То что надо для вставки скрипта внутрь другого скрипта.
get-nginx-config() {
local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot" local service=${1?$usage_msg} local slot=${2?$usage_msg} [ "$slot" == BLUE ] || [ "$slot" == GREEN ] || return 1 local container_name=${service}_${slot} case $service in # Add specific nginx configs for your services here *) nginx-config-simple-service $container_name:8080 ;; esac } nginx-config-simple-service() { local usage_msg="Usage: ${FUNCNAME[0]} proxy_pass" local proxy_pass=${1?$usage_msg} cat << EOF server { listen 80; location / { proxy_pass http://$proxy_pass; } } EOF } Это и есть весь скрипт. И вот гист с этим скриптом для скачки через wget или curl. Выполнение параметризированных скриптов на удалённом сервере Пришло время стучаться на целевой сервер. В этот раз localhost вполне подойдёт: $ ssh-copy-id localhost
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys himura@localhost's password: Number of key(s) added: 1 Now try logging into the machine, with: "ssh 'localhost'" and check to make sure that only the key(s) you wanted were added. Мы написали скрипт деплоймента, который перекачивает предварительно собранный образ на целевой сервер и бесшовно подменяет контейнер сервиса, но как его выполнить на удалённой машине? У скрипта есть аргументы, так как он универсален и может деплоить сразу несколько сервисов под один реверс-прокси (конфигами nginx можно разрулить по какому url какой будет сервис). Скрипт нельзя хранить на сервере, так как в этом случае мы не сможем его автоматически обновлять (с целью багфиксов и добавления новых сервисоы), да и вообще, стэйт = зло. Решение 1: Таки хранить скрипт на сервере, но копировать его каждый раз через scp. Затем подключиться по ssh и выполнить скрипт с необходимыми аргументами. Минусы:
Решение 2:
Плюсы:
Вот давайте только без Ansible. Да, всё уже придумано. Да, велосипед. Смотрите, какой простой, элегантный и минималистичный велосипед: $ cat << 'EOF' > deploy.sh
#!/bin/bash
usage_msg="Usage: ${FUNCNAME[0]} ssh_address image_name" ssh_address=${1?$usage_msg} image_name=${2?$usage_msg} echo "Connecting to '$ssh_address' via ssh to seamlessly deploy '$image_name'..." ( sed "\$a deploy $image_name" | ssh -T $ssh_address ) << 'END_OF_SCRIPT' deploy() { echo "Yay! The '${FUNCNAME[0]}' function is executing on '$(hostname)' with argument '$1'" } END_OF_SCRIPT EOF
$ chmod +x deploy.sh $ ./deploy.sh localhost magic-porridge-pot Connecting to localhost... Yay! The 'deploy' function is executing on 'hut' with argument 'magic-porridge-pot' Однако, мы не можем быть уверены, что на удалённом хосте есть адекватный bash, так что добавим в начало небольшую проверочку (это вместо shellbang): if [ "$SHELL" != "/bin/bash" ]
then echo "The '$SHELL' shell is not supported by 'deploy.sh'. Set a '/bin/bash' shell for '$USER@$HOSTNAME'." exit 1 fi А теперь всё по-настоящему: $ docker exec reverse-proxy rm /etc/nginx/conf.d/default.conf
$ wget -qO deploy.sh https://git.io/JUJq1 $ chmod +x deploy.sh $ ./deploy.sh localhost uptimer Sending gzipped image 'uptimer' to 'localhost' via ssh... Loaded image: uptimer:latest Connecting to 'localhost' via ssh to seamlessly deploy 'uptimer'... Deploying 'uptimer_GREEN' in place of 'uptimer_BLUE'... 06f5bc70e9c4f930e7b1f826ae2ca2f536023cc01e82c2b97b2c84d68048b18a Container started. Checking health... Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network: HTTP/1.0 503 Service Unavailable wget: server returned error: HTTP/1.0 503 Service Unavailable New 'uptimer_GREEN' service is not ready yet. Waiting (1)... Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network: HTTP/1.0 503 Service Unavailable wget: server returned error: HTTP/1.0 503 Service Unavailable New 'uptimer_GREEN' service is not ready yet. Waiting (2)... Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network: HTTP/1.0 200 OK Server: BaseHTTP/0.6 Python/3.8.3 Date: Sat, 22 Aug 2020 20:15:50 GMT Content-Type: text/html New 'uptimer_GREEN' service seems OK. Switching heads... nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 2020/08/22 20:15:54 [notice] 97#97: signal process started 'uptimer_GREEN' service is live! Killing 'uptimer_BLUE'... uptimer_BLUE Total reclaimed space: 0B Deployment successful! Теперь можно открыть http://localhost/ в браузере, запустить деплоймент ещё раз и убедиться, что он проходит бесшовно путём обновления страницы по КД во время выкладки. Не забываем убираться после работы :3 $ docker rm -f uptimer_GREEN reverse-proxy
uptimer_GREEN reverse-proxy $ docker network rm web-gateway web-gateway $ cd .. $ rm -r blue-green-deployment =========== Источник: habr.com =========== Похожие новости:
Системное администрирование ), #_*nix, #_obolochki ( Оболочки ), #_devops, #_mikroservisy ( Микросервисы ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 22:07
Часовой пояс: UTC + 5