6.11.25

Должен ли SQL Server DBA разбираться в Windows Server Failover Clustering?

Автор: Sandra Delany, Should a SQL Server DBA Know Windows Clustering?

Должен ли SQL Server DBA понимать, как устроен кластер Windows Server Failover Clustering (WSFC), уметь создавать его или диагностировать проблемы? Или нам, DBA, лучше не выходить за рамки своей зоны? В некоторых организациях проведена чёткая граница между тем, что можно и чего нельзя DBA, а роли инфраструктуры отданы системным администраторам (SA). Это нормально, но это не означает, что DBA не должен уметь разбираться с проблемами FCI (Failover Cluster Instance) или AG (Availability Group) после непланового отказа на уровне кластера. (Да, AG можно создать и без кластера, но здесь мы этот вариант не рассматриваем.)

Вы можете обнаружить, что SA не умеет строить кластер. Я видела это снова и снова. Дело не в том, что они не способны: просто не выпадала возможность, или делают это редко и нужна помощь. При создании нового кластера, если вы или SA ошибётесь, ничего страшного: кластер можно уничтожить (именно так «удаляют» кластер) и попробовать снова.

Есть задачи, которые исправить сложнее. Например, добавление узла в существующий кластер может быть непростой операцией и должно выполняться корректно: иначе можно случайно перевести диски в состояние RAW. Это плохо — настолько, что вы «вырубили» окружение в ноль (надеюсь, это был не прод!). В такой ситуации, скорее всего, придётся открывать обращение в Microsoft, чтобы помочь восстановить работоспособность дисков; или, если повезёт, SA умеет это исправлять; или можно воспользоваться инструментами ИИ для диагностики и исправления. Как бы ни решалась проблема с диском, это нужно сделать прежде, чем удастся перезапустить кластер и AG или FCI.

За годы я научилась делать всё перечисленное и чувствую себя уверенно в задачах, не всегда типичных для DBA. Не каждый DBA и не каждый системный администратор владеет этими знаниями, и это нормально. Но как минимум DBA должен уметь определить, почему FCI или AG неожиданно переехала (failover); или испытывал проблемы, чтобы донести это до SA или инфраструктурной команды.

Если у вас нет доступа к кластеру через Failover Cluster Manager, попросите SA выдать его. Сложно работать, если вы не видите, что происходит с кластером. Запущен ли кластер? Не офлайн ли важные ресурсы, и можно ли поднять их без ошибок? Всё ли в порядке с узлами? Надеюсь, ни один узел не в карантине. Запущена ли роль и запущены ли её зависимости?

Когда я делаю ревью кластера, собираю максимум информации о самом кластере, конфигурации FCI, AG. Если что‑то пойдёт не так (зловреды-вирусы, аппаратные сбои, удаление ВМ и т. п.), задокументированные сведения помогут быстро всё восстановить. У одного клиента случилась атака программы-вымогателя, и мы быстро подняли серверы, опираясь на сведения, собранные во время ревью: у SA не оказалось имён/адресов IP для кластера или прослушивателя, информации о кворуме, IP узлов.

Документируйте Windows Failover Cluster и настройки SQL Server Availability Group или Failover Cluster Instance

Кто‑то не любит писать документацию, а кто‑то обожает. Я — из второй категории. Даже если вы не строили кластер и AG/FCI, DBA должен понимать, как всё устроено. Задокументируйте то, что видите.

  • Как построен кластер?
  • Сколько узлов?
    • Каковы IP‑адреса всех узлов?
    • Если на каждом узле несколько NIC, корректен ли порядок привязки (чаще всего у серверов один NIC, но может быть несколько)?
  • Настроен ли кворум? Должен ли он быть?
  • Есть ли узел без права голоса?
    • Если да, выясните почему — возможно, его отключили из‑за расположения относительно других узлов кластера.
  • Есть ли параметры, отличающиеся от значений по умолчанию? Если нет — хорошо; если завышены — спросите почему.
    • например: lease timeout (20000), healthcheck timeout (30000), max failures in a specified period (n-1, где n — число узлов в кластере), настройки failback, multi‑subnets.
  • Изменены ли hostrecordTTL (1200) и RegisterAllProviders (1) относительно дефолтов?
  • Созданы ли SPN и т. п.?

Если что‑то выглядит странно, спросите, почему так настроено. Если используется кворум, он на диске, в файловой шаре или в облаке (witness)? Если файловая шара — существует ли целевая папка и корректны ли права на уровне шары и NTFS? Если облачный свидетель — есть ли у вас сведения для повторной настройки или восстановления, и хотите ли вы использовать то же расположение? Честно говоря, дисковые кворумы мне не нравятся: я за файловую шару или облако. Если узлов в кластере достаточно, кворум не всегда нужен. Для AG: в Active Directory у кластера должны быть права полного управления объектом прослушивателя — если нет, это следует исправить.

Диагностика кластеров для SQL Server DBA

При разборе проблем смотрите SQL Error Log, журнал событий (System) и cluster log. Обязательно изучайте журналы на других узлах кластера, особенно если трудно понять, в чём дело. И будьте осторожны, не идите по ложному следу. То есть вы можете увидеть что‑то в момент или перед аварией и принять это за корень проблемы, хотя это может быть ложная тревога. Проверьте, повторяется ли эта ошибка в другие моменты, когда всё работало, чтобы убедиться, что это действительно причина, а не случайное совпадение.

Если в журналах событий есть постоянные ошибки, которые должен исправить SA, сообщите ему. Жизнь проще, когда System‑журнал не заполнен тем, что давно следовало устранить.

В Straight Path мы сделали задание, которое парсит System‑журнал в поисках событий FailoverClustering, и помечает задание как неуспешное, если такие записи найдены. Это заставляет нашу поддержку заводить тикет, чтобы мы проверили системные журналы и устранили причины до P1‑инцидента. У одного клиента сервер‑свидетель (file share) для кворума был выведен из эксплуатации, нам об этом не сказали. События FailoverClustering были в журналах, но до поры не мешали, пока один из узлов кластера не пропатчили и не перезагрузили — и все FCI на активном узле легли. Это подтолкнуло нас к созданию задания для мониторинга System‑журнала.

Я всегда начинаю с логов и иду по следам в них. Однако не нужно пытаться изучать каждую запись в cluster log. В первый раз этот лог может ошеломить — там много информации, которая поначалу непонятна.

Если попытаться осмыслить всё содержимое, на это уйдут месяцы, и вы всё равно не поймёте каждую мелочь. Я и сама не понимаю всего, но знаю достаточно, чтобы найти проблему, если она на уровне кластера. Сохраняйте здравый смысл: отфильтруйте по правильной дате/времени — это спасает.

Когда я впервые открыла cluster log, я подумала: «нет, смотреть не буду», и ограничилась SQL Error Log и Event Log. Со временем я всё‑таки стала разбирать, что означает каждая секция (в этом хорошо помогает ИИ, которому можно «скормить» файл лога). Лучшее время разбирать cluster log — когда у вас нет P1‑инцидента: тогда есть шанс спокойно понять, что каждая часть лога сообщает. Помните, что «следует» смотреть логи со всех узлов: в одном логе проблема может не проявиться просто потому, что она возникла на другом сервере.

Если использовать параметр локального времени, проще найти момент аварии — времена в SQL Server Error Log и System log будут совпадать.

Получение cluster log

Выполните PowerShell‑команду на одном из узлов кластера, чтобы выгрузить его cluster log. Команда создаст лог на всех узлах кластера. Изучайте лог на каждом узле, а не на одном: один узел может показать первопричину, которой не видно на других. Не предполагайте, что везде всё одинаково.

Параметры , полезные для DBA

Список всех параметров: Get-ClusterLog

Использовать локальное время

По умолчанию лог формируется в GMT. Если нужны серверные локальные времена, добавьте параметр -UseLocalTime. Так диагностировать проще.

PS> get-clusterlog -useLocalTime

Ограничить диапазон по времени

Решая, насколько далеко «отматывать», не выбирайте слишком маленький интервал — убедитесь, что «ошибка» действительно ошибка, а не «обычная запись», которая просто совпала по времени. Избегайте «кроличьих нор».

Подсказки:

  • 7200 — 5 дней
  • 4320 — 3 дня
  • 1440 — 1 день
  • 360 — 6 часов
