# .SYNOPSIS Windows Container Prometheus Metrics Exporter .DESCRIPTION Prometheus exporter for Windows container metrics - container count and state, per-container CPU, memory, network I/O, restart counts, image inventory, and volume usage. Uses the Docker CLI to collect metrics and exports them in Prometheus-compatible text format. .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER Port HTTP port for http mode (default: 9517) .PARAMETER TextfileDir Directory for textfile collector output (default: C:\ProgramData\node_exporter) .PARAMETER InstallScheduledTask Switch to create a scheduled task for auto-start on system boot .PARAMETER TaskIntervalMinutes Interval in minutes for the scheduled task (default: 2) .NOTES Author: Phil Connor Contact: contact@mylinux.work Website: https://mylinux.work License: MIT Version: 1.0 Metrics Exported: Core Status: - windows_container_up - windows_container_exporter_info{version} Containers: - windows_container_total - windows_container_running - windows_container_stopped Per-Container: - windows_container_cpu_percent{name, id} - windows_container_memory_bytes{name, id} - windows_container_memory_limit_bytes{name, id} - windows_container_network_rx_bytes{name, id} - windows_container_network_tx_bytes{name, id} - windows_container_restart_count{name, id} Images: - windows_container_image_count - windows_container_image_size_bytes Volumes: - windows_container_volume_count Exporter: - windows_container_exporter_duration_seconds - windows_container_exporter_last_run_timestamp #> param( [ValidateSet('stdout', 'textfile', 'http')] [string]$Mode = 'stdout', [int]$Port = 9517, [string]$TextfileDir = 'C:\ProgramData\node_exporter', [switch]$InstallScheduledTask, [int]$TaskIntervalMinutes = 2 ) # Create a scheduled task to run this script every $TaskIntervalMinutes minutes if ($InstallScheduledTask) { $taskName = "WindowsContainerExporter" $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if (-not $existingTask) { $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -Mode textfile" if (-not $TaskIntervalMinutes -or $TaskIntervalMinutes -le 0) { throw "TaskIntervalMinutes must be a positive integer" } $taskTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes $TaskIntervalMinutes) -RepetitionDuration (New-TimeSpan -Days 365) $taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest try { Write-Host "Creating scheduled task: $taskName" Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Description "Exports Windows container metrics for Prometheus every $TaskIntervalMinutes minutes" $createdTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if (-not $createdTask) { throw "Failed to verify scheduled task creation" } Write-Host "Successfully created scheduled task: $taskName" -ForegroundColor Green } catch { Write-Error "Failed to create scheduled task: $($_.Exception.Message)" throw } } else { Write-Host "Scheduled task '$taskName' already exists, skipping creation" } } $ErrorActionPreference = 'SilentlyContinue' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-UnixTimestamp { [int][double]::Parse((Get-Date -UFormat '%s')) } function Format-MetricValue { param([double]$Value, [int]$Decimals = 2) [math]::Round($Value, $Decimals) } function Sanitize-LabelValue { param([string]$Value) $Value -replace '[\\"]', '' -replace '[\r\n]', '' } function Parse-SizeToBytes { param([string]$SizeStr) $SizeStr = $SizeStr.Trim() if ($SizeStr -match '^([\d.]+)\s*(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB)$') { $num = [double]$Matches[1] switch ($Matches[2]) { 'B' { return [long]$num } 'KB' { return [long]($num * 1000) } 'KiB' { return [long]($num * 1024) } 'MB' { return [long]($num * 1000000) } 'MiB' { return [long]($num * 1048576) } 'GB' { return [long]($num * 1000000000) } 'GiB' { return [long]($num * 1073741824) } 'TB' { return [long]($num * 1000000000000) } 'TiB' { return [long]($num * 1099511627776) } } } return 0 } function Parse-PercentValue { param([string]$PctStr) $PctStr = $PctStr.Trim().TrimEnd('%') try { return [double]$PctStr } catch { return 0.0 } } function Parse-NetworkValue { param([string]$NetStr) $NetStr = $NetStr.Trim() if ($NetStr -match '^([\d.]+)\s*(\w+)\s*/\s*([\d.]+)\s*(\w+)$') { $rxVal = Parse-SizeToBytes "$($Matches[1]) $($Matches[2])" $txVal = Parse-SizeToBytes "$($Matches[3]) $($Matches[4])" return @{ RX = $rxVal; TX = $txVal } } return @{ RX = 0; TX = 0 } } # ============================================================================ # CONTAINER METRICS # ============================================================================ function Get-ContainerMetrics { $sb = [System.Text.StringBuilder]::new() # Check Docker daemon reachability $dockerUp = $false try { $dockerInfo = docker info --format '{{.ServerVersion}}' 2>&1 if ($LASTEXITCODE -eq 0 -and $dockerInfo) { $dockerUp = $true } } catch {} # --- windows_container_up --- [void]$sb.AppendLine('# HELP windows_container_up Docker daemon reachability (1=up, 0=down)') [void]$sb.AppendLine('# TYPE windows_container_up gauge') $upVal = if ($dockerUp) { 1 } else { 0 } [void]$sb.AppendLine("windows_container_up $upVal") [void]$sb.AppendLine('') # --- windows_container_exporter_info --- [void]$sb.AppendLine('# HELP windows_container_exporter_info Exporter version information') [void]$sb.AppendLine('# TYPE windows_container_exporter_info gauge') [void]$sb.AppendLine('windows_container_exporter_info{version="1.0"} 1') [void]$sb.AppendLine('') if (-not $dockerUp) { return $sb.ToString() } # --- Container counts --- $allContainers = @() try { $allContainersRaw = docker ps -a --format '{{.ID}}|{{.Names}}|{{.State}}' 2>&1 if ($LASTEXITCODE -eq 0 -and $allContainersRaw) { $allContainers = $allContainersRaw | Where-Object { $_ -match '\|' } | ForEach-Object { $parts = $_ -split '\|', 3 [PSCustomObject]@{ ID = $parts[0].Trim() Name = $parts[1].Trim() State = $parts[2].Trim() } } } } catch {} $totalContainers = $allContainers.Count $runningContainers = ($allContainers | Where-Object { $_.State -eq 'running' } | Measure-Object).Count $stoppedContainers = ($allContainers | Where-Object { $_.State -ne 'running' } | Measure-Object).Count [void]$sb.AppendLine('# HELP windows_container_total Total containers') [void]$sb.AppendLine('# TYPE windows_container_total gauge') [void]$sb.AppendLine("windows_container_total $totalContainers") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP windows_container_running Running containers') [void]$sb.AppendLine('# TYPE windows_container_running gauge') [void]$sb.AppendLine("windows_container_running $runningContainers") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP windows_container_stopped Stopped containers') [void]$sb.AppendLine('# TYPE windows_container_stopped gauge') [void]$sb.AppendLine("windows_container_stopped $stoppedContainers") [void]$sb.AppendLine('') # --- Per-container CPU and memory from docker stats --- $statsData = @() try { $statsRaw = docker stats --no-stream --format '{{.ID}}|{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}' 2>&1 if ($LASTEXITCODE -eq 0 -and $statsRaw) { $statsData = $statsRaw | Where-Object { $_ -match '\|' } | ForEach-Object { $parts = $_ -split '\|', 5 [PSCustomObject]@{ ID = Sanitize-LabelValue $parts[0].Trim() Name = Sanitize-LabelValue $parts[1].Trim() CPUPerc = $parts[2].Trim() MemUsage = $parts[3].Trim() NetIO = $parts[4].Trim() } } } } catch {} # --- windows_container_cpu_percent --- [void]$sb.AppendLine('# HELP windows_container_cpu_percent CPU usage percentage per container') [void]$sb.AppendLine('# TYPE windows_container_cpu_percent gauge') foreach ($stat in $statsData) { $cpuPct = Format-MetricValue (Parse-PercentValue $stat.CPUPerc) $shortId = $stat.ID.Substring(0, [Math]::Min(12, $stat.ID.Length)) [void]$sb.AppendLine("windows_container_cpu_percent{name=`"$($stat.Name)`",id=`"$shortId`"} $cpuPct") } [void]$sb.AppendLine('') # --- windows_container_memory_bytes --- [void]$sb.AppendLine('# HELP windows_container_memory_bytes Memory usage in bytes per container') [void]$sb.AppendLine('# TYPE windows_container_memory_bytes gauge') foreach ($stat in $statsData) { $memParts = $stat.MemUsage -split '\s*/\s*', 2 $memUsed = Parse-SizeToBytes $memParts[0].Trim() $shortId = $stat.ID.Substring(0, [Math]::Min(12, $stat.ID.Length)) [void]$sb.AppendLine("windows_container_memory_bytes{name=`"$($stat.Name)`",id=`"$shortId`"} $memUsed") } [void]$sb.AppendLine('') # --- windows_container_memory_limit_bytes --- [void]$sb.AppendLine('# HELP windows_container_memory_limit_bytes Memory limit in bytes per container') [void]$sb.AppendLine('# TYPE windows_container_memory_limit_bytes gauge') foreach ($stat in $statsData) { $memParts = $stat.MemUsage -split '\s*/\s*', 2 $memLimit = 0 if ($memParts.Count -ge 2) { $memLimit = Parse-SizeToBytes $memParts[1].Trim() } $shortId = $stat.ID.Substring(0, [Math]::Min(12, $stat.ID.Length)) [void]$sb.AppendLine("windows_container_memory_limit_bytes{name=`"$($stat.Name)`",id=`"$shortId`"} $memLimit") } [void]$sb.AppendLine('') # --- windows_container_network_rx_bytes --- [void]$sb.AppendLine('# HELP windows_container_network_rx_bytes Network bytes received per container') [void]$sb.AppendLine('# TYPE windows_container_network_rx_bytes gauge') foreach ($stat in $statsData) { $netValues = Parse-NetworkValue $stat.NetIO $shortId = $stat.ID.Substring(0, [Math]::Min(12, $stat.ID.Length)) [void]$sb.AppendLine("windows_container_network_rx_bytes{name=`"$($stat.Name)`",id=`"$shortId`"} $($netValues.RX)") } [void]$sb.AppendLine('') # --- windows_container_network_tx_bytes --- [void]$sb.AppendLine('# HELP windows_container_network_tx_bytes Network bytes transmitted per container') [void]$sb.AppendLine('# TYPE windows_container_network_tx_bytes gauge') foreach ($stat in $statsData) { $netValues = Parse-NetworkValue $stat.NetIO $shortId = $stat.ID.Substring(0, [Math]::Min(12, $stat.ID.Length)) [void]$sb.AppendLine("windows_container_network_tx_bytes{name=`"$($stat.Name)`",id=`"$shortId`"} $($netValues.TX)") } [void]$sb.AppendLine('') # --- windows_container_restart_count --- [void]$sb.AppendLine('# HELP windows_container_restart_count Restart count per container') [void]$sb.AppendLine('# TYPE windows_container_restart_count gauge') foreach ($container in $allContainers) { $restartCount = 0 try { $inspectRaw = docker inspect --format '{{.RestartCount}}' $container.ID 2>&1 if ($LASTEXITCODE -eq 0 -and $inspectRaw -match '^\d+$') { $restartCount = [int]$inspectRaw.Trim() } } catch {} $containerName = Sanitize-LabelValue $container.Name $shortId = $container.ID.Substring(0, [Math]::Min(12, $container.ID.Length)) [void]$sb.AppendLine("windows_container_restart_count{name=`"$containerName`",id=`"$shortId`"} $restartCount") } [void]$sb.AppendLine('') # --- Image metrics --- $imageCount = 0 $imageTotalSize = 0 try { $imagesRaw = docker images --format '{{.Size}}' 2>&1 if ($LASTEXITCODE -eq 0 -and $imagesRaw) { $imageLines = $imagesRaw | Where-Object { $_.Trim() -ne '' } $imageCount = ($imageLines | Measure-Object).Count foreach ($imgSize in $imageLines) { $imageTotalSize += Parse-SizeToBytes $imgSize.Trim() } } } catch {} [void]$sb.AppendLine('# HELP windows_container_image_count Total container images') [void]$sb.AppendLine('# TYPE windows_container_image_count gauge') [void]$sb.AppendLine("windows_container_image_count $imageCount") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP windows_container_image_size_bytes Total size of all images in bytes') [void]$sb.AppendLine('# TYPE windows_container_image_size_bytes gauge') [void]$sb.AppendLine("windows_container_image_size_bytes $imageTotalSize") [void]$sb.AppendLine('') # --- Volume metrics --- $volumeCount = 0 try { $volumesRaw = docker volume ls -q 2>&1 if ($LASTEXITCODE -eq 0 -and $volumesRaw) { $volumeCount = ($volumesRaw | Where-Object { $_.Trim() -ne '' } | Measure-Object).Count } } catch {} [void]$sb.AppendLine('# HELP windows_container_volume_count Total volumes') [void]$sb.AppendLine('# TYPE windows_container_volume_count gauge') [void]$sb.AppendLine("windows_container_volume_count $volumeCount") [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # COLLECT ALL METRICS # ============================================================================ function Get-AllMetrics { $scriptStart = Get-Date $sb = [System.Text.StringBuilder]::new() # Collect container metrics [void]$sb.Append((Get-ContainerMetrics)) # Exporter runtime $scriptEnd = Get-Date $duration = Format-MetricValue ($scriptEnd - $scriptStart).TotalSeconds $timestamp = Get-UnixTimestamp [void]$sb.AppendLine('# HELP windows_container_exporter_duration_seconds Time to generate all metrics') [void]$sb.AppendLine('# TYPE windows_container_exporter_duration_seconds gauge') [void]$sb.AppendLine("windows_container_exporter_duration_seconds $duration") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP windows_container_exporter_last_run_timestamp Unix timestamp of last successful run') [void]$sb.AppendLine('# TYPE windows_container_exporter_last_run_timestamp gauge') [void]$sb.AppendLine("windows_container_exporter_last_run_timestamp $timestamp") [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # HTTP SERVER MODE # ============================================================================ function Start-HttpServer { param([int]$ListenPort) $prefix = "http://+:$ListenPort/" $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($prefix) try { $listener.Start() Write-Host "Starting Windows Container exporter on port $ListenPort..." -ForegroundColor Green Write-Host "Metrics available at http://localhost:$ListenPort/metrics" while ($listener.IsListening) { $context = $listener.GetContext() $request = $context.Request $response = $context.Response if ($request.Url.AbsolutePath -eq '/metrics') { $metrics = Get-AllMetrics $buffer = [System.Text.Encoding]::UTF8.GetBytes($metrics) $response.ContentType = 'text/plain; version=0.0.4; charset=utf-8' } else { $html = @"