Внедрение Docker для небольшого проекта в Production, часть 3

image

В предыдущих частях мы свами подготовили сервер к использованию контейнеров:
→ Часть 1. Установка CoreOS
→ Часть 2. Базовая настройка и настройка безопасности SSH

В этой части мы научимся работать с контейнерами и разворачивать стек приложений за считанные секунды. Но вначале я бы хотел сделать небольшое отступление на поступившие комментарии и замечания.

Один из важных вопросов был, а как же в CoreOS дела обстоят со Swap, отвечаю, дела отлично и вы сейчас в этом сами убедитесь. Итак, приступим к настройке. Как всегда для этого нам понадобиться подключение к серверу через SSH и текстовый редактор, кстати, хотел добавить ранее я говорил, что в системе нет редактора vim, я ошибся, он там есть, так что место vi можно использовать и vim сути это не поменяет. Для того чтобы включить своп нам не нужно будет размечать диск, для этого достаточно имеющегося раздела с ext4. Свопинг будем запускать в качестве службы, плюс этого метода в том, что своп можно отключать и включать как сервис, при этом регулируя размер свопа через файл описания службы.
Итак, в командной строке выполним следующее:

sudo vi /etc/systemd/system/swap.service

В содержимое файла внесем следующий текст

[Unit]
Description=Turn on swap partition

[Service]
Type=oneshot
Environment="SWAP_PATH=/var/vm" "SWAP_FILE=swapfile1"
ExecStartPre=-/usr/bin/rm -rf ${SWAP_PATH}
ExecStartPre=/usr/bin/mkdir -p ${SWAP_PATH}
ExecStartPre=/usr/bin/touch ${SWAP_PATH}/${SWAP_FILE}
ExecStartPre=/bin/bash -c "fallocate -l 2048m ${SWAP_PATH}/${SWAP_FILE}"
ExecStartPre=/usr/bin/chmod 600 ${SWAP_PATH}/${SWAP_FILE}
ExecStartPre=/usr/sbin/mkswap ${SWAP_PATH}/${SWAP_FILE}
ExecStartPre=/usr/sbin/sysctl vm.swappiness=10
ExecStart=/sbin/swapon ${SWAP_PATH}/${SWAP_FILE}
ExecStop=/sbin/swapoff ${SWAP_PATH}/${SWAP_FILE}
ExecStopPost=-/usr/bin/rm -rf ${SWAP_PATH}
RemainAfterExit=true

[Install]
WantedBy=multi-user.target

Для удобства настройка свопинга идет через переменные окружения

Environment="SWAP_PATH=/var/vm" "SWAP_FILE=swap_part1"

Указываем путь и имя файла, для дальнейшей работы, следующей строкой

ExecStartPre=/bin/bash -c "fallocate -l 2048m ${SWAP_PATH}/${SWAP_FILE}"

Мы скажем что размер файла у нас 2048 мегабайта, что равно 2 ГБ, думаю в нашей системе это будет с избытком.
Этот участок файла собственно отвечает за включение и отключения самого файла, думаю дополнительные разъяснения не нужны, все предельно читаемо и понятно.

ExecStart=/sbin/swapon ${SWAP_PATH}/${SWAP_FILE}
ExecStop=/sbin/swapoff ${SWAP_PATH}/${SWAP_FILE}

После этого нужно сохранить наш файл и покинуть редактор, теперь необходимо запустить службу и активировать свопинг.
Для этого как обычно воспользуемся консолью и введем команду:

sudo systemctl enable --now /etc/systemd/system/swap.service

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

free –hm

Которая покажет нам информацию о памяти в понятном виде в мегабайтах. В результате мы увидим что значения раздела Swap приобретут настроенные нами цифры, а именно всего 2ГБ, а уж использование зависит от нагрузки на систему, но так как у нас она еще не нагружена то там будут нули.

