Travis: деплой конфигов NGINX в Azure File share

By | 08/07/2017
 

Имеется ресурс группа, которая включает в себя один Azure Load Balancer, за котором находится Azure VMSS с двумя виртуальными машинами.

На машинах работает NGINX, который использует файлы настроек виртуалхостов, которые хранятся в файл-шаре, которая монтируется к обеим машинам. Полное описание проекта – Azure: VMSS за Load Balancer, renew SSL от Let’s Encrypt, SMB file share и NGINX-proxy.

Ниже – описание того, как была реализована система апдейта конфигов через Github с использованием Travis, bash и Python.

Файл-шара, которая подключается к интансам выглядит так:

datahtml – содержит каталог .well-know для ACME-авторизации при renew Let’s Enrypt.

datanginx-conf.d – содержит *.conf файлы, которые используются NGINX на обоих интансах, именно сюда выполняется деплой новых конфигов из репозитория.

Deployment workflow

Процесс выглядит следующим образом:

  1. Travis чекаутит репозиторий из Github, в котором хранятся *.conf файлы
  2. Travis выполняет nginx -t, что бы проверить, что файлы настроек в репозитории корректны (например – после апдейта какого-то файла кем-то из девелоперов)
  3. Travis запускает скрипт деплоя на Python, который:
    1. создаёт бекап текущих файлов конфиграций из файл-шары
    2. копирует новые конфиги из репозитория в эту шару
    3. проверяет их ещё  раз, уже на мастере и секондари, через nginx -t
    4. перезапускает NGINX

Let’s Ecnrypt renew

В процессе настройки – немного изменился процесс renew сертификатов.

Если изначально задачи были добавлены в cron – то теперь я их вынес в отдельный скрипт.

Задачи в кроне выглядели так:

30 2 * * 1 /opt/letsencrypt/letsencrypt-auto renew >> /var/log/le-renew.log
30 2 * * 1 rsync -avh /etc/letsencrypt/archive/jm-gw-proxy-dev.domain.ms /data/dataletsencrypt/
35 2 * * 1 service nginx reload

Новый скрипт:

#!/usr/bin/env bash

GIT_USER=username
GIT_PASS=password

le_renew () {

        /opt/letsencrypt/letsencrypt-auto renew -vvv --post-hook "systemctl reload nginx"
}

le_rsync_to_secondary () {

        local secondary_host=$1
        local secondary_user=$2

        rsync -avh --rsync-path="sudo rsync" /etc/letsencrypt/{live,archive} $secondary_user@$secondary_host:/etc/letsencrypt/
}

le_rsync_to_repo () {

        local git_user=$1
        local git_pass=$2

        cd /root
        git clone https://$git_user:$git_pass@github.com/jm/jm-gw-proxy-data.git

        rsync -avh /etc/letsencrypt/{live,archive} /root/jm-gw-proxy-data/tests/letsencrypt

        cd /root/jm-gw-proxy-data 
        git add -A && git commit -m "Lets Encrypt SSL synchronization on  $(date +"%m_%d_%Y_%H_%M")" && git push
}

repo_cleanup_local () {

        rm -rf /root/jm-gw-proxy-data
}

# 1. make `renew`
# 2. rsync from /etc/letsencrypt/{live,archive} on master to /etc/letsencrypt/ on secondary
# 3. clone jm-gw-proxy-data repo to /root
# 4. rsync /etc/letsencrypt/{live,archive} to repo jm-gw-proxy-data/tests/letsencrypt
# 5. commit && push

le_renew || exit 1
le_rsync_to_secondary 10.0.0.5 jmadmin || exit 1
le_rsync_to_repo $GIT_USER $GIT_PASS || exit 1
repo_cleanup_local

Githubтут>>>.

Запускается он на “мастере”, раз в неделю, по понедельникам, в 2 часа 30 минут:

30 2 * * 1 /root/le_update.sh

Скрипт выполняет:

  1. Let’s Ecnrypt renew сертификатов
  2. копирует обновлённые ключи на secondary
  3. клонирует репозиторий (Github) на мастер, копирует  ключи в него, пушит изменения обратно

Travis

Непосредственно деплой данных выполняется Travis.

Репозиторий выглядит так:

ls -l | sort
drwxr-xr-x 2 setevoy setevoy  4096 Aug  4 16:44 scripts
drwxr-xr-x 4 setevoy setevoy  4096 Aug  4 10:59 tests
-rw-r--r-- 1 setevoy setevoy  1019 Aug  2 15:11 music.domain.de.conf
-rw-r--r-- 1 setevoy setevoy  1020 Aug  2 15:11 domain.de.conf
-rw-r--r-- 1 setevoy setevoy  1029 Aug  2 15:11 preview.domain.com.conf
-rw-r--r-- 1 setevoy setevoy   102 Aug  4 16:21 README.md
...

Всего тут:

ls -l | grep .conf | wc -l
103

103 файла виртуалхостов для NGINX.

Каталог tests содержит:

ls -l tests/
total 16
drwxr-xr-x 4 setevoy setevoy 4096 Aug  4 10:50 letsencrypt
-rw-r--r-- 1 setevoy setevoy 3956 Aug  4 10:59 mime.types
-rw-r--r-- 1 setevoy setevoy 2310 Aug  3 16:48 nginx.conf
drwxr-xr-x 3 setevoy setevoy 4096 Aug  4 10:54 nginx_ssl

Где letsencrypt содержит копию каталогов live и archive с master-хоста с ключами, которые обновляются скриптом le_update.sh.

nginx.conf и mime.types используются для проверки файлов конфигурации перед деплоем.

.travis.yml

.travis.yml выглядит следующим образом:

language: python
python:
    - "3.6"
sudo: required
services:
  - docker
before_install:
    - ./scripts/nginx_test.sh
install: 
    - pip install azure-storage
    - pip install fabric3
script:
  - ./scripts/deploy.py 
branches:
  only:
    - master
notifications:
 slack:
   rooms:
     - akqa:Ybs***h5u#jm-devops
   on_failure: always
   on_success: always

В блоке before_install:

...
before_install:
    - ./scripts/nginx_test.sh
...

Выполняется проверка корректности файлов конфигураций перед тем, как начинать бекап-деплой.

Для проверки – Travis запускает скрипт nginx_test.sh, который запускает контейнер с NGINX, монтирует файлы и каталоги с сертификатами, и выполняет nginx -t:

#!/usr/bin/env bash

docker run -ti -v $(pwd):/etc/nginx/conf.d \
    -v $(pwd)/tests/nginx.conf:/etc/nginx/nginx.conf \
    -v $(pwd)/tests/mime.types:/etc/nginx/mime.types \
    -v $(pwd)/tests/letsencrypt/:/etc/letsencrypt/ \
    -v $(pwd)/tests/nginx_ssl/ssl:/etc/nginx/ssl \
    nginx nginx -t

if [ $? == 0 ]; then 
    echo -e "\nOK: NGINX test passed."
else 
    echo -e "\nERROR: NGINX configs test failed. Exit."
    exit 1
fi

В результатах Travis этот шаг выглядит так:

$ ./scripts/nginx_test.sh

Unable to find image ‘nginx:latest’ locally

latest: Pulling from library/nginx

Status: Downloaded newer image for nginx:latest

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok

nginx: configuration file /etc/nginx/nginx.conf test is successful

OK: NGINX test passed.

Деплой

Сам деплой реализован на Python, с использованием Azure SDK.

Деплой разбит на несколько шагов:

  1. из Aure File Share копируются все данные в Azure Storage Account, в контейнер с бекапами
  2. все *.conf файлы из текущей директории (т.е. корня репозитория) копируются в Aure File Share, перезаписывая имеющиеся там файлы
  3. скрипт выполняет nginx reload на мастере и секондари:
    1. валидация – сначала выполняется nginx -t с конфигами из файл-шары
    2. если ОК – то выполняется systemctl reload nginx.service
    3. проверяется работа NGINX – отдаёт ли 200, если ОК – то скрипт переходит к следующему серверу

Сам скрипт:

#!/usr/bin/env python

"""Backup and deploy script"""

import os
import sys
import datetime
import glob
import shutil

from fabric.api import run, env, put, sudo

import azure.common
from azure.storage.file import FileService
from azure.storage.blob import BlockBlobService


def get_backup(gw_account_name, gw_account_key, gw_account_share, backup_local_path):

    """Upload directories and files from $account_name to local $backup_local_path using Azure FileService"""

    print('\nRunning get_backup from the {} and file share {} to local path {}.\n'.format(gw_account_name, gw_account_share, backup_local_path))

    file_service = FileService(account_name=gw_account_name, account_key=gw_account_key)
    share_dirs_list = file_service.list_directories_and_files(gw_account_share)

    for share_dir_name in share_dirs_list:

        backup_local_dir = os.path.join(backup_local_path, share_dir_name.name)

        if not os.path.isdir(backup_local_dir):
            print('Local backup directory {} not found, creating...'.format(backup_local_dir))
            os.makedirs(backup_local_dir)

        share_files_list = file_service.list_directories_and_files(gw_account_share, share_dir_name.name)
        for share_file in share_files_list:
            try:
                print('Getting file: {}'.format(os.path.join('/', share_dir_name.name, share_file.name)))
                # example:
                # file_service.get_file_to_path('gwdevproxydata', 'datanginx-conf.d', 'jm-gw-proxy-dev.domain.tld.conf', '/tmp/jm-gw-proxy-dev.domain.tld.conf-out')
                file_service.get_file_to_path(gw_account_share, share_dir_name.name, share_file.name, os.path.join(backup_local_dir, share_file.name))
            # to pass /data/datahtml/.well-known dir on master host
            except azure.common.AzureMissingResourceHttpError as e:
                print('\nWARNING: {}\n'.format(e))


