Переклад статті підготовлений спеціально для студентів курсу
Два роки тому я провів
ClickHouse складається зі 170 тисяч рядків C++ коду, за винятком сторонніх бібліотек, і є однією з найменших кодових баз для розподілених баз даних. Для порівняння, SQLite не підтримує розподіл і складається з 235 тисяч рядків коду мовою С. На момент написання цієї статті свій внесок у ClickHouse внесли 207 інженерів, і інтенсивність коммітів останнім часом збільшується.
У березні 2017 року ClickHouse почав вести
У цій статті я збираюся поглянути на продуктивність кластера ClickHouse на AWS EC2 з використанням 36-ядерних процесорів та NVMe-накопичувача.
АПДЕЙТ: Через тиждень після початкової публікації цієї посади я повторно запустив тест з покращеною конфігурацією і досяг набагато кращих результатів. Ця посада була оновлена, щоб відобразити ці зміни.
Запуск кластеру AWS EC2
Я буду використовувати три екземпляри c5d.9xlarge EC2 для цієї посади. Кожен містить 36 віртуальних ЦП, 72 ГБ ОЗУ, 900 ГБ накопичувача NVMe SSD і підтримує 10-гігабітну мережу. Вони коштують $1,962/годину кожен у регіоні eu-west-1 при запуску на вимогу. Я буду використовувати Ubuntu Server 16.04 LTS як операційну систему.
Фаєрвол налаштований так, що кожна машина може зв'язуватися один з одним без обмежень, і тільки моя IPv4-адреса занесена до білого списку SSH в кластері.
NVMe-накопичувач у стані робочої готовності
Для роботи ClickHouse я створюю в NVMe-накопичувачі файлову систему у форматі EXT4 на кожному із серверів.
$ sudo mkfs -t ext4 /dev/nvme1n1
$ sudo mkdir /ch
$ sudo mount /dev/nvme1n1 /ch
Після того, як все налаштовано, ви можете побачити точку монтування та 783 ГБ простору, доступного в кожній із систем.
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
loop0 7:0 0 87.9M 1 loop /snap/core/5742
loop1 7:1 0 16.5M 1 loop /snap/amazon-ssm-agent/784
nvme0n1 259:1 0 8G 0 disk
└─nvme0n1p1 259:2 0 8G 0 part /
nvme1n1 259:0 0 838.2G 0 disk /ch
$ df -h
Filesystem Size Used Avail Use% Mounted on
udev 35G 0 35G 0% /dev
tmpfs 6.9G 8.8M 6.9G 1% /run
/dev/nvme0n1p1 7.7G 967M 6.8G 13% /
tmpfs 35G 0 35G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 35G 0 35G 0% /sys/fs/cgroup
/dev/loop0 88M 88M 0 100% /snap/core/5742
/dev/loop1 17M 17M 0 100% /snap/amazon-ssm-agent/784
tmpfs 6.9G 0 6.9G 0% /run/user/1000
/dev/nvme1n1 825G 73M 783G 1% /ch
Набір даних, який я використовуватиму в цьому тесті, є дампом даних, який я сформував з 1.1 мільярда поїздок на таксі, зроблених у Нью-Йорку за шість років. У блозі
$ sudo apt update
$ sudo apt install awscli
$ aws configure
Я встановлюю обмеження кількості одночасних запитів клієнта на 100, щоб файли завантажувалися швидше, ніж при стандартних налаштуваннях.
$ aws configure set
default.s3.max_concurrent_requests
100
Я завантажую набір даних поїздок на таксі з AWS S3 та збережу його на диску NVMe на першому сервері. Цей набір даних становить ~ 104 ГБ у GZIP-стиснутому CSV-форматі.
$ sudo mkdir -p /ch/csv
$ sudo chown -R ubuntu /ch/csv
$ aws s3 sync s3://<bucket>/csv /ch/csv
Установка ClickHouse
Я встановлюю дистрибутив OpenJDK для Java 8, тому що він необхідний для запуску Apache ZooKeeper, необхідного для розподіленого встановлення ClickHouse на всіх трьох машинах.
$ sudo apt update
$ sudo apt install
openjdk-8-jre
openjdk-8-jdk-headless
Потім я встановлюю змінне середовище JAVA_HOME
.
$ sudo vi /etc/profile
export JAVA_HOME=/usr
$ source /etc/profile
Потім я буду використовувати систему керування пакетами в Ubuntu для встановлення ClickHouse 18.16.1, glances та ZooKeeper на всі три машини.
$ sudo apt-key adv
--keyserver hkp://keyserver.ubuntu.com:80
--recv E0C56BD4
$ echo "deb http://repo.yandex.ru/clickhouse/deb/stable/ main/" |
sudo tee /etc/apt/sources.list.d/clickhouse.list
$ sudo apt-get update
$ sudo apt install
clickhouse-client
clickhouse-server
glances
zookeeperd
Я створю каталог для ClickHouse, а також здійсню деякі перевизначення конфігурації на всіх трьох серверах.
$ sudo mkdir /ch/clickhouse
$ sudo chown -R clickhouse /ch/clickhouse
$ sudo mkdir -p /etc/clickhouse-server/conf.d
$ sudo vi /etc/clickhouse-server/conf.d/taxis.conf
Це перевизначення конфігурації, які я використовуватиму.
<?xml version="1.0"?>
<yandex>
<listen_host>0.0.0.0</listen_host>
<path>/ch/clickhouse/</path>
<remote_servers>
<perftest_3shards>
<shard>
<replica>
<host>172.30.2.192</host>
<port>9000</port>
</replica>
</shard>
<shard>
<replica>
<host>172.30.2.162</host>
<port>9000</port>
</replica>
</shard>
<shard>
<replica>
<host>172.30.2.36</host>
<port>9000</port>
</replica>
</shard>
</perftest_3shards>
</remote_servers>
<zookeeper-servers>
<node>
<host>172.30.2.192</host>
<port>2181</port>
</node>
<node>
<host>172.30.2.162</host>
<port>2181</port>
</node>
<node>
<host>172.30.2.36</host>
<port>2181</port>
</node>
</zookeeper-servers>
<macros>
<shard>03</shard>
<replica>01</replica>
</macros>
</yandex>
Потім я запущу ZooKeeper та сервер ClickHouse на всіх трьох машинах.
$ sudo /etc/init.d/zookeeper start
$ sudo service clickhouse-server start
Завантаження даних у ClickHouse
На першому сервері я створю таблицю поїздок (trips
), в якій зберігатиметься набір даних поїздок у таксі з використанням движка Log.
$ clickhouse-client --host=0.0.0.0
CREATE TABLE trips (
trip_id UInt32,
vendor_id String,
pickup_datetime DateTime,
dropoff_datetime Nullable(DateTime),
store_and_fwd_flag Nullable(FixedString(1)),
rate_code_id Nullable(UInt8),
pickup_longitude Nullable(Float64),
pickup_latitude Nullable(Float64),
dropoff_longitude Nullable(Float64),
dropoff_latitude Nullable(Float64),
passenger_count Nullable(UInt8),
trip_distance Nullable(Float64),
fare_amount Nullable(Float32),
extra Nullable(Float32),
mta_tax Nullable(Float32),
tip_amount Nullable(Float32),
tolls_amount Nullable(Float32),
ehail_fee Nullable(Float32),
improvement_surcharge Nullable(Float32),
total_amount Nullable(Float32),
payment_type Nullable(String),
trip_type Nullable(UInt8),
pickup Nullable(String),
dropoff Nullable(String),
cab_type Nullable(String),
precipitation Nullable(Int8),
snow_depth Nullable(Int8),
snowfall Nullable(Int8),
max_temperature Nullable(Int8),
min_temperature Nullable(Int8),
average_wind_speed Nullable(Int8),
pickup_nyct2010_gid Nullable(Int8),
pickup_ctlabel Nullable(String),
pickup_borocode Nullable(Int8),
pickup_boroname Nullable(String),
pickup_ct2010 Nullable(String),
pickup_boroct2010 Nullable(String),
pickup_cdeligibil Nullable(FixedString(1)),
pickup_ntacode Nullable(String),
pickup_ntaname Nullable(String),
pickup_puma Nullable(String),
dropoff_nyct2010_gid Nullable(UInt8),
dropoff_ctlabel Nullable(String),
dropoff_borocode Nullable(UInt8),
dropoff_boroname Nullable(String),
dropoff_ct2010 Nullable(String),
dropoff_boroct2010 Nullable(String),
dropoff_cdeligibil Nullable(String),
dropoff_ntacode Nullable(String),
dropoff_ntaname Nullable(String),
dropoff_puma Nullable(String)
) ENGINE = Log;
Потім я розпакую і завантажую кожен із CSV-файлів у таблицю поїздок (trips
). Наступне виконано за 55 хвилин та 10 секунд. Після цієї операції розмір каталогу даних становив 134 ГБ.
$ time (for FILENAME in /ch/csv/trips_x*.csv.gz; do
echo $FILENAME
gunzip -c $FILENAME |
clickhouse-client
--host=0.0.0.0
--query="INSERT INTO trips FORMAT CSV"
done)
Швидкість імпорту становила 155 МБ стисненого CSV-контенту за секунду. Я підозрюю, що це було пов'язано з вузьким місцем у GZIP-декомпресії. Можливо, швидше було розпакувати всі файли gzip паралельно, використовуючи xargs, а потім завантажити розпаковані дані. Нижче наведено опис того, що повідомлялося під час імпорту CSV.
$ sudo glances
ip-172-30-2-200 (Ubuntu 16.04 64bit / Linux 4.4.0-1072-aws) Uptime: 0:11:42
CPU 8.2% nice: 0.0% LOAD 36-core MEM 9.8% active: 5.20G SWAP 0.0%
user: 6.0% irq: 0.0% 1 min: 2.24 total: 68.7G inactive: 61.0G total: 0
system: 0.9% iowait: 1.3% 5 min: 1.83 used: 6.71G buffers: 66.4M used: 0
idle: 91.8% steal: 0.0% 15 min: 1.01 free: 62.0G cached: 61.6G free: 0
NETWORK Rx/s Tx/s TASKS 370 (507 thr), 2 run, 368 slp, 0 oth sorted automatically by cpu_percent, flat view
ens5 136b 2Kb
lo 343Mb 343Mb CPU% MEM% VIRT RES PID USER NI S TIME+ IOR/s IOW/s Command
100.4 1.5 1.65G 1.06G 9909 ubuntu 0 S 1:01.33 0 0 clickhouse-client --host=0.0.0.0 --query=INSERT INTO trips FORMAT CSV
DISK I/O R/s W/s 85.1 0.0 4.65M 708K 9908 ubuntu 0 R 0:50.60 32M 0 gzip -d -c /ch/csv/trips_xac.csv.gz
loop0 0 0 54.9 5.1 8.14G 3.49G 8091 clickhous 0 S 1:44.23 0 45M /usr/bin/clickhouse-server --config=/etc/clickhouse-server/config.xml
loop1 0 0 4.5 0.0 0 0 319 root 0 S 0:07.50 1K 0 kworker/u72:2
nvme0n1 0 3K 2.3 0.0 91.1M 28.9M 9912 root 0 R 0:01.56 0 0 /usr/bin/python3 /usr/bin/glances
nvme0n1p1 0 3K 0.3 0.0 0 0 960 root -20 S 0:00.10 0 0 kworker/28:1H
nvme1n1 32.1M 495M 0.3 0.0 0 0 1058 root -20 S 0:00.90 0 0 kworker/23:1H
Я звільню місце на NVMe-приводі, видаляючи вихідні CSV-файли, перш ніж продовжити.
$ sudo rm -fr /ch/csv
Перетворення на колонкову форму
Двигун Log ClickHouse зберігатиме дані у рядково-орієнтованому форматі. Щоб швидше запитувати дані, я конвертую їх у колонковий формат за допомогою движка MergeTree.
$ clickhouse-client --host=0.0.0.0
Наступне виконано за 34 хвилини та 50 секунд. Після цієї операції розмір каталогу даних становив 237 ГБ.
CREATE TABLE trips_mergetree
ENGINE = MergeTree(pickup_date, pickup_datetime, 8192)
AS SELECT
trip_id,
CAST(vendor_id AS Enum8('1' = 1,
'2' = 2,
'CMT' = 3,
'VTS' = 4,
'DDS' = 5,
'B02512' = 10,
'B02598' = 11,
'B02617' = 12,
'B02682' = 13,
'B02764' = 14)) AS vendor_id,
toDate(pickup_datetime) AS pickup_date,
ifNull(pickup_datetime, toDateTime(0)) AS pickup_datetime,
toDate(dropoff_datetime) AS dropoff_date,
ifNull(dropoff_datetime, toDateTime(0)) AS dropoff_datetime,
assumeNotNull(store_and_fwd_flag) AS store_and_fwd_flag,
assumeNotNull(rate_code_id) AS rate_code_id,
assumeNotNull(pickup_longitude) AS pickup_longitude,
assumeNotNull(pickup_latitude) AS pickup_latitude,
assumeNotNull(dropoff_longitude) AS dropoff_longitude,
assumeNotNull(dropoff_latitude) AS dropoff_latitude,
assumeNotNull(passenger_count) AS passenger_count,
assumeNotNull(trip_distance) AS trip_distance,
assumeNotNull(fare_amount) AS fare_amount,
assumeNotNull(extra) AS extra,
assumeNotNull(mta_tax) AS mta_tax,
assumeNotNull(tip_amount) AS tip_amount,
assumeNotNull(tolls_amount) AS tolls_amount,
assumeNotNull(ehail_fee) AS ehail_fee,
assumeNotNull(improvement_surcharge) AS improvement_surcharge,
assumeNotNull(total_amount) AS total_amount,
assumeNotNull(payment_type) AS payment_type_,
assumeNotNull(trip_type) AS trip_type,
pickup AS pickup,
pickup AS dropoff,
CAST(assumeNotNull(cab_type)
AS Enum8('yellow' = 1, 'green' = 2))
AS cab_type,
precipitation AS precipitation,
snow_depth AS snow_depth,
snowfall AS snowfall,
max_temperature AS max_temperature,
min_temperature AS min_temperature,
average_wind_speed AS average_wind_speed,
pickup_nyct2010_gid AS pickup_nyct2010_gid,
pickup_ctlabel AS pickup_ctlabel,
pickup_borocode AS pickup_borocode,
pickup_boroname AS pickup_boroname,
pickup_ct2010 AS pickup_ct2010,
pickup_boroct2010 AS pickup_boroct2010,
pickup_cdeligibil AS pickup_cdeligibil,
pickup_ntacode AS pickup_ntacode,
pickup_ntaname AS pickup_ntaname,
pickup_puma AS pickup_puma,
dropoff_nyct2010_gid AS dropoff_nyct2010_gid,
dropoff_ctlabel AS dropoff_ctlabel,
dropoff_borocode AS dropoff_borocode,
dropoff_boroname AS dropoff_boroname,
dropoff_ct2010 AS dropoff_ct2010,
dropoff_boroct2010 AS dropoff_boroct2010,
dropoff_cdeligibil AS dropoff_cdeligibil,
dropoff_ntacode AS dropoff_ntacode,
dropoff_ntaname AS dropoff_ntaname,
dropoff_puma AS dropoff_puma
FROM trips;
Ось як виглядав glance-висновок під час операції:
ip-172-30-2-200 (Ubuntu 16.04 64bit / Linux 4.4.0-1072-aws) Uptime: 1:06:09
CPU 10.3% nice: 0.0% LOAD 36-core MEM 16.1% active: 13.3G SWAP 0.0%
user: 7.9% irq: 0.0% 1 min: 1.87 total: 68.7G inactive: 52.8G total: 0
system: 1.6% iowait: 0.8% 5 min: 1.76 used: 11.1G buffers: 71.8M used: 0
idle: 89.7% steal: 0.0% 15 min: 1.95 free: 57.6G cached: 57.2G free: 0
NETWORK Rx/s Tx/s TASKS 367 (523 thr), 1 run, 366 slp, 0 oth sorted automatically by cpu_percent, flat view
ens5 1Kb 8Kb
lo 2Kb 2Kb CPU% MEM% VIRT RES PID USER NI S TIME+ IOR/s IOW/s Command
241.9 12.8 20.7G 8.78G 8091 clickhous 0 S 30:36.73 34M 125M /usr/bin/clickhouse-server --config=/etc/clickhouse-server/config.xml
DISK I/O R/s W/s 2.6 0.0 90.4M 28.3M 9948 root 0 R 1:18.53 0 0 /usr/bin/python3 /usr/bin/glances
loop0 0 0 1.3 0.0 0 0 203 root 0 S 0:09.82 0 0 kswapd0
loop1 0 0 0.3 0.1 315M 61.3M 15701 ubuntu 0 S 0:00.40 0 0 clickhouse-client --host=0.0.0.0
nvme0n1 0 3K 0.3 0.0 0 0 7 root 0 S 0:00.83 0 0 rcu_sched
nvme0n1p1 0 3K 0.0 0.0 0 0 142 root 0 S 0:00.22 0 0 migration/27
nvme1n1 25.8M 330M 0.0 0.0 59.7M 1.79M 2764 ubuntu 0 S 0:00.00 0 0 (sd-pam)
В останньому тесті кілька стовпців було перетворено та перераховано. Я виявив, що деякі з цих функцій не працюють належним чином у цьому наборі даних. Для вирішення цієї проблеми я видалив невідповідні функції та завантажив дані без перетворення на більш детальні типи.
Розподіл даних по кластеру
Я розподілятиму дані по всіх трьох вузлах кластера. Для початку нижче створю таблицю на всіх трьох машинах.
$ clickhouse-client --host=0.0.0.0
CREATE TABLE trips_mergetree_third (
trip_id UInt32,
vendor_id String,
pickup_date Date,
pickup_datetime DateTime,
dropoff_date Date,
dropoff_datetime Nullable(DateTime),
store_and_fwd_flag Nullable(FixedString(1)),
rate_code_id Nullable(UInt8),
pickup_longitude Nullable(Float64),
pickup_latitude Nullable(Float64),
dropoff_longitude Nullable(Float64),
dropoff_latitude Nullable(Float64),
passenger_count Nullable(UInt8),
trip_distance Nullable(Float64),
fare_amount Nullable(Float32),
extra Nullable(Float32),
mta_tax Nullable(Float32),
tip_amount Nullable(Float32),
tolls_amount Nullable(Float32),
ehail_fee Nullable(Float32),
improvement_surcharge Nullable(Float32),
total_amount Nullable(Float32),
payment_type Nullable(String),
trip_type Nullable(UInt8),
pickup Nullable(String),
dropoff Nullable(String),
cab_type Nullable(String),
precipitation Nullable(Int8),
snow_depth Nullable(Int8),
snowfall Nullable(Int8),
max_temperature Nullable(Int8),
min_temperature Nullable(Int8),
average_wind_speed Nullable(Int8),
pickup_nyct2010_gid Nullable(Int8),
pickup_ctlabel Nullable(String),
pickup_borocode Nullable(Int8),
pickup_boroname Nullable(String),
pickup_ct2010 Nullable(String),
pickup_boroct2010 Nullable(String),
pickup_cdeligibil Nullable(FixedString(1)),
pickup_ntacode Nullable(String),
pickup_ntaname Nullable(String),
pickup_puma Nullable(String),
dropoff_nyct2010_gid Nullable(UInt8),
dropoff_ctlabel Nullable(String),
dropoff_borocode Nullable(UInt8),
dropoff_boroname Nullable(String),
dropoff_ct2010 Nullable(String),
dropoff_boroct2010 Nullable(String),
dropoff_cdeligibil Nullable(String),
dropoff_ntacode Nullable(String),
dropoff_ntaname Nullable(String),
dropoff_puma Nullable(String)
) ENGINE = MergeTree(pickup_date, pickup_datetime, 8192);
Потім я подбаю про те, щоб перший сервер міг бачити всі три вузли в кластері.
SELECT *
FROM system.clusters
WHERE cluster = 'perftest_3shards'
FORMAT Vertical;
Row 1:
──────
cluster: perftest_3shards
shard_num: 1
shard_weight: 1
replica_num: 1
host_name: 172.30.2.192
host_address: 172.30.2.192
port: 9000
is_local: 1
user: default
default_database:
Row 2:
──────
cluster: perftest_3shards
shard_num: 2
shard_weight: 1
replica_num: 1
host_name: 172.30.2.162
host_address: 172.30.2.162
port: 9000
is_local: 0
user: default
default_database:
Row 3:
──────
cluster: perftest_3shards
shard_num: 3
shard_weight: 1
replica_num: 1
host_name: 172.30.2.36
host_address: 172.30.2.36
port: 9000
is_local: 0
user: default
default_database:
Потім я визначу нову таблицю на першому сервері, яка базується на схемі trips_mergetree_third
і використовує Distributed двигун.
CREATE TABLE trips_mergetree_x3
AS trips_mergetree_third
ENGINE = Distributed(perftest_3shards,
default,
trips_mergetree_third,
rand());
Потім я скопіюю дані з таблиці на основі MergeTree на всі три сервери. Наступне виконано за 34 хвилини та 44 секунди.
INSERT INTO trips_mergetree_x3
SELECT * FROM trips_mergetree;
Після наведеної вище операції, я дав ClickHouse 15 хвилин, щоб відійти від позначки максимального рівня сховища. Каталоги даних зрештою становили 264 ГБ, 34 ГБ і 33 ГБ відповідно кожному з трьох серверів.
Оцінка продуктивності кластера ClickHouse
Те, що я побачив далі, було найшвидшим часом, який бачив при багаторазовому виконанні кожного запиту в таблиці trips_mergetree_x3
.
$ clickhouse-client --host=0.0.0.0
Наступне виконано за 2.449 секунди.
SELECT cab_type, count(*)
FROM trips_mergetree_x3
GROUP BY cab_type;
Наступне виконано за 0.691 секунди.
SELECT passenger_count,
avg(total_amount)
FROM trips_mergetree_x3
GROUP BY passenger_count;
Наступне виконано за 0 секунди.
SELECT passenger_count,
toYear(pickup_date) AS year,
count(*)
FROM trips_mergetree_x3
GROUP BY passenger_count,
year;
Наступне виконано за 0.983 секунди.
SELECT passenger_count,
toYear(pickup_date) AS year,
round(trip_distance) AS distance,
count(*)
FROM trips_mergetree_x3
GROUP BY passenger_count,
year,
distance
ORDER BY year,
count(*) DESC;
Для порівняння, я виконав ті ж запити в таблиці на основі MergeTree, яка знаходиться виключно на першому сервері.
Оцінка продуктивності одного вузла ClickHouse
Те, що я побачив далі, було найшвидшим часом, який бачив при багаторазовому виконанні кожного запиту в таблиці trips_mergetree_x3
.
Наступне виконано за 0.241 секунди.
SELECT cab_type, count(*)
FROM trips_mergetree
GROUP BY cab_type;
Наступне виконано за 0.826 секунди.
SELECT passenger_count,
avg(total_amount)
FROM trips_mergetree
GROUP BY passenger_count;
Наступне виконано за 1.209 секунди.
SELECT passenger_count,
toYear(pickup_date) AS year,
count(*)
FROM trips_mergetree
GROUP BY passenger_count,
year;
Наступне виконано за 1.781 секунди.
SELECT passenger_count,
toYear(pickup_date) AS year,
round(trip_distance) AS distance,
count(*)
FROM trips_mergetree
GROUP BY passenger_count,
year,
distance
ORDER BY year,
count(*) DESC;
Роздуми про результати
Це перший раз, коли безкоштовна база даних на базі процесора змогла перевершити базу даних на основі GPU у моїх тестах. Та база даних на основі GPU відтоді зазнала двох ревізій, але, проте, продуктивність, яку ClickHouse показав на одному вузлі, дуже вражає.
При цьому при виконанні Query 1 на розподіленому двигуні накладні витрати виявляються на порядок вищими. Я сподіваюся, що щось пропустив у своєму дослідженні для цієї посади, тому що було б добре побачити, як час запитів знижується, коли я додаю більше вузлів у кластер. Однак це чудово, що при виконанні інших запитів продуктивність зросла приблизно вдвічі.
Було б непогано, якби ClickHouse розвивався у напрямку того, щоб можна було відокремити сховище та обчислення, щоб вони могли масштабуватися незалежно. Підтримка HDFS, яку було додано минулого року, може стати кроком до цього. Що стосується обчислень, якщо один запит може бути прискорений при додаванні більшої кількості вузлів до кластера, то майбутнє цього програмного забезпечення буде дуже безхмарним.
Дякую, що знайшли час, щоб прочитати цей пост. Я пропоную консалтинг, архітектуру та послуги з практичного розвитку для клієнтів у Північній Америці та Європі. Якщо ви хочете обговорити, як мої пропозиції можуть допомогти вашому бізнесу, зв'яжіться зі мною через
Джерело: habr.com