AWS: миграция RTFM 3.0 (final) – CloudFormation и Ansible роли

By | 08/25/2018
 

Прошёл почти год, как я начал миграцию v2, закончил на посте Ansible: миграция RTFM 2.11 – хост Services – установка PHP, PHP-FPM.

Основной её идеей на тот момент было максимально использовать возможности AWS и Ansible – чисто из интереса и для практики, т.к. на предыдущем месте работы её было совсем мало (там в основном был Azure с его Resource Manager и скрипты на bash).

Три месяца тому я сменил работу, и теперь у меня валом и Jenkins, и AWS, и CloudFormation, и Ansible. А потому – решил максимально упростить инфрастуктуру для RTFM, и быстренько сделать новый вариант миграции (и наконец-то её таки закончить и перенести блог, а то в самом деле – “Сапожник без сапог”).

В отличии от старого варианта, где планировалось 3 сервера (один под Jenkins, второй под Web в публичной сети, один под PHP-FPM+OpenVPN+почта+прочее в приватной сети, третий – чисто под MariaDB, тоже в приватной сети), в новом варианте CI не будет вообще – достаточно bash-скрипта, а все сервисы будут работать на одном сервере в публичной сети.

Помимо просто упрощения создания такой инфрастуктуры – это поможет сэкономить, т.к. даже сейчас общий счёт в месяц на Амазоне за ЕС2 t2.micro под блог + отдельный t2.nano для MariaDB и t2.nano для OpenVPN, плюс трафик и диски тянет на 60+ баксов, например – счёт за Июль 2018 выглядит так:

Собственно, дальнейший план таков:

  • CloudFormation в роли orchestration manager для инфрастуктуры
  • Ansible – в роли configuration manager, он же будет запускать CloudFormation
  • мониторинг – CloudWatch и NGINX Amplify

В CloudFormation стеке будут:

  1. VPC
  2. 1 публичная подсеть
  3. Internet Gateway
  4. Elastic IP
  5. один EC2, к которому будут монтироваться два созданных вручную EBS – один под /backups, второй под /data – в /data будут находиться базы MariaDB и файлы сайтов, /backups – для файлов бекапов, созданных утилиткой simple-backup
  6. Security Group

Кроме этого, в AWS будут использоваться S3 корзины для бекапов, DLM для автоматизации создания снапшотов EBS-разделов (ещё одни бекапы, их много не бывает), CloudWatch для алертов.

Позже, возможно, добавлю CloudWatch агент на сервер для сбора логов и алерты на кол-во ошибок типа 401, 404 и 5хх.

Ansible будет выполнять установку и настройку:

  1. создание самого CloudFormation стека
  2. NGINX + файлы конфигурации виртуалхостов
  3. PHP-FPM + файлы конфигурации FPM-пулов
  4. MariaDB
  5. Let’s Encrypt
  6. NGINX Amplify агента
  7. logrotate
  8. unattended-upgrades
  9. (позже) OpenVPN
  10. (позже) memcached
  11. (позже) Dovecot+Postfix+Postfixadmin

Для хранения шаблонов, ролей и прочих файлов – будут использоваться два репозитория: публичный в Github для Ansible/CloudFormation, и приватный в Bitbucket – для PEM-ключей, файлов конфигурации виртуалхостов NGINX и конфигов пулов PHP-FPM.

Создание всего этого безобразия начнём с написания (вообще-то – просто копирования примера) шаблона для CloudFormation стека. Потом добавим Ansible роль с модулем cloudformation для создания стека, потом – bash-скрипт для запуска Ansible, и потом уже остальные роли для настройки сервисов.

Получившиеся в результате написания этого поста шаблон CloudFormation и Ansible роли доступны в Github (будут обновляться, поэтому примеры из поста могут не совпадать с реальными данными в репозитории).

CloudFormation

Шаблон стандартный, давно мною написанный, ничего в нём особого нет, кроме, разве что, подключения EBS по ID к EC2 и подключения IAM-роли для CloudWatch.

Создаём диск для /data:

Аналогично для /backups.

В Parameters шаблона добавляем параметры для них:

...
    "DataEBSID": {
      "Description": "Data disk EBS ID",
      "Type": "String",
      "Default": "vol-09ad53faabe393c98"
    },

    "BackupsEBSID": {
      "Description": "Data disk EBS ID",
      "Type": "String",
      "Default": "vol-0c5f083506a98b985"
    }
...

Добавляем Mappings.

Тут будут задаваться CIDR VPC и подсетей по имени стека – rtfm-dev и rtfm-production (хотя можно было вынести в параметры, и потом передавать их из переменных Ansible, что бы не усложнять):

...
  "Mappings": {

    "VPCCIDRs" : {
      "rtfm-dev" : {
        "VPCCIDRBlock" : "10.0.2.0/24"
      },
      "rtfm-production" : {
        "VPCCIDRBlock" : "10.0.1.0/24"
      }
    },

    "SubnetsCIDRs" : {
      "rtfm-dev" : {
        "EC2SubnetCIDR" : "10.0.2.0/28"
      },
      "rtfm-production" : {
        "EC2SubnetCIDR" : "10.0.1.0/28"
      }
    }

  },
...

Добавляем ресурсы.

VPC и подсеть, через Fn:FindInMap получаем значение из Mappings:

...
  "Resources" : {

    "VPC" : {
      "Type" : "AWS::EC2::VPC",
      "Properties" : {
        "CidrBlock" : { "Fn::FindInMap" : [ "VPCCIDRs", {"Ref" : "ENV"}, "VPCCIDRBlock" ] },
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "vpc"] ] } },
          {"Key" : "Env", "Value" : {"Ref" : "ENV"} }
        ]
      }
    },

    "PublicSubnet" : {
      "Type" : "AWS::EC2::Subnet",
      "Properties" : {
        "VpcId" : { "Ref" : "VPC" },
        "CidrBlock" : { "Fn::FindInMap" : [ "SubnetsCIDRs", {"Ref" : "ENV"}, "EC2SubnetCIDR" ] },
        "AvailabilityZone" : {"Ref" : "AvailabilityZone"},
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "public-net"] ] } },
          {"Key" : "Env", "Value" : {"Ref" : "ENV"} }
        ]
      }
    },
...

И EC2 с двумя EBS:

...
    "EC2Instance" : {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {
        "Tags" : [
          {"Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ {"Ref" : "AWS::StackName"}, "ec2"] ] } },
          {"Key" : "Env", "Value" : {"Ref" : "ENV"} }
        ],
        "InstanceType" : { "Ref" : "EC2InstanceType" },
        "SecurityGroupIds" : [
          {
            "Ref": "EC2SecurityGroup"
          }
        ],
        "KeyName" : { "Ref" : "KeyName" },
        "IamInstanceProfile" : {
          "Ref" : "CloudWatchAccessProfile"
        },
        "ImageId" : { "Ref" : "AMIID"  },
        "AvailabilityZone": {"Ref" : "AvailabilityZone"},
        "SubnetId": { "Ref": "PublicSubnet" },
        "Volumes" : [
          {
           "VolumeId" : { "Ref" : "DataEBSID" },
           "Device" : "/dev/xvdb"
          },
          {
           "VolumeId" : { "Ref" : "BackupsEBSID" },
           "Device" : "/dev/xvdc"
          }
        ]
      }
    }
...

Отдельно описываем создание и подключение IAM роли для CloudWatch-агента:

...
    "CloudWatchAccessProfile" : {
      "Type" : "AWS::IAM::InstanceProfile",
      "Properties" : {
        "Path" : "/",
        "Roles" : [
          { "Ref": "CloudWatchAgentAccessRole" }
        ]
      }
    },

    "CloudWatchAgentAccessRole" : {
      "Type" : "AWS::IAM::Role",
      "Properties" : {
        "RoleName": { "Fn::Join" : [ "-", [ "CloudWatchAgentAccessRole", {"Ref" : "AWS::StackName"} ] ] },
        "AssumeRolePolicyDocument": {
          "Version" : "2012-10-17",
          "Statement" : [
            {
              "Effect" : "Allow",
              "Principal" : {
                "Service" : ["ec2.amazonaws.com"]
              },
              "Action" : [
                "sts:AssumeRole"
              ]
            }
          ]
        },
        "ManagedPolicyArns": [
          "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
        ],
        "Path" : "/"
      }
    },
...

Подключается к EC2 в его Properties:

...
        "IamInstanceProfile" : {
          "Ref" : "CloudWatchAccessProfile"
        },
...

Вроде всё ОК, можно проверять:

aws --profile rtfm cloudformation validate-template --template-body file://rtfm-cf-stack.json

И создаём Dev стек:

aws --profile rtfm cloudformation create-stack --stack-name rtfm-dev --template-body file://rtfm-cf-stack.json --disable-rollback --capabilities CAPABILITY_NAMED_IAM

Готово:

Шаблон в репозитории – тут>>>.

Ansible

Роль cloudformation

Работу с Ansible начнём с роли для CloudFormation, используя модуль cloudformation.

Создаём hosts.iniinventory-файл:

[rtfm-dev]
dev.rtfm.co.ua

[rtfm-dev:vars]
env=dev
aws_env=rtfm-dev
set_hostname=rtfm-dev

Создаём каталоги для роли cloudformation:

mkdir -p roles/cloudformation/{vars,files,tasks}

Перемещаем файл шаблона в roles/cloudforamtion/files:

mv rtfm-cf-stack.json roles/cloudformation/files/

Переменные и шифрование значений

Создаём файл roles/cloudformation/vars/main.yml – тут перечислим переменные, общие для всех окружений:

region: "eu-west-1"
availability_zone: "eu-west-1a"
ami_id: "ami-05cdaf7e7b6c76277"

Глобальные переменные

Кроме переменных в файле roles/cloudformation/vars/main.yml – требуется добавить ещё три переменных с блоками IP, с которых будет разрешён доступ по SSH в SecurityGroup, и которые будут использоваться в конфигах NGINX для ограничения доступа к некоторым хостам.

Зададим их в глоабльном файле параметров, создаём каталоги:

mkdir -p group_vars/all

В этом каталоге поместим файл group_vars/all/main.yml. Т.к. этот файл будет лежать в публичном репозитории Github, а светить IP не очень хочется – то их зашифруем с помощью ansible-vault (хотя можно просто вынести в файлы переменных в приватном репозитории, о них дальше).

Создаём файл с паролем:

pwgen 12 -n 1 > ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass

Шифруем значение для переменной office_allow_location:

ansible-vault --vault-password-file ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass encrypt_string
Reading plaintext input from stdin. (ctrl-d to end input)
194.***.***.24/29!vault |
ANSIBLE_VAULT;1.1;AES256
35353463646362653633613238323634326135653265613934303532333939663437343164363931
6633613936633233336463353235613566623933333865300a363635383433623334373736303938
63363138663865393637316638623037666635333361626465653136623037343139623437633332
6331323632646238350a623261633464343161353130383134363764313233613363303634626137
38393461353237396265353539366666633333306161363233383032386362343532
Encryption successful

Вписываем значение в group_vars/all/main.yml, повторяем для home_allow_location_provider{1,2}:

office_allow_location: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          35353463646362653633613238323634326135653265613934303532333939663437343164363931
          ...
          38393461353237396265353539366666633333306161363233383032386362343532
home_allow_location_provider1: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          32613064363936363339313331336362643234636235386430313564393537646131653835313335
          ...
          39303736643434396261383838643364663963663864386338303663636462333936
home_allow_location_provider2: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          62636466336334626632383662303866316166633232336465366137396134323835646231376133
          ...
          65373866303862336534386531363439353563343531653362323662303061373830

Туда же добавляем переменную, в котором будет локальный путь к приватному репозиторию:

...
local_private_repo_location: "/home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure"

Dev/Production переменные

Возвращемся к роли cloudformation.

Создаём второй файл переменных – roles/cloudformation/vars/dev_vars.yml, сюда занесём переменные, специфичные для Dev окружения (для Production – создадим файл roles/cloudformation/vars/production_vars.yml):

termination_protection: no
key_name: "rtfm-dev-eu-west-1a"
ec2_instance_type: "t2.nano"
data_ebs_id: "vol-09ad53faabe393c98"
backups_ebs_id: "vol-0c5f083506a98b985"

Убираем все добавленные во время написания и тестирования значения из полей Defaults в шаблоне CloudFormation, т.е. вместо, например:

...
    "BackupsEBSID": {
      "Description": "Backups disk EBS ID",
      "Type": "String",
      "Default": "vol-0c5f083506a98b985"
    }
...

Оставляем:

...
    "BackupsEBSID": {
      "Description": "Backups disk EBS ID",
      "Type": "String"
    }
...

В приватном репозитории Bitbucket создаём файл aws-credenatials.yml, в котором создаём и шифруем переменные для модуля cloudformation:

ansible-vault --vault-password-file ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass encrypt_string
Reading plaintext input from stdin. (ctrl-d to end input)
AKI***FFQ!vault |
ANSIBLE_VAULT;1.1;AES256
39323435303837636563393438633036623533626331386661666634326266326239623762373037
...
38303433323531356333663663663565316139383361623035303732323638663632

Добавляем значение в /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/aws-credenatials.yml:

aws_access_key: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          39323435303837636563393438633036623533626331386661666634326266326239623762373037
          ...
          38303433323531356333663663663565316139383361623035303732323638663632

Повторяем для aws_secret_key.

Создаём файл с задачами, в котором будем вызывать модуль cloudformationroles/cloudformation/tasks/main.yml, где используем include_vars: file: "{{ env }}_vars.yml", который определяет файл загрузки переменных для Dev/Prod:

- name: "Read ENV specific variables {{ env }}_vars.yml"
  include_vars:
    file: "{{ env }}_vars.yml"

- name: "Read AWS credenatials vault {{ env }}_vars.yml"
  include_vars:
    file: "/home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/aws-credenatials.yml"

- name: "Create {{ env }} stack"
  cloudformation:

    # /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/aws-credenatials.yml
    aws_access_key: "{{ aws_access_key }}"
    aws_secret_key: "{{ aws_secret_key }}"

    # aws_env - from hosts.ini
    stack_name: "{{ aws_env }}"
    state: "present"
    disable_rollback: true
    template: "roles/cloudformation/files/rtfm-cf-stack.json"

    # roles/cloudformation/vars/main.yml
    region: "{{ region }}"

    template_parameters:

      # roles/cloudformation/vars/main.yml
      AMIID: "{{ ami_id }}"
      AvailabilityZone: "{{ availability_zone }}"
      OfficeAllowLocation: "{{ office_allow_location }}"
      HomeAllowLocationProvider1: "{{ home_allow_location_provider1 }}"
      HomeAllowLocationProvider2: "{{ home_allow_location_provider2 }}"

      # roles/cloudformation/vars/dev_vars.yml
      ENV: "{{ aws_env }}"
      KeyName: "{{ key_name }}"
      EC2InstanceType: "{{ ec2_instance_type }}"
      DataEBSID: "{{ data_ebs_id }}"
      BackupsEBSID: "{{ backups_ebs_id }}"
    tags:
      Stack: "{{ aws_env }}"

Создаём сам файл плейбука – rtfm.yml:

- hosts: all
  user: admin

  roles:
    - role: cloudformation
      tags: infra

Создаём файл настроек Ansible – ansible.cfg, переопределим некоторые дефолтные параметры:

[defaults]
host_key_checking=False
inventory = hosts.ini
retry_files_enabled = False

Всё готово, можно проверять.

Запускаем syntax-check:

ansible-playbook --tags "infra" --limit=rtfm-dev rtfm.yml --vault-password-file ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass --syntax-check
playbook: rtfm.yml

Для cloudformation модуля требуется boto3, устанавливаем:

sudo pacman -S python-boto3

Запускаем создание стека:

ansible-playbook --tags "infra" --limit=rtfm-dev rtfm.yml --vault-password-file /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass --extra-vars "ansible_connection=local"
PLAY [all] ****
TASK [Gathering Facts] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Read ENV specific variables dev_vars.yml] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Read AWS credenatials vault dev_vars.yml] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Create dev stack] ****
changed: [dev.rtfm.co.ua]
PLAY RECAP ****
dev.rtfm.co.ua             : ok=4    changed=1    unreachable=0    failed=0

