Впровадження 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 http 80
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 http 80
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.ua/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_site.ua/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/your_site.ua/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.ua/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_site.ua/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/your_site.ua/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 scripts for
}

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|звичайний|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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.