Отлично, теперь пришло время подготовить все необходимое для того чтобы развернуть наш сайт, так как сайт у нас на WordPress то и для его работы нам понадобиться Web Server (Apache, Nginx), PHP, MySQL. Лично я буду использовать следующую связку: PHP 7.1, Nginx 1.11.9, MariaDB 10.1.21. Почему эту? Да потому, что мне так нравиться. Считаю что это самая производительная связка для того чтобы использовать WordPress, но помимо этого мы еще пожалуй добавим Varnish 5, и Memcached 1.4.34 для того чтобы наш блог работал еще быстрей. Начнем мы с самого простого и перейдем к более сложному. Сперва запустим memcache, для этого как обычно в консоль дадим команду на выполнение:

docker run -d -p 11211:11211 --restart=always --log-driver=syslog --name=memcached memcached

Тут все достаточно просто, говорим контейнеру автоматически перезапускаться, логи выводим в syslog, ну и даем внятное имя. По умолчанию настройки хорошо сбалансированы, и если я не ошибаюсь там выделено 64 мегабайта памяти, для нашего блога этого будет достаточно. Для того чтобы использовать эту возможность в наш WP нужно доставить плагин, лично я использую WP Total Cache, как его настроить есть куча мануалов, мы не будем останавливаться на этом.

Далее мы запустим наш сервер баз данных, это тоже тривиальная задача и не требует много времени и настроек, команда для запуска выглядит так:

docker run -d -ti -p local_ip:3306:3306 --log-driver=syslog -v /cloud/mysql:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=PASSWORD --restart=always --name=mariadb mariadb

Расскажу, о том какие опции мы задаём при запуске, итак local_ip:3306:3306 тут нужно указать на каком IP адресе будет доступ наш сервер, если у вас приватное частное облако, то укажите адрес, который присвоен карте, которая смотрит в локальную сети облака, если у вас просто выделенная виртуалка то укажите 127.0.0.1, не нужно сервер БД публиковать на внешний IP в целях соблюдения безопасности. Таким образом, весь софт который использует ресурсы БД, будет подключаться по внутреннему адресу сети к нашей базе. Логи направляем как обычно в syslog, а вот параметр -v /cloud/mysql:/var/lib/mysql говорит нам о том, что нужно пробросить локальную папку в наш контейнер. Это сделано для того, чтобы при уничтожении контейнера наши базы остались целыми и невредимыми. Затем в контейнер нужно передать переменную окружения, MYSQL_ROOT_PASSWORD без нее наш контейнер не запуститься. Тут мы установим пароль пользователя root к нашей бд, чем сложней, тем лучше. Не ограничивайтесь в длине пароля. Вводить его придется редко. Далее известные нам параметры. Хотелось лишь бы обратить внимание на то, что я использую официальные образы этих приложений с хаба, не указывая тег, то есть по умолчанию всегда будет браться последняя версия. Но если локально образ у вас уже есть, то будет взята версия которая храниться локально, поэтому для того чтобы обновиться мы можем выполнить следующую команду:

docker pull memcached mariadb

Тогда с хаба, будет взята гарантированно последняя версия образа. Для тех кому нужно время по установленному нами часовому поясу на этапу установки, то в запуск каждого контейнера имеет смысл добавить следующую опцию

-v /etc/localtime:/etc/localtime

Она пробросит нам в контейнер файлик с настройкой временной зоны. Далее запустим наш веб сервер, для этого выполним команду:

docker run -d -p 80:80 -p 443:443 -p 81:81 -v /cloud/run/php-fpm:/sock -v /cloud/etc/nginx:/etc/nginx -v /cloud/etc/letsencrypt/:/etc/letsencrypt/ --log-driver=syslog -v /cloud/data/www/:/var/www/html --restart=always --name=nginx nginx

