Русский
Русский
English
Статистика
Реклама

Blue-Green Deployment на минималках

В этой статье мы с помощью 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, HTTPServerfrom time import monotonicapp_version = 1app_name = f'Uptimer v{app_version}.0'loading_seconds = 15 - app_version * 5class 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 > DockerfileFROM python:alpineEXPOSE 8080COPY uptimer.py app.pyCMD [ "python", "-u", "./app.py" ]EOF$ docker build --tag uptimer .Sending build context to Docker daemon  39.42kBStep 1/4 : FROM python:alpine ---> 8ecf5a48c789Step 2/4 : EXPOSE 8080 ---> Using cache ---> cf92d174c9d3Step 3/4 : COPY uptimer.py app.py ---> a7fbb33d6b7eStep 4/4 : CMD [ "python", "-u", "./app.py" ] ---> Running in 1906b4bd9fdfRemoving intermediate container 1906b4bd9fdf ---> c1655b996fe8Successfully built c1655b996fe8Successfully tagged uptimer:latest$ docker run --rm --detach --name uptimer --publish 8080:8080 uptimer8f88c944b8bf78974a5727070a94c76aa0b9bb2b3ecf6324b784e782614b2fbf$ docker psCONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS                    NAMES8f88c944b8bf        uptimer             "python -u ./app.py"   3 seconds ago       Up 5 seconds        0.0.0.0:8080->8080/tcp   uptimer$ docker logs uptimerUptimer 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 uptimeruptimer

Реверс-прокси


Чтобы наше приложение имело возможность незаметно поменяться, необходимо, чтобы перед ним была ещё какая-то сущность, которая скроет его подмену. Это может быть веб-сервер nginx в режиме реверс-прокси. Реверс-прокси устанавливается между клиентом и приложением. Он принимает запросы от клиентов и перенаправляет их в приложение а ответы приложения направляет клиентам.


Приложение и реверс-прокси можно связать внутри докера с помощью docker network. Таким образом, контейнеру с приложением можно даже не пробрасывать порт в хост-системе, это позволяет максимально изолировать приложение от угроз из внешки.


Если реверс-прокси будет жить на другом хосте, придётся отказаться от docker network и связать приложение с реверс-прокси через сеть хоста, пробросив порт приложения параметром --publish, как при первом запуске и как у реверс-прокси.


Реверс-прокси будем запускать на порту 80, ибо это именно та сущность, которой следует слушать внешку. Если 80-й порт у Вас на тестовом хосте занят, поменяйте параметр --publish 80:80 на --publish ANY_FREE_PORT:80.


Интересные техники



Распечатка


$ docker network create web-gateway5dba128fb3b255b02ac012ded1906b7b4970b728fb7db3dbbeccc9a77a5dd7bd$ docker run --detach --rm --name uptimer --network web-gateway uptimera1105f1b583dead9415e99864718cc807cc1db1c763870f40ea38bc026e2d67f$ 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:alpine80695a822c19051260c66bf60605dcb4ea66802c754037704968bc42527bf120$ docker psCONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                NAMES80695a822c19        nginx:alpine        "/docker-entrypoint."   27 seconds ago       Up 25 seconds       0.0.0.0:80->80/tcp   reverse-proxya1105f1b583d        uptimer             "python -u ./app.py"     About a minute ago   Up About a minute   8080/tcp             uptimer$ cat << EOF > uptimer.confserver {    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 reload2020/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.94kBStep 1/4 : FROM python:alpine ---> 8ecf5a48c789Step 2/4 : EXPOSE 8080 ---> Using cache ---> cf92d174c9d3Step 3/4 : COPY uptimer.py app.py ---> 3eca6a51cb2dStep 4/4 : CMD [ "python", "-u", "./app.py" ] ---> Running in 8f13c6d3d9e7Removing intermediate container 8f13c6d3d9e7 ---> 1d56897841ecSuccessfully built 1d56897841ecSuccessfully tagged uptimer:latest$ docker run --detach --rm --name uptimer_BLUE --network web-gateway uptimer96932d4ca97a25b1b42d1b5f0ede993b43f95fac3c064262c5c527e16c119e02$ docker logs uptimer_BLUEUptimer 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.confserver {    listen 80;    location / {        proxy_pass http://uptimer_BLUE:8080;    }}$ docker exec reverse-proxy nginx -s reload2020/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 uptimeruptimer$ wget -qO- http://localhost<h2>Uptimer v2.0 is running for 84.8 seconds.</h2>$ docker psCONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                NAMES96932d4ca97a        uptimer             "python -u ./app.py"     About a minute ago   Up About a minute   8080/tcp             uptimer_BLUE80695a822c19        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 lsREPOSITORY          TAG                 IMAGE ID            CREATED             SIZE$ docker image save uptimer | ssh production-server 'docker image load'Loaded image: uptimer:latest$ ssh production-server docker image lsREPOSITORY          TAG                 IMAGE ID            CREATED             SIZEuptimer             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 это наиболее минималистичный метод, но не единственный. Есть и другие:


  1. Container Registry (стандарт отрасли).
  2. Подключиться к docker daemon сервера с другого хоста:
    1. Переменная среды DOCKER_HOST.
    2. Параметр командной строки -H или --host инструмента docker-compose.
    3. 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://personeltest.ru/away/${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 << EOFserver {    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 keyshimura@localhost's password: Number of key(s) added: 1Now 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/bashusage_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-potConnecting 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 1fi

А теперь всё по-настоящему:


$ 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 uptimerSending gzipped image 'uptimer' to 'localhost' via ssh...Loaded image: uptimer:latestConnecting to 'localhost' via ssh to seamlessly deploy 'uptimer'...Deploying 'uptimer_GREEN' in place of 'uptimer_BLUE'...06f5bc70e9c4f930e7b1f826ae2ca2f536023cc01e82c2b97b2c84d68048b18aContainer started. Checking health...Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:  HTTP/1.0 503 Service Unavailablewget: server returned error: HTTP/1.0 503 Service UnavailableNew '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 Unavailablewget: server returned error: HTTP/1.0 503 Service UnavailableNew '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/htmlNew 'uptimer_GREEN' service seems OK. Switching heads...nginx: the configuration file /etc/nginx/nginx.conf syntax is oknginx: configuration file /etc/nginx/nginx.conf test is successful2020/08/22 20:15:54 [notice] 97#97: signal process started'uptimer_GREEN' service is live!Killing 'uptimer_BLUE'...uptimer_BLUETotal reclaimed space: 0BDeployment successful!

Теперь можно открыть http://localhost/ в браузере, запустить деплоймент ещё раз и убедиться, что он проходит бесшовно путём обновления страницы по КД во время выкладки.


Не забываем убираться после работы :3


$ docker rm -f uptimer_GREEN reverse-proxy uptimer_GREENreverse-proxy$ docker network rm web-gateway web-gateway$ cd ..$ rm -r blue-green-deployment
Источник: habr.com
К списку статей
Опубликовано: 24.08.2020 22:08:53
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Системное администрирование

*nix

Оболочки

Devops

Микросервисы

Bash

Nginx

Docker

Deployment

Blue-green

Zero downtime

Linux

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru