У гэтым артыкуле мы з дапамогай біць, SSH, докер и Nginx які арганізуецца бясшвоўную выкладку вэб-прыкладанні. Сіне-зялёнае разгортванне - Гэта тэхніка, якая дазваляе імгненна абнаўляць прыкладанне, не адхіляючы ніводнага запыту. Яна з'яўляецца адной са стратэгій zero downtime deployment і лепш за ўсё падыходзіць для прыкладанняў з адным інстансам, але магчымасцю загрузіць побач другі, гатовы да працы інстанс.
Дапусцім, у Вас ёсць вэб-дадатак, з якім актыўна працуе мноства кліентаў, і яму зусім ніяк нельга на пару секунд прылегчы. А Вам вельмі трэба выкаціць абнаўленне бібліятэкі, фікс бага ці новую крутую фічу. У звычайнай сітуацыі, спатрэбіцца спыніць дадатак, замяніць яго і зноў запусціць. У выпадку докера, можна спачатку замяніць, потым перазапусціць, але ўсё роўна будзе перыяд, у якім запыты да дадатку не апрацуюцца, бо звычайна з дадаткам патрабуецца некаторы час на першапачатковую загрузку. А калі яно запусціцца, але акажацца непрацаздольным? Вось такая задача, давайце яе вырашаць мінімальнымі сродкамі і максімальна элегантна.
DISCLAIMER: Вялікая частка артыкула прадстаўлена ў эксперыментальным фармаце – у выглядзе запісу кансольнай сесіі. Спадзяюся, гэта будзе не вельмі складана ўспрымаць, і гэты код сам сябе дакументуе ў дастатковым аб'ёме. Для атмасфернасці, уявіце, што гэта не проста кодсниппеты, а папера з «жалезнага» тэлетайпа.
Цікавыя тэхнікі, якія складана нагугліць проста чытаючы код апісаны ў пачатку кожнага раздзела. Калі будзе незразумела нешта яшчэ - гугліце і правярайце ў тлумачыць абалонку (балазе, ён зноў працуе, у сувязі з разблакоўкай тэлеграма). Што не гугліцца - пытайце ў каментах. З задавальненнем дапоўню адпаведны раздзел "Цікавыя тэхнікі".
Прыступім.
$ mkdir blue-green-deployment && cd $_
Сэрвіс
Зробім паддоследны сэрвіс і змесцім яго ў кантэйнер.
Цікавыя тэхнікі
cat << EOF > file-name (Вось дакумент + Перанакіраванне ўводу-вываду) - спосаб стварыць шматрадковы файл адной камандай. Усё, што bash прачытае з /dev/stdin пасля гэтага радка і да радка EOF будзе запісана ў file-name.
wget -qO- URL (тлумачыць абалонку) - вывесці атрыманы па 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.
$ 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 разу больш, чым мог бы важыць у сціснутым выглядзе. Дык паціснем жа яго ў імя эканоміі часу і трафіку:
Цяпер збяром усё, што мы рабілі ўручную ў адзін скрыпт. Пачнём з 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
Адчуванне прахалоды
Вось давайце толькі без Ansible. Так, усё ўжо прыдумана. Так, веласіпед. Глядзіце, які просты, элегантны і мінімалістычны ровар:
$ cat << 'EOF' > 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 ) << '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/JUURc
$ 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
The 'uptimer_GREEN' service is live!
Killing 'uptimer_BLUE'...
uptimer_BLUE
Total reclaimed space: 0B
Deployment successful!
Цяпер можна адкрыць http://localhost/ у браўзэры, запусціць дэплоймент яшчэ раз і пераканацца, што ён праходзіць бясшвоўна шляхам абнаўлення старонкі па КД падчас выкладкі.