您好,脚本专家!计算服务器正常运行时间

Microsoft 脚本专家

代码下载位置:HeyScriptingGuy2008_12.exe(152 KB)

运行是运行,停机是停机。看起来似乎是泾渭分明 — 但谈到服务器正常运行时间,可就不是这样了。要知道正常运行时间,就需要知道停机时间。几乎每位网络管理员都很关心服务器正常运行时间。(若非如此,则是很关心服务器停机时间)。大多数管理员都设有正常运行时间目标,并且需要向上层管理人员提交正常运行时间报告。

这有什么大不了的?似乎您只要使用 Win32_OperatingSystem WMI 类提供的两个属性,即 LastBootUpTime 和 LocalDateTime,就可以轻松完成这样的操作。您认为,只要将 LocalDateTime 减去 LastBootUptime 就万事大吉了,甚至还可以在晚餐前打场九洞高尔夫球。

因此您启动 Windows PowerShell 来查询 Win32_OperatingSystem WMI 类,并选择属性,如下所示:

PS C:\> $wmi = Get-WmiObject -Class Win32_OperatingSystem
PS C:\> $wmi.LocalDateTime - $wmi.LastBootUpTime

但是当您运行这些命令后,显示的并非易于理解的服务器正常运行时间,而是如图 1 所示的令人郁闷的错误消息。

fig01.gif

图 1 尝试减去 WMI UTC 时间值后返回错误消息(单击图像可查看大图)

此错误消息也许会误导人:“数字常量无效。”什么?您知道数字是什么,也知道常量是什么,但这些跟时间有什么关系?

面对异常的错误消息时,最好的方法就是直接查看脚本试图解析的数据。而且在使用 Windows PowerShell 时,往往也要注意所使用的数据类型。

要检查脚本所使用的数据,您可以直接将它显示在屏幕上。结果如下:

PS C:\> $wmi.LocalDateTime
20080905184214.290000-240

这个数字看起来有点奇怪,这是哪种格式的日期?为了搞清楚,我们使用 GetType 方法。GetType 的优点在于它几乎总是可供使用。您只需调用它即可。问题的根源找到了 — LocalDateTime 值被报告成字符串,而非 System.DateTime 值:

PS C:\> $wmi.LocalDateTime.gettype()

IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True     True     String
System.Object

如果您需要让两个时间相减,请确保您使用的是时间值,而不是字符串。办法很简单,就是使用 Windows PowerShell 添加到所有 WMI 类的 ConvertToDateTime 方法:

PS C:\> $wmi = Get-WmiObject -Class Win32_OperatingSystem
PS C:\> $wmi.ConvertToDateTime($wmi.LocalDateTime) –
$wmi.ConvertToDateTime($wmi.LastBootUpTime)

当您让两个时间值相减时,生成的是 System.TimeSpan 对象的实例。这表示您不必执行一大堆算法就可以选择如何显示正常运行时间信息。您只需要选择要显示的属性(希望您是以 TotalDays 而非 TotalMilliseconds 来计数正常运行时间)。下面是 System.TimeSpan 对象的默认显示:

Days              : 0
Hours             : 0
Minutes           : 40
Seconds           : 55
Milliseconds      : 914
Ticks             : 24559148010
TotalDays         : 0.0284249398263889
TotalHours        : 0.682198555833333
TotalMinutes      : 40.93191335
TotalSeconds      : 2455.914801
TotalMilliseconds : 2455914.801

此方法的问题在于它只告诉您自上次重新启动以来,服务器运行了多长时间。它并不会计算停机时间。运行与停机是同一事物的两种表象就体现在这里 — 为了计算正常运行时间,您首先要知道停机时间。

那么,您要如何计算服务器停机的时间长度?若要执行此操作,您需要知道服务器何时启动以及何时关闭。您可以从系统事件日志获取该信息。在服务器或工作站上最先开始启动的进程之一就是事件日志,事件日志也是当服务器关闭时最后停止运行的一个进程。这些开始/停止事件分别生成一个事件 ID — 当事件日志开始时生成的事件 ID 是 6005,停止时则是 6006。图 2 显示的是事件日志开始的示例。

fig02.gif

图 2 事件日志服务在计算机启动后立即开始运行(单击图像可查看大图)