Стек готов:

Скрипт запуска Ansible

Для упрощения работы, что бы не вводить команды каждый раз – можно использовать простой скрипт на bash, который будет вызывать Ansible с необходимыми параметрами:

#!/usr/bin/env bash

HELP="\n\t-c: run CloudFormation role (--tags infra) to create a stack
\n\t-a: run all App roles excluding CF (--tags app), to provision an EC2 instance
\n\t-t: tags to apply, enclosed with double quotes and separated by commas, i.e. \"common, web\"
\n\t-S: skip dry-run
\n\t-e: environment to be used in --limit, defaults to \"rtfm-dev\"
\n\t-v: vault-password file, if none -  default ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass
\n\t-r: RSA key to be used, defaults to ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm-dev-eu-west-1a.pem
\n"

[[ "$#" -lt 1 ]] && { echo -e $HELP; exit 1 ; }

### set defaults
# -c == infra, -a == app
TAGS=
# ansible_connection default to ssh
CONN="ansible_connection=ssh"
# -S 
SKIP=0
# -e
ENV="rtfm-dev"
# -v
VAULT="/home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass"
# -r
RSA_KEY="/home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm-dev-eu-west-1a.pem"

while getopts "caSe:v:r:ht:" opt; do
    case $opt in
        c)
            TAGS=infra
            CONN="ansible_connection=local"
            ;;
        a)
            TAGS=app
            ;;
        t)
            TAGS=$OPTARG
            ;;
        S)
            SKIP=1
            ;;
        e)
            ENV=$OPTARG
            ;;
        v)
            VAULT=$OPTARG
            ;;
        r)
            RSA_KEY=$OPTARG
            ;;
        h)
            echo -e $HELP
            exit 0
            ;;
        ?)
            echo -e $HELP && exit 1
            ;;
 esac
done

echo -e "\nTags: $TAGS\nEnv: $ENV\nVault: $VAULT\nRSA: $RSA_KEY\n"

read -p "Are you sure to proceed? [y/n] " -r
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
fi

dependencies_install () {

    ansible-galaxy install --role-file requirements.yml
}

syntax_check () {

    local tags=$1
    local env=$2
    local vault=$3
    local rsa=$4
    ansible-playbook --private-key $rsa --tags "$1" --limit=$env rtfm.yml --vault-password-file $vault --syntax-check
}

ansible_check () {

    local tags=$1
    local env=$2
    local vault=$3
    local rsa=$4
    local connection=$5
    local application=$6
    ansible-playbook --private-key $rsa --tags "$1" --limit=$env rtfm.yml --vault-password-file $vault --check --extra-vars "$connection $application"
}

ansible_exec () {

    local tags=$1
    local env=$2
    local vault=$3
    local rsa=$4
    local connection=$5
    local application=$6
    ansible-playbook --private-key $rsa --tags "$1" --limit=$env rtfm.yml --vault-password-file $vault --extra-vars "$connection $application"
}

# asserts
[[ $TAGS ]] || { echo -e "\nERROR: no TAGS found. Exit."; exit 1; }
[[ $ENV ]] || { echo -e "\nERROR: no ENV found. Exit."; exit 1; }
[[ $VAULT ]] || { echo -e "\nERROR: no VAULT found. Exit."; exit 1; }

echo -e "\nInstalling dependencies...\n"
if dependencies_install; then
    echo -e "\nDone."
else
    echo -e "Something went wrong. Exit."
    exit 1
fi 

echo -e "\nExecuting syntax-check..."
if syntax_check "$TAGS" $ENV $VAULT $RSA_KEY; then
    echo -e "Syntax check passed.\n"
else
    echo -e "Something went wrong. Exit."
    exit 1
fi

if [[ $SKIP == 0 ]]; then
    echo -e "Running dry-run..."
    if ansible_check "$TAGS" $ENV $VAULT $RSA_KEY $CONN $APP;then
        echo -e "Dry-run check passed.\n"
    else
        echo -e "Something went wrong. Exit."
        exit 1
    fi
else
    echo -e "Skipping dry-run.\n"
fi

read -p "Are you sure to proceed? [y/n] " -r
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
fi

echo -e "Applying roles..."
if ansible_exec "$TAGS" $ENV $VAULT $RSA_KEY $CONN $APP;then
    echo -e "Provisioning done.\n"
else
    echo -e "Something went wrong. Exit."
    exit 1
fi

Скрипт в репозитории – тут>>>.

Помощь:

./ansible_exec.sh -h
-c: run CloudFormation role (--tags infra) to create a stack
-a: run all App roles excluding CF (--tags app), to provision an EC2 instance
-t: tags to apply, enclosed with double quotes and separated by commas, i.e. "common, web"
-S: skip dry-run
-e: environment to be used in --limit, defaults to "rtfm-dev"
-v: vault-password file, if none - default ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass
-r: RSA key to be used, defaults to ~/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm-dev-eu-west-1a.pem

Скрипт задаёт дефолтные значения, если не указаны в аргументах – окружение (rtfm-dev), путь к файлу с паролем для ansible-vault и RSA-ключ для хоста.

С помощью опций -c, -a и -t можно задать теги для запускаемых ролей.

Например, для роли cloudformation это тег infra:

- hosts: all
  user: admin

  roles:
    - role: cloudformation
      tags: infra

В функции ansible_check() скрипт выполняет проверку ролей с помощью ansible-playbook --check, которую можно пропустить с помощью опции -S.

Запускаем его:

./ansible_exec.sh -c
Tags: infra
Env: rtfm-dev
Vault: /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass
RSA: /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm-dev-eu-west-1a.pem
Are you sure to proceed? [y/n] y
Installing dependencies...
[WARNING]: - SimpliField.logrotate (master) is already installed - use --force to change version to unspecified
[WARNING]: - jnv.unattended-upgrades (v1.6.0) is already installed - use --force to change version to unspecified
[WARNING]: - geerlingguy.docker (2.5.1) is already installed - use --force to change version to unspecified
Done.
Executing syntax-check...
playbook: rtfm.yml
Syntax check passed.
Running dry-run...
PLAY [all] ****
TASK [Gathering Facts] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Read ENV specific variables dev_vars.yml] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Read AWS credenatials vault dev_vars.yml] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Create dev stack] ****
ok: [dev.rtfm.co.ua]
PLAY RECAP ****
dev.rtfm.co.ua             : ok=4    changed=0    unreachable=0    failed=0
Dry-run check passed.
Are you sure to proceed? [y/n] y
Applying roles...
PLAY [all] ****
TASK [Gathering Facts] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Read ENV specific variables dev_vars.yml] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Read AWS credenatials vault dev_vars.yml] ****
ok: [dev.rtfm.co.ua]
TASK [cloudformation : Create dev stack] ****
ok: [dev.rtfm.co.ua]
PLAY RECAP ****
dev.rtfm.co.ua             : ok=4    changed=0    unreachable=0    failed=0
Provisioning done.

Стек уже создан, поэтому Ansible ничего не изменил.

Ansilbe роли 

Осталось добавить роли для установки и настройки сервисов.

Не буду тут останавливаться на всех, приведу пример добавления ролей commonnginx, mysql и letsncrypt.

common роль

Роль common выполняет установку базовых пакетов, настройку окружения, пользователей, имени хоста и т.д.

Создаём каталоги:

mkdir -p roles/common/{tasks,vars,files}

Создаём файл roles/common/tasks/main.yml:

- name: "Upgrade all packages to the latest version"
  apt:
    update_cache: yes
    upgrade: yes

- name: "Install common packages"
  apt: name={{item}} state=present
  with_items:
       - python-apt
       - mailutils
       - curl
       - dnsutils
       - telnet
       - unzip
       - parted
       - mc
       - bc
       - golang
       - python-docker
       - python-pip
       - htop
       - mysql-client
       - tree

