[Мессенджеры, Open source, Системное администрирование, PHP, Программирование] Рефакторинг пет проекта: докеризация, метрики, тесты

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
17-Фев-2021 21:32

Всем привет, я php разработчик. Я хочу поделиться историей, как я рефакторил один из своих телеграм ботов, который из поделки на коленке стал сервисом с более чем 1000 пользователей в очень узкой и специфической аудитории. ПредысторияПару лет назад я решил тряхнуть стариной и поиграть в LineAge IIна одном из популярных пиратских серверов. В этой игре есть один игровой процесс, в котором требуется "поговорить" с ящиками после смерти 4 боссов. Ящик стоит после смерти 2 минуты. Сами боссы после смерти появляются спустя 24 +/- 6ч, то есть шанс появится есть как через 18ч, так и через 30ч. У меня на тот момент была фуллтайм работа, да и в целом не было времени ждать эти ящики. Но нескольким моим персонажам требовалось пройти этот квест, поэтому я решил "автоматизировать" этот процесс. На сайте сервера есть RSS фид в формет XML, где публикуются события с серверов, включая события смерти босса.Задумка была следующей:
  • получить данные с RSS
  • сравнить данные с локальной копией в базе данных
  • если есть разница данных - сообщить об этом в телеграм канал
  • отдельно сообщать если босса не убили за первые 9ч сообщением "осталось 3ч", и "осталось 1,5ч". Допустим вечером пришло сообщение, что осталось 3ч, значит смерть босса будет до того, как я пойду спать.
Код на php был написан быстро и в итоге у меня было 3 php файла. Один был с god objectклассом, а другие два запускали программу в двух режимах - парсер новых, или проверка есть ли боссы на максимальном "респе". Запускал я их крон командами. Это работало и решало мою проблему.Другие игроки замечали, что я появляюсь в игре сразу после смерти боссов, и через 10 дней у меня на канале было около 50 подписчиков. Так же попросили сделать такое же для второго сервера этого пиратского сервиса. Задачу я тоже решил копипастой. В итоге у меня уже 4 файла с почти одинаковым кодом, и файл с god object. Потом меня попросили сделать то же самое для третьего сервера этого пиратского сервиса. И это отлично работало полтора года.В итоге у меня спустя полтора года:
  • у меня 6 файлов, дублируют себя почти полностью (по 2 файла на сервер)
  • один god object на несколько сотен строк
  • MySQL и Redis на сервере, где разместил код
  • cron задачи, которые запускают файлы
  • ~1400 подписчиков на канале в телеграм
Я откладывал месяцами рефакторинг этого кода, как говориться "работает - не трогай". Но хотелось этот проект привести в порядок, чтобы проще было вносить изменения, легче запускать и переносить на другой сервер, мониторить работоспособность и тд. При этом сделать это за выходные, в свое личное время.Ожидаемый результат после рефакторинга
  • Отрефакторить код так, чтобы легче было вносить изменения. Важный момент - отрефакторить без изменения бизнес логики, по сути раскидать god object по файлам, сам код не править, иначе это затянет сроки. Следовать PSR-12.
  • Докеризировать воркера для удобства переноса на другой сервер и прозрачность запуска и остановки
  • Запускать воркера через supervisor
  • Внедрить процесс тестирования кода, настроить Codeception
  • Докеризировать MySQL и Redis
  • Настроить Github Actions для запуска тестов и проверки на code style
  • Поднять Prometheus, Grafana для метрик и мониторинга работоспособности
  • Сделать докер контейнер, который будет отдавать метрики на страницу /metrics для Prometheus
  • Сделать докер образ для бота телеграм, который будет отдавать срез по всем статусам 4 боссов в данный момент командами боту в личку
Важное замечание. Все эти шаги выполнялись не совсем в том порядке, как я их описываю в этом туториале. Сделал все требуемое за выходные плюс пара вечеров после работы. Так же в целях не было сделать проект "идеальным", не совершать "революции", а дать возможность проекту плавно эволюционировать. Большая часть пунктов из плана давала возможность развивать проект.Шаг 1. Рефакторинг приложенияОдним из требований было не потратить на это недели, поэтому основные классы я решил сделать наследниками Singleton
<?php
declare(strict_types=1);
namespace AsteriosBot\Core\Support;
use AsteriosBot\Core\Exception\DeserializeException;
use AsteriosBot\Core\Exception\SerializeException;
class Singleton
{
    protected static $instances = [];
    /**
     * Singleton constructor.
     */
    protected function __construct()
    {
        // do nothing
    }
    /**
     * Disable clone object.
     */
    protected function __clone()
    {
        // do nothing
    }
    /**
     * Disable serialize object.
     *
     * @throws SerializeException
     */
    public function __sleep()
    {
        throw new SerializeException("Cannot serialize singleton");
    }
    /**
     * Disable deserialize object.
     *
     * @throws DeserializeException
     */
    public function __wakeup()
    {
        throw new DeserializeException("Cannot deserialize singleton");
    }
    /**
     * @return static
     */
    public static function getInstance(): Singleton
    {
        $subclass = static::class;
        if (!isset(self::$instances[$subclass])) {
            self::$instances[$subclass] = new static();
        }
        return self::$instances[$subclass];
    }
}
Таким образом вызов любого класса, который от него наследуются, можно делать методом getInstance()Вот так, например, выглядел класс подключения к базе данных
<?php
declare(strict_types=1);
namespace AsteriosBot\Core\Connection;
use AsteriosBot\Core\App;
use AsteriosBot\Core\Support\Config;
use AsteriosBot\Core\Support\Singleton;
use FaaPz\PDO\Database as DB;
class Database extends Singleton
{
    /**
     * @var DB
     */
    protected DB $connection;
    /**
     * @var Config
     */
    protected Config $config;
    /**
     * Database constructor.
     */
    protected function __construct()
    {
        $this->config = App::getInstance()->getConfig();
        $dto = $this->config->getDatabaseDTO();
        $this->connection = new DB($dto->getDsn(), $dto->getUser(), $dto->getPassword());
    }
    /**
     * @return DB
     */
    public function getConnection(): DB
    {
        return $this->connection;
    }
}
В процессе рефакторинга я не менял саму бизнес логику, оставил все "как было". Цель было именно разнести по файлам для облегчения изменения правок, а так же для возможности потом покрыть тестами.Шаг 2: Докеризация воркеровЗапуск всех контейнеров я сделал через docker-compose.ymlКонфиг сервиса для воркеров выглядит так:
worker:
    build:
      context: .
      dockerfile: docker/worker/Dockerfile
    container_name: 'asterios-bot-worker'
    restart: always
    volumes:
      - .:/app/
    networks:
      - tier
А сам docker/worker/Dockerfile выглядит так:
FROM php:7.4.3-alpine3.11
# Copy the application code
COPY . /app
RUN apk update && apk add --no-cache \
    build-base shadow vim curl supervisor \
    php7 \
    php7-fpm \
    php7-common \
    php7-pdo \
    php7-pdo_mysql \
    php7-mysqli \
    php7-mcrypt \
    php7-mbstring \
    php7-xml \
    php7-simplexml \
    php7-openssl \
    php7-json \
    php7-phar \
    php7-zip \
    php7-gd \
    php7-dom \
    php7-session \
    php7-zlib \
    php7-redis \
    php7-session
# Add and Enable PHP-PDO Extenstions
RUN docker-php-ext-install pdo pdo_mysql
RUN docker-php-ext-enable pdo_mysql
# Redis
RUN apk add --no-cache pcre-dev $PHPIZE_DEPS \
        && pecl install redis \
        && docker-php-ext-enable redis.so