通过从系统日志收集 6005 和 6006 事件,对它们进行排序,然后开始时间与停止时间相减,您就可以确定服务器在重新启动期间停机的时间长度。如果您再从此处的期间分钟数减去此停机时间长度,就可以计算出服务器正常运行时间的百分比。这就是 CalculateSystemUpTimeFromEventLog.ps1 脚本中采用的方法,如图 3 所示。

图 3 CalculateSystemUpTimeFromEventLog 3

#---------------------------------------------------------------
# CalculateSystemUpTimeFromEventLog.ps1
# ed wilson, msft, 9/6/2008
# Creates a system.TimeSpan object to subtract date values
# Uses a .NET Framework class, system.collections.sortedlist to sort the events from eventlog.
#---------------------------------------------------------------
#Requires -version 2.0
Param($NumberOfDays = 30, switch$debug)

if($debug) { $DebugPreference = " continue" }

timespan$uptime = New-TimeSpan -start 0 -end 0
$currentTime = get-Date
$startUpID = 6005
$shutDownID = 6006
$minutesInPeriod = (24*60)*$NumberOfDays
$startingDate = (Get-Date -Hour 00 -Minute 00 -Second 00).adddays(-$numberOfDays)

Write-debug "'$uptime $uptime" ; start-sleep -s 1
write-debug "'$currentTime $currentTime" ; start-sleep -s 1
write-debug "'$startingDate $startingDate" ; start-sleep -s 1

$events = Get-EventLog -LogName system | 
Where-Object { $_.eventID -eq  $startUpID -OR $_.eventID -eq $shutDownID `
  -and $_.TimeGenerated -ge $startingDate } 

write-debug "'$events $($events)" ; start-sleep -s 1

$sortedList = New-object system.collections.sortedlist

ForEach($event in $events)
{
 $sortedList.Add( $event.timeGenerated, $event.eventID )
} #end foreach event
$uptime = $currentTime - $sortedList.keys$($sortedList.Keys.Count-1)
Write-Debug "Current uptime $uptime"

For($item = $sortedList.Count-2 ; $item -ge 0 ; $item -- )
{ 
 Write-Debug "$item `t `t $($sortedList.GetByIndex($item)) `t `
   $($sortedList.Keys$item)" 
 if($sortedList.GetByIndex($item) -eq $startUpID)
 {
  $uptime += ($sortedList.Keys$item+1 - $sortedList.Keys$item)
  Write-Debug "adding uptime. `t uptime is now: $uptime"
 } #end if  
} #end for item 