PS> get-clusterlog -useLocalTime -TimeSpan 7200

Что есть в cluster log?

Путь по умолчанию: c:\windows\cluster\reports\cluster.log. Можно вывести лог локально для каждого узла через параметр -Destination. Я обычно его не использую — просто забываю о его существовании.

Секции cluster log

В логе несколько секций. Я обычно смотрю на следующие. Чтобы не прокручивать километры, ищу по времени и дате — это быстро приводит к нужному месту, особенно если лог огромный.

[=== Cluster ===]

Имя узла, с которого выгружен лог. Полезно, если у вас открыто несколько логов разных узлов.

[=== System ===]

Здесь тоже много полезного. Например, узел мог быть исключён из кластера из‑за проблем связи — и это не обязательно означает, что пострадали AG или FCI. Мне встречались ситуации, когда сервер исключался и затем возвращался, и при этом SQL Server и кластер были в порядке.

Пример: смена пароля (нормально) и исключение узла из кластера из‑за потери связи (в этом случае — проблема):

[=== Microsoft-Windows-FailoverClustering/Operational logs ===]

Здесь видно, как происходил failover роли (имени AG или FCI). Это помогает синхронизировать хронологию с SQL Error Log и System log, и искать в cluster log по дате/времени; видно проблемы связи и т. п.

Пример проблемы связи:


[=== Cluster Logs ===]

Более детальная информация о случившемся. Здесь тоже видны проблемы связи. Важно: если видите «ошибку», проверьте, не случается ли она и в «штатные» периоды, когда всё было нормально.

Пример проблем связи на порту 3343 (порт, по которому общается кластер):

Типичные проблемы

Я видела, как снимки на уровне дисков сторонних инструментов провоцировали проблемы в AG. Попытки «подкрутить» настройки кластера не помогали. С помощью SQLSentry я увидела, что на время snapshot диски блокировались, из‑за чего AG терял соединения и происходил connection timeout; разумеется, это выкидывает всех из AG, чтобы переустановить соединения. Я попросила клиента отказаться от таких snapshot. Мы проверили, что они создают резервные копии, что было крайне важно. Майк писал о «весёлых» случаях, когда клиент разворачивает SentinelOne, не посоветовавшись с нами. Это не только snapshots — загляните в эту статью и проверьте настройки антивирусов для кластеров под ваши SQL Server AG и FCI.

При неплановых failover AG или FCI для начала убедитесь, что SQL Server запущен и «счастлив». Для AG — смотрите, чтобы dashboard был зелёным, затем загляните в SQL Logs — возможно, там есть подсказки. Параллельно смотрите cluster log и System log, чтобы понять, в чём причина. Было ли это связано с истечением срока аренды, тайм-аутом подключения, сбоем в работе сети, который привёл к проблемам со связью, сбросом всех настроек кластера и группы доступности MSSQL до значений по умолчанию, из-за чего проблемы со связью вышли на первый план, проблемами с диском и т. д.? Если кластер нестабилен (проблемы связи), это нужно выяснить и сообщить сетевой команде.

Оффлайн кластера не всегда ломает AG. Я видела ситуации, когда кластер упал, а AG работала — до тех пор, пока я не пыталась добавить базы в AG: операция не выполнялась, потому что кластер был офлайн. Пришлось поднять кластер (онлайн), после чего добавить пользовательские базы получилось без проблем.

Итоги

Я убеждена: SQL Server DBA могут и должны уметь диагностировать проблемы Availability Group и Failover Cluster Instance на уровне Windows Server Failover Clustering. Если кластер нестабилен, вы рискуете впустую искать причину на уровне SQL, тогда как проблема — на уровне кластера, вызванная сетевой связью. SQL Server часто «виноват по умолчанию», пока вы не докажете обратное.

Скрипт PowerSell для помощи в сборе этой информации (подарок от переводчика):


#requires -Version 5.1
<#
.SYNOPSIS
  Сбор “сухой” документации по WSFC и SQL AG/FCI: узлы, IP/NIC, кворум, no-vote, отклонения параметров
  (LeaseTimeout/HealthCheckTimeout, MaxFailuresInPeriod, failback, multi-subnet), RegisterAllProvidersIP/HostRecordTTL, SPN.

