Створення додаткового kube-scheduler'a з кастомним набором правил планування

Створення додаткового kube-scheduler'a з кастомним набором правил планування

Kube-scheduler є невід'ємним компонентом Kubernetes, який відповідає за планування подів по нодах відповідно до заданих політик. Найчастіше, в процесі експлуатації Kubernetes-кластера нам не доводиться замислюватися про те, за якими саме політиками відбувається планування подів, оскільки набір політик дефолтного kube-scheduler'a підходить для більшості повсякденних завдань. Однак зустрічаються ситуації, коли нам важливо тонко керувати процесом розподілу подів, і для виконання цього завдання є два шляхи:

  1. Створити kube-scheduler із кастомним набором правил
  2. Написати свій власний scheduler та навчити його працювати із запитами API-сервера

У рамках цієї статті я опишу реалізацію саме першого пункту для вирішення проблеми нерівномірного планування подій на одному з наших проектів.

Коротка вступна про роботу kube-scheduler'a

Варто особливо відзначити той факт, що kube-scheduler не відповідає за безпосереднє планування подів - він відповідає лише за визначення ноди, на яку потрібно розмістити під. Інакше кажучи, результат роботи kube-scheduler'a - це ім'я ноди, яке він повертає API-серверу на запит про планування і його робота закінчується.

Спочатку kube-scheduler складає список нод, на які може бути запланований під відповідно до політиків predicates. Далі кожна нода з цього списку отримує певну кількість очок відповідно до політик priorit. В результаті вибирається нода, яка набрала максимальну кількість балів. Якщо є ноди, що набрали однаковий максимальний бал, вибирається випадково. Зі списком та описом політик predicates (filtering) та priorites (scoring) можна ознайомитись у документації.

Опис тіла проблеми

Незважаючи на велику кількість різних кластерів Kubernetes на обслуговуванні в Nixys, вперше з проблемою планування подів ми зіткнулися тільки недавно, коли для одного з наших проектів з'явилася необхідність запуску великої кількості періодичних завдань (~100 сутностей CronJob). Щоб максимально спростити опис проблеми, як приклад візьмемо один мікросервіс, в рамках якого щохвилини запускається cron-завдання, що створює деяке навантаження на CPU. Для роботи cron-завдання було виділено три абсолютно однакові за характеристиками ноди (24 vCPU на кожній).

При цьому не можна точно сказати скільки часу буде виконуватися CronJob, так як обсяг вхідних даних постійно змінюється. У середньому, при нормальній роботі kube-scheduler'a, на кожній ноді працює 3-4 екземпляри завдання, які створюють ~20-30% навантаження на CPU кожної ноди:

Створення додаткового kube-scheduler'a з кастомним набором правил планування

Сама проблема полягає в тому, що іноді поди cron-завдання переставали плануватися на одну з трьох нід. Тобто, в якийсь момент часу на одну з нід не планувалося жодного пода, тоді як на двох інших нодах працювало по 6-8 екземплярів завдання, створюючи ~40-60% навантаження на CPU:

Створення додаткового kube-scheduler'a з кастомним набором правил планування

Проблема повторювалася з випадковою періодичністю і зрідка корелювала з моментом викочування нової версії коду.

Підвищивши рівень логування kube-scheduler'a до 10 рівня (-v=10) ми почали фіксувати, скільки набирає очок у процесі оцінки кожна з нод. При нормальній роботі планування у логах можна було побачити таку інформацію:

resource_allocation.go:78] cronjob-1574828880-mn7m4 -> Node03: BalancedResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1387 millicores 4161694720 memory bytes, score 9
resource_allocation.go:78] cronjob-1574828880-mn7m4 -> Node02: BalancedResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1347 millicores 4444810240 memory bytes, score 9
resource_allocation.go:78] cronjob-1574828880-mn7m4 -> Node03: LeastResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1387 millicores 4161694720 memory bytes, score 9
resource_allocation.go:78] cronjob-1574828880-mn7m4 -> Node01: BalancedResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1687 millicores 4790840320 memory bytes, score 9
resource_allocation.go:78] cronjob-1574828880-mn7m4 -> Node02: LeastResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1347 millicores 4444810240 memory bytes, score 9
resource_allocation.go:78] cronjob-1574828880-mn7m4 -> Node01: LeastResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1687 millicores 4790840320 memory bytes, score 9
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node01: NodeAffinityPriority, Score: (0)                                                                                       
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node02: NodeAffinityPriority, Score: (0)                                                                                       
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node03: NodeAffinityPriority, Score: (0)                                                                                       
interpod_affinity.go:237] cronjob-1574828880-mn7m4 -> Node01: InterPodAffinityPriority, Score: (0)                                                                                                        
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node01: TaintTolerationPriority, Score: (10)                                                                                   
interpod_affinity.go:237] cronjob-1574828880-mn7m4 -> Node02: InterPodAffinityPriority, Score: (0)                                                                                                        
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node02: TaintTolerationPriority, Score: (10)                                                                                   
selector_spreading.go:146] cronjob-1574828880-mn7m4 -> Node01: SelectorSpreadPriority, Score: (10)                                                                                                        
interpod_affinity.go:237] cronjob-1574828880-mn7m4 -> Node03: InterPodAffinityPriority, Score: (0)                                                                                                        
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node03: TaintTolerationPriority, Score: (10)                                                                                   
selector_spreading.go:146] cronjob-1574828880-mn7m4 -> Node02: SelectorSpreadPriority, Score: (10)                                                                                                        
selector_spreading.go:146] cronjob-1574828880-mn7m4 -> Node03: SelectorSpreadPriority, Score: (10)                                                                                                        
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node01: SelectorSpreadPriority, Score: (10)                                                                                    
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node02: SelectorSpreadPriority, Score: (10)                                                                                    
generic_scheduler.go:726] cronjob-1574828880-mn7m4_project-stage -> Node03: SelectorSpreadPriority, Score: (10)                                                                                    
generic_scheduler.go:781] Host Node01 => Score 100043                                                                                                                                                                        
generic_scheduler.go:781] Host Node02 => Score 100043                                                                                                                                                                        
generic_scheduler.go:781] Host Node03 => Score 100043

Тобто. судячи з інформації, отриманої з ліг, кожна з нід набирала однакову кількість підсумкових очок і для планування вибиралася випадкова. У момент проблемного планування логи виглядали так:

resource_allocation.go:78] cronjob-1574211360-bzfkr -> Node02: BalancedResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1587 millicores 4581125120 memory bytes, score 9
resource_allocation.go:78] cronjob-1574211360-bzfkr -> Node03: BalancedResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1087 millicores 3532549120 memory bytes, score 9
resource_allocation.go:78] cronjob-1574211360-bzfkr -> Node02: LeastResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1587 millicores 4581125120 memory bytes, score 9
resource_allocation.go:78] cronjob-1574211360-bzfkr -> Node01: BalancedResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 987 millicores 3322833920 memory bytes, score 9
resource_allocation.go:78] cronjob-1574211360-bzfkr -> Node01: LeastResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 987 millicores 3322833920 memory bytes, score 9 
resource_allocation.go:78] cronjob-1574211360-bzfkr -> Node03: LeastResourceAllocation, capacity 23900 millicores 67167186944 memory bytes, total request 1087 millicores 3532549120 memory bytes, score 9
interpod_affinity.go:237] cronjob-1574211360-bzfkr -> Node03: InterPodAffinityPriority, Score: (0)                                                                                                        
interpod_affinity.go:237] cronjob-1574211360-bzfkr -> Node02: InterPodAffinityPriority, Score: (0)                                                                                                        
interpod_affinity.go:237] cronjob-1574211360-bzfkr -> Node01: InterPodAffinityPriority, Score: (0)                                                                                                        
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node03: TaintTolerationPriority, Score: (10)                                                                                   
selector_spreading.go:146] cronjob-1574211360-bzfkr -> Node03: SelectorSpreadPriority, Score: (10)                                                                                                        
selector_spreading.go:146] cronjob-1574211360-bzfkr -> Node02: SelectorSpreadPriority, Score: (10)                                                                                                        
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node02: TaintTolerationPriority, Score: (10)                                                                                   
selector_spreading.go:146] cronjob-1574211360-bzfkr -> Node01: SelectorSpreadPriority, Score: (10)                                                                                                        
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node03: NodeAffinityPriority, Score: (0)                                                                                       
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node03: SelectorSpreadPriority, Score: (10)                                                                                    
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node02: SelectorSpreadPriority, Score: (10)                                                                                    
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node01: TaintTolerationPriority, Score: (10)                                                                                   
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node02: NodeAffinityPriority, Score: (0)                                                                                       
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node01: NodeAffinityPriority, Score: (0)                                                                                       
generic_scheduler.go:726] cronjob-1574211360-bzfkr_project-stage -> Node01: SelectorSpreadPriority, Score: (10)                                                                                    
generic_scheduler.go:781] Host Node03 => Score 100041                                                                                                                                                                        
generic_scheduler.go:781] Host Node02 => Score 100041                                                                                                                                                                        
generic_scheduler.go:781] Host Node01 => Score 100038