Если присмотреться то тут уже намного больше параметров мы передаем контейнеру при запуске. Давайте остановимся на них подробнее. Для начала мы пробросим порты 80, 443 и 81 в нашу систему, если с первыми двумя все понятно, то вот 81 вызывает вопросы. Этот порт мы будем использовать для бакэнда Varnish, об этом будет дальше. Схема достаточно простая, так как Varnish не умеет работать с SSL то сперва подключение по 80 порту примет Nginx, сделает редирект на 443 порт, так как мы будем во всю использовать сертификаты от LetsEncrypt, с 443 порта редирект пойдет на Varnish, если он найдет в кеше результат запроса то выдает его, если нет то сошлеться на бакэнд по 81 порту который снова обслуживает Nginx. Пример конфигурационных файлов я выложу ниже. Для того чтобы наш контейнер с php мог обрабатывать скрпиты мы пробросим в контейнер с веб сервером папку с сокетом php-fpm вот так: -v /cloud/run/php-fpm:/sock. Далее пробросим конфигурацию -v /cloud/etc/nginx:/etc/nginx, сертификаты -v /cloud/etc/letsencrypt/:/etc/letsencrypt/. Так же настроим лог, и пробросим наш каталог с сайтами -v /cloud/data/www/:/var/www/html. На этом запуск контейнера завершен, но чтобы он действительно запустился нам нужно подготовить конфигурационные файлы. Пример файла как и обещал привожу ниже.

Nginx.conf
user  nginx;
worker_processes  1;

pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
    use epoll;
    multi_accept on;
}