.DESCRIPTION
  - Использует модули FailoverClusters (RSAT) и dbatools.
  - Выгружает JSON-отчёт + CSV-секции и краткое резюме в консоль. Дополнительно — findings.txt с выявленными отклонениями.
  - Безопасные проверки, конфигурацию не изменяет.

.PREREQUISITES
  - RSAT: Failover Clustering Tools (модуль FailoverClusters) на вашей станции или запуск на узле кластера.
    Docs: 
      - WSFC Overview: https://learn.microsoft.com/windows-server/failover-clustering/failover-cluster-overview
      - FailoverClusters module: https://learn.microsoft.com/powershell/module/failoverclusters/
      - RSAT install (Windows 10/11): https://learn.microsoft.com/windows-server/remote/remote-server-administration-tools
  - dbatools: Install-Module dbatools
    Docs: https://docs.dbatools.io

.PARAMETER ClusterName
  Имя кластера WSFC (DNS).

.PARAMETER OutputPath
  Папка для отчётов (создаётся при необходимости).

.PARAMETER SqlInstances
  Необязательно. Явный список SQL инстансов для проверки SPN. Если не указан, скрипт попытается определить инстансы по узлам кластера.

.PARAMETER SkipSpn
  Пропустить проверку SPN.

.EXAMPLES
  .\Get-WSFC-SQL-Audit.ps1 -ClusterName WSFC01
  .\Get-WSFC-SQL-Audit.ps1 -ClusterName WSFC01 -OutputPath C:\Audit -Verbose
  .\Get-WSFC-SQL-Audit.ps1 -ClusterName WSFC01 -SqlInstances @('SQLNODE1','SQLNODE2\PRD') -SkipSpn

.REFERENCE
  - WSFC Quorum: https://learn.microsoft.com/windows-server/failover-clustering/manage-cluster-quorum
  - Cluster networks (Role/Metric): https://learn.microsoft.com/powershell/module/failoverclusters/get-clusternetwork
  - AG listeners connectivity (RAP/TTL/MultiSubnet): https://learn.microsoft.com/sql/database-engine/availability-groups/windows/availability-group-listeners-client-connectivity
  - dbatools Test-DbaSpn: https://docs.dbatools.io/Test-DbaSpn
#>

[CmdletBinding()]
param(
  [Parameter(Mandatory = $true)]
  [string] $ClusterName,

  [string] $OutputPath = ".",

  [string[]] $SqlInstances,

  [switch] $SkipSpn
)

