[{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/tags/labforge/","section":"Tags","summary":"","title":"Labforge","type":"tags"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" Для описанных примеров используйте Docker Engine 20.10 или новее и Docker Compose 2.17.0 или новее. Действия в статье выполнялись с Docker Engine 29.3.0 и Docker Compose 5.1.1. Предположим, вам нужно быстро развернуть группу связанных контейнеров. Скорее всего, вы примените Docker Compose, так? Перечисляем контейнеры, собираем их в единую сеть и используем самый простой вариант — обращение по hostname. Например, мы подняли nginx и несколько сервисов, между которыми распределяется нагрузка. Однако в один день после обновления образов и пересоздания контейнеров nginx начинает всем отдавать 502 (Bad Gateway). Что произошло?\nС подобной ситуацией я столкнулся в процессе внедрения Labforge — микросервисной платформы для развертывания виртуальных машин в среде Proxmox. Обновление сервисов приводило к потере запросов и ошибкам от nginx.\nКонечное решение можно увидеть в коммитах проекта (Docker Compose — 67af0ad, nginx — 9440d6c). Далее мы разберем, почему так получается и как это можно аккуратно решить при помощи проверок сервисов и полей-расширений Docker Compose.\nАнатомия сети. Откуда берутся hostname? # Попробуем поднять простую сеть из двух контейнеров:\nservices: a: image: debian:trixie container_name: container_a command: tail -f /dev/null hostname: container_a b: image: debian:trixie container_name: container_b command: tail -f /dev/null hostname: container_b Установим dnsutils и попробуем посмотреть, откуда контейнер А «знает» контейнер Б:\n$: docker exec -it container_a sh -c \u0026#39;apt-get update \u0026amp;\u0026amp; apt-get install dnsutils -y\u0026#39; # большой вывод $: docker exec -it container_a nslookup container_b Server:\t127.0.0.11 Address:\t127.0.0.11#53 $: Non-authoritative answer: Name:\tcontainer_b Address: 172.18.0.3 $: docker exec -it container_a nslookup container_b 8.8.8.8 Server:\t8.8.8.8 Address:\t8.8.8.8#53 ** server can\u0026#39;t find container_b: NXDOMAIN Контейнеру отвечает внутренний DNS Docker — 127.0.0.11, а внешние сервисы, очевидно, ничего не знают о нашем сервисе. По умолчанию контейнеры обращаются к этому DNS и получают от него адреса хостнеймов. Исключение — например, network_mode: host, когда контейнер включен в сеть хоста.\nВ сети Compose контейнер можно найти по имени сервиса, имени контейнера и его hostname. В нашем примере сервис называется b, а имя контейнера и hostname совпадают — container_b. При пересоздании контейнера Docker обновляет соответствие имён и IP-адреса. При этом новый контейнер может получить тот же адрес, что и старый.\nЕсли мы добавим сюда больше контейнеров и попробуем пересоздать несколько из них, они могут запуститься в другом порядке и получить новые IP-адреса. Это может произойти и без изменения порядка запуска:\nОбновленный docker-compose.yml, если вы хотите повторить опыт services: a: image: debian:trixie container_name: container_a command: tail -f /dev/null hostname: container_a b: # наше приложение выключается стандартные 10 секунд image: debian:trixie container_name: container_b command: tail -f /dev/null hostname: container_b c: image: debian:trixie container_name: container_c stop_grace_period: 5s command: tail -f /dev/null hostname: container_c d: image: debian:trixie container_name: container_d stop_grace_period: 8s command: tail -f /dev/null hostname: container_d $: docker exec -it container_a nslookup container_b Server:\t127.0.0.11 Address:\t127.0.0.11#53 Non-authoritative answer: Name:\tcontainer_b Address: 172.18.0.3 $: docker compose up -d --force-recreate b c d [+] up 3/3 ✔ Container container_b Started 10.7s ✔ Container container_c Started 10.7s ✔ Container container_d Started 10.7s $: docker exec -it container_a nslookup container_b Server:\t127.0.0.11 Address:\t127.0.0.11#53 Non-authoritative answer: Name:\tcontainer_b Address: 172.18.0.5 Таким образом, приходим к выводу, что контейнеру требуется повторно разрешать имя другого контейнера при его пересоздании. Изучим на примере Labforge, как я минимизировал воздействие этого эффекта.\nnginx: переход от маршрутизации по хостнейму к upstream # В духе лучших неудачных практик проксирование трафика выполнялось простым указанием имени контейнера:\nlocation /api/cluster/ { proxy_pass http://cluster:8000; } Как раз это и приводит к «запоминанию» адреса: nginx подставляет полученный IP-адрес и после его не меняет до перезапуска контейнера. В качестве альтернативы можно указать для nginx резолвер и время, пока его записи валидны. Добавим в http:\nhttp { #\u0026lt;...\u0026gt; resolver 127.0.0.11 valid=5s ipv6=off; resolver_timeout 2s; } Здесь мы указываем в качестве резолвера Docker DNS и задаем срок действия записей — 5 секунд, после чего они будут повторно запрошены. Помимо основных опций, мы отключаем IPv6 и указываем тайм-аут в две секунды. Таким образом, если контейнер изменит IP-адрес, nginx через некоторое время обновит запись и продолжит проксирование трафика.\nКроме этого, потребуется изменить конфигурацию сервера.\nОсновное описанное решение подходит для nginx версии выше или равной 1.27.3. Проверьте используемую версию! Решение для nginx ниже версии 1.27.3 Переопределите ваши бэкенды как переменные, используя приведенный выше пример:\nlocation /api/cluster/ { set $clusterbackend \u0026#34;cluster:8000\u0026#34;; proxy_pass http://$clusterbackend; } Определить resolver все равно требуется. Этот подход тоже заставляет nginx делать DNS-запросы, но лучше обновиться и сделать так, как описано ниже.\nЛучшим вариантом будет определить бэкенды как upstream: их можно назвать таким же именем, поскольку они имеют более высокий приоритет, но из proxy_pass нужно удалить порт. На примере /api/cluster:\nhttp { #\u0026lt;...\u0026gt; resolver 127.0.0.11 valid=5s ipv6=off; resolver_timeout 2s; upstream cluster { zone cluster 64k; server cluster:8000 resolve; } #\u0026lt;...\u0026gt; server { #\u0026lt;...\u0026gt; location /api/cluster/ { proxy_pass http://cluster; } } } Обратите внимание: у upstream не указывается порт, однако, если вы ранее использовали URL, вы можете его дописать, например http://grafana/grafana/api/live, при условии, что вы определили upstream «grafana».\nКроме того, для каждого upstream создается своя зона памяти на 64 килобайта для хранения данных между рабочими процессами.\nБлагодаря описанным правкам nginx восстанавливает работу раньше следующей перезагрузки. В условиях периодических обновлений сервиса без частого пересоздания контейнеров оставшееся время простоя оказалось приемлемым. Однако в схеме остаются другие сервисы: если нет доступа к управлению логикой разрешения адресов или таких сервисов слишком много для внесения правок, можно воспользоваться обходным вариантом — перезапускать зависимые сервисы с помощью проверок состояния контейнеров (healthcheck) и указания зависимостей (depends_on). Проверки состояния позволяют определить условия, при которых контейнеры можно считать готовыми к работе, а механизм зависимостей — перезапускать сервисы при обновлении или перезапуске зависимости через Compose.\nДобавляем проверку состояния контейнера и зависимые состояния # Healthcheck — механизм Docker, который позволяет определять состояние контейнера и в зависимости от этого состояния выполнять действия. В нашем случае он будет использован для последовательного старта сервисов. Дело в том, что при простом указании сервиса в depends_on контейнер будет запущен следом за своей зависимостью, в то время как она может быть еще не инициализирована до конца. Это не полностью относится к проблеме статьи, но поясняет, почему я настаиваю на такой реализации.\nРассмотрим реализацию на примере базы данных Labforge:\nservices: postgresql: image: postgres:15-alpine restart: unless-stopped hostname: postgres container_name: labforge-db env_file: - ../docker.env volumes: - ../pgdata:/data/postgres - ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro Чтобы данные PostgreSQL сохранялись в указанном каталоге, нужно задать переменную окружения PGDATA (для текущей конфигурации — /data/postgres). В данном случае она указана в docker.env.\nДокументация образа на Docker Hub\nТеперь добавим проверку состояния контейнера — будем периодически выполнять команду pg_isready.\nservices: postgresql: image: postgres:15-alpine restart: unless-stopped hostname: postgres container_name: labforge-db env_file: - ../docker.env volumes: - ../pgdata:/data/postgres - ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}\u0026#34;] # команда проверки interval: 10s # интервал проверки retries: 5 # количество попыток start_period: 30s # льготный период для ошибок при запуске timeout: 10s # время на выполнение команды проверки Далее мы можем «привязать» другой контейнер к запуску и перезапуску этого контейнера. Например, Keycloak:\nservices: postgresql: image: postgres:15-alpine restart: unless-stopped hostname: postgres container_name: labforge-db env_file: - ../docker.env volumes: - ../pgdata:/data/postgres - ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}\u0026#34;] interval: 10s retries: 5 start_period: 30s timeout: 10s keycloak: image: quay.io/keycloak/keycloak:26.2.5 restart: unless-stopped container_name: keycloak hostname: keycloak command: start-dev volumes: - ./keycloak/jq:/usr/bin/jq:ro env_file: - ../docker.env depends_on: postgresql: condition: service_healthy restart: true # перезапускать ли контейнер при явном обновлении или перезапуске зависимости через Compose Поле condition может принимать следующие значения:\nservice_started — требование запуска контейнера; то же самое будет применено, если написать в depends_on только имя сервиса. service_healthy — наш случай: требование запуска контейнера и прохождения им healthcheck до здорового статуса. Если контейнер получит статус unhealthy, команда запуска завершится с ошибкой. service_completed_successfully — сервис должен успешно завершить работу перед запуском контейнера. Теперь, если мы запустим Docker Compose, сначала будет запущена база, затем Compose дождется ее статуса healthy, после чего будет создан и запущен контейнер Keycloak. Однако при большом количестве контейнеров текущая реализация выглядит громоздкой и повторяющейся. Например, если у нас есть сервис, который зависит от пяти других, получится конструкция вида:\nservices: service_a: # \u0026lt;...\u0026gt; depends_on: service_b: condition: service_healthy restart: true service_c: condition: service_healthy restart: true service_d: condition: service_healthy restart: true service_e: condition: service_healthy restart: true service_f: condition: service_healthy restart: true Такая же ситуация с проверками контейнеров: если у нас много однотипных веб-серверов, придется для каждого писать блок healthcheck и при необходимости его править. По этой причине для Labforge я усовершенствовал данную конструкцию.\nИзбавляемся от повторений: добавляем поля-расширения # Поля-расширения — это инструмент для создания повторяемых модулей конфигурации Docker Compose. Это могут быть как значения отдельного поля, например общие параметры env, так и самостоятельные блоки, например healthcheck.\nСервисы в Labforge используют общий образ на базе Debian, не содержащий curl. Кроме того, у сервисов нет эндпоинта для проверки готовности, поэтому при помощи bash и команды timeout реализуем простую проверку прослушивания порта. Для этого в начало файла добавляем:\nx-generic-service-check: \u0026amp;port-healtcheck test: timeout 5s bash -c \u0026#39;:\u0026gt; /dev/tcp/127.0.0.1/8000\u0026#39; || exit 1 interval: 10s timeout: 10s retries: 5 Эта проверка показывает, можно ли открыть TCP-соединение, но не гарантирует готовность приложения к приему запросов. При необходимости отредактируйте команду. При встраивании мы будем использовать идентификатор после \u0026amp;. Для имени поля-расширения главное, чтобы оно начиналось с x-. Теперь мы можем применить поле к нескольким контейнерам, например:\nx-generic-service-check: \u0026amp;port-healtcheck test: timeout 5s bash -c \u0026#39;:\u0026gt; /dev/tcp/127.0.0.1/8000\u0026#39; || exit 1 interval: 10s timeout: 10s retries: 5 services: auth-service: profiles: [\u0026#39;app\u0026#39;] image: auth-service container_name: labforge-auth hostname: auth restart: unless-stopped env_file: - ../docker.env - ../auth.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck cluster-manager: profiles: [\u0026#39;app\u0026#39;] image: cluster-manager container_name: labforge-cluster hostname: cluster restart: unless-stopped env_file: - ../docker.env - ../cluster.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck Ситуация с модификацией depends_on аналогична, но стоит учесть один факт.\nПри встраивании одного поля-расширения используется формат \u0026lt;\u0026lt;: *field, а для множественного встраивания используется массив — \u0026lt;\u0026lt;: [*field1, *field2, *field3]. Исходя из этого, зависимость от сервисов вместе с нашей проверкой может выглядеть примерно так:\nx-postgresql-dependency: \u0026amp;restart-on-db postgresql: condition: service_healthy restart: true x-keycloak-dependency: \u0026amp;restart-on-keycloak keycloak: condition: service_healthy restart: true x-generic-service-check: \u0026amp;port-healtcheck test: timeout 5s bash -c \u0026#39;:\u0026gt; /dev/tcp/127.0.0.1/8000\u0026#39; || exit 1 interval: 10s timeout: 10s retries: 5 services: postgresql: image: postgres:15-alpine restart: unless-stopped hostname: postgres container_name: labforge-db env_file: - ../docker.env volumes: - ../pgdata:/data/postgres - ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}\u0026#34;] interval: 10s retries: 5 start_period: 30s timeout: 10s keycloak: image: quay.io/keycloak/keycloak:26.2.5 restart: unless-stopped container_name: keycloak hostname: keycloak command: start-dev volumes: - ./keycloak/jq:/usr/bin/jq:ro env_file: - ../docker.env healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;exec 3\u0026lt;\u0026gt;/dev/tcp/127.0.0.1/8080;echo -e \u0026#39;GET /health/ready HTTP/1.1\\r\\nhost: http://localhost\\r\\nConnection: close\\r\\n\\r\\n\u0026#39; \u0026gt;\u0026amp;3;if [ $? -eq 0 ]; then echo \u0026#39;Healthcheck Successful\u0026#39;;exit 0;else echo \u0026#39;Healthcheck Failed\u0026#39;;exit 1;fi;\u0026#34;] interval: 20s timeout: 15s retries: 5 depends_on: \u0026lt;\u0026lt;: *restart-on-db # одиночная зависимость auth-service: profiles: [\u0026#39;app\u0026#39;] image: auth-service container_name: labforge-auth hostname: auth restart: unless-stopped env_file: - ../docker.env - ../auth.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-db, *restart-on-keycloak] # множественное определение Заключение # Теперь вы знаете, как упорядочить процесс запуска контейнеров и чем может быть плоха навигация по хостнеймам, если не выполняется повторная проверка записей. На момент написания статьи финальный docker-compose.yml выглядит следующим образом:\nФинальный docker-compose.yml x-postgresql-dependency: \u0026amp;restart-on-db postgresql: condition: service_healthy restart: true x-rabbitmq-dependency: \u0026amp;restart-on-rabbitmq rabbitmq: condition: service_healthy restart: true x-keycloak-dependency: \u0026amp;restart-on-keycloak keycloak: condition: service_healthy restart: true x-auth-dependency: \u0026amp;restart-on-auth auth-service: condition: service_healthy restart: true x-cluster-dependency: \u0026amp;restart-on-cluster cluster-manager: condition: service_healthy restart: true x-deployment-dependency: \u0026amp;restart-on-deployment deployment-api: condition: service_healthy restart: true x-kroki-dependency: \u0026amp;restart-on-kroki kroki: condition: service_healthy restart: true x-loki-dependency: \u0026amp;restart-on-loki loki: condition: service_healthy restart: true x-generic-service-check: \u0026amp;port-healtcheck test: timeout 5s bash -c \u0026#39;:\u0026gt; /dev/tcp/127.0.0.1/8000\u0026#39; || exit 1 interval: 10s timeout: 10s retries: 5 services: postgresql: image: postgres:15-alpine restart: unless-stopped hostname: postgres container_name: labforge-db env_file: - ../docker.env volumes: - ../pgdata:/data/postgres - ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}\u0026#34;] interval: 10s retries: 5 start_period: 30s timeout: 10s keycloak: image: quay.io/keycloak/keycloak:26.2.5 restart: unless-stopped container_name: keycloak hostname: keycloak command: start-dev volumes: - ./keycloak/jq:/usr/bin/jq:ro env_file: - ../docker.env healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;exec 3\u0026lt;\u0026gt;/dev/tcp/127.0.0.1/8080;echo -e \u0026#39;GET /health/ready HTTP/1.1\\r\\nhost: http://localhost\\r\\nConnection: close\\r\\n\\r\\n\u0026#39; \u0026gt;\u0026amp;3;if [ $? -eq 0 ]; then echo \u0026#39;Healthcheck Successful\u0026#39;;exit 0;else echo \u0026#39;Healthcheck Failed\u0026#39;;exit 1;fi;\u0026#34;] interval: 20s timeout: 15s retries: 5 depends_on: \u0026lt;\u0026lt;: *restart-on-db redis: image: redis:8.2.0-bookworm restart: unless-stopped container_name: redis hostname: redis nginx: profiles: [\u0026#39;app\u0026#39;] image: labforge-nginx-prod container_name: labforge-nginx hostname: nginx ports: - 443:443 - 80:80 restart: unless-stopped websocket: profiles: [\u0026#39;app\u0026#39;] image: labforge-websockify container_name: labforge-websockify hostname: websockify restart: unless-stopped env_file: - ../docker.env - ../websockify.env depends_on: \u0026lt;\u0026lt;: [*restart-on-auth, *restart-on-cluster] auth-service: profiles: [\u0026#39;app\u0026#39;] image: auth-service container_name: labforge-auth hostname: auth restart: unless-stopped env_file: - ../docker.env - ../auth.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-db, *restart-on-keycloak] cluster-manager: profiles: [\u0026#39;app\u0026#39;] image: cluster-manager container_name: labforge-cluster hostname: cluster restart: unless-stopped env_file: - ../docker.env - ../cluster.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-db, *restart-on-auth] deployment-api: profiles: [\u0026#39;app\u0026#39;] image: deployment-api container_name: labforge-deployment hostname: deployment restart: unless-stopped env_file: - ../docker.env - ../deployment.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-db, *restart-on-auth] frontend: profiles: [\u0026#39;app\u0026#39;] image: frontend-server container_name: labforge-frontend hostname: frontend restart: unless-stopped env_file: - ../docker.env - ../frontend.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-auth, *restart-on-keycloak] kroki-proxy: profiles: [\u0026#39;app\u0026#39;] image: kroki-proxy container_name: labforge-kroki-proxy hostname: kroki restart: unless-stopped env_file: - ../docker.env - ../kroki.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-auth, *restart-on-kroki] jobservice: profiles: [\u0026#39;app\u0026#39;] image: jobservice container_name: labforge-jobservice hostname: jobservice restart: unless-stopped env_file: - ../docker.env - ../jobservice.env depends_on: \u0026lt;\u0026lt;: [*restart-on-db, *restart-on-rabbitmq, *restart-on-auth, *restart-on-cluster, *restart-on-deployment, *restart-on-loki] jobservice-api: profiles: [\u0026#39;app\u0026#39;] image: jobservice-api container_name: labforge-jobservice-api hostname: jobservice-api restart: unless-stopped env_file: - ../docker.env - ../jobservice-api.env healthcheck: \u0026lt;\u0026lt;: *port-healtcheck depends_on: \u0026lt;\u0026lt;: [*restart-on-db, *restart-on-rabbitmq, *restart-on-auth, *restart-on-deployment] kroki: profiles: [\u0026#39;app\u0026#39;] image: yuzutech/kroki container_name: labforge-kroki hostname: kroki-server restart: unless-stopped healthcheck: \u0026lt;\u0026lt;: *port-healtcheck grafana: profiles: [\u0026#39;app\u0026#39;] image: labforge-grafana:12.2.0-17567790421-ubuntu container_name: labforge-grafana restart: unless-stopped hostname: grafana user: root env_file: - ../docker.env - ../auth.env volumes: - ./grafana/grafana.ini:/etc/grafana/grafana.ini:ro - ./grafana/provisioning/:/etc/grafana/provisioning/:ro - ./grafana/dashboards/:/etc/grafana/dashboards/:ro - ../grafana:/var/lib/grafana depends_on: \u0026lt;\u0026lt;: [*restart-on-keycloak, *restart-on-loki] nginx-exporter: profiles: [\u0026#39;app\u0026#39;] image: nginx/nginx-prometheus-exporter:1.4 container_name: labforge-nginx-exporter restart: unless-stopped hostname: nginx-exporter command: --nginx.scrape-uri=http://nginx:8080/stub_status prometheus: profiles: [\u0026#39;app\u0026#39;] image: prom/prometheus:v3.5.0 user: root volumes: - ./prometheus/:/etc/prometheus/:ro - ../prometheus_data/:/prometheus/ container_name: labforge-prometheus hostname: prometheus command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=7d restart: unless-stopped cadvisor: profiles: [\u0026#39;app\u0026#39;] image: gcr.io/cadvisor/cadvisor:v0.55.1 container_name: labforge-cadvisor hostname: cadvisor privileged: true volumes: - /etc/localtime:/etc/localtime:ro - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro devices: - /dev/kmsg:/dev/kmsg environment: CADVISOR_HEALTHCHECK_URL: http://localhost:9092/healthz command: - \u0026#39;-port=9092\u0026#39; restart: unless-stopped rabbitmq: profiles: [\u0026#39;app\u0026#39;] image: rabbitmq:3.13-rc-management-alpine container_name: labforge-rabbitmq hostname: rabbitmq ports: - 15672:15672 restart: unless-stopped env_file: - ../docker.env volumes: - ./rabbitmq/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro healthcheck: test: rabbitmq-diagnostics -q ping interval: 3s timeout: 3s retries: 5 loki: image: grafana/loki:3.4.1 container_name: labforge-loki hostname: loki user: root restart: unless-stopped command: - --config.file=/etc/loki/config.yaml volumes: - ./loki/config.yaml:/etc/loki/config.yaml:ro - ../loki-data:/loki healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1 interval: 10s timeout: 10s retries: 3 alloy: image: grafana/alloy:v1.12.0 container_name: labforge-alloy restart: unless-stopped hostname: alloy privileged: true volumes: - ./alloy/config.alloy:/etc/alloy/config.alloy:ro - /var/run:/var/run:ro command: - run - /etc/alloy/config.alloy ","date":"1 ноября 2026","externalUrl":null,"permalink":"/posts/docker-compose-recovery/","section":"Публикации","summary":"","title":"Восстановление и запуск системы микросервисов в Docker Compose","type":"posts"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/authors/%D0%B8%D0%BB%D1%8C%D1%8F-%D0%B7%D0%B0%D0%BC%D0%B0%D1%80%D0%B0%D1%86%D0%BA%D0%B8%D1%85/","section":"Authors","summary":"","title":"Илья Замарацких","type":"authors"},{"content":"Я разработчик и DevOps-инженер. Пишу об инженерных задачах и создаю открытые инструменты.\nЗанимаюсь разработкой инструментов для автоматизации инфраструктуры и поддержки разработки. Среди проектов — LabForge, открытая платформа для автоматизированного развёртывания учебных стендов на базе Proxmox, и awo, легковесный менеджер процессов. В разработке использую Haskell и Rust.\nСвязаться со мной: mrtstg@yandex.ru · Telegram · GitHub\n","date":"1 ноября 2026","externalUrl":null,"permalink":"/","section":"Привет, я Илья!","summary":"","title":"Привет, я Илья!","type":"page"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/posts/","section":"Публикации","summary":"","title":"Публикации","type":"posts"},{"content":"","date":"1 ноября 2026","externalUrl":null,"permalink":"/tags/%D1%80%D0%B0%D0%B7%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B0/","section":"Tags","summary":"","title":"Разработка","type":"tags"},{"content":"Наверняка все из нас могли столкнуться с тем, что из Интернета по тем или иным причинам мог пропасть полезный для вас веб-ресурс или доступ к нему. Во избежание такой ситуации я изучил и подготовил сравнение инструментов, которые позволят сохранить контент со статических (и динамических в ряде случаев) сайтов для просмотра оффлайн с вашего устройства.\nДисклеймер!\nВаши действия по сбору данных с веб-ресурсов могут быть незаконны или расценены как DDOS-атака, будьте осторожны и внимательны при выполнении подобных действий, уважайте чужой сервер.\nwget # Стандартная для большинства дистрибутивов утилита обладает функционалом рекурсивной загрузки по HTML-страницам и их адаптации для относительного вида, что позволяет локально сохранить сайт. При помощи стандартного функционала можно настроить:\nТаймауты загрузок Список целевых доменов Фильтрацию URL по регулярным выражениям Список разрешенных и запрещенных для загрузки разрешений Помимо составления дерева файлов, wget может выполнять запись в виде WARC-файла.\nЧто такое WARC?\nWARC - специальный формат для архивирования веб-ресурсов, который сохраняет файлы и их метаданные для последующего открытия при помощи специальных программ.\nСпецификация формата\nОднопоточная загрузка сайта # ## загружать страницы сайта с перерывом в 0.5..1.5 секунды, таймаут загрузки - 30 секунд, три попытки на файл wget -t=3 --show-progress --timeout=30 --recursive --no-parent --page-requisites --convert-links -U \u0026#34;Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\u0026#34; --wait=1 --random-wait --domains \u0026lt;домены для загрузки через запятую\u0026gt; \u0026lt;сайт для загрузки\u0026gt; Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nДанные будут помещены в директорию с названием домена сайта.\nПример использования на готовом сайте # wget -t=3 --retry-connrefused --retry-on-http-error=429 --show-progress --timeout=30 --no-check-certificate --recursive --no-parent --page-requisites --convert-links -U \u0026#34;Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\u0026#34; --wait=1 --random-wait --domains assets.gentoo.org,wiki.gentoo.org --reject-regex \u0026#34;(.*)\\?(.*)|.*\\.php|.*/(User|Special|Template|Gentoo_Wiki|.*_talk)\\:.*\u0026#34; -o wiki-error.log -q https://wiki.gentoo.org Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nНастраиваемые части команды и их значение -t или --tries - количество попыток --timeout - таймаут на загрузку файла включая все операции в секундах -U - используемое значение User-Agent --wait - время ожидания между загрузками в секундах --random-wait - включить случайный интервал ожидания между 0.5 и 1.5 от указанного --domains - список доменов через запятую для загрузки ресурсов --retry-connrefused - повторять загрузку в случае обрыва соединения --retry-on-http-error - коды ошибок, в случае возникновения которых будет выполняться повторная попытка -e robots=off - игнорировать файл robots.txt. Используйте флаг только если уверены, что robots.txt препятствует сбору данных! --no-check-certificate - не проверять SSL-сертификаты --reject-regex - регулярное выражение URL, которое проверяет, игнорировать ли страницу. При наличии нескольких паттернов, паттерны можно указать через | (см. пример готовой команды) Загрузка в WARC-файл # Также можно сохранить результат загрузки в WARC-файл при помощи аргумента --warc-file с указанием пути архива (расширение файла будет добавлено автоматически). Также могут пригодиться следующие опции:\n--no-warc-compression - не сжимать итоговый архив --warc-cdx - указать путь для генерации CDX файла для навигации в архиве --warc-max-size=\u0026lt;размер\u0026gt; - указать лимит WARC-файла (по умолчанию без лимита). Например, 1G. После превышения размера будет создан новый архив. Пример загрузки сайта в WARC-файл # Команда сохранит данный сайт в WARC-файл по пути /tmp/test.warc.gz.\nwget -t=3 -e robots=off --show-progress --timeout=30 --recursive --no-parent --page-requisites --convert-links -U \u0026#34;Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\u0026#34; --wait=1 --random-wait --warc-file /tmp/test --domains mrtstg.ru https://mrtstg.ru Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nwpull # Wpull - wget-совместимая утилита, написанная на Python Кристофером Фу (Christopher Foo). В отличие от wget, wpull поддерживает прерывание своей работы и продолжение при помощи ведения базы данных в виде файла, параллельную загрузку, а также интеграцию с youtube-dl и PhantomJS для загрузки видеороликов и активации JavaScript на странице, позволяя загружать не только статические сайты.\nWpull поддеживает только Python версии 3.4-3.5, поэтому самый простой способ воспользоваться утилитой - загрузить исполняемый файл с сайта Launchpad.\nВнимание!\nАктивная разработка проекта прекращена, в связи с чем при использовании программы возможны различные баги и проблемы. При тестировании утилиты возникли проблемы с конвертацией ссылок при сохранении сайта в директорию, например.\nПример использования # Большая часть опций wpull схожа с wget, с полным списком можно ознакомиться здесь. Пример команды, аналогичный предыдущему примеру:\nПочему в примере не проверяется robots.txt?\nНа текущий момент утилита иногда обратывает файл robots.txt с ошибками, ссылка на Github issue. Для избежания этого поведения выключена обработка подобных файлов (опция --sitemaps) и URL robots.txt добавлено в исключение.\nwpull https://mrtstg.ru \\ --no-check-certificate --convert-links --concurrent 8 \\ --no-robots --user-agent \u0026#34;Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\u0026#34; \\ --wait 1 --random-wait --waitretry 600 \\ --page-requisites --recursive --level inf \\ --domains mrtstg.ru \\ --escaped-fragment --strip-session-id \\ --tries 3 --retry-connrefused --retry-dns-error \\ --timeout 60 --session-timeout 21600 \\ --database mrtstg.ru.db \\ --output-file mrtstg.ru.log \\ --reject-regex \u0026#34;robots.txt\u0026#34; Сайт будет сохранен в одноименную с доменом директорию. Обратите внимание, что для загрузки \u0026ldquo;с нуля\u0026rdquo; потребуется удалить файл базы данных или поменять ее имя в команде. wpull также поддерживает сохранение результатов в WARC-файл при помощи аргумента --warc-file:\nwpull https://mrtstg.ru \\ --warc-file /tmp/mrtstg.ru \\ --no-check-certificate --convert-links --concurrent 8 \\ --no-robots --user-agent \u0026#34;Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\u0026#34; \\ --wait 1 --random-wait --waitretry 600 \\ --page-requisites --recursive --level inf \\ --domains mrtstg.ru \\ --escaped-fragment --strip-session-id \\ --tries 3 --retry-connrefused --retry-dns-error \\ --timeout 60 --session-timeout 21600 \\ --database mrtstg.ru.db \\ --output-file mrtstg.ru.log \\ --reject-regex \u0026#34;robots.txt\u0026#34; Будет создан архив /tmp/mrtstg.ru.warc.gz.\ngrab-site # grab-site - преднастроеный веб-краулер от создателей wpull. В отличие от wpull, grab-site обладает веб-панелью для вывода логов, поддеживает более новые версии Python, позволяет реконфигурировать параметры загрузки сайтов (например, списки игнорируемых URL и количество параллельных загрузок) в процессе работы. Из минусов - нет поддержки прерывания и продолжения загрузки, а также сохранение только в WARC-файлы.\nСамый простой способ запуска - docker-контейнер. Установите Docker на целевую систему, а далее выполните команду:\n## может потребоваться sudo docker run --detach --restart unless-stopped -p 29000:29000 -v ./data:/data --name warcfactory slang800/grab-site Данная команда создаст контейнер warcfactory и директорию data в рабочей директории откуда была запущена команда, в ней будут храниться собранные данные. Для того, чтобы поменять это, замените ./data на желаемый вами каталог. После запуска контейнера вы сможете открыть веб-интерфейс по адресу http://127.0.0.1:29000.\nОсновные команды для управления контейнером следующие:\ndocker start warcfactory # запустить контейнер docker stop warcfactory # остановить контейнер docker rm --force warcfactory # удалить контейнер, данные останутся Пример использования # Опции для команды сбора данных доступны в репозитории проекта, самая простая команда для запуска сбора данных выглядит следующим образом:\ndocker exec warcfactory grab-site --no-offsite-links https://mrtstg.ru После запуска вы сможете увидеть вывод в терминале и в веб-интерфейсе системы. В случае слишком долгой работы, вы можете остановить процесс при помощи комбинации \u0026lt;CTRL-C\u0026gt;\nПример веб-интерфейса После завершения работы команды, файлы .warc.gz и .cdx вы можете найти в директории ./data/\u0026lt;домен\u0026gt;-\u0026lt;дата\u0026gt;-\u0026lt;хеш импорта из веб-интерфейса\u0026gt;, если вы не изменили путь хранения данных.\nПродвинутый пример # В случае необходимости, можно передавать параметры напрямую в wpull, работающий \u0026ldquo;под капотом\u0026rdquo;, при помощи опции --wpull-args, но лучше обходиться стандартными опциями. Таким образом можно интегрировать использование youtube-dl и PhantomJS.\ndocker exec -it warcfactory grab-site \u0026#39;https://mrtstg.ru\u0026#39; --ua=\u0026#39;Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\u0026#39; --no-offsite-links --delay=300-500 --no-video --wpull-args=\u0026#39;--no-check-certificate\u0026#39; Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nИгнорирование адресов # В отличие от упомянутых ранее способов, grab-site не принимает на вход регулярные выражения, но позволяет их добавить в процессе работы краулера (или импортировать файл при помощи опции --import-ignores, но это будет сложнее из-за работы из под Docker). Для этого в папке с данными сайтов найдите нужную директорию по имени и найдите там файл ignores и допишите туда регулярные выражения, например:\n## набор правил для игнорирования сабреддитов, это пример - замените своими выражениями ^https?://(www|old)\\.reddit\\.com/gold\\?goldtype= ## URLs with utm_ can (hopefully) be safely ignored because reddit also sends ## href=\u0026#34;\u0026#34;s without the utm_ trackers. ^https?://(www|old)\\.reddit\\.com/r/[^/]+/.*[\\?\u0026amp;]utm_ ^https?://(www|old)\\.reddit\\.com/r/[^/]+/comments/[a-z0-9]+/[^/]+/[a-z0-9]+ ^https?://(www|old)\\.reddit\\.com/r/[^/]+/comments/[a-z0-9]+.*\\?sort= ^https?://(www|old)\\.reddit\\.com/r/[^/]+/comments/[a-z0-9]+/[^/]+/\\.compact ^https?://(www|old)\\.reddit\\.com/r/[^/]+/(top|new|rising|controversial|gilded|ads)/.+[\\?\u0026amp;]after= ^https?://(www|old)\\.reddit\\.com/r/[^/]+/related/ ^https?://(www|old)\\.reddit\\.com/r/[^/]+/(gilded)?\\.mobile\\? ^https?://(www|old)\\.reddit\\.com/r/[^/]+/search/?\\? ^https?://(www|old)\\.reddit\\.com/r/[^/]+/wiki/(revisions|discussions)/user/.+ ^https?://(www|old)\\.reddit\\.com/user/[^/]+/(comments/)?.+[\\?\u0026amp;]sort= ^https?://(www|old)\\.reddit\\.com/.+/\\.rss$ \\.reddit\\.com/message/compose/?\\? ^https?://(m|out|simple|amp)\\.reddit\\.com/ HTTrack # HTTrack - утилита для Linux и Windows, написанная Ксавьером Роше (Xavier Roche) под лицензией GPL3. Она позволяет загрузить сайт локально, воссоздавая дерево файлов при помощи рекурсивного обхода ссылок страниц. Утилита поддерживает достаточную конфигурацию, например:\nСписок разрешенных расширений Список разрешенных MIME-типов Загрузка через прокси-сервер Настройка количества параллельных процессов Из недостатков можно отметить низкую скорость загрузки, а также периодические баги - например, зависание потока загрузки страницы, выявленное при тестировании, что снижает количество рабочих потоков и общую скорость работы.\nПример использования # Установить HTTrack можно из стандартных пакетных репозиториев для большинства дистрибутивов - для установки доступна версия с веб-интерфейсом (webhttrack) и CLI-версия (httrack). Чтобы запустить временный веб-интерфейс введите:\nwebhttrack webhttrack --port 30000 # запустить интерфейс на конкретном порту Пример веб-интерфейса Для запуска CLI-утилиты:\nhttrack # запустить интерактивный режим httrack www.someweb.com/bob/ # запустить загрузку указанного сайта Какой инструмент лучше выбрать? # Инструмент Формат сохранения Загрузка Возобновление Интерфейс wget Файлы, WARC Однопоточная Частично CLI wpull Файлы, WARC Параллельная Да, через базу данных CLI grab-site WARC Параллельная Нет в основной версии CLI и веб-панель HTTrack Файлы Параллельная Обновление сохранённого сайта CLI, веб и GUI wget — когда нужен доступный инструмент без дополнительной установки. wpull — для параллельной загрузки с возможностью продолжить её позже, если удаётся запустить утилиту. grab-site — для архивации в WARC с наблюдением и настройкой процесса через веб-панель. HTTrack — для сохранения сайта в виде файлов и работы через графический интерфейс. Заключение # В этой статье я постарался собрать основные и наиболее полезные методы сохранения данных из Интернета. В будущих статьях разберемся, как работать с WARC файлами, более сложными сайтами (с аутентификацией и динамическим содержимым) и продвинуто пользоваться описанными инструментами.\n","date":"8 января 2026","externalUrl":null,"permalink":"/posts/save-site-review/","section":"Публикации","summary":"","title":"Сохранить Интернет: обзор основных решений для Linux","type":"posts"},{"content":"При использовании в разработке официального образа Debian я часто сталкиваюсь с недоступностью официальных репозиториев deb.debian.org, причем при замене образов начинается конфликт версий пакетов. Так и появилась данная статья, где мы:\nСоздадим базовый образ системы Debian при помощи debootstrap Внесем в него свои правки Упакуем образ системы в Docker-образ Вам понадобится:\nLinux Установленный Docker Пакеты tar, chroot Создание базового образа # Один из простых способов для создания базовой системы - debootstrap. Данный инструмент используется для установки Debian или Ubuntu из другой для выполнения установок на другие диски или развертывания экземпляра ОС внутри другой для использования через chroot. Именно таким образом мы и планируем воспользоваться данной утилитой\nУстановите пакет debootstrap. Он доступен не только под Debian, но и например под Gentoo, RHEL, Ubuntu и др. Примерные команды:\nsudo apt-get install debootstrap -y # Debian/Ubuntu sudo emerge -av dev-util/debootstrap # Gentoo sudo yum install debootstrap.noarch # RHEL, требуется EPEL После установки пакета мы готовы к создранию экземляра системы. При создании мы можем указать архитектуру системы, версию системы в виде ее наименования (или stable для текущей версии) и репозитория, откуда будет произведена загрузка пакетов. После, данный репозиторий в настройках APT в системе. Примеры команд:\n# создаем систему последней версии (13 на момент написания статьи) из стандартного репозитория в директории \u0026#34;stable-debian\u0026#34; sudo debootstrap stable ./stable-debian http://deb.debian.org/debian/ Я для своих целей создал систему 12 версии, используя репозиторий Казанского федерального университета в директории debian:\nsudo debootstrap bookworm ./debian http://mirror.kpfu.ru/debian/ После запуска команды будет создана указанная вами директория и вы удивите следующее:\nW: Cannot check Release signature; keyring file not available /usr/share/keyrings/debian-archive-keyring.gpg I: Retrieving InRelease I: Retrieving Packages I: Validating Packages I: Resolving dependencies of required packages... I: Resolving dependencies of base packages... I: Checking component main on http://mirror.kpfu.ru/debian... I: Retrieving adduser 3.134 I: Validating adduser 3.134 I: Retrieving apt 2.6.1 I: Validating apt 2.6.1 #\u0026lt;...\u0026gt; I: Chosen extractor for .deb packages: dpkg-deb I: Extracting adduser... I: Extracting apt... I: Extracting base-files... #\u0026lt;...\u0026gt; I: Installing core packages... I: Unpacking required packages... I: Unpacking adduser... I: Unpacking apt... I: Unpacking base-files... #\u0026lt;...\u0026gt; I: Unpacking the base system... Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nИ на последнем сообщении, процесс подготовки системы завершен! Наша система весит около 400 мегабайт.\nПравим chroot-систему # После создания системы, единственные правки заключаются в том, что мы указали свои репозитории. Теперь зайдем в систему используя команду chroot.\nsudo chroot ./debian /bin/bash Теперь мы внутри Debian! Можете править конфигурации, устанавливать пакета и вносить другие правки. Для выхода воспользуйтесь командой exit.\nСоздаем Docker-образ # После завершения правок, наша задача упаковать директорию в .tar.gz архив, который будет использоваться в файле сборки контейнера Dockerfile\ncd debian sudo tar -czf ../rootfs.tar.gz . cd .. Теперь, подготовим директорию в которой мы находится к сборке. Для сокращения контекста сборки рекомендую создать файл .dockerignore со следующим содержанием:\n* !rootfs.tar.gz # можете добавить другие нужные файлы и директории через \u0026#34;!\u0026#34; После, создаем Dockerfile:\nFROM scratch ADD rootfs.tar.gz / Так выглядит минимальный образ. Можно добавить команду по умолчанию, скопировать дополнительные файлы, переменные окружения (например, если вы что-то досустановили) или собрать в таком виде при помощи команды:\nsudo docker build -t mydebian . После успешной сборки можете запустить образ и проверить, что он корретно собран:\nsudo docker run -it --rm mydebian /bin/bash # выход при помощи команды \u0026#34;exit\u0026#34; На этом все! Теперь вы стали чуточку независимей в вопросах образов, как и я.\n","date":"16 сентября 2025","externalUrl":null,"permalink":"/posts/debian-container-from-scratch/","section":"Публикации","summary":"","title":"Создаем Debian-контейнер с нуля","type":"posts"},{"content":"","date":"3 сентября 2025","externalUrl":null,"permalink":"/tags/proxmox-ve/","section":"Tags","summary":"","title":"Proxmox VE","type":"tags"},{"content":"При использовании Proxmox VE и большого количества SDN сетей (в моем случае, падение производительности начинается при 100+ сетей в зоне), может наблюдаться деградация скорости обновления сетей, что может быть критично из-за последовательного выполнения задачи в кластере и помешать задачам по развертыванию виртуальных машин.\nВремя обновления SDN до оптимизации Для того, чтобы ускорить данный процесс, потребуется отключить поддержку addon scripts в ifupdown2 - утилите для управления сетевыми устройствами. Данные скрипты позволяют определять большее количество сетевых интерфейсов, однако в базовых сценариях использования не используются и понижают общую производительность при большом количестве интерфейсов. Чтобы это сделать, выполните следующие действия:\nОткройте файл /etc/network/ifupdown2/ifupdown2.conf sudo nano /etc/network/ifupdown2/ifupdown2.conf # или другой удобный редактор Найдите строку addon_scripts_support=1 и замените строку на addon_scripts_support=0\nСохраните файл. Если у вас несколько серверов в кластере, потребуется повторить действие на всех нодах!\nПосле этого, скорость работы должна вырасти. В моем, случае, повышение многократное\nВремя обновления SDN после оптимизации ","date":"3 сентября 2025","externalUrl":null,"permalink":"/posts/proxmox-slow-sdn-update/","section":"Публикации","summary":"","title":"Медленная скорость обновления SDN-сетей в Proxmox","type":"posts"},{"content":"","date":"3 сентября 2025","externalUrl":null,"permalink":"/tags/%D1%80%D0%B5%D1%88%D0%B5%D0%BD%D0%B8%D1%8F-%D0%BF%D1%80%D0%BE%D0%B1%D0%BB%D0%B5%D0%BC/","section":"Tags","summary":"","title":"Решения проблем","type":"tags"},{"content":"Настраивая свою систему на базе Gentoo, использующая драйвера Intel для работы с графикой, столкнулся с проблемой, что утилиты настройки яркости (xbacklight, например) не изменяют яркость экрана. В качестве простой альтернативы, предлагаю примитивный скрипт на Bash, который я использую в связке с i3wm.\nКак проверить работу intel_backlight? # Измените значение файла /sys/class/backlight/intel_backlight/brightness и посмотрите, сменится ли яркость экрана. Лучше предварительно посмотреть максимальное значение экрана и оттолкнуться от него\ncat /sys/class/backlight/intel_backlight/max_brightness # в моем случае, вывело 96000 echo 80000 | sudo tee /sys/class/backlight/intel_backlight/brightness Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nАвтоматизируем смену яркости # Преднастройка доступа # В первую очередь, нужно дать доступ пользователю для модификации файла без ввода пароля. Минимальный вариант в случае использования sudo выглядит примерно так:\n%brightness ALL=(ALL:ALL) NOPASSWD:/usr/bin/tee /sys/class/backlight/intel_backlight/brightness Для работы примера выше потребуется группа brightness и нахождение пользователя в ней. Для своей рабочей машины я просто дополнил правило группы sudo.\n%sudo\tALL=(ALL:ALL) ALL,NOPASSWD:/usr/bin/tee /sys/class/backlight/intel_backlight/brightness Пишем скрипт смены # Мне был необходим скрипт, который сможет расчитать нужное значение яркости экрана и понизить его на некоторое количество процентов. Значение яркости будем хранить в файле, на вход принимать операцию (повышение/понижение яркости) и значение, на которое нужно будет изменить яркость экрана.\nBACKLIGHT_FILE_PATH=\u0026#34;$HOME/.config/i3/backlight_level\u0026#34; [ -f \u0026#34;$BACKLIGHT_FILE_PATH\u0026#34; ] || echo \u0026#34;100\u0026#34; \u0026gt; $BACKLIGHT_FILE_PATH # если файла нет, создаем по умолчанию файл со 100% яркостью OPERATION=$1 DELTA=$2 Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nДалее, логика за малым - получаем значение яркости из файла, максимальное значение яркости, от которого считаем значение процента яркости и после записываем все новые значения.\nBACKLIGHT_FILE_PATH=\u0026#34;$HOME/.config/i3/backlight_level\u0026#34; [ -f \u0026#34;$BACKLIGHT_FILE_PATH\u0026#34; ] || echo \u0026#34;100\u0026#34; \u0026gt; $BACKLIGHT_FILE_PATH # если файла нет, создаем по умолчанию файл со 100% яркостью OPERATION=$1 DELTA=$2 OLD_BACKLIGHT=$(cat \u0026#34;$BACKLIGHT_FILE_PATH\u0026#34;) MAX_BACKLIGHT=$(cat /sys/class/backlight/intel_backlight/max_brightness) case \u0026#34;$OPERATION\u0026#34; in \u0026#34;+\u0026#34;) NEW_BACKLIGHT_PERCENTAGE=$(($OLD_BACKLIGHT + $DELTA)) ;; \u0026#34;-\u0026#34;) NEW_BACKLIGHT_PERCENTAGE=$(($OLD_BACKLIGHT - $DELTA)) ;; *) echo \u0026#34;Unknown operation\u0026#34; exit 1 ;; esac NEW_BACKLIGHT=$(($MAX_BACKLIGHT / 100 * $NEW_BACKLIGHT_PERCENTAGE)) echo \u0026#34;$NEW_BACKLIGHT_PERCENTAGE\u0026#34; \u0026gt; $BACKLIGHT_FILE_PATH echo \u0026#34;$NEW_BACKLIGHT\u0026#34; | sudo tee /sys/class/backlight/intel_backlight/brightness И заключительное - защита от неверных значений яркости свыше 100 процентов и 1. При желании, подсветку экрана можно вовсе выключить, но я намеренно ограничиваю скрипт на 1 проценте.\nBACKLIGHT_FILE_PATH=\u0026#34;$HOME/.config/i3/backlight_level\u0026#34; [ -f \u0026#34;$BACKLIGHT_FILE_PATH\u0026#34; ] || echo \u0026#34;100\u0026#34; \u0026gt; $BACKLIGHT_FILE_PATH # если файла нет, создаем по умолчанию файл со 100% яркостью OPERATION=$1 DELTA=$2 OLD_BACKLIGHT=$(cat \u0026#34;$BACKLIGHT_FILE_PATH\u0026#34;) MAX_BACKLIGHT=$(cat /sys/class/backlight/intel_backlight/max_brightness) case \u0026#34;$OPERATION\u0026#34; in \u0026#34;+\u0026#34;) NEW_BACKLIGHT_PERCENTAGE=$(($OLD_BACKLIGHT + $DELTA)) ;; \u0026#34;-\u0026#34;) NEW_BACKLIGHT_PERCENTAGE=$(($OLD_BACKLIGHT - $DELTA)) ;; *) echo \u0026#34;Unknown operation\u0026#34; exit 1 ;; esac [ \u0026#34;$NEW_BACKLIGHT_PERCENTAGE\u0026#34; -gt 100 ] \u0026amp;\u0026amp; NEW_BACKLIGHT_PERCENTAGE=100 [ \u0026#34;$NEW_BACKLIGHT_PERCENTAGE\u0026#34; -lt 0 ] \u0026amp;\u0026amp; NEW_BACKLIGHT_PERCENTAGE=1 NEW_BACKLIGHT=$(($MAX_BACKLIGHT / 100 * $NEW_BACKLIGHT_PERCENTAGE)) echo \u0026#34;$NEW_BACKLIGHT_PERCENTAGE\u0026#34; \u0026gt; $BACKLIGHT_FILE_PATH echo \u0026#34;$NEW_BACKLIGHT\u0026#34; | sudo tee /sys/class/backlight/intel_backlight/brightness Используем скрипт # Сам скрипт можно поместить в любую директорию в $PATH, например /usr/local/bin/backlight.sh и дать право выполнения при помощи команды chmod +x \u0026lt;путь к скрипту\u0026gt;. Примеры вызова:\nbacklight.sh + 50 backlight.sh - 25 Чтобы привязать выполнение скрипта в i3wm на нажатие кнопок яркости, добавьте следующие строки в конфиг-файл:\nbindsym XF86MonBrightnessDown exec --no-startup-id backlight.sh - 10 bindsym XF86MonBrightnessUp exec --no-startup-id backlight.sh + 10 ","date":"22 марта 2025","externalUrl":null,"permalink":"/posts/intel-backlight-script/","section":"Публикации","summary":"","title":"Управление яркостью экрана в Linux при помощи Intel Backlight","type":"posts"},{"content":"К новому году я хотел запустить какой-нибудь публично доступный проект - и тогда мне пришла в голову идея написать CLI-приложение и \u0026ldquo;раздать\u0026rdquo; доступ к нему по SSH. В этой статье разберемся, как такое сделать без рисков безопасности для сервера\nБудьте внимательны! Часть действий в статье может вас лишить доступа к серверу. Действия выполняются на Debian 12 Как это работает? # Для реализации такого процесса, мы будем использовать директиву ForceCommand. Она позволяет установить команду для выполнения вне зависимости от желания подключаемого пользователя. Попутно, обеспечим безопасность сервера при помощи выключения ряда функций SSH-сервера.\nРеализация позволяет установить для разных пользователей и групп разные команды.\nУстанавливаем SSH сервер # Если у вас есть доступ к консоли сервера, то потребуется в первую очередь вообще установить SSH-сервер.\nДля выполнения команды требуется sudo или root-доступ. apt-get update \u0026amp;\u0026amp; apt-get install openssh-server -y Клонируем SSH-сервер # Наверняка, вы захотите оставить себе доступ по SSH. Поэтому, нам придется запустить два SSH-сервера, один из которых мы будем использовать под наше приложение. Найдем, где лежит файл сервиса systemd, отвечающий за SSHD.\nsystemctl show -P FragmentPath sshd Скопируем этот файл чтобы создать свою копию сервера. Копию создадим в директории /etc/systemd/system/sshd-copy.service. Используем флаг --dereference для того, чтобы не создать символьную ссылку на файл.\nДля выполнения команды требуется sudo или root-доступ. # одной командой cp --dereference $(systemctl show -P FragmentPath sshd) /etc/systemd/system/sshd-copy.service Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nОтредактируем полученный файл, потребуется отредактировать строку ExecStart=, где мы явно укажем конфигурационный файл, который нужно будет использовать\n[Unit] Description=OpenBSD Secure Shell server Documentation=man:sshd(8) man:sshd_config(5) After=network.target auditd.service ConditionPathExists=!/etc/ssh/sshd_not_to_be_run [Service] EnvironmentFile=-/etc/default/ssh ExecStartPre=/usr/sbin/sshd -t ExecStart=/usr/sbin/sshd -D $SSHD_OPTS -f /etc/ssh/sshd_config_copy ExecReload=/usr/sbin/sshd -t ExecReload=/bin/kill -HUP $MAINPID KillMode=process Restart=on-failure RestartPreventExitStatus=255 Type=notify RuntimeDirectory=sshd RuntimeDirectoryMode=0755 [Install] WantedBy=multi-user.target Alias=sshd-ny.service Создадим теперь нужный нам конфигурационный файл.\nДля выполнения команды требуется sudo или root-доступ. cp /etc/ssh/sshd_config /etc/ssh/sshd_config_copy Теперь нам нужно определиться на каком порту будет работать каждый из SSH серверов. Для того чтобы поменять порт оригинального сервера, через который вы будете далее подключаться к серверу откройте файл /etc/ssh/sshd_config и отредактируйте строку Port (возможно, потребуется раскомментировать). Откройте порты при помощи фаерволла перед этим действием!. В данной статье, я перенесу оригинальный SSH сервер на 23 порт.\nДля выполнения команды требуется sudo или root-доступ. nano /etc/ssh/sshd_config Конфиг будет выглядеть примерно так:\nInclude /etc/ssh/sshd_config.d/*.conf Port 23 #AddressFamily any #ListenAddress 0.0.0.0 #ListenAddress :: # \u0026lt; ... \u0026gt; После разрешения конфликтов c портами, можем ввести в работу оба сервера:\nДля выполнения команды требуется sudo или root-доступ. systemctl daemon-reload systemctl restart sshd systemctl restart sshd-copy Дополнительные настройки SSH # Ограничиваем пользователей (необязательно) # В первую очередь, стоит ограничить список пользователей, которые смогут зайти. В нашем примере, создадим пользователя demo и разрешим подключение только для него.\nДля выполнения команды требуется sudo или root-доступ. useradd demo Установите пароль по желанию командой passwd demo или установите пустой командой passwd -d demo.\nДля ограничения списка пользователей допишите в /etc/ssh/sshd_config_copy:\n# через пробел можно указать неограниченное количество пользователей AllowUsers demo Для ограничения по принципу групп пользователей, используйте AllowGroups.\nОтключаем вход для root (необязательно) # Для ограничения входа пользователя root через наш сервер найдите строку PermitRootLogin в файле /etc/ssh/sshd_config_copy и приведите к виду\nPermitRootLogin no Отключаем форвадинг портов и X-приложений # Добавьте в /etc/ssh/sshd_config_copy строку\nAllowTcpForwarding no и приведите строку X11Forwarding к виду:\nX11Forwarding no Отключаем передачу файлов по SFTP # Закомментируйте в /etc/ssh/sshd_config_copy строку:\nSubsystem\tsftp\t/usr/lib/openssh/sftp-server Разрешаем вход по пустому паролю # Добавьте в /etc/ssh/sshd_config_copy строку:\nPermitEmptyPasswords yes Устанавливаем запуск приложения # Не забудьте перезагрузить SSH-сервер после смены настроек командой systemctl restart sshd-copy! Для установки глобальной команды запуска поместите директиву ForceCommand в корень конфигурационного файла. Например, при помощи этой команды можно каждые 5 секунд выводить дату и время\nForceCommand watch -n 5 echo \u0026#39;Today is $(date)\u0026#39; Если требуется для отдельного пользователя установить свою команду, то используйте ForceCommand с конструкцией Match\n# запускаем обычную консоль для админа Match User admin ForceCommand /bin/sh Также есть фильтр по группе.\nMatch Group monitoring ForceCommand /usr/bin/top ","date":"22 февраля 2025","externalUrl":null,"permalink":"/posts/running-cli-over-ssh/","section":"Публикации","summary":"","title":"Предоставляем доступ к приложению по SSH","type":"posts"},{"content":"","date":"16 февраля 2025","externalUrl":null,"permalink":"/tags/haskell/","section":"Tags","summary":"","title":"Haskell","type":"tags"},{"content":" TL;DR\nЧто вы можете попробовать сделать:\nПопробуйте собрать более старые версии библиотеки, которая создает проблемы при помощи cabal install, например, cabal install sorted-list-0.2.2.0\nЕсли проблема не воспроизводится на более старых версиях библиотеки, попробуйте собрать проект с более старой версией библиотеки через ручное ограничение версии зависимости сборки. Для GHCup это выглядит так: ghcup compile hls --version 2.9.0.0 --ghc 9.4.7 --cabal-update -j 8 -- --constraint=\u0026quot;sorted-list == 0.2.2.0\u0026quot;. Для cabal требуется только передать флаг --constraint\nПри сборке Haskell Language Server (2.9.0.0) из исходников под нужную мне версию GHC я столкнулся со следующей ошибкой:\n[ Info ] Building HLS 2.9.0.0 for GHC version 9.4.7 [ cabal ] | [ cabal ] 375 | traverse f = foldrM (\\x xs -\u0026gt; liftA2 insert (f x) $ pure xs) mempty [ cabal ] | ^^^^^^ [ cabal ] Error: [Cabal-7125] [ cabal ] Failed to build sorted-list-0.2.3.0 (which is required by exe:haskell-language-server-wrapper from haskell-language-server-2.9... [ cabal ] [ Error ] [GHCup-08841] BuildFailed failed in dir /home/ilya/.ghcup/tmp/ghcup-82b3afde9bf5bd5b/haskell-language-server-2.9.0.0: Process \u0026#34;cabal\u0026#34; with arguments... Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nHLS настойчиво отказывался собираться при любых версиях HLS и GHC, причем все связано с одной библиотекой. При попытке ручной сборки оказалось, что библиотеке не хватает функций:\n\u0026gt; cabal install sorted-list-0.2.3.0 Data/SortedList.hs:375:31: error: Variable not in scope: liftA2 :: (a0 -\u0026gt; SortedList a0 -\u0026gt; SortedList a0) -\u0026gt; m b -\u0026gt; f0 (SortedList b) -\u0026gt; m (SortedList b) | 375 | traverse f = foldrM (\\x xs -\u0026gt; liftA2 insert (f x) $ pure xs) mempty | ^^^^^^ Error: [Cabal-7125] Failed to build sorted-list-0.2.3.0. See the build log above for details. Возможно, это связано с какими-то особенностями целевой системы (сборка велась под Gentoo), или просто версия библиотеки была нерабочей, но при этом попадала в ограничения версий HLS. В любом случае, при откате библиотеки на версию назад, оказалось что проблема сборки библиотеки не воспроизводится. Итоговое решение - собрать HLS с более старым sorted-list.\nghcup compile hls --version 2.9.0.0 --ghc 9.4.7 --cabal-update -j 8 -- --constraint=\u0026#34;sorted-list == 0.2.2.0\u0026#34; Длинные строки перенесены визуально; при копировании исходные переносы сохраняются.\nНа данный момент ничего не сломалось, надеюсь вам поможет мой опыт.\n","date":"16 февраля 2025","externalUrl":null,"permalink":"/posts/ghcup-hls-build-constraint/","section":"Публикации","summary":"","title":"Ошибка \"Failed to build ...\" зависимости при сборке Haskell-проекта: возможное решение через ограничения сборки","type":"posts"},{"content":"","date":"14 февраля 2025","externalUrl":null,"permalink":"/tags/focalboard/","section":"Tags","summary":"","title":"Focalboard","type":"tags"},{"content":" Что такое Focalboard # Focalboard - open-source альтернатива Trello и других kanban досок задач. В этой статье разберемся как начать работу с Focalboard после завершения установки и запуска программы.\nЕсли вы не знаете как установить Focalboard Можете воспользоваться моим руководством по установкe Focalboard в Docker! В конце статьи вы найдете все готовые файлы. Источник: focalboard.com Установка пароля администратора # После завершения установки и открытии адреса Focalboard вам будет доступен экран входа. Для того чтобы войти в систему, вам нужно зарегистрировать аккаунт - после регистрации первого пользователя свободная регистрация отключится.\nДля регистрации потребуется email (не обязательно существующий), имя пользователя и пароль.\nПосле регистрации вас автоматически пустит на экран управления досками.\nКак создать дополнительных пользователей # Вы можете создать новых пользователей, поделившись с ними пригласительной ссылкой. Чтобы создать ее, нажмите на логотип Focalboard и нажмите на \u0026ldquo;Пригласить пользователей\u0026rdquo;. Там же вы сможете сбросить текущую ссылку для регистрации. В том же меню действий вы можете изменить свой пароль.\nОбратите внимание, это также смогут сделать другие пользователи - а также ссылка не имеет ограничений по количеству регистраций! Доски задач # Доска задач - основная сущность Focalboard, которая в себе содержит произвольное количество задач. Вы можете создать доску из готовых шаблонов, список которых представлен на стартовом экране или в меню создания доски после того как будет создана хотя бы одна доска.\nЕсли вы создадите доску из шаблона, она примет название шаблона - при создании пустой доски она не получит никакого названия. Основные настройки находятся в верхней части экрана, после выбора доски из левого бокового меню.\nВ пустой доске задачи по умолчанию получают поле Status, по которому ведется стандартная группировка. Также доступны следующие параметры для настройки у стандартного представления задачи:\nОпция \u0026ldquo;Свойства\u0026rdquo; позволяет определить, какие данные будет видно на карточке задачи. По умолчанию это только заголовок задачи. Опция группировки позволяет определить, по какому полю группируются заметки. Обязательное свойство для представления в виде доски задач. Для группировки можно выбрать любое свойство задачи, которое имеет один вариант на выбор. Опция \u0026ldquo;Фильтр\u0026rdquo; позволяет фильтровать заметки, не отображая не подходящие критериям. Фильтрация доступна по внутренним полям задачи, а также по ее заголовку. Опция \u0026ldquo;Сортировать\u0026rdquo; позволяет включить сортировку задач внутри групп и определить ее направление. Создание задачи # Вы можете создать задачу при помощи кнопки в левом верхнем углу экрана задачи, а также в любой из групп заметок, чтобы автоматически присвоить ей какое-то значение поля, по которому ведется группировка.\nЗадаче можно присвоить название, иконку (по умолчанию случайная), произвольные свойства определяемые пользователем (создаются они отсюда же). У всех задач одинаковый набор параметров.\nТакже у всех задач есть комментарии и описание, которое может состоять из:\nтекста изображений чекбоксов разделителей текста Создание свойств # Для добавления свойства к задаче, нажмите на кнопку \u0026ldquo;добавить свойство\u0026rdquo; во время редактирования задачи. После выберите тип поля - поддерживается большое количество вариантов от произвольного текста до ссылок и телефонных номеров.\nПосле выбора типа потребуется происвоить значение. У всех полей может быть пустое значение.\nШаблоны задач # Помимо создания пустой задачи, можно создать предзаполненный шаблон - в нем могут быть указаны готовые описания и свойства задачи. Для получения списка шаблонов, нажмите на стрелку рядом с созданием задачи - по умолчанию там только пустой шаблон. Нажав на шаблон, вы создадите из него задачу.\nПосле нажатия на кнопку создания шаблона, укажите заголовок задачи - он станет названием шаблона, а также остальные параметры. После окончания редактирования шаблона, он будет доступен для использования.\nПредставления досок задач # Помимо стандартного представления вида в виде kanban-доски, есть другие форматы представления задач:\nВид таблицы Вид календаря, который упорядочивает задачи по произвольному полю даты (по умолчанию - время создания) Вид галереи, где можно будет увидеть приложенные к задачам изображения На основе каждого из видов может быть создано представление задач, которое кроме формата может также отличаться набором фильтров, сортировок и группировок. Количество представлений неограничено.\nДля того чтобы дать представлению имя, нажмите на его название в левом верхнем углу интерфейса управления доской задач. Там же вы сможете создать новые представления. Параметры представлений сохраняются автоматически.\nПредставления также будут доступны в меню выбора доски задач.\nСовместная работа с доской # При помощи кнопки \u0026ldquo;Поделиться\u0026rdquo; вы можете открыть меню управления правами других пользователей на доску. Тут же вы можете получить ссылку на доску для других зарегистрированных пользователей.\nСтандартные права для пользователей, которые впервые переходят по вашей ссылке определяются параметром \u0026ldquo;Все в команде\u0026rdquo;. Если выставить значение \u0026ldquo;Никто\u0026rdquo;, то пользователи не получат никакого доступа к доске, даже если будут обладать ссылкой.\nРоли для работы с доской # Помимо опции \u0026ldquo;Никто\u0026rdquo; представлены следующие роли:\nНаблюдатель. Имеет read-only доступ к доске. Комментатор. Может оставлять комментарии к задачам. Редактор. Может управлять задачами. После того, как любой пользователь перейдет по ссылке и получит какую-либо роль, вы сможете отдельно настроить его права доступа. Или, вы можете найти нужного пользователя при помощи поиска.\nКатегории досок # По мере роста количества досок, вы можете их организовывать по категориям. По умолчанию, доски находятся в категории \u0026ldquo;Boards\u0026rdquo;. Для создания новой категории, воспользуйтесь контекстным меню существующей категории. После, вы можете при помощи drag-n-drop переместить доски между категориями.\nСоздание шаблона доски # После создания и заполнения доски, вы можете из нее создать шаблон. Для этого, в контекстном меню доски выберите опцию создания шаблона из доски. Укажите название нового шаблона в поле названия доски, отредактируйте требуемую информацию и шаблон готов! Обратите внимание, что шаблон наследует все задачи и представления доски.\nОтредактировать или удалить шаблон можно из меню создания доски и выбора шаблона перед этим.\n","date":"14 февраля 2025","externalUrl":null,"permalink":"/posts/focalboard-basics/","section":"Публикации","summary":"","title":"Основы использования Focalboard","type":"posts"},{"content":"","date":"4 февраля 2025","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"4 февраля 2025","externalUrl":null,"permalink":"/tags/telegram/","section":"Tags","summary":"","title":"Telegram","type":"tags"},{"content":" Финальный код статьи доступен на Github! В статье используется Python 3.12.7. Предварительные требования # В первую очередь, вам понадобится пользовательская сессия в Telethon через свое приложение Telegram. Как создать свое приложение Telegram, я писал в другой статье, не буду заострять внимание. После получения параметров вы сможете открыть произвольную сессию и через этот скрипт.\nДалее, вам понадобится ID канала, откуда вы хотите получить посты. Несколько вариантов описаны ниже.\nКак получить ID канала не выходя из Telegram Самое простое - скопируйте ссылку на пост. Получите ссылку вида https://t.me/c/\u0026lt;ID канала\u0026gt;/\u0026lt;номер поста\u0026gt;. ID-канала получен!\nАльтернативный способ - переслать из канала сообщение боту @ShowJsonBot. Далее, найти поле forward_origin и взять оттуда значение id. В примере ниже - это значение -1001112223333. Далее, удалите -100 в начале, то есть оставьте значение 1112223333\n... \u0026#34;forward_origin\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;channel\u0026#34;, \u0026#34;chat\u0026#34;: { \u0026#34;id\u0026#34;: -1001112223333, \u0026#34;title\u0026#34;: \u0026#34;TestChannel\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;channel\u0026#34; }, \u0026#34;message_id\u0026#34;: 145151243, \u0026#34;date\u0026#34;: 1735919220 } ... Установка зависимостей # В руководстве я буду использовать библиотеку telethon версии 1.38.0. Обратите внимание на совместимость версий, если используете отличную.\npip install telethon==1.38.0 Скрипт # Данный скрипт обрабатывает только текстовые сообщения и посты с фото. Из-за особенностей telethon посты с фото \u0026ldquo;разбиваются\u0026rdquo; на несколько.\nПолученные посты сохраняются в файл messages.json, в нем содержится:\nID поста Дата публикации в UNIX-timestamp Текст поста Изображение поста (если есть) Фото к сообщениям сохраняются в папку images. Предварительно создайте ее.\nКод также доступен на Github! from telethon import TelegramClient from telethon.helpers import TotalList import asyncio import json from telethon.types import Message, Photo, MessageMediaPhoto from telethon.tl.types import PeerChannel import logging loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) logging.basicConfig(level=logging.INFO) app = TelegramClient( session=\u0026#34;userbot\u0026#34;, api_id=0, # INSERT API_ID api_hash=\u0026#34;YOUR_HASH\u0026#34;, ) CHANNEL_ID = 0 # YOUR CHANNEL payload: list[dict] = [] async def main(client: TelegramClient): min_id = 0 max_id = 100 while True: logging.info(\u0026#34;Getting messages from {} to {}\u0026#34;.format(min_id, max_id)) res: TotalList | None | Message = await client.get_messages( PeerChannel(CHANNEL_ID), limit=(max_id - min_id), max_id=max_id, min_id=min_id, ) assert isinstance(res, TotalList) logging.info(\u0026#34;Got {} messages\u0026#34;.format(len(res))) if len(res) == 0: break message: Message for message in reversed(res): media: str | None = None date: float | None = None message_text = \u0026#34;\u0026#34; if message.date is not None: date = message.date.timestamp() if isinstance(message.message, str) and len(message.message) \u0026gt; 0: message_text += message.message if ( isinstance(message.media, MessageMediaPhoto) and message.media.photo is not None and isinstance(message.media.photo, Photo) ): path = \u0026#34;./images/{}.jpg\u0026#34;.format(message.id) await client.download_media(message.media, file=path) # type: ignore media = path elif message.media is not None: pass payload.append( {\u0026#34;id\u0026#34;: message.id, \u0026#34;text\u0026#34;: message_text, \u0026#34;media\u0026#34;: media, \u0026#34;date\u0026#34;: date} ) with open(\u0026#34;messages.json\u0026#34;, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as file: file.write(json.dumps(payload)) max_id += 100 min_id += 100 if __name__ == \u0026#34;__main__\u0026#34;: with app as client: loop.run_until_complete(main(client)) Объяснение работы скрипта # Запуск функции # Создаем event loop для обработки нашей асинхронной функции и устанавливаем его.\nloop = asyncio.new_event_loop() asyncio.set_event_loop(loop) Выставляем уровень логгирования чтобы видеть прогресс выполнения и создаем объект клиента, через который будем работать. Вставьте свои параметры приложения Telegram, имя сессии менять опционально.\nlogging.basicConfig(level=logging.INFO) app = TelegramClient( session=\u0026#34;userbot\u0026#34;, api_id=0, # INSERT API_ID api_hash=\u0026#34;YOUR_HASH\u0026#34;, ) Создаем переменную, где хранится ID канала, который мы \u0026ldquo;читаем\u0026rdquo; и переменную, куда будут записываться все сообщения\nCHANNEL_ID = 0 # YOUR CHANNEL payload: list[dict] = [] В конце скрипта добавляем открытие подключения клиентом при помощи with и запускаем нашу функцию с переданным клиентом в ранее созданном event loop\u0026rsquo;e.\nif __name__ == \u0026#34;__main__\u0026#34;: with app as client: loop.run_until_complete(main(client)) Функция обработки # Для соблюдения rate-лимитов и большей гибкости скрипт получает сообщения по 100 сообщений максимум (если посты не были удалены ранее, в таком случае меньше). Стандартная редакция скрипта начинает получения от постов с ID от 0 до 100 (т.е первые 100 сообщений в канале).\nID поста можно получить, скопировав ссылку на него. 24async def main(client: TelegramClient): 25 min_id = 0 26 max_id = 100 27 28 while True: 29 logging.info(\u0026#34;Getting messages from {} to {}\u0026#34;.format(min_id, max_id)) 30 res: TotalList | None | Message = await client.get_messages( 31 PeerChannel(CHANNEL_ID), 32 limit=(max_id - min_id), 33 max_id=max_id, 34 min_id=min_id, 35 ) 36 assert isinstance(res, TotalList) 37 logging.info(\u0026#34;Got {} messages\u0026#34;.format(len(res))) Если список сообщений пуст, скрипт завершает свою работу. Если 100 постов были удалены подряд, это также может сказаться на их количестве и вызвать ложное завершение скрипта, обратите внимание и отредактируйте поведение скрипта по необходимости.\n38if len(res) == 0: 39 break Посты идут в обратном хронолическом порядке по умолчанию. Если у объекта сообщения есть текст и дата, они записываются в переменные для дальнейшей записи в payload.\n42message: Message 43for message in reversed(res): 44 media: str | None = None 45 date: float | None = None 46 message_text = \u0026#34;\u0026#34; 47 if message.date is not None: 48 date = message.date.timestamp() 49 50 if isinstance(message.message, str) and len(message.message) \u0026gt; 0: 51 message_text += message.message Далее идет обработка медиавложений, с которой все сложнее. Поле media объекта Message может быть None, а также иметь следующий тип:\nTypeMessageMedia = Union[ MessageMediaEmpty, MessageMediaPhoto, MessageMediaGeo, MessageMediaContact, MessageMediaUnsupported, MessageMediaDocument, MessageMediaWebPage, MessageMediaVenue, MessageMediaGame, MessageMediaInvoice, MessageMediaGeoLive, MessageMediaPoll, MessageMediaDice, MessageMediaStory, MessageMediaGiveaway, MessageMediaGiveawayResults, MessageMediaPaidMedia ] Поэтому, если вы хотите обработать отличные от изображений вложения, требуется проверка на то, чем является поле и дальнейшая обработка класса.\nОбработка изображений также усложняется из-за системы типов, поскольку тип MessageMediaPhoto имеет опциональное поле photo, содержащее в себе тип TypePhoto выглядящий следующим образом:\nTypePhoto = Union[PhotoEmpty, Photo] Таким образом, проверка ожидает что у сообщения есть медиавложение в виде фото, в котором информация о фото присутствует. В таком случае клиент скачивает медиафайл в папку images с именем в виде ID сообщения. После, вся полученная информация добавляется в массив payload.\n53if ( 54 isinstance(message.media, MessageMediaPhoto) 55 and message.media.photo is not None 56 and isinstance(message.media.photo, Photo) 57): 58 path = \u0026#34;./images/{}.jpg\u0026#34;.format(message.id) 59 await client.download_media(message.media, file=path) # type: ignore 60 media = path 61elif message.media is not None: 62 pass 63 64payload.append( 65 {\u0026#34;id\u0026#34;: message.id, \u0026#34;text\u0026#34;: message_text, \u0026#34;media\u0026#34;: media, \u0026#34;date\u0026#34;: date} 66) В конце, мы сдвигаем порог ID постов на 100 и записываем имеющиеся данные в файл, чтобы они не \u0026ldquo;потерялись\u0026rdquo; в случае ошибки.\n67with open(\u0026#34;messages.json\u0026#34;, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as file: 68 file.write(json.dumps(payload)) 69max_id += 100 70min_id += 100 Заключение # Данный скрипт может развиваться под различные применения, поэтому я надеюсь что он полслужит отправной точкой для вашей задачи и поможет совладать с тяжелой структурой Telethon'а. Удачи!\n","date":"4 февраля 2025","externalUrl":null,"permalink":"/posts/telethon-channel-rewrite/","section":"Публикации","summary":"","title":"Получаем все сообщения канала на Python и Telethon","type":"posts"},{"content":"","date":"25 января 2025","externalUrl":null,"permalink":"/tags/rust/","section":"Tags","summary":"","title":"Rust","type":"tags"},{"content":"Сборка образа Docker - дело зачастую нетривиальное, однако собранный не лучшим способом образ может уничтожить большое количество времени на сборку и еще большее количество дискового пространства. В этой статье я хочу собрать лучшие рекомендации и методологии для оптимизации процесса сборки образа и его размера.\nОжидается, что вы знакомы с основами Docker и Dockerfile.\nПриведенные в статье способы проверены на Docker 27.5.1 в связке с Buildkit 0.20.0! Оптимизация контекста сборки # Перед началом сборки Docker собирает контекст сборки - файлы, которые могут быть использованы при сборке контейнера. По умолчанию, Docker может копировать любые данные из директории, указанной в команде docker build. Попадание в контекст файлов разработки, таких как __pycache__ директории в Python, node_modules в NodeJS и подобных может серьезно замедлить старт сборки.\nПомимо этого, неосторожные операции копирования могут привести к попаданию файлов окружения и конфигурации в образ, что может создать серьезные риски безопасности в случае, если образ хранится во внешнем репозитории или даже доступен публично для загрузки.\nДля ограничения контекста сборки используется файл .dockerignore, имеющий формат аналогичный .gitignore. Файлы, указанные в нем не попадают в контекст сборки. Он может располагаться в корне директории проекта, который вы собираете или быть привязан к определенному Dockerfile. Для проекта на Python, использующего файл .env для конфигурации он может выглядеть следующим образом:\n.env __pycache__ *.pyc С подробной документацией об использовании .dockerignore вы можете ознакомиться здесь Как работает сборка образа # Образ Docker основан на слоистой файловой системе, что значит что каждое действие во время сборки представляется отдельным слоем. При выполнении каждого слоя, накопленные данные передаются в следующий и так далее. Если во время сборки Docker выполняет то же действие в том же окружении, оно может быть сохранено в кеш и далее не выполняться повторно, что сокращает время сборки.\nИсточник: dockerdocs Однако, если результат какого-либо из слоев изменится, кеш будет инвалидирован в измененном слое и слоях после него. Из примера выше, если файл Makefile или main.c будет изменен, то кеш инвалидируется на этапе COPY и сборка на этапе make build будет произведена заново.\nКак понять, что попало в образ? # При помощи сторонних утилит вы можете изучить, что попало (или не попало) в ваш образ в случае возникновения проблем. Для таких целей я рекомендую использование утилиты dive. Она доступна под многие системы (Mac, Windows, DEB/RPM-based дистрибутивы и др.), а также вы можете собрать ее из исходного кода или запустить при помощи Docker.\nДалее, при помощи команды dive \u0026lt;название образа\u0026gt; вы можете изучить его слои, а также посмотреть, что изменилось при выполнении каждого слоя\nИнтерфейс утилиты Dive при изучении образа Приемы оптимизации # Выбор оптимальных образов # Коротко: большое количество образов имеют slim или alpine версии, которые могут отвечать вашим запросам при меньшем весе. Однако используйте их с осторожностью. Самое простое, что можно сделать - это поменять базовый образ, который вы используете. Существует большое количество редакций образов, которые отличаются по следующим параметрам:\nИспользование musl вместо glibc - чаще всего используется при работе с компилируемыми языками, например Rust. Может быть частично или полностью несовместим с рядом инструментов и библиотек Меньший набор встроенных библиотек (например, python:\u0026lt;version\u0026gt; и python:\u0026lt;version\u0026gt;-slim) Базовый дистрибутив (например, nginx:alpine использует Alpine Linux вместо Debian) По различных причинам, эти образы могут вам не подойти. Например, библиотека uwsgi для Python требует дополнительные библиотеки при установке и без дополнительных вмешательств установится только на \u0026ldquo;полноценный\u0026rdquo; Python-образ, который тяжелее. Однако в таком случае вы можете использовать мультиэтапную сборку для сборки библиотек на более тяжелом образе и их перемещении в более легкий. Далее в статье вы увидите подобные примеры.\nПример мультиэтапной сборки для Python-проекта из файла requirements.txt\n1FROM python:3.11 AS builder 2WORKDIR /app 3COPY requirements.txt . 4RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt 5 6FROM python:3.11-slim 7WORKDIR /usr/src/app 8COPY . . 9COPY --from=builder /app/wheels /wheels 10RUN pip install --no-cache /wheels/* 11CMD \u0026#34;\u0026lt;ваша команда\u0026gt;\u0026#34; А разработчики образа NodeJS вообще не рекомендуют использовать slim-образ, если у вас нет проблем с дисковым пространством - поскольку в этой редакции существует только минимальное окружение для запуска node.\nКроме того, alpine-версии образов также используют musl, что может серьезно повлиять на производительность или даже усложнить оптимизацию веса образа (источник 1, источник 2)\nПри копировании файлов # Коротко: копируйте мало и по делу. Избегайте \u0026ldquo;тяжелых\u0026rdquo; действий под командами, которые копируют большое количество файлов. Ограничивайте контекст сборки при помощи .dockerignore Рассмотрим пример с Python приложением из двух файлов: main.py с кодом и requirements.txt для установки зависимостей. Исходный Dockerfile выглядит так:\n1FROM python:3.12 2WORKDIR /app 3COPY . . 4RUN pip install -r requirements.txt 5CMD [\u0026#34;python\u0026#34;, \u0026#34;main.py\u0026#34;] Что не так?\nНе ясно, что вообще передается. Без корректного .dockerignore (и даже с ним) в контейнер могут попасть \u0026ldquo;лишние\u0026rdquo; данные Любые правки в код, которые попадут на 3 строке файла инвалидируют все действия после - в том числе установку зависимостей В первую очередь, вынесем установку зависимостей до передачи файлов проекта - это позволит их поместить в кеш на более долгое время (в нашем случае - до тех пор, пока requirements.txt не поменяется). Однако нам нужен файл requirements.txt для установки библиотек, поэтому разделяем этап копирования исходного кода и зависимостей.\n1FROM python:3.12 2WORKDIR /app 3COPY requirements.txt . 4RUN pip install -r requirements.txt 5COPY main.py . 6CMD [\u0026#34;python\u0026#34;, \u0026#34;main.py\u0026#34;] Теперь мы можем спокойно редактировать код и быстро пересобирать образ!\nПри установке пакетов # Коротко: по необходимости делите действия на составные и зачищайте кеш пакетных менеджеров или используйте cache mounts Допустим, вы собираете образ с графическим интерфейсом внутри (например, вы RDP поднимаете в Docker), рассмотрим такой пример:\n1FROM debian:bookworm 2RUN apt-get update \u0026amp;\u0026amp; apt-get install -y xfce4 xfce4-goodies Что не так?\nВ общем, этап рабочий - и будет кешироваться, но при модификации списка пакетов будут повторно загружаться все остальные (а у нас их тут весом под гигабайт), что не всегда приятно В образе контейнера остаются ненужные файлы, например, в нашем случае, кеш пакетного менеджера APT С загрузкой тяжеловесных пакетов можно разобраться разбив загрузки на несколько этапов, однако проблема избыточного веса сложнее - в более старых версиях Docker мы могли бы удалять директории APT:\n1FROM debian:bookworm 2RUN apt-get update 3RUN apt-get install -y xfce4 xfce4-goodies 4RUN apt clean all \u0026amp;\u0026amp; rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*deb Но при использовании новых версий Buildkit мы можем воспользоваться таким нововведением, как монтирование кеша (cache mounts). Идея заключается во временном монтировании директорий - благодаря чему не остается лишних файлов в контейнере. Монтированный кеш сохраняется между разными сборками, что ускоряет время сборки. Перепишем наш пример следующим образом:\n1FROM debian:bookworm 2RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\ 3 --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get update 4RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\ 5 --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get install -y xfce4 xfce4-goodies Обратите внимание: от инвалидации кеша это не спасет. А также, монтировать директории придется на каждом этапе где это необходимо. То есть такой файл не будет успешно отработан:\n1FROM debian:bookworm 2RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\ 3 --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get update 4RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\ 5 --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get install -y xfce4 xfce4-goodies 6RUN apt-get install -y sudo 7# команда не будет отработана, т.к данные apt-get update не сохранены и пакет не будет найден Как использовать монтирование кеша в своем сценарии?\nМинимальная нотация: --mount=type=cache,target=\u0026lt;директория, которую кешируете\u0026gt;. Флаг sharing=locked нужен, если использование кеша между параллельными сборками единовременно может быть опасно и вы хотите этого избежать\nПри сборке программ/переносимых файлов # Коротко: используйте мультиэтапную сборку с наименьшим допустимым образом Рассмотрим пример со сборкой проекта на Haskell (да, несколько необычно, но поможет показать комбинацию подходов):\n1FROM haskell:9.4.7-buster 2WORKDIR /project 3COPY . . 4RUN stack install 5CMD [\u0026#34;/root/.local/bin/my-app-exe\u0026#34;] Что не так?\nТитанический вес образа (3.4GB!) при сборке бинарного файла со статическими зависимостями В окружении остается большое количество артефактов разработки (например, исходный код), которые больше не нужны В случае, если контейнер, используемый для сборки не нужен для запуска полученных файлов можно применить мультиэтапную сборку, в процессе которой выполняются действия в рамках одного базового образа (или нескольких, если этапов много) - а далее полученные файлы можно перенести в итоговый контейнер, который и будет сохранен. Перенесем наш собранный бинарный файл в контейнер с Debian для последующего запуска\n1# присваиваем контейнеру псевдоним builder для более простого обращения 2FROM haskell:9.4.7-buster AS builder 3WORKDIR /project 4COPY . . 5# вызываем сборку проекта через Stack (пакетный менеджер) 6RUN stack install 7 8FROM debian:bookworm 9WORKDIR /project 10COPY --from=builder /root/.local/bin/my-app-exe . 11CMD [\u0026#34;my-app-exe\u0026#34;] Вуаля! Итоговый размер контейнера - 122 мегабайта!\nДополнительное действие: Stack нуждается в сборке индекса библиотек перед тем, как их загрузить - поэтому вызов stack install может не кешироваться. Для этого, добавим вызов команды stack update для обновления индекса до копирования любых файлов (что позволит сохранить этап в кеше). Можете добавить подобные действия в свои сценарии сборки, если есть такая необходимость.\n1FROM haskell:9.4.7-buster AS builder 2RUN stack update 3WORKDIR /project 4COPY . . 5RUN stack install 6 7FROM debian:bookworm 8WORKDIR /project 9COPY --from=builder /root/.local/bin/my-app-exe . 10CMD [\u0026#34;my-app-exe\u0026#34;] Похожим образом, например, можно оптимизировать сборку фронтенда на NodeJS и ее помещение в образ NGINX\n1FROM node:lts-slim AS builder 2WORKDIR /project 3COPY package.json package-lock.json ./ 4RUN npm install 5COPY . . 6RUN npm build 7 8FROM nginx:stable-alpine 9# копируем конфиг NGINX, написанный нами 10COPY nginx.conf /etc/nginx/conf.d/site.conf 11# копируем результаты сборки 12COPY --from=builder /project/dist /frontend Если вы собираете много однотипных образов # Помимо этого, вы можете собирать промежуточный образ, из которого будут собираться остальные. Из рабочего примера - у меня есть проект с рядом микросервисов на Haskell, большая часть которых использует схожий технологический стек и ряд общих самописных библиотек. При сборке их образов, в первую очередь я собираю \u0026ldquo;родительский\u0026rdquo; образ в котором компилирую библиотеки и зависимости (благодаря чему они помещаются в кеш пакетного менеджера Stack). После этого, я запускаю мультиэтапную сборку каждого микросервиса, в которой остается собрать сам проект (поскольку зависимости уже собраны и берутся из кеша) и переместить его в легковесный образ Debian. Экспериментируйте и находите общие рутинные действия, которые можно объединить!\nЕдиновременное копирование файлов при помощи монтирования директорий # Помимо монтирования директорий для их кeширования, Buildkit поддерживает такую возможность как монтирование директории в рамках одного этапа сборки. Рассмотрим на примере приложения на Rust:\n1FROM rust AS builder 2WORKDIR /project 3COPY . . 4# собираем проект 5RUN cargo install --path . 6 7FROM debian:bookworm 8WORKDIR /project 9# копируем бинарный файл 10COPY --from=builder /project/target/release/app . 11CMD [\u0026#34;./app\u0026#34;] Не самый релевантный случай, однако мы можем избавиться от постоянного копирования файлов и ограничиться временным монтированием исходного кода. Монтирование директорий работает по аналогии с монтированием кеша, поэтому бинарный файл не будет сохранен - поэтому его нужно скопировать в другую директорию на том же этапе\n1FROM rust AS builder 2WORKDIR /project 3RUN --mount=type=bind,target=.,src=.,rw cargo install --path . \u0026amp;\u0026amp; cp /project/target/release/app / 4 5FROM debian:bookworm 6WORKDIR /project 7COPY --from=builder /app . 8CMD [\u0026#34;./app\u0026#34;] Синтаксис монтирования директорий\nМинимальная нотация: --mount=type=bind,target=., где target - целевая директория монтирования в контейнере\nДополнительно можно указать доступность для записи (по умолчанию директория read-only) при помощи флага rw, а также указать источник монтирования помощи флага src.\nЗаключение # В данной статье я собрал большинство полезных практик оптимизации скорости сборки образа и его итогового размера, которые сам знаю и использую. Надеюсь, эти советы помогут вам сделать использование контейнеров легче, проще, а иногда и дешевле!\n","date":"25 января 2025","externalUrl":null,"permalink":"/posts/building-better-images/","section":"Публикации","summary":"","title":"Собирая лучшие образы Docker: способы оптимизации времени и размера сборки","type":"posts"},{"content":"Во время разработки одного из проектов появилась необходимость автоматического получения постов из Telegram канала, на который пользователь подписан. Со стороны ботов это возможно только при условии участия бота в администрации канала, что не предоставлялось возможным. Обходным решением может быть создание бота на основе пользовательского аккаунта при помощи библиотеки Pyrogram.\nОбратите внимание: автор Pyrogram прекратил поддержку библиотеки 24 декабря 2024. Автор статьи не гарантирует совместимость с современными функциями Telegram и работу кода. В данной статье используется Python версии 3.12.7. Также, во время реализации бота на Pyrogram попалась библиотека Telethon. Она поддерживается, работает в некоторых ситуациях стабильнее, но куда хуже задокументирована с позиции типов, что может усложнить процесс разработки, особенно если вы используете аннотацию типов, как я.\nСоздание приложения # Прежде чем начать разработку бота, потребуется создать приложение для Telegram на специальной странице. Посредством этого приложения будет получен доступ к клиентскому API Telegram\nПосле аутентификации, перейдите в раздел API development tools. Далее, потребуется создать приложение с произвольными данными.\nПосле создания приложения, потребуется сохранить параметры api_id и api_hash.\nПодготовка окружения # Создание файла окружения # Создайте файл .env, в котором будут описаны все требуемые параметры:\n# api_id приложения Telegram USERBOT_APP_ID=123 # api_hash приложения Telegram USERBOT_APP_HASH=abc # мобильный телефон аккаунта, который вы будете использовать. Используется только в Pyrogram USER_PHONE=88005553535 Установка зависимостей # Для обоих библиотек потребуется вспомогательная библиотека для загрузки параметров из .env, я буду использовать python-dotenv\n# для pip pip install python-dotenv==1.0.1 Далее, установите требуемую библиотеку, которую хотите использовать:\n# установка pyrogram pip install pyrogram==2.0.106 # установка telethon pip install telethon==1.37.0 Как долго живет сессия? # Зависит от настроек аккаунта. Сессия Pyrogram/telethon регистрируется как полноценная сессия в Telegram и может быть прервана досрочно или по тайм-ауту активности, который можно установить в пользовательских настройках.\nКак получить ID канала? # С точки зрения клиента, пост в канале - сообщение в отдельном чате, поэтому в первую очередь потребуется ID канала. Один из простых способов для этого - переслать сообщение боту @ShowJsonBot. Далее, найти поле forward_origin и взять оттуда значение id. В примере ниже - это значение -1001112223333. Для Pyrogram потребуется полное значение, для telethon уберите -100 в начале, то есть оставьте значение 1112223333\n... \u0026#34;forward_origin\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;channel\u0026#34;, \u0026#34;chat\u0026#34;: { \u0026#34;id\u0026#34;: -1001112223333, \u0026#34;title\u0026#34;: \u0026#34;TestChannel\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;channel\u0026#34; }, \u0026#34;message_id\u0026#34;: 145151243, \u0026#34;date\u0026#34;: 1735919220 } ... Поместите ID канала в переменную CHANNEL_ID в .env файле.\nРазработка на Pyrogram # Получение файла сессии # Прежде чем мы начнем управлять аккаунту, потребуется создать и подтвердить сессию. Создадим для этого следующий скрипт\nfrom dotenv import load_dotenv import asyncio from pyrogram.client import Client import os load_dotenv() # загружаем .env API_ID = os.environ[\u0026#34;USERBOT_APP_ID\u0026#34;] API_HASH = os.environ[\u0026#34;USERBOT_APP_HASH\u0026#34;] ACCOUNT_PHONE = os.environ[\u0026#34;USER_PHONE\u0026#34;] async def main(): async with Client( name=\u0026#34;userbot\u0026#34;, api_id=API_ID, api_hash=API_HASH, phone_number=ACCOUNT_PHONE ) as app: await app.send_message(\u0026#34;me\u0026#34;, \u0026#34;It worked!\u0026#34;) if __name__ == \u0026#34;__main__\u0026#34;: asyncio.run(main()) После запуска скрипта, вы получите примерно следующий вывод, где потребуется ввести код подтверждения из Telegram и облачный пароль, если он у вас настроен. Если все отработает правильно, вы увидите в Избранных сообщение It worked!\nTgCrypto is missing! Pyrogram will work the same, but at a much slower speed. More info: https://docs.pyrogram.org/topics/speedups Welcome to Pyrogram (version 2.0.106) Pyrogram is free software and comes with ABSOLUTELY NO WARRANTY. Licensed under the terms of the GNU Lesser General Public License v3.0 (LGPL-3.0). The confirmation code has been sent via Telegram app Enter confirmation code: \u0026lt;ВАШ КОД\u0026gt; The two-step verification is enabled and a password is required Password hint: None Enter password (empty to recover): \u0026lt;ОБЛАЧНЫЙ ПАРОЛЬ\u0026gt; Если вас интересует ускорение работы бота, можете изучить документацию по этой теме и установить дополнительные зависимости tgcrypto и применить uvloop в своем проекте для ускорения работы. После выполнения скрипта будет создан файл userbot.session, который будет использоваться для управления аккаунтом. Если вы держите скрипт в другой директории (например, в папке src) лучше дополнительно указать параметр workdir для Client чтобы файл сессии не сохранялся в директорию кода. Следующий пример подойдет, если вы запускаете код из одной директории\n# ... async def main(): async with Client( name=\u0026#34;userbot\u0026#34;, api_id=API_ID, api_hash=API_HASH, phone_number=ACCOUNT_PHONE, workdir=\u0026#34;./\u0026#34;, ) as app: await app.send_message(\u0026#34;me\u0026#34;, \u0026#34;It worked!\u0026#34;) # ... Отслеживаем определенный канал # После этого, перепишем код и создадим функцию-обработчик, которая будет проверять, от требуемого чата это сообщение или нет.\nfrom dotenv import load_dotenv from pyrogram.types import Message from pyrogram.client import Client from pyrogram import filters import os load_dotenv() # загружаем .env API_ID = os.environ[\u0026#34;USERBOT_APP_ID\u0026#34;] API_HASH = os.environ[\u0026#34;USERBOT_APP_HASH\u0026#34;] ACCOUNT_PHONE = os.environ[\u0026#34;USER_PHONE\u0026#34;] CHANNEL_ID: int = int(os.environ[\u0026#34;CHANNEL_ID\u0026#34;]) app = Client( name=\u0026#34;userbot\u0026#34;, api_id=API_ID, api_hash=API_HASH, phone_number=ACCOUNT_PHONE, workdir=\u0026#34;./\u0026#34;, ) @app.on_message(filters.channel) # используем встроенный в библиотеку фильтр на сообщения из каналов async def channel_handler(client: Client, message: Message): if message.sender_chat.id == CHANNEL_ID: # проверяем, нужный ли это канал if message.caption is not None: # проверяем, есть ли подпись к медиафайлу в сообщении print(message.caption) elif message.text is not None: # проверяем, есть ли у сообщения текст print(message.text) else: print(\u0026#34;Сообщение без текста!\u0026#34;) if __name__ == \u0026#34;__main__\u0026#34;: app.run() Если вы хотите усложнить логику обработки, полезно будет изучить поля класса Message.\nПолезные ссылки # Общая документация Pyrogram Документация Pyrogram по обработке событий Документация Pyrogram по использованию фильтров Разработка на telethon # Получение файла сессии # Прежде чем мы начнем управлять аккаунту, потребуется создать и подтвердить сессию. Создадим для этого следующий скрипт\nfrom dotenv import load_dotenv from telethon import TelegramClient import os load_dotenv() # загружаем .env API_ID = os.environ[\u0026#34;USERBOT_APP_ID\u0026#34;] API_HASH = os.environ[\u0026#34;USERBOT_APP_HASH\u0026#34;] app = TelegramClient( session=\u0026#34;userbot\u0026#34;, api_id=int(API_ID), api_hash=API_HASH, ) if __name__ == \u0026#34;__main__\u0026#34;: app.start() app.run_until_disconnected() После запуска скрипта, вы получите примерно следующий вывод, где потребуется ввести код подтверждения из Telegram и облачный пароль, если он у вас настроен. Если все отработает правильно, вы увидите аналогичный вывод в конце с именем вашего аккаунта. Можете завершить работу скрипта при помощи \u0026lt;CTRL+C\u0026gt;\nPlease enter your phone (or bot token): \u0026lt;ВАШ НОМЕР\u0026gt; Please enter the code you received: \u0026lt;ВАШ КОД\u0026gt; Please enter your password: \u0026lt;ОБЛАЧНЫЙ ПАРОЛЬ\u0026gt; Signed in successfully as \u0026lt;ИМЯ ВАШЕГО АККАУНТА\u0026gt;; remember to not break the ToS or you will risk an account ban! В качестве ускорения работы бота, можете установить библиотеку cryptg После выполнения скрипта будет создан файл userbot.session, который будет использоваться для управления аккаунтом. Файл создается по умолчанию в рабочей директории, откуда вы запускаете скрипт.\nОтслеживаем определенный канал # После этого, перепишем код и создадим функцию-обработчик, которая будет проверять, от требуемого чата это сообщение или нет.\nfrom dotenv import load_dotenv from telethon import TelegramClient, events, types from telethon.tl.types import PeerChannel import os load_dotenv() # загружаем .env API_ID = os.environ[\u0026#34;USERBOT_APP_ID\u0026#34;] API_HASH = os.environ[\u0026#34;USERBOT_APP_HASH\u0026#34;] CHANNEL_ID: int = int(os.environ[\u0026#34;CHANNEL_ID\u0026#34;]) app = TelegramClient( session=\u0026#34;userbot\u0026#34;, api_id=int(API_ID), api_hash=API_HASH, ) @app.on(events.NewMessage(incoming=True)) async def handler(event: events.NewMessage.Event): message: types.Message = event.message text = message.message peer_id = event.message.peer_id if not isinstance(peer_id, PeerChannel): return channel_id = peer_id.channel_id if channel_id == CHANNEL_ID: if text is None or len(text) == 0: print(\u0026#34;Нет текста!\u0026#34;) return print(text) if __name__ == \u0026#34;__main__\u0026#34;: app.start() app.run_until_disconnected() Флаг incoming отвечает за фильтрацию входящих сообщений для исключения отправленных нами сообщений.\nПолезные ссылки # Документация Telethon Документация типа события NewMessage Заключение # Оба примера выводят текст сообщения в консоль, однако вы можете свободно расширить логику. Для своего проекта я помещаю сообщения в очередь RabbitMQ для дальшейшей обработки. Надеюсь, это руководство поможет вам в реализации ваших проектов!\n","date":"17 января 2025","externalUrl":null,"permalink":"/posts/telegram-channel-userbot/","section":"Публикации","summary":"","title":"Чтение канала через Telegram-userbot'а через Pyrogram или telethon","type":"posts"},{"content":"","date":"19 декабря 2024","externalUrl":null,"permalink":"/tags/docker-compose/","section":"Tags","summary":"","title":"Docker Compose","type":"tags"},{"content":" Что такое Focalboard # Focalboard - open-source альтернатива Trello и других kanban досок задач. Один из официальных способ развертывания приложения - Docker-образ, использующий по умолчанию sqlite в качестве базы данных. Это руководство поможет развернуть Focalboard с использованием БД PostgreSQL при помощи Docker Compose.\nИсточник: focalboard.com Предварительные шаги # Установка Docker # Для развертывания потребуется Docker и установленный Docker Compose плагин. Самый простой способ выполнить установку - запустить скрипт с get.docker.com. Он поддерживает большинство операционных систем.\nwget -O docker.sh get.docker.com sudo bash docker.sh Конфигурация сервиса # Focalboard конфигурируется при помощи файла config.json, расположенном в каталоге /opt/focalboard Docker-образа. Расшифровку всех параметров можно изучить на странице руководства администратора. Стандартный файл можно вывести в терминал при помощи команды\ndocker run --rm mattermost/focalboard cat /opt/focalboard/config.json Для подключения к PostgreSQL требуется заменить поля dbtype и dbconfig. Для использования с контейнером потребуется следующий конфиг-файл:\n{ \u0026#34;serverRoot\u0026#34;: \u0026#34;http://localhost:8000\u0026#34;, \u0026#34;port\u0026#34;: 8000, \u0026#34;dbtype\u0026#34;: \u0026#34;postgres\u0026#34;, \u0026#34;dbconfig\u0026#34;: \u0026#34;postgres://focal:focal@db/focal?sslmode=disable\u0026amp;connect_timeout=10\u0026#34;, \u0026#34;postgres_dbconfig\u0026#34;: \u0026#34;dbname=focal sslmode=disable\u0026#34;, \u0026#34;useSSL\u0026#34;: false, \u0026#34;webpath\u0026#34;: \u0026#34;./pack\u0026#34;, \u0026#34;filespath\u0026#34;: \u0026#34;./data/files\u0026#34;, \u0026#34;telemetry\u0026#34;: true, \u0026#34;session_expire_time\u0026#34;: 2592000, \u0026#34;session_refresh_time\u0026#34;: 18000, \u0026#34;localOnly\u0026#34;: false, \u0026#34;enableLocalMode\u0026#34;: true, \u0026#34;localModeSocketLocation\u0026#34;: \u0026#34;/var/tmp/focalboard_local.socket\u0026#34; } Файл docker-compose.yml # services: focalboard: ports: - 8000:8000 restart: always image: mattermost/focalboard container_name: focalboard volumes: - ./config.json:/opt/focalboard/config.json:ro postgres: hostname: db image: postgres:16 container_name: focalboard-postgres restart: unless-stopped volumes: - ./postgres:/var/lib/postgresql/data healthcheck: test: [\u0026#39;CMD-SHELL\u0026#39;, \u0026#39;pg_isready -U focal\u0026#39;] interval: 10s timeout: 5s retries: 5 environment: POSTGRES_USER: focal POSTGRES_PASSWORD: focal POSTGRES_DB: focal PGDATA: /var/lib/postgresql/data/pgdata В данную конфигурацию включена healthcheck-проверка PostgreSQL, а Focalboard доступен по 8000 порту. Для замены порта, который будет доступен с машины, замените первый порт, например - 30000:8000\nРазвертывание # Скопируйте файлы config.json и docker-compose.yml Выполните команду docker compose [--project-name focalboard] up -d для запуска сервисов. Флаг --project-name пригодится, если ваша директория не имеет уникального имени и может пересекаться с другими развертываниями Docker Compose Сервис будет доступен по порту 8000. Данные сервиса будут сохранены в директорию postgres в рабочей директории Для остановки, выполните команду docker compose [--project-name focalboard] down Конфигурация nginx # Если требуется пропустить трафик Focalboard через nginx, также потребуется специальная конфигурация, поскольку Focalboard использует Websocket. Если nginx и Focalboard находятся на одном сервере, подойдет следующая конфигурация:\nupstream focalboard { server localhost:8000; keepalive 32; } server { listen 80 default_server; server_name focalboard.example.com; location ~ /ws/* { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; client_max_body_size 50M; proxy_set_header Host $http_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 $scheme; proxy_set_header X-Frame-Options SAMEORIGIN; proxy_buffers 256 16k; proxy_buffer_size 16k; client_body_timeout 60; send_timeout 300; lingering_timeout 5; proxy_connect_timeout 1d; proxy_send_timeout 1d; proxy_read_timeout 1d; proxy_pass http://focalboard; } location / { client_max_body_size 50M; proxy_set_header Connection \u0026#34;\u0026#34;; proxy_set_header Host $http_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 $scheme; proxy_set_header X-Frame-Options SAMEORIGIN; proxy_buffers 256 16k; proxy_buffer_size 16k; proxy_read_timeout 600s; proxy_cache_revalidate on; proxy_cache_min_uses 2; proxy_cache_use_stale timeout; proxy_cache_lock on; proxy_http_version 1.1; proxy_pass http://focalboard; } } Если на сервере находится не только Focalboard, потребуется удалить опцию default_server, а также заменить focalboard.example.com на свое доменное имя.\n","date":"19 декабря 2024","externalUrl":null,"permalink":"/posts/focalboard-docker-compose-deploy/","section":"Публикации","summary":"","title":"Установка Focalboard с использованием Docker Compose","type":"posts"}]