|
<?php |
|
// file::Pushgateway/PushgatewayClient.php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Pushgateway; |
|
|
|
use Prometheus\CollectorRegistry; |
|
use Prometheus\Storage\Adapter; |
|
use Prometheus\Storage\APC; |
|
use PrometheusPushGateway\PushGateway as BasePushGateway; |
|
use GuzzleHttp\Client as GuzzleClient; |
|
use GuzzleHttp\HandlerStack; |
|
use RuntimeException; |
|
|
|
/** |
|
* Клиент для отправки метрик в Prometheus Pushgateway из PHP-приложений в Kubernetes. |
|
* |
|
* Использует официальную библиотеку promphp/prometheus_push_gateway_php как транспорт, |
|
* добавляя специфичную для нашей инфраструктуры логику: |
|
* - автоматическое извлечение K8s-метаданных с каскадными фоллбэками |
|
* - инъекция 6 обязательных лейблов (новый стандарт + legacy) |
|
* - фоновая отправка через pcntl_fork (CLI) или синхронная (FPM) |
|
* - гибкая конфигурация: таймауты, auth, SSL, доп. лейблы |
|
* |
|
* @example |
|
* $pusher = PushgatewayClient::create( |
|
* gatewayUrl: 'http://pushgateway:9091', |
|
* serviceName: 'my-php-app', |
|
* ); |
|
* $pusher->start(); |
|
* |
|
* $counter = $pusher->getRegistry()->getOrRegisterCounter( |
|
* 'app', 'orders_total', 'Total orders', ['status'] |
|
* ); |
|
* $counter->inc(['success']); // K8s-лейблы добавятся автоматически |
|
*/ |
|
class PushgatewayClient |
|
{ |
|
// === Новый стандарт именования лейблов === |
|
public const LABEL_CLUSTER_NAME = 'k8s_cluster_name'; |
|
public const LABEL_NAMESPACE_NAME = 'k8s_namespace_name'; |
|
public const LABEL_POD_NAME = 'k8s_pod_name'; |
|
|
|
// === Legacy-лейблы (для обратной совместимости) === |
|
public const LABEL_OLD_CLUSTER = 'k8s_cluster'; |
|
public const LABEL_OLD_NAMESPACE = 'namespace'; |
|
public const LABEL_OLD_POD = 'pod'; |
|
|
|
// === Переменные окружения === |
|
private const ENV_CLUSTER_NAME = 'K8S_CLUSTER_NAME'; |
|
private const ENV_NAMESPACE_NAME = 'K8S_NAMESPACE_NAME'; |
|
private const ENV_POD_NAME = 'K8S_POD_NAME'; |
|
|
|
// === Legacy-переменные (убрать в будущих версиях) === |
|
private const ENV_LEGACY_CLUSTER = 'CI_ENV'; |
|
private const ENV_LEGACY_HOSTNAME = 'HOSTNAME'; |
|
|
|
// === Системные константы === |
|
private const K8S_NAMESPACE_FILE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; |
|
private const VALUE_NOT_AVAILABLE = 'not_available'; |
|
|
|
// === Настройки по умолчанию === |
|
private const DEFAULT_PUSH_INTERVAL = 30.0; |
|
private const DEFAULT_HTTP_TIMEOUT = 2.0; |
|
|
|
/** @var array<string, string> */ |
|
private readonly array $k8sLabels; |
|
|
|
private readonly BasePushGateway $gateway; |
|
private readonly CollectorRegistry $registry; |
|
private readonly string $serviceName; |
|
private readonly float $pushIntervalSeconds; |
|
private readonly bool $skipSslVerify; |
|
|
|
private ?bool $isRunning = null; |
|
private ?int $pid = null; |
|
|
|
/** |
|
* @param BasePushGateway $gateway Экземпляр официального PushGateway-клиента |
|
* @param CollectorRegistry $registry Экземпляр CollectorRegistry для регистрации метрик |
|
* @param string $serviceName Имя сервиса (job name в Pushgateway) |
|
* @param array<string, string> $k8sLabels Предварительно собранные Kubernetes-лейблы |
|
* @param float $pushIntervalSeconds Интервал отправки метрик |
|
* @param bool $skipSslVerify Пропускать проверку SSL (только для dev) |
|
*/ |
|
public function __construct( |
|
BasePushGateway $gateway, |
|
CollectorRegistry $registry, |
|
string $serviceName, |
|
array $k8sLabels, |
|
float $pushIntervalSeconds = self::DEFAULT_PUSH_INTERVAL, |
|
bool $skipSslVerify = false, |
|
) { |
|
$this->gateway = $gateway; |
|
$this->registry = $registry; |
|
$this->serviceName = $serviceName; |
|
$this->k8sLabels = $k8sLabels; |
|
$this->pushIntervalSeconds = $pushIntervalSeconds; |
|
$this->skipSslVerify = $skipSslVerify; |
|
} |
|
|
|
/** |
|
* Factory-метод с полной конфигурацией. |
|
* |
|
* @param string $gatewayUrl URL Pushgateway |
|
* @param string $serviceName Имя сервиса (job name) |
|
* @param Adapter|null $storageAdapter Адаптер хранилища (по умолчанию APC) |
|
* @param array<string, mixed> $options Опции: |
|
* - push_interval_seconds: float |
|
* - http_timeout_seconds: float |
|
* - auth_token: string|null |
|
* - auth_username: string|null (для basic auth) |
|
* - auth_password: string|null |
|
* - extra_labels: array<string, string> |
|
* - skip_ssl_verify: bool |
|
* - ca_cert_path: string|null (путь к кастомному CA-сертификату) |
|
*/ |
|
public static function create( |
|
string $gatewayUrl, |
|
string $serviceName, |
|
?Adapter $storageAdapter = null, |
|
array $options = [], |
|
): self { |
|
$storageAdapter ??= new APC(); |
|
$registry = new CollectorRegistry($storageAdapter, false); |
|
|
|
$config = array_merge([ |
|
'push_interval_seconds' => self::DEFAULT_PUSH_INTERVAL, |
|
'http_timeout_seconds' => self::DEFAULT_HTTP_TIMEOUT, |
|
'auth_token' => null, |
|
'auth_username' => null, |
|
'auth_password' => null, |
|
'extra_labels' => [], |
|
'skip_ssl_verify' => false, |
|
'ca_cert_path' => null, |
|
], $options); |
|
|
|
// Собираем K8s-лейблы с фоллбэками |
|
$k8sLabels = self::buildK8sLabels((array) $config['extra_labels']); |
|
|
|
// Создаём Guzzle-клиент с нашей конфигурацией |
|
$guzzleConfig = self::buildGuzzleConfig( |
|
timeoutSeconds: (float) $config['http_timeout_seconds'], |
|
authToken: $config['auth_token'], |
|
authUsername: $config['auth_username'], |
|
authPassword: $config['auth_password'], |
|
skipSslVerify: (bool) $config['skip_ssl_verify'], |
|
caCertPath: $config['ca_cert_path'], |
|
); |
|
$httpClient = new GuzzleClient($guzzleConfig); |
|
|
|
// Создаём официальный PushGateway-клиент с нашим HTTP-клиентом |
|
$gateway = new BasePushGateway($gatewayUrl, $httpClient); |
|
|
|
return new self( |
|
gateway: $gateway, |
|
registry: $registry, |
|
serviceName: $serviceName, |
|
k8sLabels: $k8sLabels, |
|
pushIntervalSeconds: (float) $config['push_interval_seconds'], |
|
skipSslVerify: (bool) $config['skip_ssl_verify'], |
|
); |
|
} |
|
|
|
/** |
|
* Возвращает CollectorRegistry для регистрации метрик. |
|
*/ |
|
public function getRegistry(): CollectorRegistry |
|
{ |
|
return $this->registry; |
|
} |
|
|
|
/** |
|
* Возвращает массив текущих Kubernetes-лейблов (для отладки). |
|
* |
|
* @return array<string, string> |
|
*/ |
|
public function getK8sLabels(): array |
|
{ |
|
return $this->k8sLabels; |
|
} |
|
|
|
// ======================================================================== |
|
// === Методы получения Kubernetes-метаданных (как в Go-версии) === |
|
// ======================================================================== |
|
|
|
private static function getClusterName(): string |
|
{ |
|
$value = getenv(self::ENV_CLUSTER_NAME); |
|
if ($value !== false && $value !== '') { |
|
return $value; |
|
} |
|
$legacy = getenv(self::ENV_LEGACY_CLUSTER); |
|
if ($legacy !== false && $legacy !== '') { |
|
return $legacy; |
|
} |
|
return self::VALUE_NOT_AVAILABLE; |
|
} |
|
|
|
private static function getNamespace(): string |
|
{ |
|
$value = getenv(self::ENV_NAMESPACE_NAME); |
|
if ($value !== false && $value !== '') { |
|
return $value; |
|
} |
|
$content = @file_get_contents(self::K8S_NAMESPACE_FILE); |
|
if ($content !== false) { |
|
return trim($content); |
|
} |
|
return self::VALUE_NOT_AVAILABLE; |
|
} |
|
|
|
private static function getPodName(): string |
|
{ |
|
$value = getenv(self::ENV_POD_NAME); |
|
if ($value !== false && $value !== '') { |
|
return $value; |
|
} |
|
$legacy = getenv(self::ENV_LEGACY_HOSTNAME); |
|
if ($legacy !== false && $legacy !== '') { |
|
return $legacy; |
|
} |
|
return self::VALUE_NOT_AVAILABLE; |
|
} |
|
|
|
/** |
|
* Собирает 6 обязательных Kubernetes-лейблов + пользовательские. |
|
*/ |
|
private static function buildK8sLabels(array $extraLabels): array |
|
{ |
|
$clusterName = self::getClusterName(); |
|
$namespace = self::getNamespace(); |
|
$podName = self::getPodName(); |
|
|
|
$labels = [ |
|
// Новый стандарт |
|
self::LABEL_CLUSTER_NAME => $clusterName, |
|
self::LABEL_NAMESPACE_NAME => $namespace, |
|
self::LABEL_POD_NAME => $podName, |
|
// Legacy для обратной совместимости |
|
self::LABEL_OLD_CLUSTER => $clusterName, |
|
self::LABEL_OLD_NAMESPACE => $namespace, |
|
self::LABEL_OLD_POD => $podName, |
|
]; |
|
|
|
return array_merge($labels, $extraLabels); |
|
} |
|
|
|
// ======================================================================== |
|
// === Конфигурация HTTP-клиента (Guzzle) === |
|
// ======================================================================== |
|
|
|
/** |
|
* Создаёт конфигурацию для Guzzle-клиента. |
|
* |
|
* @return array{timeout: float, headers?: array, verify: bool|string, auth?: array} |
|
*/ |
|
private static function buildGuzzleConfig( |
|
float $timeoutSeconds, |
|
?string $authToken, |
|
?string $authUsername, |
|
?string $authPassword, |
|
bool $skipSslVerify, |
|
?string $caCertPath, |
|
): array { |
|
$config = [ |
|
'timeout' => $timeoutSeconds, |
|
// verify: false — отключает проверку, строка — путь к CA-сертификату |
|
'verify' => $skipSslVerify ? false : ($caCertPath ?? true), |
|
]; |
|
|
|
// Bearer-токен |
|
if ($authToken !== null && $authToken !== '') { |
|
$config['headers']['Authorization'] = 'Bearer ' . $authToken; |
|
} |
|
|
|
// Basic Auth |
|
if ($authUsername !== null && $authPassword !== null) { |
|
$config['auth'] = [$authUsername, $authPassword]; |
|
} |
|
|
|
return $config; |
|
} |
|
|
|
// ======================================================================== |
|
// === Управление отправкой метрик === |
|
// ======================================================================== |
|
|
|
/** |
|
* Запускает фоновую периодическую отправку метрик. |
|
* |
|
* @return bool Успешность запуска |
|
*/ |
|
public function start(): bool |
|
{ |
|
if ($this->isRunning === true) { |
|
return false; |
|
} |
|
|
|
if (PHP_SAPI === 'cli' && function_exists('pcntl_fork')) { |
|
$pid = pcntl_fork(); |
|
|
|
if ($pid === -1) { |
|
error_log('[Pushgateway] Failed to fork background process'); |
|
return false; |
|
} |
|
|
|
if ($pid > 0) { |
|
$this->isRunning = true; |
|
$this->pid = $pid; |
|
return true; |
|
} |
|
|
|
// Дочерний процесс |
|
$this->runPushLoop(); |
|
exit(0); |
|
} |
|
|
|
// Fallback: одна отправка (для FPM) |
|
$this->pushOnce(); |
|
$this->isRunning = true; |
|
|
|
return true; |
|
} |
|
|
|
/** |
|
* Останавливает фоновую отправку. |
|
*/ |
|
public function stop(): void |
|
{ |
|
if ($this->pid !== null && $this->isRunning === true) { |
|
if (function_exists('posix_kill')) { |
|
posix_kill($this->pid, SIGTERM); |
|
} |
|
$this->isRunning = false; |
|
$this->pid = null; |
|
} |
|
} |
|
|
|
/** |
|
* Немедленно отправляет метрики в Pushgateway (синхронно). |
|
* |
|
* @throws RuntimeException при ошибке отправки |
|
*/ |
|
public function pushOnce(): void |
|
{ |
|
// Официальная библиотека принимает лейблы как третий аргумент |
|
// Формат: ['label_name' => 'label_value', ...] |
|
$error = $this->gateway->push($this->registry, $this->serviceName, $this->k8sLabels); |
|
|
|
if ($error !== null) { |
|
throw new RuntimeException(sprintf( |
|
'Pushgateway error: %s', |
|
$error |
|
)); |
|
} |
|
} |
|
|
|
/** |
|
* Основной цикл фоновой отправки. |
|
*/ |
|
private function runPushLoop(): void |
|
{ |
|
$this->isRunning = true; |
|
$this->pushOnce(); |
|
|
|
while ($this->isRunning) { |
|
sleep((int) $this->pushIntervalSeconds); |
|
|
|
if ($this->pid !== null && function_exists('posix_kill')) { |
|
if (posix_kill($this->pid, 0) === false) { |
|
break; |
|
} |
|
} |
|
|
|
$this->pushOnce(); |
|
} |
|
} |
|
} |