Резервное копирование в Kubernetes с помощью K8up и Kasten K10 by Veeam

kf3maamrrrbqhk8imytjjbt5lak.png


Разработчикам и системным администраторам важно обеспечивать надежность и доступность данных в приложениях Kubernetes. Несмотря на высокую степень автоматизации и управления контейнерами, всегда остаются риски сбоев или человеческих ошибок — поэтому необходимо заранее задуматься о резервном копировании.

Меня зовут Филипп, я системный администратор в отделе Data- и ML-продуктов Selectel. В этой статье постараюсь раскрыть, какие есть решения для резервного копирования в Kubernetes, и на простом примере покажу, как с ними работать. Подробности под катом.

Оглавление

→ Резервное копирование в Kubernetes
→ Какие есть варианты
→ Подготовка рабочего окружения
→ Резервное копирование на базе K8up
→ Резервное копирование на базе Kasten K10 by Veeam

Резервное копирование в Kubernetes


Динамичная и распределенная архитектура Kubernetes обязывает использовать резервные копии. Необходимо учитывать состояние каждого контейнера, конфигурации, секрета и блока данных, которые находятся в постоянном хранилище.

e9ec17a2f9d061775cd659e7befa406f.png


Важно отметить, что в Kubernetes управление постоянным хранилищем осуществляется через основные компоненты:

  • PVC (Persistent Volume Claim) — это запрос на использование хранилища со стороны приложения или пользователя.
  • PV (Persistent Volume) — Физическое или логическое хранилище в кластере, которое может быть связано с внешними хранилищами.
  • Storage Class — определяет, как PV создается.
  • CSI (Container Storage Interface) — плагин, который позволяет взаимодействовать с различными внешними хранилищами.
  • Внешнее хранилище: Облачное или локальное хранилище, где фактически хранятся данные.


008fe222ea8e220d4d19e5f904648f13.png


Также Kubernetes поддерживает парадигму Infrastructure as code, IaC. Это позволяет автоматизировать и стандартизировать развертывание и управление ресурсами с помощью GitOps. Однако даже в таком случае есть важная деталь, которую нельзя игнорировать: сохранность данных.

Пока ресурсы типа config-map, secret, deployment, service, ingress и другие могут быть воссозданы из кода по IaC, данные в PV восстановить в случае сбоя, удаления или взлома не получится.

7b8d0b204cd559de9f60e3e7f750907a.png


Какие есть варианты


061480a2c3757b98afcc028d099735a5.png


cacf5bc4c432679b3fe73a17354cd803.png


Можно придумать разные способы создания резервной копии постоянных данных. Это могут быть, например, кастомные скрипты, которые запускаются по расписанию (cron) и создают снапшоты (снимки) самих PV.

При создании снимков PV могут возникнуть проблемы, если во время процесса в базу данных активно записываются данные. Например, снапшот может получиться неполным или поврежденным. Тогда при попытке восстановиться есть риск, что база данных запустится некорректно.

Однако есть сложные системы, которые обеспечивают непрерывное резервное копирование и автоматическое восстановление в случае сбоя. Среди них — Velero.io, Kaste K10, Cohesity, Portworx и другие.

Выбор стратегии резервного копирования будет зависеть от текущей реализации. Например, если ваше высоконагруженное приложение запущено в Managed Kubernetes, а стремительно растущие базы данных — на сетевых дисках, необходимо подумать о том, чтобы резервное копирование не влияло на работоспособность IT-систем.


Например, если основная база данных — PostgreSQL, необходимо подумать о бэкапировании с помощью streaming replication и настроить резервное копирование на secondary-реплике через pg_dump, pg_basebackup, Barman или WAL-E. А также — определиться с периодичностью бэкапирования, его шифрованием, способом хранения, мониторингом и проверкой восстановления из резервных копий.

Далее мы рассмотрим два инструмента K8UP и Kasten K10 от Veeam, которые предоставляют различные функции для резервного копирования. Но прежде подготовим тестовый стенд в облаке Selectel.

ylvrxk3nzxo2hatk_n3n5rvw77c.png


Подготовка рабочего окружения