http {
    server_tokens off;

    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
               
    access_log /dev/stdout;

    sendfile        on;
    sendfile_max_chunk 128k;
    keepalive_timeout  65;
    keepalive_requests 10;
    client_body_buffer_size 1K;
    client_header_buffer_size 2k;
    large_client_header_buffers 2 1k;
    client_max_body_size 32m;
    fastcgi_buffers 64 16K;
    fastcgi_buffer_size 64k;
    client_body_timeout 10;
    client_header_timeout 10;
    reset_timedout_connection on;
    send_timeout 1;
    tcp_nopush on;
    tcp_nodelay on;
    open_file_cache max=200000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;
    include /etc/nginx/sites-enabled/*.conf;
}

Site-0001.conf


# frontend configuration section

# listen based 80 http
server {
    listen 80 default_server;
    server_name www.your_site.ru;
    location /.well-known {
        root /var/www/html;
    }
    return 301 https://$host$request_uri;
}

# listen based 80 http
server {
    listen 80;
    server_name your_site.ru;
    location /.well-known {
        root /var/www/html;
    }
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name www.your_site.ru;
    location /.well-known {
        root /var/www/html;
    }
    ssl on;
    ssl_certificate /etc/letsencrypt/live/your_site.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_site.ru/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/your_site.ru/chain.pem;
    return 301 https://your_site.ru$request_uri;

}

server {
    listen 443 ssl http2 default_server;
    server_name your_site.ru;

    ssl on;
    ssl_stapling on;

    ssl_certificate /etc/letsencrypt/live/your_site.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_site.ru/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/your_site.ru/chain.pem;

    root /var/www/html/your_site.ru;

    rewrite /wp-admin$ $scheme://$host$uri/ permanent;
    keepalive_timeout               60 60;
    
    gzip                on;
    gzip_comp_level     1;
    gzip_min_length     512;
    gzip_buffers        8 64k;
    gzip_types text/plain;
    gzip_proxied        any;

    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:ssl_session_cache:10m;
    ssl_session_timeout 2m;

    ssl_dhparam /etc/nginx/ssl/dh2048.pem;

    ssl_protocols TLSv1.2 TLSv1.1 TLSv1;

    ssl_ciphers EECDH+ECDSA+AESGCM:EECDH+aRSA+AESGCM:EECDH+ECDSA+SHA512:EECDH+ECDSA+SHA384:EECDH+ECDSA+SHA256:ECDH+AESGCM:ECDH+AES256:DH+AESGCM:DH+AES256:RSA+AESGCM:!aNULL:!eNULL:!LOW:!RC4:!3DES:!MD5:!EXP:!PSK:!SRP:!DSS;

    add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains';


    location / {
    location = /wp-login.php {
        auth_basic "Restricted";
        auth_basic_user_file /etc/nginx/.htpasswd/passwd;
        proxy_pass http://your_interal_ip:81;

    }

    location ~* /wp-admin/~^.*\$ {
        auth_basic "Authorization Required";
        auth_basic_user_file /etc/nginx/.htpasswd/passwd;
        proxy_pass http://your_interal_ip:81;

    }
        proxy_pass      http://your_interal_ip:6081/;
        proxy_set_header    Host              $host;
        proxy_set_header    X-Real-IP         $remote_addr;
        proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto https;
        proxy_set_header    X-Forwarded-Port  443;
    }

}

# end of frontend configuration section


# backend configuration

server {
    listen 81;
    root /var/www/html/your_site.ru;

    gzip                on;
    gzip_comp_level     7;
    gzip_min_length     512;
    gzip_buffers        8 64k;
    gzip_types text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/svg+xml;
    gzip_proxied        any;

    server_name your_site.ru;
    
    index index.html index.php;

location / {

    if ($host !~ ^(your_site.ru)$ ) {
        return 444;
    }    
    
    try_files $uri $uri/ /index.php?$args;
    error_page 404 /404.html;
    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location ~ /\.ht {
        deny all;
    }

    location ~* /(?:uploads|files)/.*\.php$ {
        deny all; # deny for scripts
    }

    location ~* ^.+\.(ogg|ogv|svg|svgz|eot|otf|woff|mp4|ttf|rss|atom|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)$ {
        access_log off;
        log_not_found off;
        expires max; # cashe for static
    }
        

    location  = /favicon.ico {
        log_not_found off;
        access_log off;
    }

    location  = /robots.txt {
        allow all;
        log_not_found off;
        access_log off;
    }
    
    location = /xmlrpc.php {
        deny all;
    }



#deny referer
    if ( $http_referer ~* (babes|forsale|girl|jewelry|love|nudit|organic|poker|porn|sex|teen) ) {
        return 403;
    }

    if ($http_user_agent ~* LWP::Simple|BBBike|wget) {
        return 403;
    }

    if ($http_user_agent ~* msnbot|scrapbot) {
        return 403;
    }
}

    location ~ [^/]\.php(/|$) {
        fastcgi_split_path_info ^(.+?\.php)(/.*)$;
        if (!-f $document_root$fastcgi_script_name) {
            return 404;
        }

    include fastcgi_params;
    fastcgi_param HTTPS on;
    fastcgi_ignore_client_abort     off;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/sock/php-fpm.sock;
}
}

Пояснять о значениях в конфигах не буду, на хабре есть статьи по которым я сам настраивал эти сервисы, при желании их можно легко найти.

Теперь приступим к самому интересному, это сборка нашего PHP 7, дело в том, что базовый образ не включает все необходимые нам расширения, поэтому мы соберем образ сами. Для того, чтобы у вас заработал практически любой движек, с любой темой, нам нужно создать следующий контейнер описав его Dockerfile:

FROM php:7-fpm
RUN apt-get update \
    && apt-get -y install \
            libmagickwand-dev \
	    libmcrypt-dev \
	    libpng12-dev \
            libjpeg62-turbo-dev \
            libfreetype6-dev \
            libmemcached-dev \
            libicu-dev \
        --no-install-recommends \
    && pecl install imagick \
    && docker-php-ext-enable imagick\
    && curl -L -o /tmp/memcached.tar.gz "https://github.com/php-memcached-dev/php-memcached/archive/php7.tar.gz" \
    && mkdir -p /usr/src/php/ext/memcached \
    && tar -C /usr/src/php/ext/memcached -zxvf /tmp/memcached.tar.gz --strip 1 \
    && docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \
    && docker-php-ext-configure memcached \
    && docker-php-ext-install gd mcrypt mysqli pdo_mysql zip calendar opcache memcached exif intl sockets \
    && rm -rf /tmp/* /var/cache/apk/* /var/lib/apt/lists/* \

Так как базовый образ построен на Debian Jessie то мы не будем нарушать эту традицию, и соберем на его базе свой, лишь только добавим в него нужные нам расширения. Далее дело останется за мылам, прописать конфиг, и запустить контейнер.

После сборки контейнера нужно выдернуть файлы конфигурации для правки, предположим при сборке мы назвали контейнер local/php7. Далее приступаем к конфигурированию:

docker create --name=php7 local/php7
docker cp php7:/usr/local/etc /cloud/etc/php-fpm

Все, мы скопировали дефолтные конфигурационные файлы в нашу директорию, осталось их немного настроить.

docker-php-custom-user.ini
default_charset = "UTF-8"
file_uploads = On
max_file_uploads = 20
date.timezone = "Europe/Moscow"
cgi.fix_pathinfo=1
display_errors = Off
log_errors = On
log_errors_max_len = 1024
html_errors = On

register_globals = Off
short_open_tag = Off
safe_mode = Off
output_buffering = Off
zlib.output_compression = Off
implicit_flush = Off
allow_call_time_pass_reference = Off
max_execution_time = 30
max_input_time = 60
max_input_vars = 10000
variables_order = "EGPCS”
register_argc_argv = Off
magic_quotes_gpc = Off
magic_quotes_runtime = Off
magic_quotes_sybase = Off
session.use_cookies = 1

magic_quotes_gpc = Off;
default_charset	= UTF-8;
memory_limit = 64M;
max_execution_time = 36000;
upload_max_filesize = 999M;
mysql.connect_timeout = 20;
session.auto_start = Off;
session.use_only_cookies = On;
session.use_cookies = On;
session.use_trans_sid = Off;
session.cookie_httponly = On;
session.gc_maxlifetime = 3600;
allow_url_fopen = on;

Далее в этой же папке создаем файлы с именем дополнения для php и расширением ini следующего содержания:

extension=imagick.so

Это например файл docker-php-ext-imagick.ini

И так для каждого. Приводить все я их не буду, принцип я думаю понятен. Для особо ленивых я выложил это все тут.

Самая важная настройка лежит в файле zz-docker.conf

[global]
daemonize = no

[www]
listen = /sock/php-fpm.sock

Не забудьте ее прописать, иначе через unix сокеты у нас php-fpm работать не будет. Если же ее оставить по умолчанию, то php-fpm запуститься на 9000 порту, и нам нужно будет поменять наш upstream в nginx с сокета на tcp, это для тех, кто в будущем захочет вынести php-fpm на отдельную машину. В рамках виртуального частного облака это не только просто, но и безопасно.

Все готово, теперь запускаем контейнер введя в консоль команду:

docker run -d -v /cloud/run/php-fpm:/sock -v /cloud/etc/php-fpm/etc:/usr/local/etc -v /cloud/data/www:/var/www/html -v /cloud/log/php-fpm:/var/log/php-fpm --log-driver=syslog --restart=always --name=php7 visman/php7.1 

Думаю описывать параметры команды запуска контейнера не буду, отмечу лишь то, что в контейнер я пробросил папку -v /cloud/data/www:/var/www/html с файлами веб сервера, сами думаю, знаете для чего.

Итак подведем промежуточный итог, у нас есть Nginx готовый принимать подключения, есть PHP-FPM 7.1, который будет обрабатывать наши php файлы, есть база данных, и есть зачатки кеширования в виде memcached. Теперь нам нужно настроить Varnish. Но сперва соберем наш образ, как обычно ниже привожу Dockerfile


FROM debian:jessie

RUN export DEBIAN_FRONTEND=noninteractive && \
    apt-get update -y -q && \
    apt-get install -y -q apt-transport-https curl && \
    rm -rf /var/lib/apt/lists/*

RUN curl -k https://repo.varnish-cache.org/GPG-key.txt | apt-key add - && \
	echo "deb https://repo.varnish-cache.org/debian/ jessie varnish-4.1" | tee -a /etc/apt/sources.list.d/varnish-cache.list && \
        apt-get update -y -q && \
        apt-get install -y -q gcc libjemalloc1 libedit2 && \
        curl -O https://repo.varnish-cache.org/pkg/5.0.0/varnish_5.0.0-1_amd64.deb && \
        dpkg -i varnish_5.0.0-1_amd64.deb &&\
        rm varnish_5.0.0-1_amd64.deb && \
	apt-get install -y -q varnish-agent && \
	rm -rf /var/lob/apt/lists/*
	
ADD docker-entrypoint.sh /usr/bin/entrypoint.sh
ADD varnish /etc/default/varnish

RUN chmod +x /usr/bin/entrypoint.sh

EXPOSE 6081 6082 6085

ENTRYPOINT ["/usr/bin/entrypoint.sh"] 

Прошу заметить, я ставлю последнюю 5-ю версию не из репозиториев, а вручную. Так как она вышла не так давно и не успела попасть в репы. Более того, я добавляю Varnish Agent, он к сожалению версии 4.1, но это нам не помешает. Для чего увидим позже.

entrypoint.sh


#!/bin/bash
set -e

service varnish start
varnish-agent -c 6085 -H /var/www/html/varnish-dashboard/
tailf /etc/varnish/default.vcl

Последняя строка служит нам для того, чтобы вывести наши правила кеширования для Varnish, она не обязательна, но на моменте отладки удобно посмотреть подхватывает он нужные правила или нет.

varnish

RELOAD_VCL=1

START=yes

# Maximum number of open files (for ulimit -n)
NFILES=131072

# Maximum locked memory size (for ulimit -l)
# Used for locking the shared memory log in memory.  If you increase log size,
# you need to increase this number as well
MEMLOCK=82000

DAEMON_OPTS="-a :6081 \
     -T :6082 \
     -f /etc/varnish/default.vcl \
     -S /etc/varnish/secret \
     -s malloc,256m"

Это файл настроек я выделяю 256 МБ под кеш, лично мне этого достаточно. Далее нам нужно настроить Varnish Dashboard, как это сделано, описано тут. Я не буду описывать этот процесс, но кому нужно пиши в коменты или ЛС я обязательно помогу. Для nginx файл настроек будет такой:

server {
    listen 80;
    server_name varnish.your_site.ru;
    return 301 https://$host$request_uri;

    if ($scheme != "https") {
        return 301 https://$host$request_uri;
    } # managed by Certbot

}
server {
	listen 443 ssl http2;
	server_name varnish.your_site.ru;

	ssl on;

	ssl_certificate /etc/letsencrypt/live/varnish. your_site.ru/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/varnish. your_site.ru/privkey.pem;
	ssl_trusted_certificate /etc/letsencrypt/live/varnish. your_site.ru/chain.pem;

location /.well-known {
        root /var/www/html;
    }

    location / {
        proxy_pass http://interal_ip:6085;
    }
}

Теперь запустим контейнер
docker run -d -ti -p 6082:6082 -p 6081:6081 -p 6085:6085 -v /cloud/data/www/varnish-dashboard:/var/www/html/varnish-dashboard -v /cloud/etc/varnish:/etc/varnish -v /etc/localtime:/etc/localtime --log-driver=syslog --name=varnish visman/d_varnish:5

Для тех, кому лень собирать свой образ, в строке запуска уже есть мой образ, собранный ранее. Вам нужно лишь разместить конфиги на своих местах. Я намеренно не выложил файл default.vcl так как у каждого он свой, более того есть хорошая статья, как настроить Varnish. Вот собственно мы и закончили установку нашего супового набора для запуска блога на WP.

Я намеренно упустил часть конфигурирования MariaDB, так как в интернетах полно мануалов как это сделать. Я не использую docker-compose по причине того, что в поставке CoreOS его нет, но это решаемо. Да и запуск единичных сервисов мне кажется более удобным. Тем более все команды можно завернуть в один скриптик, прописать в cloud-init, использовать Ansible….

Спасибо за внимание, продолжение следует. Если что и забыл, извиняюсь, так как пишу статью отрываясь от работы.

Комментарии (0)

© Habrahabr.ru