З яких видно, що одна з нід набирала менше підсумкових очок, ніж інші, і тому планування виконувалося лише на дві ноди, які набрали максимальний бал. Таким чином ми точно переконалися, що проблема полягає саме у плануванні подів.

Подальший алгоритм вирішення проблеми був для нас очевидним — проаналізувати логи, зрозуміти з якого саме пріоритету нода не дістала очок і, за необхідності, скоригувати політики дефолтного kube-scheduler'а. Однак тут ми зіткнулися із двома суттєвими складнощами:

  1. На максимальному рівні логування (10) відображається набір очок лише за деякими пріоритетами. У наведеному вище уривку ліг можна помітити, що за всіма пріоритетами, відображеними в логах, ноди набирають однакову кількість очок при нормальному та проблемному плануванні, проте фінальний результат у разі проблемного планування відрізняється. Таким чином, можна дійти невтішного висновку, що з якимось пріоритетам підрахунок очок відбувається “за кадром”, і ми не маємо можливості зрозуміти з якого саме пріоритету нода не добрала окуляри. Цю проблему ми докладно описали у питання репозиторія Kubernetes на Github На момент написання статті було отримано відповідь від розробників, що підтримка логування буде додана в оновленнях Kubernetes v1.15,1.16, 1.17 та XNUMX.
  2. Немає простого способу зрозуміти з яким саме набором політик зараз працює kube-scheduler. Так, у документації цей список перерахований, але в ньому немає інформації, які конкретно ваги виставлені кожній з політик priorites. Побачити ваги або відредагувати політики дефолтного kube-scheduler'а можна лише в вихідниках.

Варто зазначити, що один раз нам вдалося зафіксувати, що нода не добирала очки з політики ImageLocalityPriority, яка нараховує очки ноді, якщо на ній вже є образ, необхідний для запуску програми. Т. е. в момент викочування нової версії програми cron-завдання встигало запускатися на двох нодах, викачуючи на них новий образ з docker registry, і таким чином дві ноди отримували більший підсумковий бал щодо третьої.

Як я вже писав вище, в логах ми не бачимо інформації про оцінку політики ImageLocalityPriority, тому щоб перевірити своє припущення, ми спулили образ з новою версією програми на третю ноду, після чого планування запрацювало коректно. Саме через політику ImageLocalityPriority проблема планування спостерігалася досить рідко, частіше вона була пов'язана з чимось іншим. Через те, що ми не могли повноцінно дебатувати кожну з політик у списку priorites дефолтного kube-scheduler'a, у нас виникла потреба у гнучкому управлінні політиками планування подів.