Подготовим тестовый стенд, в который установим простейшее приложение с постоянным хранилищем данных. За основу возьмем Managed Kubernetes, а в качестве хранилища резервных копий — объектное хранилище S3, к которому настроим доступ через сервисного пользователя и S3-ключ.

Подготовка кластера Managed Kubernetes


В панели управления нужно перейти в раздел Kubernetes:

e8a435e61303d8e1aef3b6c13856f4a1.png


8ecc4abe5c0f97bc7c4da9583595d1a8.png


ffb77debf8e9e765af5ccadbc1f72b01.png


Далее дождитесь статуса ACTIVE и скачайте kubeconfig для дальнейшей работы с кластером:

1f2dfa2b7f500c8f382a386e8d2e8605.png


Для работы с кластером можно использовать Lens или Kubectl. Для kubectl необходимо указать переменную KUBECONFIG:

 #: export KUBECONFIG=<ПОЛНЫЙ ПУТЬ ДО KUBECONFIG ФАЙЛА>


В качестве проверки можно получить пространство имен кластера:

#: kubectl get namespace


Подготовка объектного хранилища


8dcce7b136525d8b31059956241de986.png


Далее нужно создать и настроить контейнер: ввести его название, выбрать Приватный тип, класс хранения Стандартное хранение и нажать кнопку.

3d6b5e35b2df4cd0ff4df9b6d782aa2a.png


Создание сервисного пользователя и ключа S3


38e7c0e8a2748e1de151cf584e3881f0.png


27bff1c7c167fbab28ac284c379e8bf1.png


6ad87700f3963e2430d33efd442fd92a.png


ca35bfa22af70605320d5a56110817f8.png


Важно: S3 access и secret keys необходимо сохранить в надежном месте.


Установка тестового приложения в кластере


Теперь осталось установить в кластер простейшее тестовое приложение. Для этого подготовим манифест создания Storage Class, Deployment и Persistent Volume Claim и применим его:

# nginx-pvc.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: default
  annotations:
    storageclass.kubernetes.io/is-default-class: 'true'
provisioner: cinder.csi.openstack.org
parameters:
  availability: ru-9a
  fsType: ext4
  type: fast.ru-9a
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: Immediate
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nginx-pvc
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx-container
          image: nginx:latest
          ports:
            - containerPort: 80
          volumeMounts:
            - name: nginx-volume
              mountPath: /usr/share/nginx/html
      volumes:
        - name: nginx-volume
          persistentVolumeClaim:
            claimName: nginx-pvc
#: kubectl apply -f nginx-pvc.yaml


Готово — теперь можем начать работу с инструментами резервного копирования в Kubernetes.

Резервное копирование на базе K8up


K8up (/keɪtæpp/ или просто «кетчуп») — это оператор для автоматизации резервного копирования данных в Kubernetes, в основе которого лежит restic.


Возможности инструмента

  • Автоматическое копирование данных из PVC и БД.
  • Запуск резервного копирования по требованию и расписанию.
  • Сохранение копий в разных местах, включая Amazon S3 и Minio.
  • Восстановление данных через командную строку.
  • Возможность создавать резервные копии, которые учитывают особенности приложений и включают в себя результаты работы любого инструмента, способного выдавать данные в стандартный поток вывода.


Установка


Рассмотрим процесс установки и настройки K8up в подготовленном рабочем окружении. Для наглядности представим схему работы инструмента.

ec154ce9712a3702aadfe9c7817378d1.png


Устанавливаем Custom Resource Definition:

#: kubectl apply -f https://github.com/k8up-io/k8up/releases/download/k8up-4.4.1/k8up-crd.yaml


Для удобства устанавливаем K8up в отдельное пространство имен:

#: kubectl create namespace k8up
#: helm repo add k8up-io https://k8up-io.github.io/k8up
#: helm install -n k8up k8up k8up-io/k8up


Настройка хранилища для резервных копий


Перед созданием резервных копий необходимо указать хранилище, в котором они будут храниться. Подробнее о вариантах хранения можно почитать в документации restic.Секреты для доступа по S3
Ранее мы подготовили объектное хранилище. Теперь создадим секреты, чтобы у K8up был к нему доступ. Укажем токен доступа к S3 и пароль для restic-репозитория:

# secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: "s3-secrets"
  namespace: k8up
stringData:
  bucket: ""
  password: ””
  username: ””
---
apiVersion: v1
kind: Secret
metadata:
  name: "restic-repository-password"
  namespace: k8up
stringData:
  password: "secret_pass" # Это пароль для restic repository - создается при инициализации, его можно поменять на любой, желательно надежный и состоящий из более чем 16 символов


Применим описанный манифест в кластер:

#: kubectl apply -f secrets.yaml


Подготовительные работы выполнены, теперь можем приступить к созданию резервных копий.

Создание снимка из PVC


Теперь попробуем сделать бэкап данных приложения, которое мы задеплоили на этапе подготовки рабочего окружения. Для этого необходимо создать Custom Resource для K8up, тем самым объяснить, что и как мы хотим копировать.

Создадим CR-Backup, который сделает резервное копирование всех PVC в пространстве имен, где будет создан данный ресурс, а после — применим манифест:

# backup.yaml
apiVersion: k8up.io/v1
kind: Backup
metadata:
  name: k8up-test-swift
  namespace: k8up
spec:
  failedJobsHistoryLimit: 4
  successfulJobsHistoryLimit: 0
  backend:
    repoPasswordSecretRef:
      name: "restic-repository-password"
      key: "password"
    s3:
      accessKeyIDSecretRef:
        key: username
        name: s3-secrets
      bucket: test
      endpoint: s3.ru-1.storage.selcloud.ru
      secretAccessKeySecretRef:
        key: password
        name: s3-secrets
#: kubectl apply -f backup.yaml


После создания ресурса K8up автоматически начнет поиск и создание снимков всех PVC с режимами доступа к хранилищу типа RMX (ReadMany, Execute) и RWO (ReadWriteOnce). Если все было выполнено правильно, мы должны увидеть успешно завершенный под с именем »backup-k8up-…», который подмонтировал к себе нужный PVC (в нашем случае — nginx-pvc) и создал снимок файловой системы с помощью restic, пример успешно завершенного пода представлен на скриншоте ниже:

93d4029caddb5ebeece05d7fdbc3943b.png


Под K8up.

0777e67cf2531ea0d9655dbe920e23c1.png


То же самое можно проверить через CLI с помощью команд:

#: kubectl get -A backups.k8up.io
#: kubectl get -A snapshots.k8up.io


В K8up также можно настроить резервное копирование по расписанию, добавить проверку целостности с помощью custom-resource — Schedule.

Восстановление из снимка PVC


Локальное восстановление


Можно установить утилиту restic и подмонтировать снимок локально, указав чувствительные данные для доступа к объектному хранилищу. Но прежде необходимо узнать SNAPSHOT ID:

RESTIC_REPOSITORY="s3://s3.ru-1.storage.selcloud.ru//" \
AWS_ACCESS_KEY_ID="" \
AWS_SECRET_ACCESS_KEY="" \
RESTIC_PASSWORD="" \
restic snapshots


Теперь мы можем скопировать к себе резервную копию командой:

RESTIC_REPOSITORY="s3://s3.ru-1.storage.selcloud.ru//" \
AWS_ACCESS_KEY_ID="" \
AWS_SECRET_ACCESS_KEY="" \
RESTIC_PASSWORD="" \
restic restore  --target ~/Desktop/mnt/


В папке ~/Desktop/mnt/ можно увидеть все файлы, находящиеся в снимке.

Восстановление в кластере Custom Resource


Для демонстрации изменим index.html — вернем предыдущее состояние файла до внесения правок. Просто добавим какой-то текст в файл index.html, например Hacker destroyed this site!

c0922f864e883c4133851f463adb7359.png


Теперь восстановим предыдущее состояние файла в кластере, для этого необходимо создать customResource Restore и новый PVC, в который будем восстанавливать данные:

# restore.yaml
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: restore-test-mfw
  namespace: k8up
  annotations:
    # set to "true" to include in future backups
    k8up.io/backup: "false"
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: k8up.io/v1
kind: Restore
metadata:
  name: restore-test-mfw
  namespace: k8up
spec:
  podSecurityContext:
    fsGroup: 65532
    fsGroupChangePolicy: OnRootMismatch
  restoreMethod:
    folder:
      claimName: restore-test-mfw
  backend:
    envFrom:
    - secretRef:
        name: "open-stack-secret"
    repoPasswordSecretRef:
      name: "restic-repository-password"
      key: "password"
    swift:
      path: "/container-path"
      container: "cmlp-testing-rpd-5921-ff-research-k8up"


В качестве восстановленной PVC можем указать новую, в которую восстановили бэкап при помощи следующей CLI команды:

kubectl patch -n k8up deployment nginx-deployment -p '{"spec":{"template":{"spec":{"volumes":[{"name":"nginx-volume","persistentVolumeClaim":{"claimName":"restore-test-mfw"}}]}}}}'


И убедиться, что файл index.html будет без внесенных изменений, т.е. файл будет восстановлен из резервной копии.

Создание бэкапа баз данных


Проблема при создании бэкапов баз данных в том, что необходимо остановить запись в базу, чтобы на момент создания снимка не было никаких активных и открытых ячеек. Иначе в резервной копии будут незавершенные операции.

В результате мы пришли к выводу, что делать бэкап базы данных лучше встроенными утилитами — например, pg_dump, mongodump и другими. K8up позволяет нам использовать механизм запуска команд в поде, к которому будут добавлены аннотации.

Добавление аннотаций


Процесс создания аннотаций и их описание есть в документации K8up. Ниже — основные конфигурации для них.

annotations:
        k8up.io/backupcommand: sh -c 'mongodump --username=$MONGODB_ROOT_USER --password=$MONGODB_ROOT_PASSWORD --archive'
            k8up.io/file-extension: .archive


Конфигурация аннотации MongoDB.

annotations:
      k8up.io/backupcommand: sh -c 'PGDATABASE="$POSTGRES_DB" PGUSER="$POSTGRES_USER" PGPASSWORD="$POSTGRES_PASSWORD" pg_dump --clean'
      k8up.io/file-extension: .sql


Конфигурация аннотации PostgreSQL.


template:
  metadata:
    labels:
      app: my-db
    annotations:
      k8up.io/backupcommand: sh -c 'PGDATABASE="$POSTGRES_DB" PGUSER="$POSTGRES_USER" PGPASSWORD="$POSTGRES_PASSWORD" pg_dump --clean'
      k8up.io/file-extension: .sql
      k8up.io/backupcommand-container: postgres
  spec:
    containers:
      - name: pgbouncer
      - name: postgres
      - name: prometheus-exporter
        ...


Сборка аннотации на определенный контейнер.

Резервное копирование на базе Kasten K10 by Veeam


c52e8a304afd6b4426c8709965057000.png


Kasten K10 создан специально для Kubernetes и представляет собой платформу управления данными Cloud Native для операций «второго дня».

Возможности инструмента


Инструмент предоставляет командам DevOps простую, масштабируемую и безопасную систему для резервного копирования и восстановления приложений Kubernetes. Kasten K10 можно интегрировать с реляционными и NoSQL-базами данных и основными дистрибутивами Kubernetes.

Важно: бесплатная версия Kasten K10 ограничена пятью нодами.


Установка


PRE-FLIGHT проверки


Проверим наличие всех необходимых компонентов для Kasten K10:

#: curl https://docs.kasten.io/tools/k10_primer.sh | bash


В выводе команды мы увидим, чего не хватает для корректной работы K10. В нашем случае отсутствует CRD для создания снапшотов. Решим эту проблему.

Выведем имеющиеся для Volume CRD:

#: kubectl api-resources | grep volume


Теперь мы можем установить недостающие CRD и проверить, что они появились в списке api-resources:

#: kubectl apply -f https://raw.githubusercontent.com/selectel/mks-csi-snapshotter/master/deploy/setup-snapshot-controller.yaml
#: kubectl api-resources | grep volume
persistentvolumeclaims                pvc                     v1                                          true         PersistentVolumeClaim
persistentvolumes                     pv                      v1                                          false        PersistentVolume
volumesnapshotclasses                 vsclass,vsclasses   snapshot.storage.k8s.io/v1                  false        VolumeSnapshotClass
volumesnapshotcontents                vsc,vscs                snapshot.storage.k8s.io/v1                  false        VolumeSnapshotContent
volumesnapshots                       vs                      snapshot.storage.k8s.io/v1                  true         VolumeSnapshot
volumeattachments                                         storage.k8s.io/v1                           false        VolumeAttachment


Повторно прогоняем проверки PRE-FLIGHT:

#: curl https://docs.kasten.io/tools/k10_primer.sh | bash


Ошибка по CRD-Base пропала, но появилась новая: CSI Provisioner doesn’t have VolumeSnapshotClass — Error. Все верно: CRD есть, а VolumeSnapshotClass отсутствует. Попробуем решить эту проблему.

Рабочий вариант — вручную подготовить манифест. Приведу пример для Cinder OpenStack. Подробнее о драйверах можно почитать на сайте.

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
driver: cinder.csi.openstack.org
metadata:
  annotations:
    k10.kasten.io/is-snapshot-class: "true"
  name: csi-hostpath-snapclass-v1
deletionPolicy: Delete


Применим манифест и проверим наличие ресурса VolumeSnapshotClass:

#: kubectl apply -f VolumeSnapshotClass.yaml
volumesnapshotclass.snapshot.storage.k8s.io/cinder-csi-openstack-org created
#: kubectl get -f VolumeSnapshotClass.yaml
NAME                           DRIVER                         DELETIONPOLICY   AGE
csi-hostpath-snapclass-v1   cinder.csi.openstack.org   Delete               20s


Теперь можем снова запустить проверку PRE-FLIGHT:

#: curl https://docs.kasten.io/tools/k10_primer.sh | bash


Все пункты должны быть в статусе ОК.

Дополнительно необходимо добавить аннотацию k10.kasten.io/volume-snapshot-class в default storage-class:

#: kubectl annotate storageclass default \
    k10.kasten.io/volume-snapshot-class=csi-hostpath-snapclass-v1


На этом предварительные настройки для работы Kasten K10 закончены. Теперь можем перейти к установке самого инструмента.

Установка Kasten K10


Добавляем репозиторий Kasten:

#: helm repo add kasten https://charts.kasten.io/


Создаем отдельное пространство имен:

#: kubectl create namespace kasten-io


Устанавливаем Helm-чарт с включением sidecar kanister, который необходим для создания снапшотов. С версии Kasten K10 6.5.0 он по умолчанию выключен.

#: helm install k10 kasten/k10 --namespace=kasten-io --set injectKanisterSidecar.enabled=true --set-string injectKanisterSidecar.namespaceSelector.matchLabels.k10/injectKanisterSidecar=true


Дожидаемся полной установки компонентов и пробрасываем порт на локальный хост:

#: kubectl get pods --namespace kasten-io --watch
#: kubectl --namespace kasten-io port-forward service/gateway 8080:8000


Теперь можем открыть веб-интерфейс по следующему адресу:

http://127.0.0.1:8080/k10/#/dashboard


Настройка хранилища для резервных копий


Для создания резервных копий из снимков, перемещения приложений и их данных между кластерами и разными облаками, а также — последующего импорта/экспорта этих резервных копий в другой кластер используются профили хранилища.

Инициализация профиля S3


Как и в случае с K8up, настроим объектное хранилище в качестве основного для всех резервных копий. Это можно сделать как через веб-интерфейс, так и с помощью YAML-манифестов.