"Total up time on $env:computername since $startingDate is " + "{0:n2}" -f `
  $uptime.TotalMinutes + " minutes."
$UpTimeMinutes = $Uptime.TotalMinutes
$percentDownTime = "{0:n2}" -f (100 - ($UpTimeMinutes/$minutesInPeriod)*100)
$percentUpTime = 100 - $percentDowntime

"$percentDowntime% downtime and $percentUpTime% uptime."

该脚本一开始使用 Param 语句来定义几个命令行参数,从命令行运行该脚本时,您可以更改这些参数值。第一个参数 $NumberOfDays 可使您指定用于正常运行时间报告的不同天数。(请注意,我在该脚本中提供的默认值为 30 天,因此即使您不为该参数提供任何值,也可以运行该脚本。当然,您可以根据需要来更改此值)。

第二个参数 switch$debug 是切换参数,如果在运行脚本时将此参数添加到命令行上,可使您通过脚本获取特定的调试信息。此信息能让您对于从脚本获取的结果更有把握。有时候可能不显示 6006 事件日志服务停止消息,这可能是因为服务器遭遇了灾难性故障,因而无法写入事件日志,导致脚本让两个正常运行时间值相减,影响了最终结果。

$debug 变量是通过命令行提供的,它将在变量上显示:drive。在此情况下,$debugPreference 变量值将设为“continue”,也就是说,脚本会继续运行,而提供给 Write-Debug 的所有值都会显示出来。请注意,$debugPreference 的值默认为 silentlycontinue,因此如果您未将 $debugPreference 的值设为“continue”,则该脚本将继续运行,但是提供给 Write-Debug 的值将为 silent(也就是说,将不会显示这些值)。

当脚本运行时,生成的输出会列出每一个 6005 和 6006 事件日志条目(如图 4 所示),并显示正常运行时间计算。通过该信息,您可以确认结果的准确性。

fig04.gif

图 4 调试模式显示了添加到正常运行时间计算的每一个时间值的记录(单击图像可查看大图)

下一步是创建 System.TimeSpan 对象的实例。您可以使用 New-Object cmdlet 来创建将用于执行日期差异计算的默认 timespan 对象:

PS C:\> timespan$ts = New-Object system.timespan

但是 Windows PowerShell 实际上有一个可用来创建 timespan 对象的 New-TimeSpan cmdlet,因此我们有道理使用此 cmdlet。使用此 cmdlet 会让脚本更容易阅读,而且所创建的对象与使用 New-Object 创建的 timespan 对象是等效的。

现在,您可以初始化一些变量,就从用来表示当前时间与日期值的 $currentTime 开始。从 Get-Date cmdlet 可获取此信息:

$currentTime = get-Date

接着,初始化用来表示启动和关闭 eventID 数字的两个变量。您不一定非得进行此初始化,不过如果您避免以字符串文字值的形式嵌入这两个变量,读取代码和进行故障排除会更容易。

下一步是创建名为 $minutesInPeriod 的变量,用于表示计算结果以便用来确认这段期间的分钟数:

$minutesInPeriod = (24*60)*$NumberOfDays

最后,您需要创建 $startingDate 变量,此变量用于表示代表报告期间开始时间的 System.DateTime 对象。日期将是该期间的开始日期那天的午夜:

$startingDate = (Get-Date -Hour 00 -Minute 00 -Second 00).adddays(-$numberOfDays)

创建这些变量之后,您将在事件日志中检索事件,并将查询结果存储在 $events 变量中。使用 Get-EventLog cmdlet 来查询事件日志并将“system”指定为日志名称。在 Windows PowerShell 2.0 中,您可以使用 –source 参数来减少需要从 Where-Object cmdlet 中排除的信息量。但是在 Windows PowerShell 1.0 中,在这方面您无法选择,因此必须逐一整理通过查询返回的未经筛选的事件。将事件输送到 Where-Object cmdlet 以筛选出适当的事件日志条目。当您检查 Where-Object 筛选器时,您将会明白脚本专家要求您创建变量以保存参数的原因。

比起使用字符串文字值,您能够更轻松地阅读命令。get eventID 等同于 $startUpID 或 $shutDownID。您也应确保事件日志条目的 timeGenerated 属性大于或等于 $startingDate,如下所示:

$events = Get-EventLog -LogName system |
Where-Object { $_.eventID -eq  $startUpID -OR $_.eventID -eq $shutDownID -and $_.TimeGenerated -ge $startingDate }

请记住,此命令只能本地运行。在 Windows PowerShell 2.0 中,您可以使用 –computerName 参数来远程运行此命令。

下一步是创建排序的列表对象。为什么呢?因为当您遍历事件集合时,无法保证事件日志条目的报告顺序。即使将对象输送到 Sort-Object cmdlet,并将结果保存回变量中,当您遍历对象并将结果存储到哈希表时,您也无法确定列表会保持排序步骤的结果。

为了回避这些棘手又难以调试的问题,您可使用对象的默认构造函数来创建 System.Collections.SortedList 对象的实例。默认的构造函数告诉排序的列表按时间顺序对日期进行排序。将空白的排序列表对象存储到 $sortedList 变量中:

$sortedList = New-object system.collections.sortedlist

创建排序的列表对象后,您需要填充此对象。为此,可使用 ForEach 语句并遍历存储在 $entries 变量中的事件日志条目的集合。遍历集合时,$event 变量会记录您在集合中的位置。您可以使用 add 方法将两个属性添加到 System.Collections.SortedList 对象中。排序的列表允许您添加 key 属性和 value 属性(类似于 Dictionary 对象,只不过您还可以像对数组一样对集合进行索引)。将 timegenerated 属性添加为 key 属性,将 eventID 添加为 value 属性:

ForEach($event in $events)
{
 $sortedList.Add( $event.timeGenerated,
 $event.eventID )
} #end foreach event

接着,您将计算服务器的当前正常运行时间。为此,您需要使用排序列表中最新的事件日志条目。请注意,最新的事件日志条目始终是 6005 实例,因为如果它是 6006,就表示服务器仍处于停机状态。由于索引是从零开始的,因此最新的条目应该是计数 –1。

要检索时间生成的值,您需要查看排序列表的 key 属性。若要获取索引值,请使用 count 属性,并使其减一。然后,从您早期填充的 $currenttime 变量中存储的日期时间值减去 6005 事件的生成时间。只有在调试模式下运行脚本,您才可以显示此计算结果。这段代码如下所示:

$uptime = $currentTime -
$sortedList.keys$($sortedList.Keys.Count-1)
Write-Debug "Current uptime $uptime"

现在该遍历排序的列表对象并计算服务器的正常运行时间了。由于使用的是 System.Collections.Sorted 列表对象,您可以利用能对列表进行索引这个优势。为此,可使用 for 语句,从计数 -2 开始,因为之前我们已使用计数 -1 来计算当前的正常运行时间长度。

我们将反向计数来获取正常运行时间,因此在 for 语句的第二个位置中指定的条件是当项大于或等于 0 时。在 for 语句的第三个位置使用 --,这将使 $item 的值逐次递减一。如果脚本以 –debug 开关运行,您可以使用 Write-Debug cmdlet 显示出索引编号的值。您也可以使用 `t 字符切换来显示出 timegenerated 时间值。这段代码如下所示:

For($item = $sortedList.Count-2 ; $item -ge 
  0 ; $item--)
{ 
 Write-Debug "$item `t `t $($sortedList.
 GetByIndex($item)) `t `
   $($sortedList.Keys$item)" 

如果 eventID 值等于 6005(也就是启动 eventID 值),您可以从先前的停机值减去开始时间来计算正常运行时间的长度。将此值存储在 $uptime 变量中。如果以调试模式运行,您可以使用 Write-Debug cmdlet 将这些值显示在屏幕上:

 if($sortedList.GetByIndex($item) -eq $startUpID)
 {
  $uptime += ($sortedList.Keys$item+1 
  - $sortedList.Keys$item)
  Write-Debug "adding uptime. `t uptime is now: $uptime"
 } #end if  
} #end for item

最后,您需要生成报告。从系统环境变量计算机获取计算机名称。您可使用存储在 $startingdate 值中的当前时间并显示该期间正常运行时间的总分钟数。您可使用格式说明符 {0:n2} 将数值显示为两位数字。接着计算停机时间百分比,方法是将正常运行时间分钟数除以报告所占用的分钟数。使用相同的格式说明符将数值显示为两位数字。为了增添些乐趣,您也可以计算正常运行时间的百分比,然后显示出两个值,如下所示:

"Total up time on $env:computername since $startingDate is " + "{0:n2}" -f `
  $uptime.TotalMinutes + " minutes."
$UpTimeMinutes = $Uptime.TotalMinutes
$percentDownTime = "{0:n2}" -f (100 - ($UpTimeMinutes/$minutesInPeriod)*100)
$percentUpTime = 100 - $percentDowntime
"$percentDowntime% downtime and $percentUpTime% uptime."

现在脚本专家要言归正传,讨论最先提出的问题:正常运行时,何时会停机?现在您应该明白,要讨论正常运行时间就一定要考虑停机时间。如果您觉得有趣,不妨查看 TechNet 上的更多“您好,脚本专家”专栏,或访问脚本中心

版本问题

特约编辑 Michael Murgolo 在他的便携式计算机上测试 Calculate­System­Up­timeFromEventLog.ps1 脚本时,遇到了一个非常恼人的错误。我将此脚本交给朋友 Jit 进行测试,他也遇到了相同的错误。是什么错误呢?如下所示:

PS C:\> C:\fso\CalculateSystemUpTimeFromEventLog.ps1
Cannot index into a null array.
At C:\fso\CalculateSystemUpTimeFromEventLog.ps1:36 char:43 + $uptime = 
$currentTime - $sortedList.keys$ <<<< ($sortedList.Keys.Count-1)
Total up time on LISBON since 09/02/2008 00:00:00 is 0.00 minutes.
100.00% downtime and 0% uptime.

“无法对空数组进行索引”,该错误指示数组的创建不正确。因此我研究了创建该数组的代码:

ForEach($event in $events)
{
 $sortedList.Add( $event.timeGenerated,
 $event.eventID )
} #end foreach event

结果表明,代码没问题。导致出现此错误的原因究竟是什么呢?

接着我决定查看 SortedList 对象。为此,我编写了一个简单脚本来创建 System.Collections.SortedList 类的实例,并在其中添加了一些信息。此时,我使用 key 属性显示出索引键的列表。该代码如下所示:

$aryList = 1,2,3,4,5
$sl = New-Object Collections.SortedList
ForEach($i in $aryList)
{
 $sl.add($i,$i)
}

$sl.keys

这段代码在我的计算机上能够正常运行,但在 Jit 的计算机上运行失败。真是差劲!但至少它为我指出了正确的方向。原来是 Windows PowerShell 1.0 中的 System.Collections.SortedList 有问题。而我刚好运行的是尚未发布的最新版 Windows PowerShell 2.0,在此版本中已经修复该错误,因此代码能正常运行。

那么,我们的脚本该如何应变?我发现,SortedList 类有一个名为 GetKey 的方法,此方法对 Windows PowerShell 1.0 和 Windows PowerShell 2.0 都适用。因此针对 1.0 版本的脚本,我们让代码改用 GetKey 而非遍历索引键集合。在 2.0 版本的脚本中,我们添加一个要求 Windows PowerShell 2.0 版的标记。如果您试图在 Windows PowerShell 1.0 机器上运行此脚本,该脚本会直接退出,而不会出现错误。

Michael 还指出了一些与设计相关的考虑事项,不过这些并非错误。他提到计算机在处于休眠或睡眠状态时,脚本将无法正确检测正常运行时间。确实是这样,因为我们并未检测或查找这些事件。

不过事实上,我并不关心自己的便携式计算机或桌面计算机的正常运行时间。我只关心服务器上的正常运行时间,而我还没碰到过服务器睡眠或休眠的情况。当然这有可能发生,使用这种方法来节约数据中心的电能也许是一种很有趣的方式,只是到目前为止我还都没碰到过这种情况。如果您的服务器处于休眠状态,请记得告诉我哦。您可以通过电子邮件与我联系:Scripter@Microsoft.com

Scripto 博士的脚本谜题

每月挑战不仅测试您解决谜题的能力,而且测试您的脚本编写技能。

2008 年 12 月:PowerShell 命令

以下列表包含 21 个 Windows PowerShell 命令。框中包含与列表相同的命令,但是被隐藏起来了。您的工作就是找出这些以水平、垂直、对角(正向或反向)方式隐藏的命令。

EXPORT-CSV FORMAT-LIST FORMAT-TABLE
GET-ACL GET-ALIAS GET-CHILDITEM
GET-LOCATION INVOKE-ITEM MEASURE-OBJECT
NEW-ITEMPROPERTY OUT-HOST OUT-NULL
REMOVE-PSSNAPIN SET-ACL SET-TRACESOURCE
SPLIT-PATH START-SLEEP STOP-SERVICE
SUSPEND-SERVICE WRITE-DEBUG WRITE-WARNING

\\msdnmagtst\MTPS\TechNet\issues\en\2008\12\HeyScriptingGuy - 1208\Figures\puzzle.gif

答案是:

Scripto 博士的脚本谜题

答案是:2008 年 12 月:PowerShell 命令

fig12.gif

Ed Wilson 是 Microsoft 的高级顾问,也是知名的脚本专家。他是 Microsoft 认证培训师,为世界各地的 Microsoft Premier 客户组织召开了广受欢迎的 Windows PowerShell 研讨会。他曾编写了八本著作,其中有多本著作探讨 Windows 脚本;此外,他也为许多其他书籍做出了贡献。Ed 拥有 20 多个行业证书。

Craig Liebendorfer 是语言艺术家,也是 Microsoft Web 的长期编辑。Craig 一直无法相信他可以每天靠舞文弄墨来维持生计。无厘头式的幽默是他的最爱之一,因此他应该非常适合这个工作。Craig 认为美丽动人的女儿是自己一生最大的成就。