Envoy в Legacy-среде: использование протоколов xDS для управления Data Plane
Привет, Хабр! Давайте продолжим изучать возможности Envoy, но уже в контексте динамической конфигурации. В первой статье мы рассматривали настройку статической конфигурации, однако она имеет свои особенности. Статическая конфигурация подходит, когда ваши upstream (серверы, к которым Envoy отправляет запросы) редко изменяются. Envoy работает как прокси, и каждый запрос проходит через него. Чтобы правильно обработать запрос, Envoy должен иметь актуальную информацию о бэкенд-серверах, а точнее знать IP-адреса и порты. Когда информация о бэкенде меняется, необходимо обновить конфигурацию в статическом файле и перезапустить Envoy, что не всегда удобно.
Введение
В современных распределённых системах важным элементом является эффективное управление трафиком между сервисами. Каждая организация, особенно работающая с legacy-инфраструктурой, сталкивается с вызовами обеспечения высокой доступности, гибкости и быстрого реагирования на изменения. Именно здесь на первый план выходит Envoy — прокси следующего поколения, который обеспечивает не только балансировку нагрузки, но и динамическую маршрутизацию и глубокую интеграцию с системами мониторинга.
Для управления Envoy используются xDS-протоколы — мощный инструмент, предоставляющий возможность централизованного управления прокси. Этот подход напоминает управление в таких платформах, как Istio, где Envoy выступает в роли data plane, а control plane (например, Istiod) занимается конфигурацией и распределением данных. Подобная архитектура помогает легко масштабировать сервисы и внедрять сложные схемы маршрутизации.
Однако использование таких передовых технологий в legacy-инфраструктуре часто сопряжено с дополнительными сложностями. Устаревшие системы могут не поддерживать современные API, иметь ограниченные вычислительные ресурсы или быть частью распределённой среды с несовместимыми технологиями. В таких случаях xDS становится инструментом, способным связать воедино старые и новые компоненты, предоставляя единый интерфейс для управления.
В данной статье мы рассмотрим:
Что такое xDS-протоколы и их роль в управлении Data Plane.
Как использовать xDS в legacy-инфраструктуре.
Популярные библиотеки для реализации control plane на Go и Java.
Пример реализации взаимодействия Envoy с xDS Control Plane.
Что такое xDS-протоколы и их роль в управлении Data Plane?
xDS — семейство протоколов, разработанных для динамической конфигурации Envoy. Основные протоколы включают:
ADS (Aggregated Discovery Service) объединяет несколько xDS API в один поток, упрощая управление ресурсами и обеспечивая согласованность обновлений. ADS позволяет упорядочить обновления API и обеспечить привязку к одному серверу управления для одного узла Envoy. Это позволяет избежать проблем с координацией нескольких потоков или серверов. ADS особенно полезен для последовательного обновления конфигураций, например, сначала обновляются данные кластеров (CDS/EDS), а затем маршруты (RDS), что позволяет избежать сбоев при обновлении.
LDS (Listener Discovery Service) управляет динамической настройкой слушателей.
CDS (Cluster Discovery Service) позволяет обновлять кластеры без перезапуска.
EDS (Endpoint Discovery Service) отвечает за динамическое управление конечными точками в кластерах.
RDS (Route Discovery Service) позволяет обновлять маршруты для HTTP без прерывания работы.
SDS (Secret Discovery Service) управляет секретами, такими как сертификаты и ключи.
VHDS, SRDS, ECDS предоставляют дополнительные возможности для оптимизации маршрутизации и конфигураций фильтров.
Delta xDS поддерживает обновления только измененных или добавленных ресурсов, что значительно снижает нагрузку на сеть и сервер управления. В отличие от традиционных механизмов доставки данных («state of the world»), Delta xDS отправляет только изменения, а не полные конфигурации.
xDS TTL задает время жизни ресурсов для защиты от недоступности плоскости управления.
Все xDS API основаны на gRPC (некоторые поддерживают REST) и используют формат данных Protocol Buffers (protobuf
), также поддерживают двунаправленный поток данных между Envoy и сервером управления. Когда Envoy запускается, он инициирует сессию с сервером управления, отправляя запросы на получение данных конфигурации. Сервер отвечает соответствующими обновлениями, используя механизмы инкрементальных или полных обновлений.
Одной из ключевых особенностей ADS является возможность последовательного обновления конфигураций без сбоев. Например, для изменения маршрутизации домена foo.com
с кластера X на кластер Y сначала передаются обновления для кластеров (CDS/EDS
), а затем маршруты (RDS
). Без использования ADS такие изменения потребовали бы координации между несколькими потоками или серверами управления, что усложняет процесс. ADS объединяет все API в один поток, что упрощает управление и обеспечивает «гладкость» обновлений.
Delta xDS существенно снижает нагрузку при обновлениях в крупных развертываниях. Вместо отправки полного списка ресурсов, каждое обновление содержит только добавленные, измененные или удаленные ресурсы — проще говоря, diff
. Это особенно полезно в масштабных системах, где даже небольшие изменения могут приводить к значительным издержкам при использовании «state of the world» обновлений. Такой метод взаимодействия по умолчанию используется в istio с версии >= 1.22, что существенно сократило издержки при построении service mesh в режиме sidecar mode, когда в огромных кластерах рассылка уведомлений съедала кучу сетевого трафика и ресурсов CPU.
Также имеется поддержка Watch-механизма. Это означает, что Envoy активно отслеживает изменения на сервере управления, что позволяет мгновенно реагировать на обновления без необходимости перезапуска. Например, если добавляется новый маршрут или изменяется политика безопасности, эти изменения автоматически синхронизируются между сервером управления и прокси.
Кроме того, xDS-протокол включает в себя контроль версий через идентификаторы ресурсов (resource version
) и систему подтверждения (ACK/NACK
). Это позволяет Envoy убедиться, что он получил корректную конфигурацию, и в случае ошибок откатится к последней стабильной версии. Подробнее почитать про каждый протокол можно здесь
Варианты транспортного протокола xDS
xDS поддерживает четыре варианта транспортного протокола на основе потокового gRPC. Эти варианты определяются двумя измерениями:
State of the World (SotW) против Incremental:
В режиме «SotW» клиент указывает все имена ресурсов, которые его интересуют, в каждом запросе, а сервер возвращает полный список запрашиваемых ресурсов.
В режиме Incremental обе стороны передают только изменения относительно предыдущего состояния, что улучшает масштабируемость и позволяет «ленивую» загрузку ресурсов.
Отдельные потоки gRPC для каждого типа ресурсов против агрегированного потока:
Отдельные потоки предоставляют модель eventual consistency.
Агрегированные потоки используются в средах, где требуется явный контроль последовательности.
Итого, четыре варианта транспортного протокола xDS:
State of the World (SotW) — отдельный поток gRPC для каждого типа ресурсов.
Incremental xDS — инкрементальные данные, отдельный поток gRPC для каждого ресурса.
Aggregated Discovery Service (ADS) — SotW, агрегированный поток для всех типов ресурсов.
Incremental ADS — инкрементальные данные, агрегированный поток для всех ресурсов.
Архитектура взаимодействия Envoy и Control Plane
Взаимодействие состоит из двух основных компонентов:
Control Plane отвечает за управление конфигурацией и предоставление актуальных данных через xDS.
Data Plane (Envoy) получает конфигурации от Control Plane и применяет их в реальном времени.
Плюсы:
Динамическая конфигурация: обновление конфигураций без рестарта.
Централизованное управление: упрощение управления большим числом прокси.
Масштабируемость: легко адаптируется к изменению инфраструктуры.
Минусы:
Сложность: требует настройки и разработки собственного Control Plane (но можно использовать совместимый с API envoy control plane от Gloo или Istio).
Зависимость от xDS API: требует совместимости с последними версиями Envoy.
Высокая чувствительность к ошибкам: ошибки в Control Plane могут повлиять на весь трафик.
Библиотеки для реализации Control Plane
Существует две библиотеки от разработчиков Envoy для реализации собственного control plane на базе — Go Control Plane и Java Control Plane
Критерий | Golang (go-control-plane) | Java (java-control-plane) |
Производительность | Высокая, минимальные накладные расходы | Средняя, накладные расходы на JVM |
Сложность кода | Простая, минималистичная | Сложная |
Скорость разработки | Быстрая | Средняя, может быть медленнее из-за шаблонов |
Удобство интеграции | Отлично подходит для CNCF-экосистемы | Хорошо подходит для интеграции с корпоративными системами |
Обучение и поддержка | Простая для изучения | Требуется больше знаний для настройки и работы |
Потребление ресурсов | Экономичная | Затратная по ресурсам |
Особенности Golang:
Высокая производительность:
Go отлично подходит для разработки высокопроизводительных систем благодаря встроенной поддержке конкурентности через goroutines.
Go-control-plane оптимизирована для обработки больших объемов данных в реальном времени.
Простота кода:
Лаконичный синтаксис Go позволяет быстро понимать и разрабатывать решения.
Библиотека
go-control-plane
поддерживает интуитивно понятный API.
Компиляция в единый бинарный файл:
Совместимость с экосистемой CNCF:
Go является предпочтительным языком для проектов CNCF (Kubernetes, Prometheus и др.), что упрощает интеграцию с другими инструментами.
Особенности Java:
Мощная экосистема:
Java имеет зрелую экосистему с большим количеством фреймворков и библиотек для упрощения работы (Spring, Micronaut).
Существуют мощные инструменты для построения сложных сервисов.
Поддержка многопоточности:
Java предоставляет мощные инструменты для работы с потоками (ExecutorService, ForkJoinPool), что удобно для сложных задач управления.
Богатые возможности анализа данных:
Java предоставляет развитые библиотеки для работы с сериализацией, логированием, мониторингом и диагностиками.
Широкая поддержка корпоративных решений:
Запуск Envoy с динамической конфигурацией
admin:
access_log_path: /dev/null
address:
socket_address:
address: 0.0.0.0
port_value: 9901
node:
# Устанавливаем идентификатор кластера
cluster: envoy_cluster
# Устанавливаем идентификатор узла
id: envoy_node
dynamic_resources:
ads_config:
#api_type: DELTA_GRPC
api_type: GRPC
grpc_services:
- envoy_grpc:
cluster_name: ads_cluster
cds_config:
ads: {}
lds_config:
ads: {}
static_resources:
clusters:
- name: ads_cluster
type: STRICT_DNS
load_assignment:
cluster_name: ads_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: controlplane.xds.local
port_value: 18000
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options:
connection_keepalive:
interval: 30s
timeout: 5s
upstream_connection_options:
tcp_keepalive: {}
Что здесь мы наконфигурировали? В разделе node
задаются идентификаторы узла и кластера, которые Envoy использует для связи с Control Plane.
dynamic_resources
включает настройки динамических ресурсов:ads_config
задаёт параметры для использования ADS через gRPC.cds_config
иlds_config
указывают на получение конфигураций кластеров и слушателей через ADS.
В
static_resources
конфигурируется кластерads_cluster
для подключения к серверу управления xDS.
Рекомендуется настроить либо HTTP/2, либо TCP keepalive для обнаружения проблем соединения и возможности переподключения Envoy. TCP keepalive менее затратен, но может быть недостаточным, если между Envoy и сервером управления есть TCP-прокси. HTTP/2 PING немного дороже, но позволяет обнаружить проблемы через большее число прокси. Для включения Delta xDS
необходимо задать поле api_type
как DELTA_GRPC
в конфигурации API.
Golang control plane
За control plane возьмем официальный пример от разработчиков библиотеки и разберем его последовательно. Также в документации есть краткий гайд, но без подробных объяснений. Постараемся это немного исправить.
Определение констант
Константы, как и переменные, предназначены для хранения данных. Однако в отличие от переменных, значения констант заданы один раз и не могут быть изменены динамически в процессе работы.
Скрытый текст
const (
ClusterName = "example_proxy_cluster"
RouteName = "local_route"
ListenerName = "listener_0"
ListenerPort = 10000
UpstreamHost = "www.envoyproxy.io"
UpstreamPort = 80
)
Создание кластера
Кластер описывает группу серверов, на которые Envoy направляет трафик. Здесь задаются таймаут подключения, стратегия балансировки нагрузки (Round Robin) и тип DNS-обнаружения.
Скрытый текст
func makeCluster(clusterName string) *cluster.Cluster {
return &cluster.Cluster{
Name: clusterName,
ConnectTimeout: durationpb.New(5 * time.Second),
ClusterDiscoveryType: &cluster.Cluster_Type{Type: cluster.Cluster_LOGICAL_DNS},
LbPolicy: cluster.Cluster_ROUND_ROBIN,
LoadAssignment: makeEndpoint(clusterName),
DnsLookupFamily: cluster.Cluster_V4_ONLY,
}
}
.
Определение endpoint
Endpoint описывает, как Envoy будет подключаться к upstream-серверам. Этот код определяет, что Envoy направляет трафик на www.envoyproxy.io:80 через протокол TCP.
Скрытый текст
func makeEndpoint(clusterName string) *endpoint.ClusterLoadAssignment {
return &endpoint.ClusterLoadAssignment{
ClusterName: clusterName,
Endpoints: []*endpoint.LocalityLbEndpoints{{
LbEndpoints: []*endpoint.LbEndpoint{{
HostIdentifier: &endpoint.LbEndpoint_Endpoint{
Endpoint: &endpoint.Endpoint{
Address: &core.Address{
Address: &core.Address_SocketAddress{
SocketAddress: &core.SocketAddress{
Protocol: core.SocketAddress_TCP,
Address: UpstreamHost,
PortSpecifier: &core.SocketAddress_PortValue{
PortValue: UpstreamPort,
},
},
},
},
},
},
}},
}},
}
}
Определение маршрутов
Маршруты задают правила обработки входящих запросов. В блоке VirtualHost
все запросы с путями, начинающимися с /, перенаправляются на кластер с переписыванием заголовка Host
. Также задается разрешение для всех доменов через *
Скрытый текст
func makeRoute(routeName, clusterName string) *route.RouteConfiguration {
return &route.RouteConfiguration{
Name: routeName,
VirtualHosts: []*route.VirtualHost{{
Name: "local_service",
Domains: []string{"*"},
Routes: []*route.Route{{
Match: &route.RouteMatch{
PathSpecifier: &route.RouteMatch_Prefix{Prefix: "/"},
},
Action: &route.Route_Route{
Route: &route.RouteAction{
ClusterSpecifier: &route.RouteAction_Cluster{
Cluster: clusterName,
},
HostRewriteSpecifier: &route.RouteAction_HostRewriteLiteral{
HostRewriteLiteral: UpstreamHost,
},
},
},
}},
}},
}
}
Создание слушателя
Слушатель отвечает за прием входящих соединений. Например, HTTP-запросы можно обрабатывать следующим образом: этот код создает HTTP-слушатель (он же фильтр HttpConnectionManager
), который принимает запросы на 0.0.0.0:10000
по TCP
и перенаправляет их на ранее определенный маршрут. Также добавляется префикс к метрикам http
.
Скрытый текст
func makeHTTPListener(listenerName, route string) *listener.Listener {
routerConfig, _ := anypb.New(&router.Router{})
manager := &hcm.HttpConnectionManager{
CodecType: hcm.HttpConnectionManager_AUTO,
StatPrefix: "http",
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
Rds: &hcm.Rds{
ConfigSource: makeConfigSource(),
RouteConfigName: route,
},
},
HttpFilters: []*hcm.HttpFilter{{
Name: "http-router",
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: routerConfig},
}},
}
pbst, err := anypb.New(manager)
if err != nil {
panic(err)
}
return &listener.Listener{
Name: listenerName,
Address: &core.Address{
Address: &core.Address_SocketAddress{
SocketAddress: &core.SocketAddress{
Protocol: core.SocketAddress_TCP,
Address: "0.0.0.0",
PortSpecifier: &core.SocketAddress_PortValue{
PortValue: ListenerPort,
},
},
},
},
FilterChains: []*listener.FilterChain{{
Filters: []*listener.Filter{{
Name: "http-connection-manager",
ConfigType: &listener.Filter_TypedConfig{
TypedConfig: pbst,
},
}},
}},
}
}
Генерация снимка
После определения всех компонентов их можно объединить в снимок (snapshot). Снимок сохраняется в оперативной памяти в виде хеш-таблицы (под капотом используется обычная map
, она же key=value
хранилище). Он включает ресурсы разных типов: кластеры (CDS), маршруты (RDS), слушатели (LDS), точки назначения (EDS) и секреты (SDS). Снимок ассоциируется с версией (version) и используется для синхронизации состояния между xDS-сервером и Envoy.
Скрытый текст
func GenerateSnapshot() *cache.Snapshot {
snap, _ := cache.NewSnapshot("1",
map[resource.Type][]types.Resource{
resource.ClusterType: {makeCluster(ClusterName)},
resource.RouteType: {makeRoute(RouteName, ClusterName)},
resource.ListenerType: {makeHTTPListener(ListenerName, RouteName)},
},
)
return snap
}
1) Создание версии снимка
Версия задается строкой, например "1"
. Она используется для определения необходимости обновления конфигурации на стороне Envoy. Если версия изменилась, Envoy автоматически применяет новые настройки.
snap, _ := cache.NewSnapshot("1", ...)
2) Добавление ресурсов
Все ресурсы группируются в карту (map
), где ключом выступает тип ресурса, а значением — список объектов этого типа. Примеры типов:
resource.ClusterType
— для кластеров;resource.RouteType
— для маршрутов;resource.ListenerType
— для слушателей.
map[resource.Type][]types.Resource{
resource.ClusterType: {makeCluster(ClusterName)},
resource.RouteType: {makeRoute(RouteName, ClusterName)},
resource.ListenerType: {makeHTTPListener(ListenerName, RouteName)},
}
3) Создание ресурсов
Для каждого типа вызываются специализированные функции:
makeCluster
создает кластер с настройками DNS-обнаружения и стратегией балансировки нагрузки (Round Robin).makeRoute
создает маршруты, которые связывают HTTP-запросы с кластерами.makeHTTPListener
создает слушатели, которые обрабатывают входящие подключения на указанном порту.
4) Сборка снимка
После создания ресурсов они добавляются в снимок:
snap, _ := cache.NewSnapshot("1", map[resource.Type][]types.Resource{
resource.ClusterType: {makeCluster(ClusterName)},
resource.RouteType: {makeRoute(RouteName, ClusterName)},
resource.ListenerType: {makeHTTPListener(ListenerName, RouteName)},
}
5) Созданный снимок нужно передать в кеш, чтобы он стал доступен для Envoy:
// Create a cache
cache := cache.NewSnapshotCache(false, cache.IDHash{}, l)
// Create the snapshot that we'll serve to Envoy
snapshot := example.GenerateSnapshot()
if err := snapshot.Consistent(); err != nil {
l.Errorf("snapshot inconsistency: %+v\n%+v", snapshot, err)
os.Exit(1)
}
l.Debugf("will serve snapshot %+v", snapshot)
// Add the snapshot to the cache
if err := cache.SetSnapshot(context.Background(), nodeID, snapshot); err != nil {
l.Errorf("snapshot error %q for %+v", err, snapshot)
os.Exit(1)
}
NewSnapshotCache
управляет снимками для узлов (node-id
).GenerateSnapshot
создает новый снимок.SetSnapshot
передает снимок в кеш.
Реализация xDS-сервера
Скрытый текст
const (
grpcKeepaliveTime = 30 * time.Second
grpcKeepaliveTimeout = 5 * time.Second
grpcKeepaliveMinTime = 30 * time.Second
grpcMaxConcurrentStreams = 1000000
)
type Server struct {
xdsserver server.Server
}
func NewServer(ctx context.Context, cache cache.Cache, cb *test.Callbacks) *Server {
srv := server.NewServer(ctx, cache, cb)
return &Server{srv}
}
func (s *Server) Run(port uint) {
grpcServer := grpc.NewServer(
grpc.MaxConcurrentStreams(grpcMaxConcurrentStreams),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: grpcKeepaliveTime,
Timeout: grpcKeepaliveTimeout,
}),
)
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal(err)
}
s.registerServer(grpcServer)
log.Printf("Server listening on %d\n", port)
if err = grpcServer.Serve(lis); err != nil {
log.Println(err)
}
}
func RunServer(srv server.Server, port uint) {
var grpcOptions []grpc.ServerOption
grpcOptions = append(grpcOptions,
grpc.MaxConcurrentStreams(grpcMaxConcurrentStreams),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: grpcKeepaliveTime,
Timeout: grpcKeepaliveTimeout,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: grpcKeepaliveMinTime,
PermitWithoutStream: true,
}),
)
grpcServer := grpc.NewServer(grpcOptions...)
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal(err)
}
registerServer(grpcServer, srv)
log.Printf("management server listening on %d\n", port)
if err = grpcServer.Serve(lis); err != nil {
log.Println(err)
}
}
Здесь относительно все просто: описана логика запуска и работы gRPC-сервера, структура — она же обертка над xDS, константы для keepalive, максимальное кол-во потоков и задаются обработчики сервисов:
Скрытый текст
func (s *Server) registerServer(grpcServer *grpc.Server) {
// register services
discoverygrpc.RegisterAggregatedDiscoveryServiceServer(grpcServer, s.xdsserver)
endpointservice.RegisterEndpointDiscoveryServiceServer(grpcServer, s.xdsserver)
clusterservice.RegisterClusterDiscoveryServiceServer(grpcServer, s.xdsserver)
routeservice.RegisterRouteDiscoveryServiceServer(grpcServer, s.xdsserver)
listenerservice.RegisterListenerDiscoveryServiceServer(grpcServer, s.xdsserver)
secretservice.RegisterSecretDiscoveryServiceServer(grpcServer, s.xdsserver)
runtimeservice.RegisterRuntimeDiscoveryServiceServer(grpcServer, s.xdsserver)
}
Большинство параметров можно условно вынести в отдельный внешний JSON-конфиг, который система сможет периодически перечитывать в цикле. Например, в конфиге можно указать версию снимка (snapshot), которая сейчас захардкожена в коде, или настроить переопределение констант. После обновления версии Envoy автоматически распознает изменения и перечитает конфигурацию, обеспечивая динамическую адаптацию без необходимости перезапуска. Такой подход добавляет гибкость в управлении настройками и упрощает их изменение.
Тем не менее, этот вариант не стоит воспринимать как универсальное или единственно правильное решение. Окончательный выбор всегда остается за архитектором или разработчиком, исходя из особенностей конкретного проекта. Например, для более сложных сценариев может понадобиться интеграция с системами управления конфигурацией, такими как Consul
или Etcd
, чтобы обеспечить централизованное хранение и оркестрацию настроек. Такой подход не только повысит масштабируемость, но и добавит надежности, особенно в распределенных системах.
Использование протоколов xDS предоставляет мощный инструмент для управления Data Plane прокси-серверов Envoy в legacy-инфраструктуре. Эти протоколы обеспечивают гибкость, динамическую конфигурацию и высокую масштабируемость, что особенно важно для legacy-систем, где статическая конфигурация может быть сложной или неэффективной.
Благодаря xDS становится возможным реализовать централизованное управление маршрутизацией, балансировкой нагрузки, обнаружением сервисов и безопасностью, что упрощает интеграцию Envoy в существующую инфраструктуру. Использование gRPC для взаимодействия между Envoy и Control Plane обеспечивает низкую задержку и надежность передачи данных, а концепция ресурсной модели позволяет гибко адаптировать протоколы под конкретные задачи.
Однако внедрение xDS требует тщательного планирования, особенно в контексте legacy-систем, где возможны ограничения по совместимости и производительности. Это подразумевает глубокое понимание архитектуры xDS и потенциала каждого протокола (ADS, CDS, LDS, RDS и других), чтобы успешно интегрировать их в существующую экосистему.
Таким образом, xDS протоколы открывают значительные возможности для модернизации legacy-инфраструктуры, обеспечивая более гибкое управление сетевыми потоками и поддержание высокой доступности сервисов. Однако для достижения максимального эффекта важно учитывать индивидуальные особенности каждой системы и тщательно продумывать архитектурные решения.