# Install PHP Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Remove Cache
RUN rm -rf /var/cache/apk/*
# setup supervisor
ADD docker/supervisor/asterios.conf /etc/supervisor/conf.d/asterios.conf
ADD docker/supervisor/supervisord.conf /etc/supervisord.conf
VOLUME ["/app"]
WORKDIR /app
RUN composer install
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Обратите внимание на последнюю строку в Dockerfile, там я запускаю supervisord, который будет мониторить работу воркеров.Шаг 3: Настройка supervisorВажный дисклеймер по supervisor. Он предназначен для работы с процессами, которые работают долго, и в случае его "падения" - перезапустить. Мои же php скрипты работали быстро и сразу завершались. supervisor пробовал их перезапустить, и в конце концов переставал пытаться поднять снова. Поэтому я решил сам код воркера запускать на 1 минуту, чтобы это работало с supervisor. Код файла worker.php
<?php
require __DIR__ . '/vendor/autoload.php';
use AsteriosBot\Channel\Checker;
use AsteriosBot\Channel\Parser;
use AsteriosBot\Core\App;
use AsteriosBot\Core\Connection\Log;
$app = App::getInstance();
$checker = new Checker();
$parser = new Parser();
$servers = $app->getConfig()->getEnableServers();
$logger = Log::getInstance()->getLogger();
$expectedTime = time() + 60; // +1 min in seconds
$oneSecond = time();
while (true) {
    $now = time();
    if ($now >= $oneSecond) {
        $oneSecond = $now + 1;
        try {
            foreach ($servers as $server) {
                $parser->execute($server);
                $checker->execute($server);
            }
        } catch (\Throwable $e) {
            $logger->error($e->getMessage(), $e->getTrace());
        }
    }
    if ($expectedTime < $now) {
        die(0);
    }
}
У RSS есть защита от спама, поэтому пришлось сделать проверку на секунды и посылать не более 1го запроса в секунду. Таким образом мой воркер каждую секунду выполняет 2 действия, сначала проверяет rss, а затем калькулирует время боссов для сообщений о старте или окончании времени респауна боссов. После 1 минуты работы воркер умирает, и его перезапускает supervisorСам конфиг supervisor выглядит так:
[program:worker]
command = php /app/worker.php
stderr_logfile=/app/logs/supervisor/worker.log
numprocs = 1
user = root
startsecs = 3
startretries = 10
exitcodes = 0,2
stopsignal = SIGINT
reloadsignal = SIGHUP
stopwaitsecs = 10
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr = true
После старта контейнеров супервизор стартует воркера автоматически. Важный момент - в файле основного конфига /etc/supervisord.confобязательно нужно указать демонизация процесса, а так же подключение своих конфигов
[supervisord]
nodaemon=true
[include]
files = /etc/supervisor/conf.d/*.conf
Набор полезных команд supervisorctl:
supervisorctl status       # статус воркеров
supervisorctl stop all     # остановить все воркера
supervisorctl start all    # запустить все воркера
supervisorctl start worker # запустить один воркера с конфига, блок [program:worker]
Шаг 4: Настройка Codeception Я планирую в свободное время по чуть-чуть покрывать unit тестами уже существующий код, а со временем сделать еще и интеграционные. Пока что настроил только юнит тестирование и написал пару тестов на особо важную бизнес логику. Настройка была тривиальной, все завелось с коробки, только добавил в конфиг поддержку базы данны
# Codeception Test Suite Configuration
#
# Suite for unit or integration tests.
actor: UnitTester
modules:
    enabled:
        - Asserts
        - \Helper\Unit
        - Db:
              dsn: 'mysql:host=mysql;port=3306;dbname=test_db;'
              user: 'root'
              password: 'password'
              dump: 'tests/_data/dump.sql'
              populate: true
              cleanup: true
              reconnect: true
              waitlock: 10
              initial_queries:
                - 'CREATE DATABASE IF NOT EXISTS test_db;'
                - 'USE test_db;'
                - 'SET NAMES utf8;'
    step_decorators: ~
Шаг 5: Докеризация MySQL и RedisНа сервере, где работало это приложение, у меня было еще пара других ботов. Все они использовали один сервер MySQL и один Redis для кеша. Я решил вынести все, что связано с окружением в отельный docker-compose.yml, а самих ботов залинковать через внешний docker networkВыглядит это так:
version: '3'
services:
  mysql:
    image: mysql:5.7.22
    container_name: 'telegram-bots-mysql'
    restart: always
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: "${DB_PASSWORD}"
      MYSQL_ROOT_HOST: '%'
    volumes:
      - ./docker/sql/dump.sql:/docker-entrypoint-initdb.d/dump.sql
    networks:
      - tier
  redis:
    container_name: 'telegram-bots-redis'
    image: redis:3.2
    restart: always
    ports:
      - "127.0.0.1:6379:6379/tcp"
    networks:
      - tier
  pma:
    image: phpmyadmin/phpmyadmin
    container_name: 'telegram-bots-pma'
    environment:
      PMA_HOST: mysql
      PMA_PORT: 3306
      MYSQL_ROOT_PASSWORD: "${DB_PASSWORD}"
    ports:
      - '8006:80'
    networks:
      - tier
networks:
  tier:
    external:
      name: telegram-bots-network
DB_PASSWORD я храню в .env файле, а ./docker/sql/dump.sql у меня лежит бекап для инициализации базы данных. Так же я добавил external network так же, как в этом конфиге - в каждом docker-compose.yml каждого бота на сервере. Таким образом они все находятся в одной сети и могут использовать общие базу данных и редис.Шаг 6: Настройка Github ActionsВ шаге 4 этого туториала я добавил тестовый фреймфорк Codeception, который для тестирования требует базу данных. В самом проекте нет базы, в шаге 5 я ее вынес отдельно и залинковал через external docker network. Для запуска тестов в Github Actions я решил полностью собрать все необходимое на лету так же через docker-compose.
name: Actions
on:
  pull_request:
    branches: [master]
  push:
    branches: [master]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Get Composer Cache Directory
        id: composer-cache
        run: |
          echo "::set-output name=dir::$(composer config cache-files-dir)"
      - uses: actions/cache@v1
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-
      - name: Composer validate
        run: composer validate
      - name: Composer Install
        run: composer install --dev --no-interaction --no-ansi --prefer-dist --no-suggest --ignore-platform-reqs
      - name: PHPCS check
        run: php vendor/bin/phpcs --standard=psr12 app/ -n
      - name: Create env file
        run: |
          cp .env.github.actions .env
      - name: Build the docker-compose stack
        run: docker-compose -f docker-compose.github.actions.yml -p asterios-tests up -d
      - name: Sleep
        uses: jakejarvis/wait-action@master
        with:
          time: '30s'
      - name: Run test suite
        run: docker-compose -f docker-compose.github.actions.yml -p asterios-tests exec -T php vendor/bin/codecept run unit
Инструкция onуправляет когда билд триггернётся. В моем случае - при создании пулл реквеста или при коммите в мастер.Инструкция uses: actions/checkout@v2 запускает проверку доступа процесса к репозиторию. Далее идет проверка кеша композера, и установка пакетов, если в кеше не найденоЗатем в строке run: php vendor/bin/phpcs --standard=psr12 app/ -nя запускаю проверку кода соответствию стандарту PSR-12 в папке ./app Так как тут у меня специфическое окружение, я подготовил файл .env.github.actionsкоторый копируется в .env Cодержимое .env.github.actions
SERVICE_ROLE=test
TG_API=XXXXX
TG_ADMIN_ID=123
TG_NAME=AsteriosRBbot
DB_HOST=mysql
DB_NAME=root
DB_PORT=3306
DB_CHARSET=utf8
DB_USERNAME=root
DB_PASSWORD=password
LOG_PATH=./logs/
DB_NAME_TEST=test_db
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
SILENT_MODE=true
FILLER_MODE=true
Из важного тут только настройки базы данных, которые не должны отличаться от настроек базы в этом окружении.Затем я собираю проект при помощи docker-compose.github.actions.ymlв котором прописано все необходимое для тестирвания, контейнер с проектом и база данных. Содержимое docker-compose.github.actions.yml:
version: '3'
services:
  php:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    container_name: 'asterios-tests-php'
    volumes:
      - .:/app/
    networks:
      - asterios-tests-network
  mysql:
    image: mysql:5.7.22
    container_name: 'asterios-tests-mysql'
    restart: always
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: asterios
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - ./tests/_data/dump.sql:/docker-entrypoint-initdb.d/dump.sql
    networks:
      - asterios-tests-network
#
#  redis:
#    container_name: 'asterios-tests-redis'
#    image: redis:3.2
#    ports:
#      - "127.0.0.1:6379:6379/tcp"
#    networks:
#      - asterios-tests-network
networks:
  asterios-tests-network:
    driver: bridge
Я закомментировал контейнер с Redis, но оставил возможность использовать его в будущем. Сборка с кастомным docker-compose файлом, а затем тесты - запускается так
docker-compose -f docker-compose.github.actions.yml -p asterios-tests up -d
docker-compose -f docker-compose.github.actions.yml -p asterios-tests exec -T php vendor/bin/codecept run unit
Внимательный читатель обратит внимание на пункт между стартом контейнеров и запуском тестов. Это задержка в 30 секунд для того, чтобы база данных успела заполниться тестовыми данными.Шаг 7: Настройка Prometheus и GrafanaВ шаге 5 я вынес MySQL и Redis в отдельный docker-compose.yml. Так как Prometheus и Grafana тоже общие для всех моих телеграм ботов, я их добавил туда же. Сам конфиг этих контейнеров выглядит так:
prometheus:
    image: prom/prometheus:v2.0.0
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
    restart: always
    ports:
      - 9090:9090
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    networks:
      - tier
  grafana:
    container_name: 'telegram-bots-grafana'
    image: grafana/grafana:7.1.1
    ports:
      - 3000:3000
    environment:
      - GF_RENDERING_SERVER_URL=http://renderer:8081/render
      - GF_RENDERING_CALLBACK_URL=http://grafana:3000/
      - GF_LOG_FILTERS=rendering:debug
    volumes:
      - ./grafana.ini:/etc/grafana/grafana.ini
      - grafanadata:/var/lib/grafana
    networks:
      - tier
    restart: always
  renderer:
    image: grafana/grafana-image-renderer:latest
    container_name: 'telegram-bots-grafana-renderer'
    restart: always
    ports:
      - 8081
    networks:
      - tier
Они так же залинкованы одной сетью, которая потом линкуется с external docker network. Prometheus: я прокидываю свой конфиг prometheus.yml, где я могу указать источники для парсинга метрикGrafana: я создаю volume, где будут храниться конфиги и установленные плагины. Так же я прокидываю ссылку на сервис рендеринга графиков, который мне понадобиться для отправки alert. С этим плагином alert приходит со скриншотом графика.Поднимаю проект и устанавливаю плагин, затем перезапускаю Grafana контейнер
docker-compose up -d
docker-compose exec grafana grafana-cli plugins install grafana-image-renderer
docker-compose stop  grafana
docker-compose up -d grafana
Шаг 8: Публикация метрик приложенияДля сбора и публикации метрик я использовал endclothing/prometheus_client_phpТак выглядит мой класс для метрик
<?php
declare(strict_types=1);
namespace AsteriosBot\Core\Connection;
use AsteriosBot\Core\App;
use AsteriosBot\Core\Support\Singleton;
use Prometheus\CollectorRegistry;
use Prometheus\Exception\MetricsRegistrationException;
use Prometheus\Storage\Redis;
class Metrics extends Singleton
{
    private const METRIC_HEALTH_CHECK_PREFIX = 'healthcheck_';
    /**
     * @var CollectorRegistry
     */
    private $registry;
    protected function __construct()
    {
        $dto = App::getInstance()->getConfig()->getRedisDTO();
        Redis::setDefaultOptions(
            [
                'host' => $dto->getHost(),
                'port' => $dto->getPort(),
                'database' => $dto->getDatabase(),
                'password' => null,
                'timeout' => 0.1, // in seconds
                'read_timeout' => '10', // in seconds
                'persistent_connections' => false
            ]
        );
        $this->registry = CollectorRegistry::getDefault();
    }
    /**
     * @return CollectorRegistry
     */
    public function getRegistry(): CollectorRegistry
    {
        return $this->registry;
    }
    /**
     * @param string $metricName
     *
     * @throws MetricsRegistrationException
     */
    public function increaseMetric(string $metricName): void
    {
        $counter = $this->registry->getOrRegisterCounter('asterios_bot', $metricName, 'it increases');
        $counter->incBy(1, []);
    }
    /**
     * @param string $serverName
     *
     * @throws MetricsRegistrationException
     */
    public function increaseHealthCheck(string $serverName): void
    {
        $prefix = App::getInstance()->getConfig()->isTestServer() ? 'test_' : '';
        $this->increaseMetric($prefix . self::METRIC_HEALTH_CHECK_PREFIX . $serverName);
    }
}
Для проверки работоспособности парсера мне нужно сохранить метрику в Redis после получения данных с RSS. Если данные получены, значит все нормально, и можно сохранить метрику
if ($counter) {
            $this->metrics->increaseHealthCheck($serverName);
        }