begin {
  $ErrorActionPreference = 'Stop'

  # =========================
  # Раздел: Импорт модулей
  # =========================
  function Import-FailoverClustersModule {
    # FailoverClusters — модуль RSAT (Windows PowerShell only).
    # В PowerShell 7+ используем прокси-импорт из Windows PowerShell.
    $loaded = $false
    try {
      if ($PSVersionTable.PSVersion.Major -ge 6) {
        Import-Module FailoverClusters -UseWindowsPowerShell -ErrorAction Stop
      } else {
        Import-Module FailoverClusters -ErrorAction Stop
      }
      $loaded = $true
    } catch {
      $loaded = $false
    }
    if (-not $loaded) {
      $hint = @(
        "Не найден модуль FailoverClusters.",
        "Установите RSAT: Failover Clustering Tools (Windows 10/11 — 'Дополнительные компоненты').",
        "Docs: https://learn.microsoft.com/windows-server/remote/remote-server-administration-tools",
        "Либо запустите скрипт на одном из узлов кластера."
      ) -join ' '
      throw $hint
    }
  }

  function Try-ImportDbatools {
    try {
      Import-Module dbatools -ErrorAction Stop
      return $true
    } catch {
      Write-Warning "Модуль dbatools не найден. SPN-проверки будут пропущены. Установить: Install-Module dbatools"
      return $false
    }
  }

  # =========================
  # Раздел: Вспомогательные функции
  # =========================
  function Get-IPv4ByNode {
    param([string]$ComputerName)
    # Сбор IPv4 адресов узла через CIM
    try {
      Get-CimInstance -ComputerName $ComputerName -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = True" |
      ForEach-Object {
        $ips = $_.IPAddress | Where-Object { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' -and $_ -notlike '169.254.*' }
        foreach ($ip in $ips) {
          [pscustomobject]@{
            ComputerName = $ComputerName
            Interface    = $_.Description
            IPAddress    = $ip
          }
        }
      }
    } catch {
      [pscustomobject]@{
        ComputerName = $ComputerName
        Interface    = ''
        IPAddress    = "ERROR: $($_.Exception.Message)"
      }
    }
  }

  function Get-SubnetString {
    param(
      [string]$Ip,
      [int]$PrefixLength,
      [string]$SubnetMask
    )
    if ($PrefixLength) { return "$Ip/$PrefixLength" }
    if ($SubnetMask) {
      try {
        $ipBytes   = $Ip.Split('.')   | ForEach-Object {[int]$_}
        $maskBytes = $SubnetMask.Split('.') | ForEach-Object {[int]$_}
        $net = for ($i=0;$i -lt 4;$i++){ $ipBytes[$i] -band $maskBytes[$i] }
        $bits = ($maskBytes | ForEach-Object { [Convert]::ToString($_,2).PadLeft(8,'0') }) -join ''
        $prefix = ($bits.ToCharArray() | Where-Object { $_ -eq '1' } | Measure-Object).Count
        return ("{0}.{1}.{2}.{3}/{4}" -f $net[0],$net[1],$net[2],$net[3],$prefix)
      } catch { return "$Ip/$SubnetMask" }
    }
    return $Ip
  }

  Import-FailoverClustersModule
  $hasDbatools = if ($SkipSpn) { $false } else { Try-ImportDbatools }
}

process {
  # =========================
  # Раздел: Подготовка вывода
  # =========================
  $outDir = New-Item -ItemType Directory -Path $OutputPath -Force
  $stamp  = Get-Date -Format yyyyMMdd_HHmmss

  # =========================
  # Раздел: Чтение конфигурации кластера
  # =========================
  $cluster   = Get-Cluster -Name $ClusterName
  $nodes     = Get-ClusterNode -Cluster $cluster
  $networks  = Get-ClusterNetwork -Cluster $cluster
  $resources = Get-ClusterResource -Cluster $cluster
  $groups    = Get-ClusterGroup -Cluster $cluster | Where-Object { $_.Name -notin @('Cluster Group','Available Storage') }
  $nodeCount = $nodes.Count

  # =========================
  # Раздел: Узлы и их IP
  # =========================
  $nodeIp = foreach ($n in $nodes.Name) { Get-IPv4ByNode -ComputerName $n }
  $nodeInfo = foreach ($n in $nodes) {
    [pscustomobject]@{
      Node       = $n.Name
      State      = $n.State
      NodeWeight = $n.NodeWeight
      IPv4       = ($nodeIp | Where-Object { $_.ComputerName -eq $n.Name } | Select-Object -ExpandProperty IPAddress -Unique) -join ','
    }
  }

  # =========================
  # Раздел: Сети, роли и "порядок привязки" (метрики)
  # Docs: Get-ClusterNetwork Role/Metric — https://learn.microsoft.com/powershell/module/failoverclusters/get-clusternetwork
  # =========================
  $netInfo = $networks | Select-Object Name, Role, Metric, AutoMetric
  $private = $networks | Where-Object Role -eq 'ClusterOnly' | Sort-Object Metric | Select-Object -First 1
  $public  = $networks | Where-Object Role -eq 'ClusterAndClient' | Sort-Object Metric | Select-Object -First 1
  $nicOrderOk = $false
  $nicOrderNote = ''
  if ($private -and $public) {
    $nicOrderOk = [int]$private.Metric -lt [int]$public.Metric
    if (-not $nicOrderOk) { $nicOrderNote = "Ожидание: приватная сеть (ClusterOnly) должна иметь меньшую метрику, чем публичная (ClusterAndClient)" }
  } else {
    $nicOrderNote = "Не найдены обе роли сетей (нужны ClusterOnly и ClusterAndClient)"
  }

  # =========================
  # Раздел: Кворум и свидетель (witness)
  # Docs: https://learn.microsoft.com/windows-server/failover-clustering/manage-cluster-quorum
  # =========================
  $quorum = Get-ClusterQuorum -Cluster $cluster
  $witnessInUse = ($quorum.QuorumType -notmatch '^NodeMajority$')
  $shouldHaveWitness = ($nodeCount -gt 1) # Рекомендация: при >1 узле использовать witness
  $quorumRecommendationOk = if ($shouldHaveWitness) { $witnessInUse } else { $true }
  $noVoteNodes = $nodes | Where-Object NodeWeight -eq 0 | Select-Object -ExpandProperty Name

  # =========================
  # Раздел: Параметры групп (MaxFailuresInPeriod, Failback)
  # =========================
  $recommendedMaxFailures = [Math]::Max(0, $nodeCount - 1)
  $groupInfo = foreach ($g in $groups) {
    [pscustomobject]@{
      GroupName          = $g.Name
      GroupType          = $g.GroupType
      OwnerNode          = $g.OwnerNode
      FailoverThreshold  = $g.FailoverThreshold
      FailoverPeriodHrs  = $g.FailoverPeriod
      ThresholdVsRec     = if ($null -ne $g.FailoverThreshold) { "$($g.FailoverThreshold)/$recommendedMaxFailures" } else { '' }
      AutoFailback       = $g.AutoFailbackType
      FailbackStartHour  = $g.FailbackWindowStart
      FailbackEndHour    = $g.FailbackWindowEnd
      PreferredOwners    = ($g.PreferredOwners -join ',')
    }
  }

  # =========================
  # Раздел: Параметры AG-ресурсов (LeaseTimeout 20000 ms, HealthCheckTimeout 30000 ms)
  # =========================
  $agResources = $resources | Where-Object ResourceType -eq 'SQL Server Availability Group'
  $agParams = foreach ($r in $agResources) {
    $pars   = Get-ClusterParameter -InputObject $r
    $lease  = ($pars | Where-Object Name -eq 'LeaseTimeout').Value
    $health = ($pars | Where-Object Name -eq 'HealthCheckTimeout').Value
    [pscustomobject]@{
      AgName                = $r.Name
      Group                 = $r.OwnerGroup
      LeaseTimeoutMs        = [int]$lease
      LeaseIsDefault        = ([int]$lease -eq 20000)
      HealthCheckTimeoutMs  = [int]$health
      HealthIsDefault       = ([int]$health -eq 30000)
    }
  }

  # =========================
  # Раздел: Лисенеры (RegisterAllProvidersIP, HostRecordTTL, MultiSubnet)
  # Docs: https://learn.microsoft.com/sql/database-engine/availability-groups/windows/availability-group-listeners-client-connectivity
  # =========================
  $listenerInfo = foreach ($agRes in $agResources) {
    $grp = $agRes.OwnerGroup
    $nn  = $resources | Where-Object { $_.OwnerGroup -eq $grp -and $_.ResourceType -eq 'Network Name' } | Select-Object -First 1
    if (-not $nn) { continue }
    $nnPars = Get-ClusterParameter -InputObject $nn
    $regAll = ($nnPars | Where-Object Name -eq 'RegisterAllProvidersIP').Value
    $ttl    = ($nnPars | Where-Object Name -eq 'HostRecordTTL').Value

    $ips = $resources | Where-Object { $_.OwnerGroup -eq $grp -and $_.ResourceType -eq 'IP Address' }
    $ipRows = foreach ($ipRes in $ips) {
      $p = Get-ClusterParameter -InputObject $ipRes
      $addr   = ($p | Where-Object Name -eq 'Address').Value
      $prefix = ($p | Where-Object Name -eq 'PrefixLength').Value
      $mask   = ($p | Where-Object Name -eq 'SubnetMask').Value
      [pscustomobject]@{
        Address      = $addr
        PrefixLength = $prefix
        SubnetMask   = $mask
        Subnet       = (Get-SubnetString -Ip $addr -PrefixLength $prefix -SubnetMask $mask)
      }
    }
    $uniqueSubnets = $ipRows | Select-Object -ExpandProperty Subnet -Unique
    $multiSubnet = ($uniqueSubnets.Count -gt 1)

    [pscustomobject]@{
      AgName                 = $agRes.Name
      ListenerNetworkName    = $nn.Name
      RegisterAllProvidersIP = [int]$regAll
      RegisterAllIsDefault   = ([int]$regAll -eq 1)
      HostRecordTTL          = [int]$ttl
      HostRecordTTLIsDefault = ([int]$ttl -eq 1200)
      MultiSubnet            = $multiSubnet
      Subnets                = ($uniqueSubnets -join ',')
    }
  }

  # =========================
  # Раздел: Определение SQL инстансов (если не заданы) и проверка SPN (dbatools)
  # =========================
  $spnTest = @()
  $spnMissing = @()
  if (-not $SqlInstances -or $SqlInstances.Count -eq 0) {
    try {
      # Находим службы SQL Engine на узлах кластера
      if ($hasDbatools) {
        $srv = Get-DbaService -ComputerName $nodes.Name -Type Engine -EnableException:$false | Where-Object { $_.InstanceName }
        $SqlInstances = foreach ($s in $srv) {
          if ($s.InstanceName -eq 'MSSQLSERVER') { $s.ComputerName } else { "$($s.ComputerName)\$($s.InstanceName)" }
        }
        $SqlInstances = $SqlInstances | Sort-Object -Unique
      }
    } catch {
      Write-Warning "Не удалось автоматически определить SQL инстансы: $($_.Exception.Message)"
    }
  }

  if ($hasDbatools -and $SqlInstances -and $SqlInstances.Count -gt 0) {
    try {
      # Docs: Test-DbaSpn — https://docs.dbatools.io/Test-DbaSpn
      $spnTest = Test-DbaSpn -SqlInstance $SqlInstances -EnableException:$false
      $spnMissing = $spnTest | Where-Object { $_.IsSet -eq $false -or $_.Valid -eq $false -or $_.Status -match 'Missing|NotRegistered|Invalid' }
    } catch {
      $spnMissing = @([pscustomobject]@{ Note = "Не удалось выполнить Test-DbaSpn: $($_.Exception.Message)" })
    }
  }

  # =========================
  # Раздел: Итоговый отчёт (JSON + CSV)
  # =========================
  $report = [pscustomobject]@{
    Timestamp = (Get-Date).ToString('s')
    Cluster   = [pscustomobject]@{
      Name                 = $cluster.Name
      Nodes                = $nodeInfo
      Networks             = $netInfo
      NicOrderOk           = $nicOrderOk
      NicOrderNote         = $nicOrderNote
      QuorumType           = $quorum.QuorumType
      QuorumResource       = $quorum.QuorumResource
      WitnessInUse         = $witnessInUse
      ShouldHaveWitness    = $shouldHaveWitness
      WitnessConfigOk      = $quorumRecommendationOk
      DynamicQuorum        = $cluster.DynamicQuorum
      WitnessDynamicWeight = $cluster.WitnessDynamicWeight
      NoVoteNodes          = ($noVoteNodes -join ',')
    }
    Sql      = [pscustomobject]@{
      Groups               = $groupInfo
      AgParameters         = $agParams
      Listeners            = $listenerInfo
    }
    Spn      = [pscustomobject]@{
      InstancesChecked     = ($SqlInstances -join ',')
      MissingOrInvalid     = $spnMissing
    }
  }

  $jsonPath = Join-Path $outDir.FullName "Wsfc-Sql-Report-$($cluster.Name)-$stamp.json"
  $report | ConvertTo-Json -Depth 6 | Out-File -FilePath $jsonPath -Encoding UTF8

  ($nodeInfo)     | Export-Csv (Join-Path $outDir.FullName "nodes.csv")      -NoTypeInformation -Encoding UTF8
  ($netInfo)      | Export-Csv (Join-Path $outDir.FullName "networks.csv")   -NoTypeInformation -Encoding UTF8
  ($groupInfo)    | Export-Csv (Join-Path $outDir.FullName "groups.csv")     -NoTypeInformation -Encoding UTF8
  ($agParams)     | Export-Csv (Join-Path $outDir.FullName "ag-params.csv")  -NoTypeInformation -Encoding UTF8
  ($listenerInfo) | Export-Csv (Join-Path $outDir.FullName "listeners.csv")  -NoTypeInformation -Encoding UTF8
  if ($spnMissing -and @($spnMissing).Count -gt 0) {
    $spnMissing | Export-Csv (Join-Path $outDir.FullName "spn-missing.csv") -NoTypeInformation -Encoding UTF8
  }

  # =========================
  # Раздел: Findings (отклонения от дефолтов/рекомендаций)
  # =========================
  $findings = New-Object System.Collections.Generic.List[string]

  if (-not $nicOrderOk) { $findings.Add("NIC order: приватная сеть не имеет меньшую метрику, чем публичная. $nicOrderNote") }
  if ($shouldHaveWitness -and -not $witnessInUse) { $findings.Add("Quorum: кластер из $nodeCount узлов без witness — рекомендуется настроить witness.") }
  if ($noVoteNodes -and @($noVoteNodes).Count -gt 0) { $findings.Add("No-vote узлы: $(@($noVoteNodes) -join ', '). Проверьте причины (site/latency/ручная настройка).") }

  foreach ($g in $groupInfo) {
    if ($null -ne $g.FailoverThreshold -and $g.FailoverThreshold -gt $recommendedMaxFailures) {
      $findings.Add("Group '$($g.GroupName)': FailoverThreshold=$($g.FailoverThreshold) > рекомендованного $recommendedMaxFailures.")
    }
  }

  foreach ($ap in $agParams) {
    if ($ap.LeaseTimeoutMs -ne 20000) {
      $findings.Add("AG '$($ap.AgName)': LeaseTimeoutMs=$($ap.LeaseTimeoutMs) (не дефолт 20000).")
    }
    if ($ap.HealthCheckTimeoutMs -ne 30000) {
      $findings.Add("AG '$($ap.AgName)': HealthCheckTimeoutMs=$($ap.HealthCheckTimeoutMs) (не дефолт 30000).")
    }
  }

  foreach ($li in $listenerInfo) {
    if ($li.MultiSubnet -and $li.RegisterAllProvidersIP -ne 1) {
      $findings.Add("Listener '$($li.ListenerNetworkName)' (AG '$($li.AgName)'): MultiSubnet=true, а RegisterAllProvidersIP=$($li.RegisterAllProvidersIP) — ожидается 1.")
    }
    if ($li.RegisterAllProvidersIP -ne 1) {
      $findings.Add("Listener '$($li.ListenerNetworkName)': RegisterAllProvidersIP=$($li.RegisterAllProvidersIP) (не дефолт 1).")
    }
    if ($li.HostRecordTTL -ne 1200) {
      $findings.Add("Listener '$($li.ListenerNetworkName)': HostRecordTTL=$($li.HostRecordTTL) (не дефолт 1200 сек).")
    }
  }

  if ($spnMissing -and @($spnMissing).Count -gt 0) {
    $findings.Add("SPN: обнаружены отсутствующие/некорректные записи. См. spn-missing.csv (для исправления: Test-DbaSpn -SetSpn или Set-DbaSpn).")
  }

  $findingsPath = Join-Path $outDir.FullName "findings.txt"
  if ($findings.Count -gt 0) {
    $findings | Out-File -FilePath $findingsPath -Encoding UTF8
  } else {
    "Отклонений от дефолтов/рекомендаций не обнаружено." | Out-File -FilePath $findingsPath -Encoding UTF8
  }

  # =========================
  # Раздел: Краткое резюме в консоль
  # =========================
  Write-Host "Cluster: $($cluster.Name)"
  Write-Host "Nodes: $nodeCount; No-vote: $((@($noVoteNodes)).Count)"
  Write-Host "Quorum: $($quorum.QuorumType); WitnessInUse=$witnessInUse; ShouldHaveWitness=$shouldHaveWitness"
  Write-Host "NIC order OK: $nicOrderOk ($nicOrderNote)"
  Write-Host "AGs found: $(@($agResources).Count)"
  Write-Host "Report JSON: $jsonPath"
  Write-Host "CSV: nodes.csv, networks.csv, groups.csv, ag-params.csv, listeners.csv, spn-missing.csv"
  Write-Host "Findings: $findingsPath"
}

Комментариев нет:

Отправить комментарий