- Регистрация
- 21.07.20
- Сообщения
- 40.408
- Реакции
- 1
- Репутация
- 0
В этой статье мы с помощью
Допустим, у Вас есть веб-приложение, с которым активно работает множество клиентов, и ему совершенно никак нельзя на пару секунд прилечь. А Вам очень нужно выкатить обновление библиотеки, фикс бага или новую крутую фичу. В обычной ситуации, потребуется остановить приложение, заменить его и снова запустить. В случае докера, можно сначала заменить, потом перезапустить, но всё равно будет период, в котором запросы к приложению не обработаются, ведь обычно приложению требуется некоторое время на первоначальную загрузку. А если оно запустится, но окажется неработоспособным? Вот такая задача, давайте её решать минимальными средствами и максимально элегантно.
DISCLAIMER: Большая часть статьи представлена в экспериментальном формате — в виде записи консольной сессии. Надеюсь, это будет не очень сложно воспринимать, и этот код сам себя документирует в достаточном объёме. Для атмосферности, представьте, что это не просто кодсниппеты, а бумага из "железного" телетайпа.
Интересные техники, которые сложно нагуглить просто читая код описаны в начале каждого раздела. Если будет непонятно что-то ещё — гуглите и проверяйте в
Приступим.
$ mkdir blue-green-deployment && cd $_
Сервис
Сделаем подопытный сервис и поместим его в контейнер.
Интересные техники
Распечатка
$ cat 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'
{app_name} is running for {t:3.1f} seconds.
\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 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/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/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
Uptimer v1.0 is running for 15.4 seconds.
$ docker rm --force uptimer
uptimer
Реверс-прокси
Чтобы наше приложение имело возможность незаметно поменяться, необходимо, чтобы перед ним была ещё какая-то сущность, которая скроет его подмену. Это может быть веб-сервер
Приложение и реверс-прокси можно связать внутри докера с помощью
Если реверс-прокси будет жить на другом хосте, придётся отказаться от 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-
Uptimer v1.0 is running for 11.5 seconds.
$ 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 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/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
Uptimer v1.0 is running for 104.1 seconds.
Бесшовный деплоймент
Выкатим новую версию приложения (с двухкратным бустом 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
Uptimer v2.0 is running for 23.9 seconds.
$ 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-
Uptimer v2.0 is running for 63.4 seconds.
$ docker rm -f uptimer
uptimer
$ wget -qO-
Uptimer v2.0 is running for 84.8 seconds.
$ 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
Передача образа через docker image save/load — это наиболее минималистичный метод, но не единственный. Есть и другие:
Второй способ (с тремя вариантами его реализации) хорошо описан в статье
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 требует небольших пояснений:
Всё равно в вызывающей функции мы проверяем результат её работы, а проверять exit code средствами bash намного проще, чем строку. К тому же, получить из неё строку очень просто:
get-active-slot service && echo BLUE || echo GREEN.
Даже двух хватит, последнее тут просто для полноты, чтобы не писать else.
Осталась неопределённой только функция, возвращающая конфиги nginx: get-nginx-config service_name deployment_slot. По аналогии с хелсчеком, тут можно задать любой конфиг для любого сервиса. Из интересного — только cat code>, что позволяет убрать все табы в начале. Правда, цена благовидного форматирования — смешанные табы с пробелами, что сегодня считается очень дурным тоном. Но bash форсит табы, а в конфиге nginx тоже было бы неплохо иметь нормальное форматирование. Короче, тут смешение табов с пробелами кажется действительно лучшим решением из худших. Однако, в сниппете ниже Вы этого не увидите, так как хабр "делает хорошо", меняя все табы на 4 пробела и делая невалидным EOF.
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 {
proxy_pass http://$proxy_pass;
}
}
EOF
}
Это и есть весь скрипт. И вот
Выполнение параметризированных скриптов на удалённом сервере
Пришло время стучаться на целевой сервер. В этот раз 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 deploy.sh
#!/bin/bash
usage_msg="Usage: $0 ssh_address local_image_tag"
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 ) code>
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, так что добавим в начало небольшую проверочку (это вместо
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
$ 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!
Теперь можно открыть
Не забываем убираться после работы :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
You must be registered for see links
,
You must be registered for see links
,
You must be registered for see links
и
You must be registered for see links
организуем бесшовную выкладку веб-приложения.
You must be registered for see links
— это техника, позволяющая мгновенно обновлять приложение, не отклоняя ни одного запроса. Она является одной из стратегий zero downtime deployment и лучше всего подходит для приложений с одним инстансом, но возможностью загрузить рядом второй, готовый к работе инстанс.Допустим, у Вас есть веб-приложение, с которым активно работает множество клиентов, и ему совершенно никак нельзя на пару секунд прилечь. А Вам очень нужно выкатить обновление библиотеки, фикс бага или новую крутую фичу. В обычной ситуации, потребуется остановить приложение, заменить его и снова запустить. В случае докера, можно сначала заменить, потом перезапустить, но всё равно будет период, в котором запросы к приложению не обработаются, ведь обычно приложению требуется некоторое время на первоначальную загрузку. А если оно запустится, но окажется неработоспособным? Вот такая задача, давайте её решать минимальными средствами и максимально элегантно.
DISCLAIMER: Большая часть статьи представлена в экспериментальном формате — в виде записи консольной сессии. Надеюсь, это будет не очень сложно воспринимать, и этот код сам себя документирует в достаточном объёме. Для атмосферности, представьте, что это не просто кодсниппеты, а бумага из "железного" телетайпа.
You must be registered for see links
Интересные техники, которые сложно нагуглить просто читая код описаны в начале каждого раздела. Если будет непонятно что-то ещё — гуглите и проверяйте в
You must be registered for see links
(благо, он снова работает, в связи с разблокировкой телеграма). Что не гуглится — спрашивайте в комментах. С удовольствием дополню соответствующий раздел "Интересные техники".Приступим.
$ mkdir blue-green-deployment && cd $_
Сервис
Сделаем подопытный сервис и поместим его в контейнер.
Интересные техники
- cat file-name (
You must be registered for see links+You must be registered for see links) — способ создать многострочный файл одной командой.
- wget -qO- URL (
You must be registered for see links) — вывести полученный по HTTP документ в /dev/stdout (аналог curl URL).
- wget -qO- URL (
Распечатка
Я специально разрываю сниппет, чтобы включить подсветку для Python. В конце будет ещё один такой кусок. Считайте, что в этих местах бумага порвалась и была склеена.
$ cat 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'
{app_name} is running for {t:3.1f} seconds.
\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 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-
You must be registered for see links
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-
You must be registered for see links
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
Uptimer v1.0 is running for 15.4 seconds.
$ docker rm --force uptimer
uptimer
Реверс-прокси
Чтобы наше приложение имело возможность незаметно поменяться, необходимо, чтобы перед ним была ещё какая-то сущность, которая скроет его подмену. Это может быть веб-сервер
You must be registered for see links
в
You must be registered for see links
. Реверс-прокси устанавливается между клиентом и приложением. Он принимает запросы от клиентов и перенаправляет их в приложение а ответы приложения направляет клиентам.Приложение и реверс-прокси можно связать внутри докера с помощью
You must be registered for see links
. Таким образом, контейнеру с приложением можно даже не пробрасывать порт в хост-системе, это позволяет максимально изолировать приложение от угроз из внешки.Если реверс-прокси будет жить на другом хосте, придётся отказаться от docker network и связать приложение с реверс-прокси через сеть хоста, пробросив порт приложения параметром --publish, как при первом запуске и как у реверс-прокси.
Реверс-прокси будем запускать на порту 80, ибо это именно та сущность, которой следует слушать внешку. Если 80-й порт у Вас на тестовом хосте занят, поменяйте параметр --publish 80:80 на --publish ANY_FREE_PORT:80.
Интересные техники
- "В docker-сетях, созданных пользователем, с контейнерами можно связываться не только по IP адресу. Имя контейнера также резолвится в его айпишник" (
You must be registered for see links, пункт 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-
You must be registered for see links
Uptimer v1.0 is running for 11.5 seconds.
$ 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 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-
You must be registered for see links
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
Uptimer v1.0 is running for 104.1 seconds.
Бесшовный деплоймент
Выкатим новую версию приложения (с двухкратным бустом 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
Uptimer v2.0 is running for 23.9 seconds.
$ 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-
You must be registered for see links
Uptimer v2.0 is running for 63.4 seconds.
$ docker rm -f uptimer
uptimer
$ wget -qO-
You must be registered for see links
Uptimer v2.0 is running for 84.8 seconds.
$ 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
Второй способ (с тремя вариантами его реализации) хорошо описан в статье
You must be registered for see links
.deploy.sh
Теперь соберём всё, что мы делали вручную в один скрипт. Начнём с top-level функции, а потом посмотрим на остальные, используемые в ней.
Интересные техники
- ${parameter?err_msg} — одно из заклинаний bash-магии (aka
You must be registered for see links). Если 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.
А трёх условий точно хватает, чтобы различить все состояния?
You must be registered for see links
Даже двух хватит, последнее тут просто для полноты, чтобы не писать else.
Осталась неопределённой только функция, возвращающая конфиги nginx: get-nginx-config service_name deployment_slot. По аналогии с хелсчеком, тут можно задать любой конфиг для любого сервиса. Из интересного — только cat code>, что позволяет убрать все табы в начале. Правда, цена благовидного форматирования — смешанные табы с пробелами, что сегодня считается очень дурным тоном. Но bash форсит табы, а в конфиге nginx тоже было бы неплохо иметь нормальное форматирование. Короче, тут смешение табов с пробелами кажется действительно лучшим решением из худших. Однако, в сниппете ниже Вы этого не увидите, так как хабр "делает хорошо", меняя все табы на 4 пробела и делая невалидным EOF.
You must be registered for see links
.Чтоб два раза не вставать, сразу расскажу про cat code>, который ещё встретится далее. Если писать просто cat code>, то внутри 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 {
proxy_pass http://$proxy_pass;
}
}
EOF
}
Это и есть весь скрипт. И вот
You must be registered for see links
для скачки через 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 deploy.sh
#!/bin/bash
usage_msg="Usage: $0 ssh_address local_image_tag"
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 ) code>
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, так что добавим в начало небольшую проверочку (это вместо
You must be registered for see links
):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
You must be registered for see links
$ 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!
Теперь можно открыть
You must be registered for see links
в браузере, запустить деплоймент ещё раз и убедиться, что он проходит бесшовно путём обновления страницы по КД во время выкладки.Не забываем убираться после работы :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