Создадим и настроим S3-профиль хранения резервных копий в объектном хранилище Selectel. Для этого перейдем на страницу профилей (http://127.0.0.1:8080/k10/#/profiles/location) — нас встретит такая страница:

185c45b6a341a3e3b7709ac2998d56d5.png


1ac3fb3bbeaca2a9229c24402c5ace1e.png


Важно: в качестве endpoint нужно указать s3.ru-1.storage.selcloud.ru

Далее необходимо сохранить профиль. Если все сделали правильно, в списке увидите профиль со статусом VALID:

4daa1c5ba8ca0e0821c423ddd2960be8.png


Настройка резервного копирования


Приступим к настройке периодических резервных копий нашего приложения.

Укажем label для пространства имен, куда установлено приложение. В нашем случае namespace=default:

#: kubectl label namespace default k10/injectKanisterSidecar=true


Создадим Policy. Это можно сделать из веб-панели Kasten K10, но мы воспользуемся манифестом:

#sample-backup-action.yam
apiVersion: config.kio.kasten.io/v1alpha1
kind: Policy
metadata:
  name: test
  namespace: kasten-io
spec:
  frequency: "@hourly"
  paused: false
  actions:
    - action: backup
      backupParameters:
        profile:
          name: selectel-s3
          namespace: kasten-io
    - action: export
      exportParameters:
        frequency: "@hourly"
        migrationToken:
          name: test-migration-token
          namespace: kasten-io
        exportData:
          enabled: true
      retention: {}
  retention:
    hourly: 24
    daily: 7
    weekly: 4
    monthly: 12
    yearly: 7
  selector:
    matchExpressions:
      - key: k10.kasten.io/appNamespace
        operator: In
        values:
          - default


Применим манифест в кластере:

#: kubectl create -f sample-backup-action.yaml


Если все сделано правильно, должен создаться backupaction. Его можно вывести с помощью специальной команды:

#: kubectl get backupactions.actions.kio.kasten.io
NAME              CREATED AT             STATE      PCT
scheduled-pwbvk   2023-11-02T09:08:00Z   Complete   100


Во вкладке Action веб-интерфейса можно проверить статус резервного копирования:

ae5e5ccf39ea71bb777fc3833a6ce77f.png


Копируем файл в приложение


Для проверки восстановления из резервной копии скопируем файл, например, Snapshot-class.yaml и вручную запустим бэкапирование. После удалим файл и восстановимся из резервной копии.

Копируем файл Snapshot-class.yaml:

#: kubectl cp Snapshot-class.yaml default/nginx-deployment-5dcc6d978b-lc96j:/usr/share/nginx/html/


Вручную запускаем резервное копирование. Это можно сделать на странице Policies:

846c03c5045ce4e8270205683b300290.png


Дожидаемся завершения резервного копирования (статус можно отслеживать на странице Dashboard), а после — удаляем файл из примонтированной папки в контейнере:

#: kubectl --namespace default exec -it nginx-deployment-5dcc6d978b-ps6rs -- /bin/sh
Defaulted container "nginx-container" out of: nginx-container, kanister-sidecar
# rm /usr/share/nginx/html/Snapshot-class.yaml


Восстановление


Теперь можем проверить, восстанавливается ли система из резервной копии.

Перейдем во вкладку Application → Item-Menu → Restore:

2117b454b128642ae5fdb5bbc993c403.png


Выберем нужную точку восстановления:

f0c79aa9fde5a76876c1789bc9146858.png


В появившемся окне Restore Point выберем приложение, поставим галочку рядом с Data-Only Restore и нажмем кнопку Restore:

8abc95d773cfa9a4d761c057240b9145.png


Необходимо дождаться, пока задача на восстановление будет выполнена, и проверить, что ранее удаленный файл снова доступен в примонтированной папке:

1ad7a0caf34c7f5969f9977e4dd0d810.png


Готово — резервное копирование на базе Kasten K10 работает!

Подводим итоги


Организация резервного копирования — сложный и комплексный процесс. Необходимо подумать о надежном хранении бэкапов, мониторинге их состояния, а также проверке на восстановление из них.

В своей ML-платформе мы используем инструмент K8up для резервного копирования важной информации — например, служебных данных ClearML. В качестве хранилища используем объектное хранилище. K8up создает файловые копии один раз в день, а также проверяет их на целостность.

Если вам интересно посмотреть, как это работает на практике, протестируйте нашу ML-платформу. Мы разворачиваем ее индивидуально для каждого клиента и можем включить в сборку такие open source-инструменты, как ClearML или Kubeflow. В общем, все для того, чтобы вы смогли организовать полный цикл обучения и тестирования ML-моделей.

© Habrahabr.ru