def push_backup(bac_account_name, bac_account_key, bac_container_name, backup_local_path):

    """Upload directories and files from $backup_local_path to account_name using Azure BlockBlobService"""

    print('\nRunning push_backup from local path {} to the {} and container {}\n'.format(backup_local_path, bac_account_name, bac_container_name))

    now = datetime.datetime.today().strftime('%Y_%m_%d_%H_%M')

    for root, dirs, files in os.walk(backup_local_path, topdown=True):
        for name in dirs:
            path = os.path.join(root, name)
            for filename in os.listdir(path):
                fullpath = os.path.join(path, filename)

                block_blob_service = BlockBlobService(account_name=bac_account_name, account_key=bac_account_key)
                # example
                # block_blob_service.create_blob_from_path(container_name, 'datanginx-conf.d/jm-gw-proxy-production.domain.tld.conf',  '/tmp/datanginx-conf.d/jm-gw-proxy-production.domain.tld.conf')
                print('Uploading {} as {}'.format(fullpath, os.path.join(now, name, filename)))
                block_blob_service.create_blob_from_path(bac_container_name, os.path.join(now, name, filename), fullpath)


def update_datanginxconfd(gw_account_name, gw_account_key, gw_account_share):

    """Upload data from cloned repo to the GW Storage account into the datanginx-conf.d directory with overwriting"""

    print('\nRunning update_confd to the {} and file share {} to the path datanginx-conf.d.\n'.format(gw_account_name, gw_account_share))

    file_service = FileService(account_name=gw_account_name, account_key=gw_account_key)

    configs = glob.glob('*.conf')
    for config in configs:
        print('Uploading config: {}'.format(config))
        file_service.create_file_from_path(gw_account_share, 'datanginx-conf.d', config, config)


def cleanup_backup_local_path(backup_local_path):

    print('Cleaning up local {} directory....'.format(backup_local_path))
    shutil.rmtree(backup_local_path)


def nginx_reload(gw_proxy_host, gw_proxy_user, gw_proxy_key, gw_proxy_ports):

    """Will connect to the Master and Secondary host to execute "nginx -t" before reload"""

    for port in gw_proxy_ports:
        env.host_string = gw_proxy_host + ':' + str(port)
        env.key_filename = [os.path.join('.ssh', gw_proxy_key)]
        env.user = gw_proxy_user

        validate_status = sudo('nginx -t')

        if validate_status.return_code == 0:
            print('OK: NGINX configs validated\n')
        else:
            print('ERROR: can\'t validate NGINX\n')
            exit(1)

        reload_status = sudo('systemctl reload nginx.service')

        if reload_status.return_code == 0:
            print('\nOK: NGINX reload complete\n')
        else:
            print('\nERROR: can\'t reload NGINX\n')
            exit(1)

        nginx_status = run('curl -s localhost > /dev/null')

        if nginx_status.return_code == 0:
            print('\nOK: NGINX reloaded status code: {}\n'.format(nginx_status.return_code))
        else:
            print('\nERROR: NGINX reloaded status code: {}\n'.format(nginx_status.return_code))
            exit(1)

if __name__ == "__main__":

    try:
        gw_account_name = os.environ['GW_ACCOUNT_NAME']
        gw_account_key = os.environ['GW_ACCOUNT_KEY']
        gw_account_share = os.environ['GW_ACCOUNT_SHARE']

        bac_account_name = os.environ['BAC_ACCOUNT_NAME']
        bac_account_key = os.environ['BAC_ACCOUNT_KEY']
        bac_account_container = os.environ['BAC_ACCOUNT_CONTAINER']

        backup_local_path = os.environ['BAC_LOCAL_PATH']

        gw_proxy_host = os.environ['GW_PROXY_HOST']
        gw_proxy_user = os.environ['GW_PROXY_USER']
        gw_proxy_key = os.environ['GW_PROXY_KEY']
        gw_proxy_ports = [2200, 2201]

    except KeyError as e:
        print('ERROR: no such environment variable - {}'.format(e))
        sys.exit(1)

    # download all files and directories from:
    # $gw_account_name (jmgatewayproxydata) $gw_account_share (gwproxydata)
    # to $backup_local_path (/tmp/GW_TEMP_BACKUP)
    get_backup(gw_account_name, gw_account_key, gw_account_share, backup_local_path)

    # upload all data from:
    # $backup_local_path (/tmp/GW_TEMP_BACKUP)
    # to:
    # $bac_account_name (jmbackup) $bac_account_container (jm-gw-proxy-backup)
    push_backup(bac_account_name, bac_account_key, bac_account_container, backup_local_path)

    # upload all *.conf files from local directory (i.e. cloned repository)
    # to the  $gw_account_name (jmgatewayproxydata) $gw_account_share (gwproxydata)
    update_datanginxconfd(gw_account_name, gw_account_key, gw_account_share)

    # SSH to the $gw_proxy_host (jm-gw-proxy-production.domain.tld)
    # and execute:
    # 1. nginx -t
    # 2. systemctl reload nginx.service
    # 3. curl -s localhost > /dev/null
    nginx_reload(gw_proxy_host, gw_proxy_user, gw_proxy_key, gw_proxy_ports)

    # remove local $backup_local_path (/tmp/GW_TEMP_BACKUP)
    # just in case, as anyway builds are in Travis Docker containers
    cleanup_backup_local_path(backup_local_path)

Githubтут>>>.

Завершение деплоя в Travis выглядит так:

В целом на этом деплой заканчивается.