Постановка завдання

Ми хотіли, щоб вирішення проблеми було максимально точковим, тобто основні сутності Kubernetes (здесь мається на увазі дефолтний kube-scheduler) повинні залишатися незмінними. Нам не хотілося вирішувати проблему в одному місці та створювати її в іншому. Таким чином, ми дійшли двох варіантів вирішення проблеми, які були озвучені у вступі до статті — створення додаткового scheduler'а або написання свого. Основна вимога до планування cron-завдань - рівномірний розподіл навантаження за трьома нодами. Цю вимогу можна задовольнити вже існуючими політиками kube-scheduler'a, тому на вирішення нашого завдання немає сенсу писати свій власний scheduler.

Інструкція створення та Deployment додаткового kube-scheduler'a описані в документації. Однак, нам здалося, що сутності Deployment недостатньо для забезпечення стійкості до відмов у роботі такого критичного сервісу як kube-scheduler, тому ми вирішили розгорнути новий kube-scheduler як Static Pod, за яким стежитиме безпосередньо Kubelet. Таким чином, у нас склалися такі вимоги до нового kube-scheduler'у:

  1. Сервіс має бути розгорнутий як Static Pod на всіх майстрах кластера
  2. Повинна бути передбачена стійкість до відмов на випадок недоступності активного пода з kube-scheduler'ом
  3. Основним пріоритетом при плануванні має бути кількість доступних ресурсів на ноді (LeastRequestedPriority)

Реалізація рішення

Варто одразу відзначити, що всі роботи ми проводитимемо в Kubernetes v1.14.7, т.к. саме ця версія використовувалася у проекті. Почнемо з написання маніфесту для нашого нового kube-scheduler'a. За основу візьмемо дефолтний маніфест (/etc/kubernetes/manifests/kube-scheduler.yaml) і наведемо його до наступного виду:

kind: Pod
metadata:
  labels:
    component: scheduler
    tier: control-plane
  name: kube-scheduler-cron
  namespace: kube-system
spec:
      containers:
      - command:
        - /usr/local/bin/kube-scheduler
        - --address=0.0.0.0
        - --port=10151
        - --secure-port=10159
        - --config=/etc/kubernetes/scheduler-custom.conf
        - --authentication-kubeconfig=/etc/kubernetes/scheduler.conf
        - --authorization-kubeconfig=/etc/kubernetes/scheduler.conf
        - --v=2
        image: gcr.io/google-containers/kube-scheduler:v1.14.7
        imagePullPolicy: IfNotPresent
        livenessProbe:
          failureThreshold: 8
          httpGet:
            host: 127.0.0.1
            path: /healthz
            port: 10151
            scheme: HTTP
          initialDelaySeconds: 15
          timeoutSeconds: 15
        name: kube-scheduler-cron-container
        resources:
          requests:
            cpu: '0.1'
        volumeMounts:
        - mountPath: /etc/kubernetes/scheduler.conf
          name: kube-config
          readOnly: true
        - mountPath: /etc/localtime
          name: localtime
          readOnly: true
        - mountPath: /etc/kubernetes/scheduler-custom.conf
          name: scheduler-config
          readOnly: true
        - mountPath: /etc/kubernetes/scheduler-custom-policy-config.json
          name: policy-config
          readOnly: true
      hostNetwork: true
      priorityClassName: system-cluster-critical
      volumes:
      - hostPath:
          path: /etc/kubernetes/scheduler.conf
          type: FileOrCreate
        name: kube-config
      - hostPath:
          path: /etc/localtime
        name: localtime
      - hostPath:
          path: /etc/kubernetes/scheduler-custom.conf
          type: FileOrCreate
        name: scheduler-config
      - hostPath:
          path: /etc/kubernetes/scheduler-custom-policy-config.json
          type: FileOrCreate
        name: policy-config