Где переменная $counter это количество записей в RSS. Там будет 0, если получить данные не удалось, и значит метрика не будет сохранена. Это потом понадобится для alert по работе сервиса. Затем нужно метрики опубликовать на странице /metric чтобы Prometheus их спарсил. Добавим хост в конфиг prometheus.yml из шага 7.
# my global config
global:
  scrape_interval:     5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).
scrape_configs:
  - job_name: 'bots-env'
    static_configs:
      - targets:
          - prometheus:9090
          - pushgateway:9091
          - grafana:3000
          - metrics:80 # тут будут мои метрики по uri /metrics
Код, который вытащит метрики из Redis и создаст страницу в текстовом формате. Эту страничку будет парсить Prometheus
$metrics = Metrics::getInstance();
$renderer = new RenderTextFormat();
$result = $renderer->render($metrics->getRegistry()->getMetricFamilySamples());
header('Content-type: ' . RenderTextFormat::MIME_TYPE);
echo $result;
Теперь настроим сам дашборд и alert. В настройках Grafana сначала укажите свой Prometheus как основной источник данных, а так же я добавил основной канал нотификации Телеграм (там добавляете токен своего бота и свой chat_id с этим ботом)
Настройка Grafana
  • Метрика increase(asterios_bot_healthcheck_x3[1m]) Показывает на сколько метрика asterios_bot_healthcheck_x3 увеличилась за 1 минуту
  • Название метрики (будет под графиком)
  • Название для легенды в пункте 4.
  • Легенда справа из пункта 3.
  • Правило, по которому проверяется метрика. В моем случае проверяет что за последние 30 секунд проблем не было
  • Правило, по которому будет срабатывать alert. В моем случае "Когда сумма из метрики А между сейчас и 10 секунд назад"
  • Если нет данных вообще - слать alert
  • Сообщение в alert
