blob: 6d68a9d3f061a8e8f41ec4c04db91a5ed05cd773 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Shared gRPC channel target / options helpers for sync and aio reporters.
Multi-backend design (aligned with skywalking-nodejs native failover):
- One channel for the process lifetime; no hand-rolled poll/reselect manager.
- Single address → plain host:port (DNS for hostnames, with re-resolve).
- Multiple addresses are assembled like Node sw-static endpoints
({host, port} list, IPv4 / IPv6 / hostname can coexist).
- grpcio cannot register a custom scheme; the endpoint list is encoded for
C-core: homogeneous ipv4:/ipv6:, mixed families via ipv6: + IPv4-mapped
(::ffff:a.b.c.d) so pick_first can try both families.
- Hostnames in a multi list are resolved once at channel build (grpcio cannot
keep a literal hostname in ipv4:/ipv6:). No periodic DNS re-resolve for multi.
- pick_first shuffleAddressList is on (per-process random preferred backend).
Channel target / grpc.default_authority still follow config order (TLS SAN).
- Invalid entries are logged and dropped; never silently ignored without a log.
- HTTP proxy disabled; keepalive channel options intentionally omitted (OAP conflict).
- Unary and sync streaming RPCs use a deadline (Node 10s floor, always >
agent_queue_timeout + margin so sync collect is not cut off by the batching window).
Aio client-streaming collect/collectBatch/collectSnapshot omit timeout=
(generators await empty queues). DEADLINE_EXCEEDED / RESOURCE_EXHAUSTED on a
READY backend do not rotate or rebuild; failover is for unreachable backends,
not a slow but connected one.
- READY gate (application-level): skip report RPCs unless channel connectivity is READY;
nudge IDLE via get_state(True) so gating does not starve reconnect (Node watch parity).
- Reconnect backoff max 30s (Node multi-backend parity).
- service_config retries only ManagementService.reportInstanceProperties on UNAVAILABLE
(max 3); never retry client-streaming collect.
"""
from __future__ import annotations
import ipaddress
import json
import socket
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Sequence, Tuple
import grpc
from skywalking.loggings import logger
# Retry only unary idempotent reportInstanceProperties (Node service_config parity).
# Client-streaming collect must NOT be retried — replay would duplicate segments.
# keepAlive relies on the next heartbeat tick instead.
_PROPERTIES_RETRY_SERVICE_CONFIG = json.dumps({
# Shuffle is LB-layer (Node parity): target string stays config-order so
# grpc.default_authority / TLS SNI remain the first configured endpoint.
'loadBalancingConfig': [{'pick_first': {'shuffleAddressList': True}}],
'methodConfig': [{
'name': [{
'service': 'skywalking.v3.ManagementService',
'method': 'reportInstanceProperties',
}],
'retryPolicy': {
'maxAttempts': 3,
'initialBackoff': '1s',
'maxBackoff': '10s',
'backoffMultiplier': 2,
'retryableStatusCodes': ['UNAVAILABLE'],
},
}],
})
# Channel options shared by sync + aio. Do NOT add keepalive_* options here.
# pick_first + shuffle lives in grpc.service_config (not grpc.lb_policy_name).
GRPC_CHANNEL_OPTIONS: Tuple[Tuple[str, int | str], ...] = (
('grpc.enable_http_proxy', 0),
('grpc.enable_retries', 1),
('grpc.service_config', _PROPERTIES_RETRY_SERVICE_CONFIG),
('grpc.initial_reconnect_backoff_ms', 1000),
('grpc.min_reconnect_backoff_ms', 1000),
# Cap aligns with Node multi-backend (~30s); shorter caps reconnect too aggressively.
('grpc.max_reconnect_backoff_ms', 30000),
)
# Node default RPC deadline is 10s. Sync streaming collect must outlive the queue
# batch window with room for protobuf encode + RTT + server handling.
# Sync generators may spend nearly the full queue window on the final queue.get
# (absolute batch deadline); margin keeps healthy sends off DEADLINE_EXCEEDED.
# Do not apply this to aio client-streaming: those generators await queue.get() forever.
_GRPC_RPC_TIMEOUT_FLOOR_SEC = 10.0
_GRPC_RPC_TIMEOUT_MARGIN_SEC = 5.0
def grpc_call_timeout() -> float:
"""Seconds for unary / sync-streaming stub timeout=. Always > agent_queue_timeout + margin."""
from skywalking import config
return max(
_GRPC_RPC_TIMEOUT_FLOOR_SEC,
float(config.agent_queue_timeout) + _GRPC_RPC_TIMEOUT_MARGIN_SEC,
)
_AUTH_LOG_INTERVAL_SEC = 60.0
_last_auth_log_at = 0.0
_CONNECTIVITY_LOG_INTERVAL_SEC = 30.0
_last_connectivity_log_at: Dict[str, float] = {}
_DNS_LOOKUP_TIMEOUT_SEC = 5.0
# Thread-local: set while create_*_channel builds the agent→OAP channel so sw_grpc
# does not attach client interceptors (multi-address targets no longer match config).
_building_agent_collector = threading.local()
_SW_AGENT_COLLECTOR_ATTR = '_sw_agent_collector_channel'
@contextmanager
def agent_collector_channel_scope():
_building_agent_collector.active = True
try:
yield
finally:
_building_agent_collector.active = False
def is_building_agent_collector_channel() -> bool:
return bool(getattr(_building_agent_collector, 'active', False))
def mark_agent_collector_channel(channel):
try:
setattr(channel, _SW_AGENT_COLLECTOR_ATTR, True)
except Exception: # noqa: BLE001 - exotic channel wrappers
pass
return channel
def is_agent_collector_channel(channel) -> bool:
return bool(getattr(channel, _SW_AGENT_COLLECTOR_ATTR, False))
class AddressKind(Enum):
IPV4 = 'ipv4'
IPV6 = 'ipv6'
HOSTNAME = 'hostname'
@dataclass(frozen=True)
class BackendAddress:
host: str
port: int
kind: AddressKind
def endpoint(self) -> str:
if self.kind == AddressKind.IPV6:
return f'[{self.host}]:{self.port}'
return f'{self.host}:{self.port}'
def _classify_host(host: str) -> Optional[AddressKind]:
if not host or any(c.isspace() or ord(c) < 32 for c in host) or '/' in host:
return None
# Zone indices (fe80::1%eth0) are not usable in static ipv6: targets.
if '%' in host:
return None
try:
ip = ipaddress.ip_address(host)
except ValueError:
return AddressKind.HOSTNAME
if isinstance(ip, ipaddress.IPv4Address):
return AddressKind.IPV4
return AddressKind.IPV6
def parse_backend_address(raw: str) -> Optional[BackendAddress]:
"""Parse a single host:port (IPv6 requires [host]:port). Returns None if invalid."""
text = (raw or '').strip()
if not text:
return None
host: str
port_str: str
if text.startswith('['):
# [ipv6]:port
closing = text.find(']')
if closing <= 1 or closing + 1 >= len(text) or text[closing + 1] != ':':
return None
host = text[1:closing]
port_str = text[closing + 2:]
else:
if text.count(':') != 1:
# Ambiguous IPv6 without brackets, or missing port.
return None
host, port_str = text.rsplit(':', 1)
host = host.strip()
port_str = port_str.strip()
if not host or not port_str:
return None
try:
port = int(port_str)
except ValueError:
return None
if port < 1 or port > 65535:
return None
kind = _classify_host(host)
if kind is None:
return None
return BackendAddress(host=host, port=port, kind=kind)
def parse_backend_addresses(services: str) -> List[BackendAddress]:
"""
Split SW_AGENT_COLLECTOR_BACKEND_SERVICES on commas.
Invalid entries are skipped with an error log (never silent).
"""
parts = [p.strip() for p in (services or '').split(',') if p.strip()]
addresses: List[BackendAddress] = []
for part in parts:
addr = parse_backend_address(part)
if addr is None:
logger.error(
'Invalid collector backend address %r in SW_AGENT_COLLECTOR_BACKEND_SERVICES; '
'expected host:port or [ipv6]:port',
part,
)
continue
addresses.append(addr)
return addresses
def sw_static_endpoints(addresses: Sequence[BackendAddress]) -> List[Dict]:
"""
Node sw-static resolver output shape: a list of endpoints, each with
addresses: [{host, port}]. IPv4, IPv6, and hostnames can share one list.
"""
return [
{'addresses': [{'host': addr.host, 'port': addr.port}]}
for addr in addresses
]
def _lookup_hostname(host: str, port: int) -> List[BackendAddress]:
"""
Resolve hostname to BackendAddress IPs (order preserved, duplicates dropped).
Multi-address targets require literal IPs for ipv4:/ipv6:. A hung DNS lookup
must not block agent startup or process exit — bound the wait and run the
lookup on a daemon thread (ThreadPoolExecutor workers are non-daemon and
would keep the process alive after timeout).
"""
import threading
from concurrent.futures import Future, TimeoutError as FuturesTimeout
fut: Future = Future()
def _run() -> None:
try:
fut.set_result(socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM))
except Exception as exc: # noqa: BLE001 - forward any lookup failure to waiter
if not fut.done():
fut.set_exception(exc)
threading.Thread(target=_run, name=f'sw-dns-{host}', daemon=True).start()
try:
infos = fut.result(timeout=_DNS_LOOKUP_TIMEOUT_SEC)
except FuturesTimeout:
logger.error(
'Timed out resolving collector hostname %r:%s after %.1fs; skipping this backend',
host,
port,
_DNS_LOOKUP_TIMEOUT_SEC,
)
return []
except socket.gaierror as exc:
logger.error(
'Failed to resolve collector hostname %r:%s (%s); skipping this backend',
host,
port,
exc,
)
return []
except Exception as exc: # noqa: BLE001 - never crash agent init on DNS oddities
logger.error(
'Unexpected error resolving collector hostname %r:%s (%s); skipping this backend',
host,
port,
exc,
)
return []
resolved: List[BackendAddress] = []
seen = set()
for family, _type, _proto, _canon, sockaddr in infos:
if family == socket.AF_INET:
ip = sockaddr[0]
kind = AddressKind.IPV4
elif family == socket.AF_INET6:
ip = sockaddr[0]
if '%' in ip:
ip = ip.split('%', 1)[0]
# Skip IPv4-mapped IPv6; the AF_INET result already covers that backend.
try:
packed = ipaddress.ip_address(ip)
except ValueError:
continue
if packed.ipv4_mapped is not None:
continue
kind = AddressKind.IPV6
else:
continue
key = (kind, ip, port)
if key in seen:
continue
seen.add(key)
resolved.append(BackendAddress(host=ip, port=port, kind=kind))
if not resolved:
logger.error(
'Collector hostname %r:%s resolved to no usable IPv4/IPv6 address; skipping',
host,
port,
)
return resolved
def expand_backend_addresses(addresses: Sequence[BackendAddress]) -> List[BackendAddress]:
"""
Expand hostnames to literal IPs for C-core static targets.
Literal IP entries are kept as-is. Failed hostname lookups are skipped with error logs.
"""
expanded: List[BackendAddress] = []
seen = set()
for addr in addresses:
candidates: Sequence[BackendAddress]
if addr.kind == AddressKind.HOSTNAME:
candidates = _lookup_hostname(addr.host, addr.port)
else:
candidates = (addr,)
for item in candidates:
key = (item.kind, item.host, item.port)
if key in seen:
continue
seen.add(key)
expanded.append(item)
return expanded
def _ipv4_mapped_v6(ipv4: str) -> str:
return f'::ffff:{ipv4}'
def encode_sw_static_for_c_core(addresses: Sequence[BackendAddress]) -> str:
"""
Encode a Node-style mixed endpoint list for grpcio/C-core.
Homogeneous lists use ipv4:/ipv6:. Mixed IPv4+IPv6 uses the ipv6 resolver
with IPv4-mapped addresses so pick_first can try both families.
"""
if not addresses:
raise ValueError(
'No valid collector backend address in SW_AGENT_COLLECTOR_BACKEND_SERVICES'
)
if any(a.kind == AddressKind.HOSTNAME for a in addresses):
raise ValueError('encode_sw_static_for_c_core requires literal IP endpoints')
kinds = {a.kind for a in addresses}
if kinds == {AddressKind.IPV4}:
return 'ipv4:' + ','.join(f'{a.host}:{a.port}' for a in addresses)
if kinds == {AddressKind.IPV6}:
return 'ipv6:' + ','.join(f'[{a.host}]:{a.port}' for a in addresses)
parts = []
for addr in addresses:
if addr.kind == AddressKind.IPV4:
parts.append(f'[{_ipv4_mapped_v6(addr.host)}]:{addr.port}')
else:
parts.append(f'[{addr.host}]:{addr.port}')
logger.info(
'Encoding mixed-family sw-static endpoints for grpcio via ipv6 IPv4-mapped list: %s',
parts,
)
return 'ipv6:' + ','.join(parts)
def prepare_grpc_channel_endpoints(
addresses: Sequence[BackendAddress],
) -> Tuple[str, str]:
"""
Build (channel_target, default_authority) from parsed backends.
Authority prefers the first *usable* original entry's host:port (hostname kept
for TLS SAN when that name resolved). Never points at a hostname that DNS skipped.
"""
if not addresses:
raise ValueError(
'No valid collector backend address in SW_AGENT_COLLECTOR_BACKEND_SERVICES'
)
if len(addresses) == 1:
ep = addresses[0].endpoint()
return ep, ep
to_encode: List[BackendAddress] = []
seen = set()
authority: Optional[str] = None
for orig in addresses:
if orig.kind == AddressKind.HOSTNAME:
candidates = _lookup_hostname(orig.host, orig.port)
if not candidates:
continue
if authority is None:
# Prefer original hostname for :authority / SNI (Node sw-static style).
authority = orig.endpoint()
else:
candidates = (orig,)
if authority is None:
authority = orig.endpoint()
for item in candidates:
key = (item.kind, item.host, item.port)
if key in seen:
continue
seen.add(key)
to_encode.append(item)
if not to_encode:
raise ValueError(
'No usable collector backend address after DNS expansion of '
'SW_AGENT_COLLECTOR_BACKEND_SERVICES'
)
if authority is None:
authority = to_encode[0].endpoint()
if any(a.kind == AddressKind.HOSTNAME for a in addresses):
logger.info(
'Expanded multi-backend collector addresses %s -> %s (authority=%s)',
[a.endpoint() for a in addresses],
[a.endpoint() for a in to_encode],
authority,
)
return encode_sw_static_for_c_core(to_encode), authority
def _resolve_channel_target_and_authority() -> Tuple[str, str]:
"""
Never raise into the host app. prepare_grpc_channel_endpoints stays strict;
factories degrade so the agent can idle behind the READY gate.
"""
from skywalking import config
raw = config.agent_collector_backend_services
addresses = parse_backend_addresses(raw)
try:
return prepare_grpc_channel_endpoints(addresses)
except ValueError:
if addresses:
target = addresses[0].endpoint()
logger.error(
'No usable collector backend after DNS expansion of %r; '
'falling back to plain target %s so grpcio can re-resolve',
raw,
target,
)
return target, target
fallback = (raw or '').strip() or 'localhost:1'
logger.error(
'No valid collector backend address in %r; opening a channel to %s '
'(agent stays up; READY gate skips reports)',
raw,
fallback,
)
return fallback, fallback
def build_grpc_target(addresses: Sequence[BackendAddress]) -> str:
"""Build a gRPC channel target string (see prepare_grpc_channel_endpoints)."""
target, _authority = prepare_grpc_channel_endpoints(addresses)
return target
def resolve_grpc_target(services: Optional[str] = None) -> str:
from skywalking import config
raw = config.agent_collector_backend_services if services is None else services
return build_grpc_target(parse_backend_addresses(raw))
def _channel_options(default_authority: str) -> Tuple[Tuple[str, int | str], ...]:
options = list(GRPC_CHANNEL_OPTIONS)
# Align with Node sw-static getDefaultAuthority (first usable backend).
options.append(('grpc.default_authority', default_authority))
return tuple(options)
def create_sync_channel():
"""Create one sync gRPC channel (caller may wrap with auth interceptor)."""
from skywalking import config
target, authority = _resolve_channel_target_and_authority()
options = _channel_options(authority)
logger.info('Creating gRPC channel to collector target %s (authority=%s)', target, authority)
with agent_collector_channel_scope():
try:
if config.agent_force_tls:
channel = grpc.secure_channel(target, grpc.ssl_channel_credentials(), options=options)
else:
channel = grpc.insecure_channel(target, options=options)
except Exception: # noqa: BLE001 - never fail host process start
logger.exception(
'Failed to create gRPC channel to %s; using localhost:1 placeholder',
target,
)
channel = grpc.insecure_channel('localhost:1', options=options)
return mark_agent_collector_channel(channel)
def create_aio_channel(interceptors=None):
"""Create one aio gRPC channel with optional interceptors."""
from skywalking import config
target, authority = _resolve_channel_target_and_authority()
options = _channel_options(authority)
logger.info('Creating aio gRPC channel to collector target %s (authority=%s)', target, authority)
with agent_collector_channel_scope():
try:
if config.agent_force_tls:
channel = grpc.aio.secure_channel(
target,
grpc.ssl_channel_credentials(),
options=options,
interceptors=interceptors,
)
else:
channel = grpc.aio.insecure_channel(target, options=options, interceptors=interceptors)
except Exception: # noqa: BLE001 - never fail host process start
logger.exception(
'Failed to create aio gRPC channel to %s; using localhost:1 placeholder',
target,
)
channel = grpc.aio.insecure_channel('localhost:1', options=options, interceptors=interceptors)
return mark_agent_collector_channel(channel)
def _unwrap_connectivity_state(channel, try_to_connect: bool):
"""
Read channel connectivity for sync, aio, and intercept_channel wrappers.
grpc.aio.Channel exposes get_state(). Sync grpc._channel.Channel does not
(subscribe only); use C-core check_connectivity_state on the cython channel.
Intercepted channels nest the real Channel on ``_channel``.
"""
get_state = getattr(channel, 'get_state', None)
if callable(get_state):
return get_state(try_to_connect)
candidate = channel
for _ in range(4):
inner = getattr(candidate, '_channel', None)
if inner is None:
break
check = getattr(inner, 'check_connectivity_state', None)
if callable(check):
code = check(try_to_connect)
for state in grpc.ChannelConnectivity:
if state.value[0] == code:
return state
return None
candidate = inner
return None
def is_channel_ready(channel) -> bool:
"""
Application-level READY gate (gRPC only exposes connectivity state).
Returns True only when connectivity is READY. Uses try_to_connect=True so an
IDLE channel is nudged into CONNECTING — otherwise skipping all RPCs would
leave the channel idle forever (same role as Node watchConnectivityState
with requestConnection=true). CONNECTING / TRANSIENT_FAILURE still return
False so reporters skip until READY.
If connectivity cannot be read, returns True (fail-open) so a missing API
cannot permanently silence reporting.
"""
try:
state = _unwrap_connectivity_state(channel, True)
except Exception: # noqa: BLE001 - defensive for closed / exotic channels
return True
if state is None:
return True
return state == grpc.ChannelConnectivity.READY
def is_auth_rpc_error(error: BaseException) -> bool:
code = getattr(error, 'code', None)
if not callable(code):
return False
try:
status = code()
except Exception: # noqa: BLE001 - defensive for non-grpc exceptions
return False
return status in (grpc.StatusCode.UNAUTHENTICATED, grpc.StatusCode.PERMISSION_DENIED)
def log_auth_failure_throttled(error: BaseException) -> None:
"""Auth failures must not rotate backends; same cluster shares one token."""
global _last_auth_log_at
now = time.monotonic()
if now - _last_auth_log_at < _AUTH_LOG_INTERVAL_SEC:
return
_last_auth_log_at = now
logger.error(
'Collector rejected authentication (%s). Check SW_AGENT_AUTHENTICATION; '
'the agent will not rotate backends for auth failures.',
error,
)
def log_connectivity_event(kind: str, message: str, *args) -> None:
"""Throttle disconnect warnings; recovery stays informative but rate-limited."""
now = time.monotonic()
last = _last_connectivity_log_at.get(kind, 0.0)
if now - last < _CONNECTIVITY_LOG_INTERVAL_SEC:
return
_last_connectivity_log_at[kind] = now
if kind == 'recovered':
logger.info(message, *args)
else:
logger.warning(message, *args)
def apply_connectivity_transition(prev, state) -> None:
"""Shared INFO/WARN side-effects for sync subscribe and aio watch."""
if state == grpc.ChannelConnectivity.TRANSIENT_FAILURE:
log_connectivity_event(
'transient_failure',
'gRPC collector channel disconnected (TRANSIENT_FAILURE)',
)
elif state == grpc.ChannelConnectivity.IDLE and prev == grpc.ChannelConnectivity.READY:
log_connectivity_event('idle', 'gRPC collector channel disconnected (IDLE)')
elif state == grpc.ChannelConnectivity.READY and prev in (
grpc.ChannelConnectivity.TRANSIENT_FAILURE,
grpc.ChannelConnectivity.IDLE,
grpc.ChannelConnectivity.CONNECTING,
None,
):
log_connectivity_event('recovered', 'gRPC collector channel recovered (READY)')
def handle_rpc_error(error: BaseException, on_connectivity_error) -> None:
"""
Shared RpcError side-effects for sync/aio reporters.
Auth: throttle log only (no channel rebuild / backend rotate).
Other: invoke connectivity recovery hook (resubscribe / debug), never rebuild channel.
Failover is for an unreachable backend, not functional health of a connected one.
DEADLINE_EXCEEDED and RESOURCE_EXHAUSTED on a READY channel are intentionally
left to the call site: pick_first will not move off a slow-but-READY backend,
and this agent does not rotate or rebuild the channel for those codes.
"""
if is_auth_rpc_error(error):
log_auth_failure_throttled(error)
return
on_connectivity_error()