- name: "Create partition on the {{ data_volume_device }}"
  parted:
    device: "{{ data_volume_device }}"
    number: 1
    state: present

- name: "Create {{ data_volume_mount_path }} directory"
  file:
    path: "{{ data_volume_mount_path }}"
    state: directory
    mode: 0755
    recurse: yes

- name: "Create a ext4 filesystem on the {{ data_volume_partition }}"
  filesystem:
    fstype: ext4
    dev: "{{ data_volume_partition }}"

- name: "Mount data-volume {{ data_volume_partition }} to the {{ data_volume_mount_path }}"
  mount:
    path: "{{ data_volume_mount_path }}"
    src: "{{ data_volume_partition }}"
    state: mounted
    fstype: ext4

...

- name: "Set hostname"
  hostname:
    name: "{{ set_hostname }}"

- name: "Add hostname to /etc/hosts"
  lineinfile:
    dest: /etc/hosts
    regexp: '^127\.0\.0\.1[ \t]+localhost'
    line: "127.0.0.1 localhost {{ set_hostname }} {{ inventory_hostname }}"
    state: present

- name: "Update /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg"
  lineinfile:
    dest: /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg
    regexp: '^manage_etc_hosts: true'
    line: "manage_etc_hosts: false"
    state: present

- name: "Set timezone to Europe/Kiev"
  timezone:
    name: Europe/Kiev

- name: "Change root mailbox"
  lineinfile:
    dest: /etc/aliases
    regexp: '^root: '
    line: "root: {{ notify_email }}"
    state: present

- name: "Update mail aliases"
  shell:
    newaliases

- name: "Add the 'setevoy' system user"
  user:
    name: "setevoy"
    shell: "/bin/bash"
    append: yes
    groups: "sudo"

- name: "Copy .vimrc to the 'root' user"
  copy:
    src: "files/vimrc"
    dest: "/root/.vimrc"

- name: "Copy .vimrc to the 'setevoy' user"
  copy:
    src: "files/vimrc"
    dest: "/home/setevoy/.vimrc"

Тут устанавливаем пакеты, создаём разделы на дисках /dev/xvdb для /data и /dev/xvdc для /backups, монтируем их, создаём файловые системы, задаём имя хоста из переменной set_hostname, заданной в файле hosts.ini, настраиваем почту root, добавляем пользователя setevoy и копируем .vimrc.

Проверяем подключенные к инстансу диски:

root@ip-10-0-2-12:/home/admin# lsblk
NAME    MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
xvda    202:0    0   8G  0 disk
└─xvda1 202:1    0   8G  0 part /
xvdb    202:16   0   8G  0 disk
xvdc    202:32   0   8G  0 disk

xvdb будет /data, xvdc будет /backups.

В roles/common/vars/main.yml задаём переменные:

notify_email: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          62376361343165653932663834366434626366646230623964623531356363313835623566383236
          3732323331646437643161383134356231316564316665360a656666306239343866306431616639
          61373739623637623766386162343036303031393332373537306435363433313762313864333261
          6265643131393939320a343938626264323635363534353334303163353566653866366161393930
          35313036616363393636656163623231346265353032396235363134383831346461
data_volume_device: "/dev/xvdb"
data_volume_partition: "/dev/xvdb1"
data_volume_mount_path: "/data"
backups_volume_device: "/dev/xvdc"
backups_volume_partition: "/dev/xvdc1"
backups_volume_mount_path: "/backups"

notify_email шифруем аналогично тому, как шифровали IP, что бы не светить лишний раз почтовый ящик в сети.

Создаём файл roles/common/files/vimrc:

set tabstop=4
set shiftwidth=4
set softtabstop=4
set expandtab
set paste
set smartindent
syntax on

Добавляем become: true и вызов роли в плейбук rtfm.yml с указанием тегов common и apps, что бы иметь возможность запускать его со всеми другими ролями указав скрипту указав -a, или отдельно только эту роль – указав-t "common":

- hosts: all
  become:
    true

  roles:
    - role: cloudformation
      tags: infra
    - role: common
      tags: common, app

Переносим в ansible.cfg пользователя:

[defaults]
remote_user=admin
host_key_checking=False
inventory = hosts.ini
retry_files_enabled = False

Вроде ОК? Запускаем скрипт, первый раз с опцией -S, что бы пропустить syntax-check (или руками устанавливаем python-apt на хосте):

./ansible_exec.sh -a -S
Tags: app
Env: rtfm-dev
Vault: /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass
RSA: /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm-dev-eu-west-1a.pem
Are you sure to proceed? [y/n] y
Installing dependencies...
...
Done.
Executing syntax-check...
playbook: rtfm.yml
Syntax check passed.
Skipping dry-run.
Are you sure to proceed? [y/n] y
Applying roles...
PLAY [all] ****
TASK [Gathering Facts] ****
ok: [dev.rtfm.co.ua]
TASK [common : Upgrade all packages to the latest version] ****
[WARNING]: Could not find aptitude. Using apt-get instead
changed: [dev.rtfm.co.ua]
TASK [common : Install common packages] ****
changed: [dev.rtfm.co.ua] => (item=[u'python-apt', u'mailutils', u'curl', u'dnsutils', u'telnet', u'unzip', u'parted', u'mc', u'bc', u'golang', u'python-docker', u'python-pip', u'htop', u'mysql-client', u'tree'])
TASK [common : Create partition on the /dev/xvdb] ****
changed: [dev.rtfm.co.ua]
TASK [common : Create /data directory] ****
changed: [dev.rtfm.co.ua]
TASK [common : Create a ext4 filesystem on the /dev/xvdb1] ****
changed: [dev.rtfm.co.ua]
TASK [common : Mount data-volume /dev/xvdb1 to the /data] ****
changed: [dev.rtfm.co.ua]
TASK [common : Create partition on the /dev/xvdc] ****
changed: [dev.rtfm.co.ua]
TASK [common : Create /backups directory] ****
changed: [dev.rtfm.co.ua]
TASK [common : Create a ext4 filesystem on the /dev/xvdc1] ****
changed: [dev.rtfm.co.ua]
TASK [common : Mount backups-volume /dev/xvdc1 to the /backups] ****
changed: [dev.rtfm.co.ua]
TASK [common : Set hostname] ****
changed: [dev.rtfm.co.ua]
TASK [common : Add hostname to /etc/hosts] ****
changed: [dev.rtfm.co.ua]
TASK [common : Update /etc/cloud/cloud.cfg.d/01_debian_cloud.cfg] ****
changed: [dev.rtfm.co.ua]
TASK [common : Set timezone to Europe/Kiev] ****
changed: [dev.rtfm.co.ua]
...
Provisioning done.

Проверяем разделы:

root@ip-10-0-2-12:/home/admin# lsblk
NAME    MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
xvda    202:0    0   8G  0 disk
└─xvda1 202:1    0   8G  0 part /
xvdb    202:16   0   8G  0 disk
└─xvdb1 202:17   0   8G  0 part /data
xvdc    202:32   0   8G  0 disk
└─xvdc1 202:33   0   8G  0 part /backups

Всё на месте, всё готово.

nginx роль

Здесь пришлось немного повозиться из-за желания разделить данные между приватным и публичным репозиторием.

Создаём каталоги для роли в публичном репозитории:

mkdir -p roles/nginx/{tasks,vars,files,templates}

Для создания пользователей и каталогов – используем список web_projects с вложенными списками domains, а затем плагин nested.

В приватном репозитории создаём каталог vars и файл ~/Work/RTFM/Bitbucket/rtfm-infrastructure/vars/dev_web_projects.yml, в котором перечисляем пользователей и их домены:

web_projects:
  - name: rtfm
    domains:
      - dev.rtfm.co.ua
      - test.rtfm.co.ua
  - name: setevoy
    domains:
      - dev.test.setevoy.kiev.ua
  ...

В публичном репозии создаём файл roles/nginx/vars/main.yml, в котором задаём переменную web_data_root_prefix:

web_data_root_prefix: "/data/www"

Создаём файл roles/nginx/tasks/main.yml, пока добавим только установку nginx, создание пользователей и каталогов.

При этом при создании пользователей – пропускаем пользователя setevoy, т.к. он используется не только для NGINX, и уже создан в роли common (но требуется в списке web_users для создания каталогов).

При создании пользователей в “Add web-users” – используем with_items, где в цикле передаём item.name из списка web_pojects:

...
- name: "Add web-users"
  user:
    name: "{{ item.name }}"
    shell: "/usr/sbin/nologin"
  with_items: "{{ web_projects }}"
  when: '"setevoy" not in  item'
...

А следующим шагом, в “Check {{ web_data_root_prefix }} directories for virtual hosts” – используем список “{{ web_projects }}” и его элемент item.name для создания каталогов вида /data/www/rtfm/, и вложенный список directories, из которого используем имена хостов для создания директорий вида /data/www/rtfm/dev.rtfm.co.ua/ и /data/www/rtfm/test.rtfm.co.ua/:

...
- name: "Check {{ web_data_root_prefix }} directories for virtual hosts"
  file:
    path: "{{ web_data_root_prefix }}/{{ item.0.name }}/{{ item.1 }}"
    state: directory
    recurse: yes
  with_subelements:
    - "{{ web_projects }}"
    - domains
...

Всё вместе сейчас выглядит так:

- name: "Read private env-specific variables from the {{ local_private_repo_location}}/vars/{{ env }}_web_projects.yml"
  include_vars:
    file: "{{ local_private_repo_location}}/vars/{{ env }}_web_projects.yml"

- name: "Add web-users"
  user:
    name: "{{ item.name }}"
    shell: "/usr/sbin/nologin"
  with_items: "{{ web_projects }}"
  when: '"setevoy" not in  item'

- name: "Check {{ web_data_root_prefix }} directories for virtual hosts"
  file:
    path: "{{ web_data_root_prefix }}/{{ item.0.name }}/{{ item.1 }}"
    state: directory
    owner: "{{ item.0.name }}"
    group: "{{ item.0.name }}"
    recurse: yes
  with_subelements:
    - "{{ web_projects }}"
    - domains

- name: "Install NGINX"
  apt:
    name: nginx

Добавляем роль nginx в плейбук с тегами web, nginxapp:

- hosts: all
  become:
    true

  roles:
    - role: test
      tags: test
    - role: cloudformation
      tags: infra
    - role: common
      tags: common, app
    - role: nginx
      tags: web, nginx, app

Используя теги – будет возможность запустить только роль nginx, указав тег nginx, роли nginx и php-fpm – указав тег web, или все роли – указав тег app.

Проверяем:

./ansible_exec.sh -t "web"
Tags: web
Env: rtfm-dev
Vault: /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm_ansible_vault_pass
RSA: /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/aws-credenatials/rtfm-dev-eu-west-1a.pem
Are you sure to proceed? [y/n] y
Installing dependencies...
[WARNING]: - SimpliField.logrotate (master) is already installed - use --force to change version to unspecified
...
Done.
Executing syntax-check...
playbook: rtfm.yml
Syntax check passed.
Running dry-run...
...
TASK [nginx : Read private env-specific variables from the /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/vars/dev_web_projects.yml] **************************************************************************************************
ok: [dev.rtfm.co.ua]
TASK [nginx : Add web-users] ****
changed: [dev.rtfm.co.ua] => (item={u'domains': [u'dev.rtfm.co.ua', u'test.rtfm.co.ua'], u'name': u'rtfm'})
skipping: [dev.rtfm.co.ua] => (item={u'domains': [u'dev.test.setevoy.kiev.ua'], u'name': u'setevoy'})
...
TASK [nginx : Check /data/www directories for virtual hosts] ****
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'dev.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'test.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'dev.test.setevoy.kiev.ua'))
...
TASK [nginx : Install NGINX] ****
ok: [dev.rtfm.co.ua]
PLAY RECAP ****
dev.rtfm.co.ua             : ok=5    changed=2    unreachable=0    failed=0
Dry-run check passed.
Are you sure to proceed? [y/n] y
Applying roles...
PLAY [all] ****
TASK [Gathering Facts] ****
ok: [dev.rtfm.co.ua]
TASK [nginx : Read private env-specific variables from the /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/vars/dev_web_projects.yml] **************************************************************************************************
ok: [dev.rtfm.co.ua]
TASK [nginx : Add web-users] ****
changed: [dev.rtfm.co.ua] => (item={u'domains': [u'dev.rtfm.co.ua', u'test.rtfm.co.ua'], u'name': u'rtfm'})
skipping: [dev.rtfm.co.ua] => (item={u'domains': [u'dev.test.setevoy.kiev.ua'], u'name': u'setevoy'})
...
TASK [nginx : Check /data/www directories for virtual hosts] ****
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'dev.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'test.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'dev.test.setevoy.kiev.ua'))
...
TASK [nginx : Install NGINX] ****
ok: [dev.rtfm.co.ua]
PLAY RECAP ****
dev.rtfm.co.ua             : ok=5    changed=2    unreachable=0    failed=0
Provisioning done.

Проверяем пользователей:

root@rtfm-dev:/home/admin# cat /etc/shadow | grep 'rtfm\|setevoy'
setevoy:!:17766:0:99999:7:::
rtfm:!:17766:0:99999:7:::

Проверяем логин:

root@rtfm-dev:/home/admin# su -l rtfm
This account is currently not available.
root@rtfm-dev:/home/admin# su -l setevoy
setevoy@rtfm-dev:~$

ОК – все, кроме пользователя setevoy логиниться не могут (см. /bin/false vs /sbin/nologin).

И каталоги:

root@rtfm-dev:/home/admin# ls -l /data/www/ | grep 'rtfm\|setevoy'
drwxr-xr-x 2 rtfm    rtfm    4096 Aug 23 12:46 rtfm
drwxr-xr-x 2 setevoy setevoy 4096 Aug 23 12:46 setevoy

Вложенные каталоги для виртуалхостов:

root@rtfm-dev:/home/admin# ls -l /data/www/rtfm/
total 8
drwxr-xr-x 2 rtfm rtfm 4096 Aug 24 10:03 dev.rtfm.co.ua
drwxr-xr-x 2 rtfm rtfm 4096 Aug 24 10:03 test.rtfm.co.ua

Хорошо, всё работает.

Далее надо добавить копирование файлов настроек самого NGINX и виртуалхостов.

Создаём файл roles/nginx/templates/nginx.conf.j2:

user  www-data;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    client_max_body_size 240M;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    log_format  main_ext  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for" '
                      '"$host" sn="$server_name" '
                      'rt=$request_time '
                      'ua="$upstream_addr" us="$upstream_status" '
                      'ut="$upstream_response_time" ul="$upstream_response_length" '
                      'cs=$upstream_cache_status' ;

    #access_log  /var/log/nginx/access.log  main;
    access_log  /var/log/nginx/access.log  main_ext;
    error_log  /var/log/nginx/error.log warn;

    sendfile        on;
    tcp_nopush     on;

    # enable gzip
    gzip  on;
    gzip_http_version 1.0;
    gzip_comp_level 2;
    gzip_min_length 1100;
    gzip_buffers     4 8k;
    gzip_proxied any;
    gzip_types
    # text/html is always compressed by HttpGzipModule
        text/css
        text/javascript
        text/xml
        text/plain
        text/x-component
        application/javascript
        application/json
        application/xml
        application/rss+xml
        font/truetype
        font/opentype
        application/vnd.ms-fontobject
        image/svg+xml;
    gzip_static on;
    gzip_proxied        expired no-cache no-store private auth;
    gzip_disable        "MSIE [1-6]\.";
    gzip_vary           on;

    keepalive_timeout  65;

    include /etc/nginx/conf.d/*.conf;
}

Создаём каталоги в приватном репозитории:

mkdir -p ~/Work/RTFM/Bitbucket/rtfm-infrastructure/nginx/templates/{dev,production}

Создаём конфиг для Dev~/Work/RTFM/Bitbucket/rtfm-infrastructure/nginx/templates/dev/dev.rtfm.co.ua.conf.j2:

server {

    listen 80;
    server_name {{ item.1 }};

    root {{ web_data_root_prefix }}/rtfm/{{ item.1 }};

    access_log /var/log/nginx/{{ item.1 }}-access.log main_ext;
    error_log /var/log/nginx/{{ item.1 }}-error.log warn;

    allow {{ office_allow_location }};
    allow {{ home_allow_location_provider1 }};
    allow {{ home_allow_location_provider2 }};
    deny all;

    error_page 404 /404.html;
        location = /404.html {
            root {{ web_data_root_prefix }}/{{ item.0.name }}/{{ item.1 }};
    }
...
    location ~ \.php$ {
        proxy_read_timeout 3000;
        include /etc/nginx/fastcgi_params;
        fastcgi_pass unix:/var/run/{{ item.1 }}-php-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

В шаблоне используем {{ item.0.name }} из списка web_projects для подстановки пользователя, и {{ item.1 }} из вложенного списка domains для подстановки домена.

Пока без SSL, позже добавим Lets Encrypt роль и обновим шаблон.

Повторяем для всех доменов из web_projects:

ls -l ~/Work/RTFM/Bitbucket/rtfm-infrastructure/nginx/templates/dev/ | grep 'rtfm\|setevoy'
-rw-r--r-- 1 setevoy setevoy 3465 Aug 23 13:38 dev.rtfm.co.ua.conf.j2
-rw-r--r-- 1 setevoy setevoy 3465 Aug 24 10:15 dev.test.setevoy.kiev.ua.conf.j2
-rw-r--r-- 1 setevoy setevoy 3465 Aug 24 10:14 test.rtfm.co.ua.conf.j2

В приватном репозитории создаём каталог для файлов .htaccess:

mkdir ~/Work/RTFM/Bitbucket/rtfm-infrastructure/nginx/files

Создаём файл с паролем для HTTP-авторизации:

htpasswd -c /home/setevoy/Work/RTFM/Bitbucket/rtfm-infrastructure/nginx/files/htpasswd_rtfm setevoy
New password:
Re-type new password:
Adding password for user setevoy

Повторяем для всех проектов (rtfm, setevoy etc).

Обновляем roles/nginx/tasks/main.yml – добавляем копирование файлов, в “Add NGINX virtualhosts configs” снова используем вложенный список domains:

...
- name: "Replace NGINX config"
  template:
    src: "templates/nginx.conf.j2"
    dest: "/etc/nginx/nginx.conf"
    owner: "root"
    group: "root"
    mode: 0644

- name: "Copy Status HTTP auth file"
  copy:
    src: "{{ local_private_repo_location }}/nginx/files/htpasswd_{{ item.name }}"
    dest:  "{{ web_data_root_prefix }}/{{ item.name }}/.htpasswd_{{ item.name }}"
    owner: "www-data"
    group: "www-data"
    mode: 0600
  with_items: "{{ web_projects }}"
  ignore_errors: True

- name: "Add NGINX virtualhosts configs"
  template:
    src: "{{ local_private_repo_location }}/nginx/templates/{{ env }}/{{ item.1 }}.conf.j2"
    dest: "/etc/nginx/conf.d/{{ item.1 }}.conf"
    owner: "root"
    group: "root"
    mode: 0644
  with_subelements:  
    - "{{ web_projects }}"
    - domains

Запускаем Ansible:

...
TASK [nginx : Replace NGINX config] ****
ok: [dev.rtfm.co.ua]
TASK [nginx : Copy Status HTTP auth file] ****
changed: [dev.rtfm.co.ua] => (item={u'domains': [u'dev.rtfm.co.ua', u'test.rtfm.co.ua'], u'name': u'rtfm'})
changed: [dev.rtfm.co.ua] => (item={u'domains': [u'dev.test.setevoy.kiev.ua'], u'name': u'setevoy'})
...
TASK [nginx : Add NGINX virtualhosts configs] ****
ok: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'dev.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'test.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'dev.test.setevoy.kiev.ua'))
...
PLAY RECAP ****
dev.rtfm.co.ua             : ok=8    changed=2    unreachable=0    failed=0
Provisioning done.

Проверяем файлы на хосте:

root@rtfm-dev:/home/admin# ls -l /etc/nginx/conf.d/ | grep 'rtfm\|setevoy'
-rw-r--r-- 1 root root 3316 Aug 24 09:11 dev.rtfm.co.ua.conf
-rw-r--r-- 1 root root 3316 Aug 24 10:20 dev.test.setevoy.kiev.ua.conf
-rw-r--r-- 1 root root 3316 Aug 24 10:20 test.rtfm.co.ua.conf

Содержимое конфигов:

root@rtfm-dev:/home/admin# head /etc/nginx/conf.d/dev.rtfm.co.ua.conf
server {
listen 80;
server_name dev.rtfm.co.ua;
root /data/www/rtfm/dev.rtfm.co.ua;
access_log /var/log/nginx/dev.rtfm.co.ua-access.log main_ext;
error_log /var/log/nginx/dev.rtfm.co.ua-error.log warn;
root@rtfm-dev:/home/admin# head /etc/nginx/conf.d/dev.rtfm.co.ua.conf /etc/nginx/conf.d/dev.test.setevoy.kiev.ua.conf
==> /etc/nginx/conf.d/dev.rtfm.co.ua.conf <==
server {
listen 80;
server_name dev.rtfm.co.ua;
root /data/www/rtfm/dev.rtfm.co.ua;
access_log /var/log/nginx/dev.rtfm.co.ua-access.log main_ext;
error_log /var/log/nginx/dev.rtfm.co.ua-error.log warn;
==> /etc/nginx/conf.d/dev.test.setevoy.kiev.ua.conf <==
server {
listen 80;
server_name dev.test.setevoy.kiev.ua;
root /data/www/rtfm/dev.test.setevoy.kiev.ua;
access_log /var/log/nginx/dev.test.setevoy.kiev.ua-access.log main_ext;
error_log /var/log/nginx/dev.test.setevoy.kiev.ua-error.log warn;

Коректность конфигов (потом добавим это в Ansible):

root@rtfm-dev:/home/admin# nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Перечитываем их:

root@rtfm-dev:/home/admin# service nginx reload

Создаём каталог и добавляем тестовый файлик:

root@rtfm-dev:/home/admin# mkdir /data/www/rtfm/dev.rtfm.co.ua
root@rtfm-dev:/home/admin# echo "It works!" > /data/www/rtfm/dev.rtfm.co.ua/test.html

Проверяем доступ:

В roles/nginx/tasks/main.yml добавляем проверку корректности конфигов и перезагрузку NGINX:

...
- name: "Check NGINX configs"
  shell: "/usr/sbin/nginx -t"
  register: nginx_config_status

- name: "NGINX test status"
  debug:
    msg: "{{ nginx_config_status }}"

- name: "NGINX test return code"
  debug:
    msg: "{{ nginx_config_status.rc }}"

- name: "Service NGINX restart and enable on boot"
  systemd:
    name: nginx
    state: restarted
    enabled: yes
    daemon_reload: yes
  when: nginx_config_status.rc == 0

Роль php-fpm аналогична, её описывать не буду, есть>>> в репозитории.

mysql роль

Создаём каталог роли:

mkdir -p roles/mysql/{tasks,vars}

Создаём файл с задачами.

Начнём с установки MariaDB и смены пути к каталогу с данными – вместо дефолтного /var/lib/mysql создадим каталог /data/mysql, что бы базы лежали на отдельном EBS (хотя, возможно, спорное решение – но пока попробую держать отдельно).

В файле roles/mysql/tasks/main.yml добавляем задачи:

- name: "Install MariaDB server"
  package:
    name: "{{ item }}"
    state: installed
  with_items:
    - mariadb-server
    - python-mysqldb

- name: "Create {{ db_datadir }}"
  file:
    path: "{{ db_datadir }}"
    state: directory
    owner: "mysql"
    group: "mysql"

- name: "Check if {{ db_datadir }}/mysql present"
  stat:
    path: "{{ db_datadir }}/mysql"
  register: db_data_dir

- name: "Synchronize /var/lib/mysql with {{ db_datadir }}"
  synchronize:
    src: "/var/lib/mysql/"
    dest: "{{ db_datadir }}"
  delegate_to: "{{ inventory_hostname }}"
  when: not db_data_dir.stat.exists and not db_data_dir.stat.exists

- name: "Change 'datadir' to the {{ db_datadir }}"
  lineinfile:
    dest: "/etc/mysql/mariadb.conf.d/50-server.cnf"
    regexp: '^datadir'
    line: "datadir = {{ db_datadir }}"
    state: present

- name: "MariaDB restart and enable on boot"
  systemd:
    name: mariadb
    state: restarted
    enabled: yes
    daemon_reload: yes

- name: "Delete default /var/lib/mysql"
  file:
    state: absent
    path: "/var/lib/mysql/"
  when: db_default_dir.stat.exists and db_default_dir.stat.exists

Тут:

  1. устанавливаем MariaDB
  2. создаём /data/mysql
  3. проверяем существует ли каталог (база mysql/data/mysql/mysql: если установка новая, на новый EBS – то каталог будет не найден, и его надо будет синхронизировать из /var/lib/mysql, иначе MariaDB не запустится после обновления параметра datadir, если это обновление уже настроенного хоста (/data подключается из отдельного EBS, на котором уже могут быть данные) – то пропускаем синхронизацию
  4. копируем базы, если /data/mysql/mysql не найден
  5. обновляем datadir в /etc/mysql/mariadb.conf.d/50-server.cnf
  6. перезапускаем MariaDB
  7. удаляем /var/lib/mysql

Создаём файл с переменными – roles/mysql/vars/main.yml, тут только одна:

db_datadir: "/data/mysql"

(правильнее было бы в roles/mysql/defaults/main.yml, потом перенесу)

Добавляем роль в плейбук rtfm.yml:

- hosts: all
  become:
    true

  roles:
    - role: test
      tags: test
    - role: cloudformation
      tags: infra
    - role: common
      tags: common, app
    - role: nginx
      tags: web, app
    - role: php-fpm
      tags: web, app
    - role: mysql
      tags: db, app

Запускаем:

...
TASK [mysql : Install MariaDB server] ****
changed: [dev.rtfm.co.ua] => (item=mariadb-server)
changed: [dev.rtfm.co.ua] => (item=python-mysqldb)
TASK [mysql : Create /data/mysql] ****
changed: [dev.rtfm.co.ua]
TASK [mysql : Check if /data/mysql/mysql present] ****
ok: [dev.rtfm.co.ua]
TASK [mysql : Synchronize /var/lib/mysql with /data/mysql] ****
changed: [dev.rtfm.co.ua -> dev.rtfm.co.ua]
TASK [mysql : Change 'datadir' to the /data/mysql] ****
changed: [dev.rtfm.co.ua]
TASK [mysql : MariaDB restart and enable on boot] ****
changed: [dev.rtfm.co.ua]
TASK [mysql : Delete default /var/lib/mysql] ****
changed: [dev.rtfm.co.ua]
...

Проверяем:

root@rtfm-dev:/home/admin# mysql -u root -ptestpw --execute 'SHOW VARIABLES WHERE Variable_Name LIKE "datadir"'
+---------------+--------------+
| Variable_name | Value        |
+---------------+--------------+
| datadir       | /data/mysql/ |
+---------------+--------------+

Далее требуется создать базы и пользователей.

В приватном репозитории добавляем файл ~/Work/RTFM/Bitbucket/rtfm-infrastructure/vars/dev_db_projects.yml.

В нём задаём данные доступа для root, список db_users с пользователями и их паролями (потом их зашифруем), и список db_projects с вложенными списками databases:

db_root_user: root
db_root_pass: "testpw"
db_root_host: localhost

db_users:

  rtfm:
    password: "testpw"
    host: localhost

  setevoy:
    password: "testpw"
    host: localhost
...

db_projects:

  - name: rtfm
    databases:
      - rtfm_dev
      - rtfm_test

  - name: setevoy
    databases:
      - money_dev
      - test_dev
...

Возвращаемся к roles/mysql/tasks/main.yml и добавляем задачи:

  1. обновить пароль root
  2. удалить анонимного пользователя
  3. отключить root логин с localhost без пароля
  4. создать базы данных из списков databases в списке db_projects
  5. создать пользователей из списка db_users
  6. задать им права доступа к базам из списков databases списка db_projects, используя append_privs, что бы дать привилегии на несколько баз

Получилось немного запутанно – но работает (хотя наверняка есть более “прямой” путь решения).

Задачи:

...
- name: "Update mysql root password"
  mysql_user:
    name: "{{ db_root_user }}"
    host: "{{ db_root_host }}"
    password: "{{ db_root_pass }}"
    login_user: "{{ db_root_user }}"
    login_password: "{{ db_root_pass }}"
    check_implicit_admin: yes
    priv: "*.*:ALL,GRANT"

- name: "Remove anonymous user"
  mysql_user:
    name: ''
    host: localhost
    state: absent
    login_user: "{{ db_root_user }}"
    login_password: "{{ db_root_pass }}"

- name: "Disable root@localhost NOPASSWD login"
  command: "{{ item }}"
  with_items:
   - "mysql -u {{ db_root_user }} -p{{ db_root_pass }} --execute=\"UPDATE mysql.user SET plugin = '' WHERE user = 'root' AND host = 'localhost'\""
   - "mysql -u {{ db_root_user }} -p{{ db_root_pass }} --execute=\"FLUSH PRIVILEGES\""

- name: "Create databases"
  mysql_db:
    name: "{{ item.1 }}"
    state: present
    login_user: "{{ db_root_user }}"
    login_password: "{{ db_root_pass }}"
  with_subelements:
    - "{{ db_projects }}"
    - databases

- name: "Create projects users"
  mysql_user:
    name: "{{ item.key }}"
    password: "{{ item.value.password }}"
    state: present
    login_user: "{{ db_root_user }}"
    login_password: "{{ db_root_pass }}"
  with_dict: "{{ db_users }}"

- name: "Grant permissions"
  mysql_user:
    name: "{{ item.0.name }}"
    priv: "{{ item.1 }}.*:ALL"
    append_privs: "yes"
    state: present
    login_user: "{{ db_root_user }}"
    login_password: "{{ db_root_pass }}"
  with_subelements:
    - "{{ db_projects }}"
    - databases

Запускаем:

TASK [mysql : Update mysql root password] ****
changed: [dev.rtfm.co.ua]
TASK [mysql : Remove anonymous user] ****
changed: [dev.rtfm.co.ua]
TASK [mysql : Disable root@localhost NOPASSWD login] ****
changed: [dev.rtfm.co.ua] => (item=mysql -u root -ptestpw --execute="UPDATE mysql.user SET plugin = '' WHERE user = 'root' AND host = 'localhost'")
changed: [dev.rtfm.co.ua] => (item=mysql -u root -ptestpw --execute="FLUSH PRIVILEGES")
TASK [mysql : Create databases] ****
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'rtfm_dev'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'rtfm_test'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'money_dev'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'test_dev'))
...
TASK [mysql : Create projects users] ****
changed: [dev.rtfm.co.ua] => (item={'value': {u'host': u'localhost', u'password': u'testpw'}, 'key': u'rtfm'})
changed: [dev.rtfm.co.ua] => (item={'value': {u'host': u'localhost', u'password': u'testpw'}, 'key': u'setevoy'})
...
TASK [mysql : Grant permissions] ****
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'rtfm_dev'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'rtfm_test'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'money_dev'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'test_dev'))
...
PLAY RECAP ****
dev.rtfm.co.ua             : ok=9    changed=6    unreachable=0    failed=0
Provisioning done.

Проверяем:

root@rtfm-dev:/home/admin# mysql -u rtfm -ptestpw --execute 'show databases'
+--------------------+
| Database           |
+--------------------+
| information_schema |
| rtfm_dev           |
| rtfm_test          |
+--------------------+

С этим закончили.

letsencrypt роль

Осталось установить Let’s Encrypt клиент и настроить SSL, после чего можно копировать блог и тестить всё получившееся.

У Ansible уже есть модуль letsencrypt – но я предпочту создать роль сам.

notify_email переносим из roles/common/vars/main.yml в group_vars/all/main.yml.

Создаём каталог:

mkdir -p roles/letsencrypt/tasks

Создаём файл roles/letsencrypt/tasks/main.yml:

- name: "Read private env-specific variables from the {{ local_private_repo_location}}/vars/{{ env }}_web_projects.yml"
  include_vars:
    file: "{{ local_private_repo_location}}/vars/{{ env }}_web_projects.yml"

- name: "Install Let's Encrypt client"
  apt:
    name=letsencrypt
    state=latest

- name: "Stop NGINX"
  systemd:
    name: nginx
    state: stopped
  ignore_errors: true

- name: "Check existing certificates"
  command: "ls -1 /etc/letsencrypt/live/"
  register: live_certs
  ignore_errors: true

- name: "Certs found"
  debug:
    msg: "{{ live_certs.stdout }}"

- name: "Obtain certificates"
  command: "letsencrypt certonly --standalone --agree-tos -m {{ notify_email }} -d {{ item.1 }}"
#  debug:
#    msg: "getting cert {{ item.1 }}"
  with_subelements:
    - "{{ web_projects }}"
    - domains 
  when: "item.1 not in live_certs.stdout_lines"

- name: "Start NGINX"
  systemd:
    name: nginx
    state: started
  ignore_errors: true

- name: "Update renewal settings to web-root"
  lineinfile:
    dest: "/etc/letsencrypt/renewal/{{ item.1 }}.conf"
    regexp: '^authenticator '
    line: "authenticator = webroot"
    state: present
  with_subelements:
    - "{{ web_projects }}"
    - domains

- name: "Add Let's Encrypt cronjob for cert renewal"
  cron:
    name: letsencrypt_renewal
    special_time: weekly
    job: letsencrypt renew --webroot -w /var/www/html/ &> /var/log/letsencrypt/letsencrypt.log && service nginx reload

Тут есть один “костыль”: изначально сертификаты получаются через standalone аутентификатор (command: "letsencrypt certonly --standalone), т.к. если установка на новый хост – то NGINX там ещё не будет, а обновляться будут через webroot– т.к. NGINX уже будет запущен, а останавливать его для проверки апдейтов сертификатов – идея так себе. Следовательно – надо обновить конфигурацию в конфигах в каталоге /etc/letsencrypt/renewal/, что и делается в задаче “Update renewal settings to web-root”. Должно работать, проверю по ходу дела.

Добавляем в плейбук rtfm.yml перед NGINX, т.к. его конфиги уже будут с настроенным SSL:

- hosts: all
  become:
    true

  roles:
    - role: test
      tags: test
    - role: cloudformation
      tags: infra
    - role: common
      tags: common, app
    - role: letsencrypt
      tags: ssl, app
    - role: nginx
      tags: nginx, web, app
    - role: php-fpm
      tags: php, web, app
    - role: mysql
      tags: db, app

Запускаем Ansible:

...
TASK [letsencrypt : Install Let's Encrypt client] ****
changed: [dev.rtfm.co.ua]
TASK [letsencrypt : Stop NGINX] ****
changed: [dev.rtfm.co.ua]
TASK [letsencrypt : Obtain certificates] ****
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'dev.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'rtfm'}, u'test.rtfm.co.ua'))
changed: [dev.rtfm.co.ua] => (item=({u'name': u'setevoy'}, u'dev.test.setevoy.kiev.ua'))
...
TASK [letsencrypt : Start NGINX] ****
changed: [dev.rtfm.co.ua]
TASK [letsencrypt : Add Let's Encrypt cronjob for cert renewal] ****
changed: [dev.rtfm.co.ua]
PLAY RECAP ****
dev.rtfm.co.ua             : ok=7    changed=4    unreachable=0    failed=0    
Provisioning done.

Обновляем шаблоны NGINX.

Т.к. на Dev доступ ограничен с помощью ngx_http_access_module и директив allow/deny – то надо обновить шаблоны, и разрешить доступ к .well_known.

Редактируем nginx/templates/dev/dev.rtfm.co.ua.conf.j2:

server {

    listen 80;
    server_name {{ item.1 }};

    server_tokens off;

    location ~ /.well-known {
        allow all;
    }

    location / {
        allow {{ office_allow_location }};
        allow {{ home_allow_location_provider1 }};
        allow {{ home_allow_location_provider2 }};
        deny all;
        return 301 https://{{ item.1 }}$request_uri;
    }
}

server {

    listen 443 ssl;
    server_name {{ item.1 }};

    root {{ web_data_root_prefix }}/rtfm/{{ item.1 }};

    add_header Strict-Transport-Security "max-age=31536000; includeSubdomains" always;
    server_tokens off;

    access_log /var/log/nginx/{{ item.1 }}-access.log main_ext;
    error_log /var/log/nginx/{{ item.1 }}-error.log warn;

    ssl_certificate /etc/letsencrypt/live/{{ item.1 }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ item.1 }}/privkey.pem;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_dhparam /etc/nginx/dhparams.pem;
    ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_stapling on;
    ssl_stapling_verify on;

    allow {{ office_allow_location }};
    allow {{ home_allow_location_provider1 }};
    allow {{ home_allow_location_provider2 }};
    deny all;

   ...

Обновляем остальные конфиги, деплоим их на хост.

Копирование блога

Эту задачу можно тоже автоматизировать в Ansible, но лучше пока руками.

Копируем файлы:

root@rtfm-dev:/home/admin# scp -r setevoy@rtfm.co.ua:/data/rtfm.co.ua/* /data/www/rtfm/dev.rtfm.co.ua/

Создаём дамп базы данных:

14:30:48 [setevoy@rtfm-prod-current ~] $ mysqldump -h 172.31.64.60 -u setevoy -p rtfm_db1 > rtfm_db1.sql

Копируем его на Dev:

root@rtfm-dev:/home/admin# scp  setevoy@rtfm.co.ua:/home/setevoy/rtfm_db1.sql .

Заливаем в базу rtfm_dev:

root@rtfm-dev:/home/admin# mysql -u rtfm -p rtfm_dev < rtfm_db1.sql

Обновляем домен в базе (тут префикс таблиц db1_ вместо дефолтного wp_):

MariaDB [rtfm_dev]> UPDATE db1_options SET option_value = replace(option_value, 'https://rtfm.co.ua', 'https://dev.rtfm.co.ua') WHERE option_name = 'home' OR option_name = 'siteurl';
Query OK, 2 rows affected (0.00 sec)
MariaDB [rtfm_dev]> UPDATE db1_posts SET guid = replace(guid, 'https://rtfm.co.ua', 'https://dev.rtfm.co.ua');
Query OK, 6209 rows affected (0.20 sec)
MariaDB [rtfm_dev]> UPDATE db1_posts SET post_content = replace(post_content, 'https://rtfm.co.ua', 'https://dev.rtfm.co.ua');
Query OK, 3312 rows affected (1.58 sec)
MariaDB [rtfm_dev]> UPDATE db1_postmeta SET meta_value = replace(meta_value, 'https://rtfm.co.ua', 'https://dev.rtfm.co.ua');
Query OK, 1482 rows affected (0.26 sec)

Обновляем параметры в /data/www/rtfm/dev.rtfm.co.ua/wp-config.phpDB_NAME, DB_USER, DB_PASSWORD, DB_HOST.

Проверяем:

Так… Ну – почти… Что-то с плагином sitemap-generator – он реально древний, и для PHP-7 не заточен. Да и виджет плагина Rating Widget тоже выдаёт варнинги, и тоже из-за переезда PHP 5 => 7 – надо пообщаться с их саппортом.

Но в целом – осталось добавить остальные роли, типа exim, amplify-agent, logrotate – и всё готово к миграции.