Выглядит alert в телеграм так (помните мы настраивали рендеринг картинок для alert?)
Alert в Телеграм
  • Обратите внимание, alert заметил падение, но все восстановилось. Grafana приготовилась слать alert, но передумала. Это то самое правило 30 секунд
  • Тут уже все упало больше чем на 30 секунд и alert был отправлен
  • Сообщение, которое мы указали в настройках alert
  • Ссылка на dashboard
  • Источник метрики
Шаг 9: Телеграм ботНастройка телеграм бота ничем не отличается от настройки воркера. Телеграм бот по сути у меня это еще один воркер, я запустил его при помощи добавления настроек в supervisor. Тут уже рефакторинг проекта дал свои плоды, запуск бота был быстрым и простым.ИтогиЯ жалею, что не сделал этого раньше. Я останавливал бота на период переустановки с новыми конфигами, и пользователи сразу стали просить пару новых фичей, которые добавить стали легче и быстрее. Надеюсь эта публикация вдохновит отрефакторить свой пет-проект и привести его в порядок. Не переписать, а именно отрефакторить. Ссылки на проекты
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_messendzhery (Мессенджеры), #_open_source, #_sistemnoe_administrirovanie (Системное администрирование), #_php, #_programmirovanie (Программирование), #_php, #_telegram, #_docker, #_dockercompose, #_prometheus, #_grafana, #_petproject, #_github_actions, #_testing, #_messendzhery (
Мессенджеры
)
, #_open_source, #_sistemnoe_administrirovanie (
Системное администрирование
)
, #_php, #_programmirovanie (
Программирование
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 13:56
Часовой пояс: UTC + 5