Коротко за основними змінами:

  1. Змінили ім'я пода та контейнера на kube-scheduler-cron
  2. Вказали використання портів 10151 та 10159 так як визначено опцію hostNetwork: true і ми не можемо використовувати ті ж порти, що і дефолтний kube-scheduler (10251 та 10259)
  3. За допомогою параметра —config вказали файл конфігурації, з якого повинен запускатися сервіс
  4. Налаштували монтування файлу конфігурації (scheduler-custom.conf) та файла політик планування (scheduler-custom-policy-config.json) з хоста

Не забуваємо, що нашому kube-scheduler'у будуть потрібні права, аналогічні дефолтному. Редагуємо його кластерну роль:

kubectl edit clusterrole system:kube-scheduler

...
   resourceNames:
    - kube-scheduler
    - kube-scheduler-cron
...

Тепер поговоримо про те, що має бути у файлі конфігурації та файлі з політиками планування:

  • Файл конфігурації (scheduler-custom.conf)
    Для отримання конфігурації дефолтного kube-scheduler'a необхідно скористатися параметром --write-config-to з документації. Отриману конфігурацію розмістимо у файлі /etc/kubernetes/scheduler-custom.conf і приведемо до такого вигляду:

apiVersion: kubescheduler.config.k8s.io/v1alpha1
kind: KubeSchedulerConfiguration
schedulerName: kube-scheduler-cron
bindTimeoutSeconds: 600
clientConnection:
  acceptContentTypes: ""
  burst: 100
  contentType: application/vnd.kubernetes.protobuf
  kubeconfig: /etc/kubernetes/scheduler.conf
  qps: 50
disablePreemption: false
enableContentionProfiling: false
enableProfiling: false
failureDomains: kubernetes.io/hostname,failure-domain.beta.kubernetes.io/zone,failure-domain.beta.kubernetes.io/region
hardPodAffinitySymmetricWeight: 1
healthzBindAddress: 0.0.0.0:10151
leaderElection:
  leaderElect: true
  leaseDuration: 15s
  lockObjectName: kube-scheduler-cron
  lockObjectNamespace: kube-system
  renewDeadline: 10s
  resourceLock: endpoints
  retryPeriod: 2s
metricsBindAddress: 0.0.0.0:10151
percentageOfNodesToScore: 0
algorithmSource:
   policy:
     file:
       path: "/etc/kubernetes/scheduler-custom-policy-config.json"

Коротко за основними змінами:

  1. Задали в schedulerName ім'я нашого сервісу kube-scheduler-cron.
  2. У параметрі lockObjectName також потрібно задати ім'я нашого сервісу та переконатися, що параметр leaderElect виставлений у значення true (якщо у вас одна майстер-нода, можна виставити значення false).
  3. Вказали шлях до файлу з описом політик планування у параметрі algorithmSource.

Варто докладніше зупинитись на другому пункті, де ми редагуємо параметри для ключа leaderElection. Для забезпечення стійкості до відмов ми активували (leaderElect) процес вибору провідного (майстра) між подами нашого kube-scheduler'a за допомогою використання єдиного для них endpoint (resourceLock) з ім'ям kube-scheduler-cron (lockObjectName) у просторі імен kube-system (lockObjectNamespace). Про те як у Kubernetes забезпечується висока доступність основних компонентів (у тому числі kube-scheduler) можна ознайомитись у статті.

  • Файл політик планування (scheduler-custom-policy-config.json)
    Як я вже писав раніше - дізнатися з якими конкретно політиками працює дефолтний kube-scheduler ми можемо лише аналізуючи його код. Тобто ми не можемо отримати файл з політиками планування дефолтного kube-scheduler'а за аналогією до файлу конфігурації. Опишемо політики планування, що нас цікавлять, у файлі /etc/kubernetes/scheduler-custom-policy-config.json наступним чином:

{
  "kind": "Policy",
  "apiVersion": "v1",
  "predicates": [
    {
      "name": "GeneralPredicates"
    }
  ],
  "priorities": [
    {
      "name": "ServiceSpreadingPriority",
      "weight": 1
    },
    {
      "name": "EqualPriority",
      "weight": 1
    },
    {
      "name": "LeastRequestedPriority",
      "weight": 1
    },
    {
      "name": "NodePreferAvoidPodsPriority",
      "weight": 10000
    },
    {
      "name": "NodeAffinityPriority",
      "weight": 1
    }
  ],
  "hardPodAffinitySymmetricWeight" : 10,
  "alwaysCheckAllPredicates" : false
}

Таким чином, kube-scheduler спочатку складає список нод, на які може бути запланований під відповідно до політики GeneralPredicates (яка включає набір політик PodFitsResources, PodFitsHostPorts, HostName і MatchNodeSelector). І далі проводиться оцінка кожної ноди відповідно до набору політик у масиві priorities. Для виконання умов нашого завдання ми вирішили, що такий набір політик буде оптимальним рішенням. Нагадаю, що набір політик з їх докладним описом доступний документації. Для виконання свого завдання ви можете просто змінити набір політик і призначити їм відповідні ваги.

Маніфест нового kube-scheduler'а, який ми створювали на початку глави, назвемо kube-scheduler-custom.yaml і розмістимо наступним шляхом /etc/kubernetes/manifests на трьох майстер-нодах. Якщо все виконано правильно, Kubelet на кожній ноді запустить під, а в логах нашого нового kube-scheduler'а ми побачимо інформацію про те, що наш файл з політиками успішно застосувався:

Creating scheduler from configuration: {{ } [{GeneralPredicates <nil>}] [{ServiceSpreadingPriority 1 <nil>} {EqualPriority 1 <nil>} {LeastRequestedPriority 1 <nil>} {NodePreferAvoidPodsPriority 10000 <nil>} {NodeAffinityPriority 1 <nil>}] [] 10 false}
Registering predicate: GeneralPredicates
Predicate type GeneralPredicates already registered, reusing.
Registering priority: ServiceSpreadingPriority
Priority type ServiceSpreadingPriority already registered, reusing.
Registering priority: EqualPriority
Priority type EqualPriority already registered, reusing.
Registering priority: LeastRequestedPriority
Priority type LeastRequestedPriority already registered, reusing.
Registering priority: NodePreferAvoidPodsPriority
Priority type NodePreferAvoidPodsPriority already registered, reusing.
Registering priority: NodeAffinityPriority
Priority type NodeAffinityPriority already registered, reusing.
Creating scheduler with fit predicates 'map[GeneralPredicates:{}]' and priority functions 'map[EqualPriority:{} LeastRequestedPriority:{} NodeAffinityPriority:{} NodePreferAvoidPodsPriority:{} ServiceSpreadingPriority:{}]'

Тепер залишається тільки вказати у spec'і нашої CronJob'и, що всі запити на планування її pod'ів повинен обробляти наш новий kube-scheduler:

...
 jobTemplate:
    spec:
      template:
        spec:
          schedulerName: kube-scheduler-cron
...

Висновок

Кінець кінцем ми отримали додатковий kube-scheduler з унікальним набором політик планування, за роботою якого стежить безпосередньо kubelet. Крім того, ми налаштували вибори нового лідера між подами нашого kube-scheduler'а у випадку, якщо старий лідер з якихось причин стає недоступним.

Звичайні програми та послуги продовжують плануватися через дефолтний kube-scheduler, а всі cron-завдання повністю переведені на новий. Навантаження, створюване cron-завданнями, тепер рівномірно розподіляється по всіх нодах. Враховуючи, що більша частина cron-завдань виконується на тих же нодах, що й основні додатки проекту, це дозволило значно знизити ризик переїзду подів через брак ресурсів. Після впровадження додаткового kube-scheduler'а проблем з нерівномірним плануванням cron-задач більше не виникало.

Також читайте інші статті у нашому блозі:

Джерело: habr.com

Додати коментар або відгук