mirror of
https://github.com/apple/container.git
synced 2026-07-12 20:47:04 +00:00
cf0eba495e
Closes #824 This implements statistics gathering across the various components, but ultimately this is for implementing a new CLI command: `container stats`. This shows memory usage, cpu usage, network and block i/o and the number of processes in the container. The new command can inspect stats for 1-N containers and by default continuously updates in a `top` like stream. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Documentation update ## Testing - [x] Tested locally - [x] Added/updated tests - [x] Added/updated docs
263 lines
10 KiB
Swift
263 lines
10 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 Apple Inc. and the container project authors.
|
|
//
|
|
// Licensed 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
|
|
//
|
|
// https://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.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import ArgumentParser
|
|
import ContainerClient
|
|
import ContainerizationError
|
|
import ContainerizationExtras
|
|
import Foundation
|
|
|
|
extension Application {
|
|
public struct ContainerStats: AsyncParsableCommand {
|
|
public static let configuration = CommandConfiguration(
|
|
commandName: "stats",
|
|
abstract: "Display resource usage statistics for containers")
|
|
|
|
@Argument(help: "Container ID or name (optional, shows all running containers if not specified)")
|
|
var containers: [String] = []
|
|
|
|
@Option(name: .long, help: "Format of the output")
|
|
var format: ListFormat = .table
|
|
|
|
@Flag(name: .long, help: "Disable streaming stats and only pull the first result")
|
|
var noStream = false
|
|
|
|
@OptionGroup
|
|
var global: Flags.Global
|
|
|
|
public init() {}
|
|
|
|
public func run() async throws {
|
|
if format == .json || noStream {
|
|
// Static mode - get stats once and exit
|
|
try await runStatic()
|
|
} else {
|
|
// Streaming mode - continuously update like top
|
|
// Enter alternate screen buffer and hide cursor
|
|
print("\u{001B}[?1049h\u{001B}[?25l", terminator: "")
|
|
fflush(stdout)
|
|
|
|
defer {
|
|
// Exit alternate screen buffer and show cursor again
|
|
print("\u{001B}[?25h\u{001B}[?1049l", terminator: "")
|
|
fflush(stdout)
|
|
}
|
|
|
|
try await runStreaming()
|
|
}
|
|
}
|
|
|
|
private func runStatic() async throws {
|
|
let allContainers = try await ClientContainer.list()
|
|
|
|
let containersToShow: [ClientContainer]
|
|
if containers.isEmpty {
|
|
// No containers specified - show all running containers
|
|
containersToShow = allContainers.filter { $0.status == .running }
|
|
} else {
|
|
// Validate all specified containers exist before proceeding
|
|
var found: [ClientContainer] = []
|
|
for containerId in containers {
|
|
guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "Error: No such container: \(containerId)"
|
|
)
|
|
}
|
|
found.append(container)
|
|
}
|
|
containersToShow = found
|
|
}
|
|
|
|
let statsData = try await collectStats(for: containersToShow)
|
|
|
|
if format == .json {
|
|
let jsonStats = statsData.map { $0.stats2 }
|
|
let data = try JSONEncoder().encode(jsonStats)
|
|
print(String(data: data, encoding: .utf8)!)
|
|
return
|
|
}
|
|
|
|
printStatsTable(statsData)
|
|
}
|
|
|
|
private func runStreaming() async throws {
|
|
// If containers were specified, validate they all exist upfront
|
|
if !containers.isEmpty {
|
|
let allContainers = try await ClientContainer.list()
|
|
for containerId in containers {
|
|
guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "Error: No such container: \(containerId)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
clearScreen()
|
|
// Show header right away.
|
|
printStatsTable([])
|
|
|
|
while true {
|
|
do {
|
|
let allContainers = try await ClientContainer.list()
|
|
|
|
let containersToShow: [ClientContainer]
|
|
if containers.isEmpty {
|
|
containersToShow = allContainers.filter { $0.status == .running }
|
|
} else {
|
|
var found: [ClientContainer] = []
|
|
for containerId in containers {
|
|
if let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) {
|
|
found.append(container)
|
|
}
|
|
}
|
|
containersToShow = found
|
|
}
|
|
|
|
let statsData = try await collectStats(for: containersToShow)
|
|
|
|
// Clear screen and reprint
|
|
clearScreen()
|
|
printStatsTable(statsData)
|
|
|
|
if statsData.isEmpty {
|
|
try await Task.sleep(for: .seconds(2))
|
|
}
|
|
} catch {
|
|
clearScreen()
|
|
print("Error collecting stats: \(error)")
|
|
try await Task.sleep(for: .seconds(2))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct StatsSnapshot {
|
|
let container: ClientContainer
|
|
let stats1: ContainerClient.ContainerStats
|
|
let stats2: ContainerClient.ContainerStats
|
|
}
|
|
|
|
private func collectStats(for containers: [ClientContainer]) async throws -> [StatsSnapshot] {
|
|
var snapshots: [StatsSnapshot] = []
|
|
|
|
// First sample
|
|
for container in containers {
|
|
guard container.status == .running else { continue }
|
|
do {
|
|
let stats1 = try await container.stats()
|
|
snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1))
|
|
} catch {
|
|
// Skip containers that error out
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Wait 2 seconds for CPU delta calculation
|
|
if !snapshots.isEmpty {
|
|
try await Task.sleep(for: .seconds(2))
|
|
|
|
// Second sample
|
|
for i in 0..<snapshots.count {
|
|
do {
|
|
let stats2 = try await snapshots[i].container.stats()
|
|
snapshots[i] = StatsSnapshot(
|
|
container: snapshots[i].container,
|
|
stats1: snapshots[i].stats1,
|
|
stats2: stats2
|
|
)
|
|
} catch {
|
|
// Keep the original stats if second sample fails
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return snapshots
|
|
}
|
|
|
|
/// Calculate CPU percentage from two stat snapshots
|
|
/// - Parameters:
|
|
/// - cpuUsageUsec1: CPU usage in microseconds from first sample
|
|
/// - cpuUsageUsec2: CPU usage in microseconds from second sample
|
|
/// - timeDeltaUsec: Time delta between samples in microseconds
|
|
/// - Returns: CPU percentage where 100% = one fully utilized core
|
|
static func calculateCPUPercent(
|
|
cpuUsageUsec1: UInt64,
|
|
cpuUsageUsec2: UInt64,
|
|
timeDeltaUsec: UInt64
|
|
) -> Double {
|
|
let cpuDelta =
|
|
cpuUsageUsec2 > cpuUsageUsec1
|
|
? cpuUsageUsec2 - cpuUsageUsec1
|
|
: 0
|
|
return (Double(cpuDelta) / Double(timeDeltaUsec)) * 100.0
|
|
}
|
|
|
|
static func formatBytes(_ bytes: UInt64) -> String {
|
|
let kib = 1024.0
|
|
let mib = kib * 1024.0
|
|
let gib = mib * 1024.0
|
|
|
|
let value = Double(bytes)
|
|
|
|
if value >= gib {
|
|
return String(format: "%.2f GiB", value / gib)
|
|
} else if value >= mib {
|
|
return String(format: "%.2f MiB", value / mib)
|
|
} else {
|
|
return String(format: "%.2f KiB", value / kib)
|
|
}
|
|
}
|
|
|
|
private func printStatsTable(_ statsData: [StatsSnapshot]) {
|
|
let header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]]
|
|
var rows = header
|
|
|
|
for snapshot in statsData {
|
|
let stats1 = snapshot.stats1
|
|
let stats2 = snapshot.stats2
|
|
|
|
let cpuPercent = Self.calculateCPUPercent(
|
|
cpuUsageUsec1: stats1.cpuUsageUsec,
|
|
cpuUsageUsec2: stats2.cpuUsageUsec,
|
|
timeDeltaUsec: 2_000_000 // 2 seconds in microseconds
|
|
)
|
|
let cpuStr = String(format: "%.2f%%", cpuPercent)
|
|
|
|
let memUsageStr = "\(Self.formatBytes(stats2.memoryUsageBytes)) / \(Self.formatBytes(stats2.memoryLimitBytes))"
|
|
let netStr = "\(Self.formatBytes(stats2.networkRxBytes)) / \(Self.formatBytes(stats2.networkTxBytes))"
|
|
let blockStr = "\(Self.formatBytes(stats2.blockReadBytes)) / \(Self.formatBytes(stats2.blockWriteBytes))"
|
|
|
|
let pidsStr = "\(stats2.numProcesses)"
|
|
|
|
rows.append([snapshot.container.id, cpuStr, memUsageStr, netStr, blockStr, pidsStr])
|
|
}
|
|
|
|
// Always print header, even if no containers
|
|
let formatter = TableOutput(rows: rows)
|
|
print(formatter.format())
|
|
}
|
|
|
|
private func clearScreen() {
|
|
// Move cursor to home position and clear from cursor to end of screen
|
|
print("\u{001B}[H\u{001B}[J", terminator: "")
|
|
fflush(stdout)
|
|
}
|
|
}
|
|
}
|