Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

Azure runbook 예제 - 6시간 동안 수행 중인 VM을 중지

지난 글의 runbook 스크립트와 그 제어에 이어서,

Azure runbook을 PowerShell 또는 C# 코드로 실행하는 방법
; https://www.sysnet.pe.kr/2/0/11516

보통 runbook에서 어떤 동작을 구현하고 싶다면 Azure Portal의 Cloud Shell을 통해 Azure 명령어를 실행해 보고 나서 옮기는 것이 편합니다. 가령 Azure의 VM 하나를 멈추게 만들고 싶은 경우 아래와 같이 테스트해 볼 수 있습니다.

PS Azure:\> Stop-AzureRmVM -Name "vm1" -ResourceGroupName "myservice2" -Force

명령어 수행이 잘 되는 것을 확인했으면 이제 적절하게 인자를 골라내 다음과 같이 runbook 스크립트를 만듭니다.

# runbook - stop_vm

param (
    [Parameter(Mandatory=$true)][object]$targetVM
)

Stop-AzureRmVM -Name $targetVM.VMName -ResourceGroupName "myservice2" -Force

그런데, 위의 스크립트는 다음과 같은 오류가 발생합니다.

Stop-AzureRmVM : Run Login-AzureRmAccount to login.
At line:7 char:1
+ Stop-AzureRmVM -Name $targetVM.VMName -ResourceGroupName "myservice2" -Force
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [Stop-AzureRmVM], PSInvalidOperationException
    + FullyQualifiedErrorId : InvalidOperation,Microsoft.Azure.Commands.Compute.StopAzureVMCommand

왜냐하면 Azure runbook 실행 환경 내에서 Stop-AzureRmVM과 같은 Azure Automation 명령어를 사용하려면 실행 문맥에서 인증을 필요로 하기 때문입니다. 의외로 이 방법을 알아내는 것이 좀 힘들었는데요, Automation Accounts를 생성하는 경우 기본 생성된 runbook 중에 "AzureAutomationTutorial"을 통해 알아낼 수 있었습니다. 그래서 다음과 같이 작성하면 됩니다.

param (
    [Parameter(Mandatory=$true)][object]$targetVM
)

$azureConnection = Get-AutomationConnection -Name AzureRunAsConnection

Add-AzureRmAccount -ServicePrincipal -Tenant $azureConnection.TenantID -ApplicationID $azureConnection.ApplicationID -CertificateThumbprint $azureConnection.CertificateThumbprint
    

Stop-AzureRmVM -Name $targetVM.VMName -ResourceGroupName "myservice2" -Force

특이하게 Get-AutomationConnection 명령어의 경우 검색해 봐도 정식 도움말이 (2018-05-09 기준) 없습니다. 아마도 Azure runbook 실행 환경 내에서만 특별하게 제공되는 듯한데 이 때문에 "AzureAutomationTutorial" 예제 스크립트 외에는 달리 참조할만한 곳이 없었습니다.

이렇게 만들어진 runbook을 PowerShell 클라이언트로 다음과 같이 실행할 수 있습니다.

PS C:\WINDOWS\system32> $json = '{ "VMName": "vm1" }' | ConvertFrom-Json

PS C:\WINDOWS\system32> $arg = @{ 'targetVM' = $json }

PS C:\WINDOWS\system32> $RBParams = @{ AutomationAccountName = 'testrunbook'; ResourceGroupName = 'myservice2'; Name = 'stop_vm'; Parameters = $arg }

PS C:\WINDOWS\system32> Start-AzureRmAutomationRunbook @RBParams

C#으로 실행하는 방법은 지난번 글과 다르지 않으며 첨부 파일은 이 글의 예제에 맞게 수정한 것입니다.




이번엔 약간 다른 시나리오를 생각해 보겠습니다. 예를 들어, VM이 6시간 이상 실행 중일 때 중지하도록 만들고 싶다면 어떻게 해야 할까요? 그러려면 우선 VM의 운영 시간을 구해야 하는데 다음의 글에 따라,

Get-AzureRmVM, Our script, Extra credit : Obtain Azure VM uptime
; https://4sysops.com/archives/check-azure-vm-status-with-powershell/

Get-AzureRmVM 명령어를 이용해 알아낼 수 있습니다.

PS C:\WINDOWS\system32> $vmStatus = Get-AzureRmVM -ResourceGroupName "myservice2" -Name "vm1" -Status
WARNING: Get-AzureRmVM: A property of the output of this cmdlet will change in an upcoming breaking change release. The Storag
eAccountType property for a DataDisk will return Standard_LRS and Premium_LRS

PS C:\WINDOWS\system32> $vmStatus

ResourceGroupName    : myservice2
Name                 : vm1
Disks[0]             : 
  Name               : vm1_OsDisk_1_eab77339733f42a29dd77430fccc8a42
  Statuses[0]        : 
    Code             : ProvisioningState/succeeded
    Level            : Info
    DisplayStatus    : Provisioning succeeded
    Time             : 2018-05-09 오전 6:29:53
PlatformFaultDomain  : 0
PlatformUpdateDomain : 0
Statuses[0]          : 
  Code               : ProvisioningState/succeeded
  Level              : Info
  DisplayStatus      : Provisioning succeeded
  Time               : 2018-05-09 오전 6:29:53
Statuses[1]          : 
  Code               : PowerState/deallocated
  Level              : Info
  DisplayStatus      : VM deallocated


PS C:\WINDOWS\system32> $vmStatus.Statuses[1].Code
PowerState/deallocated

현재 위의 상태는 "중지됨(할당 취소됨)"입니다. 다음은 각각의 경우에 대한 Statuses 값을 보여줍니다.

[시작 중]

ResourceGroupName    : myservice2
Name                 : vm1
Disks[0]             : 
  Name               : vm1_OsDisk_1_eab77339733f42a29dd77430fccc8a42
  Statuses[0]        : 
    Code             : ProvisioningState/succeeded
    Level            : Info
    DisplayStatus    : Provisioning succeeded
    Time             : 2018-05-09 오전 7:34:34
PlatformFaultDomain  : 0
PlatformUpdateDomain : 0
Statuses[0]          : 
  Code               : ProvisioningState/updating
  Level              : Info
  DisplayStatus      : Updating
Statuses[1]          : 
  Code               : PowerState/starting
  Level              : Info
  DisplayStatus      : VM starting

[실행 중]

Name                 : vm1
Disks[0]             : 
  Name               : vm1_OsDisk_1_eab77339733f42a29dd77430fccc8a42
  Statuses[0]        : 
    Code             : ProvisioningState/succeeded
    Level            : Info
    DisplayStatus    : Provisioning succeeded
    Time             : 2018-05-09 오전 7:34:34
PlatformFaultDomain  : 0
PlatformUpdateDomain : 0
VMAgent              : 
  VmAgentVersion     : Unknown
  Statuses[0]        : 
    Code             : ProvisioningState/Unavailable
    Level            : Warning
    DisplayStatus    : Not Ready
    Message          : VM status blob is found but not yet populated.
    Time             : 2018-05-09 오전 7:35:39
Statuses[0]          : 
  Code               : ProvisioningState/succeeded
  Level              : Info
  DisplayStatus      : Provisioning succeeded
  Time               : 2018-05-09 오전 7:35:31
Statuses[1]          : 
  Code               : PowerState/running
  Level              : Info
  DisplayStatus      : VM running

[할당을 취소하는 중]

ResourceGroupName    : myservice2
Name                 : vm1
Disks[0]             : 
  Name               : vm1_OsDisk_1_eab77339733f42a29dd77430fccc8a42
  Statuses[0]        : 
    Code             : ProvisioningState/succeeded
    Level            : Info
    DisplayStatus    : Provisioning succeeded
    Time             : 2018-05-09 오전 7:34:34
PlatformFaultDomain  : 0
PlatformUpdateDomain : 0
VMAgent              : 
  VmAgentVersion     : 2.7.41491.875
  Statuses[0]        : 
    Code             : ProvisioningState/succeeded
    Level            : Info
    DisplayStatus    : Ready
    Message          : GuestAgent is running and accepting new configurations.
    Time             : 2018-05-09 오전 7:41:29
Statuses[0]          : 
  Code               : ProvisioningState/updating
  Level              : Info
  DisplayStatus      : Updating
Statuses[1]          : 
  Code               : PowerState/deallocating
  Level              : Info
  DisplayStatus      : VM deallocating

따라서, Statuses[1].DisplayStatus == "VM running" 상태를 기준으로 Statuses[0].Time을 현재 시간과 비교해 6시간이 지난 경우 VM을 중지하라고 스크립트를 구성하면 됩니다. 다음은 이를 반영해 stop_vm 스크립트를 작성한 것입니다.

param ([Parameter(Mandatory=$true)][object]$targetVM)

$azureConnection = Get-AutomationConnection -Name AzureRunAsConnection

Add-AzureRmAccount -ServicePrincipal -Tenant $azureConnection.TenantID `
    -ApplicationID $azureConnection.ApplicationID -CertificateThumbprint $azureConnection.CertificateThumbprint

$vmStatus = Get-AzureRmVM -ResourceGroupName "myservice2" -Name $targetVM.VMName -Status

if ($vmStatus.Statuses[1].DisplayStatus -eq "VM running")
{
    Write-Output "VM: Running"

    $uptime = New-TimeSpan -Start $vmStatus.Statuses[0].Time -End (Get-Date)
    if ($uptime.TotalHours -gt 6)
    {
        Write-Output "VM update: {$uptime.TotalHours}"
        Stop-AzureRmVM -Name $targetVM.VMName -ResourceGroupName "myservice2" -Force
    }
    else
    {
        Write-Output "VM update: {$uptime.TotalHours}"
    }
}
else
{
    Write-Output "VM: Not running"
}




6시간마다 중지하도록 stop_vm runbook도 완성했는데, 이 스크립트를 사용자가 일일이 시간마다 실행해 줄 수는 없습니다. ^^ 당연히 자동화해야 할 텐데요, 이를 위해 runbook에서는 다음과 같이 "schedule(일정)" 기능을 제공합니다.

stop_vm_runbook_1.png

특정 시간에 한 번만 실행하는 것도 가능하지만, 이 글의 시나리오를 위해선 되풀이하도록 설정하는 것이 현실적입니다. 따라서 다음과 같이 "1시간 간격"으로 되풀이하도록 일정을 만들 수 있습니다.

stop_vm_runbook_2.png

현재 Azure에서 설정 가능한 되풀이 단위는 "시간, 일, 주, 월"이며 따라서 "1 시간"이 최소 간격입니다. 만약 다른 시간 간격을 사용하고 싶다면 Webhook으로 노출시켜 원하는 간격으로 직접 호출하는 스케줄링을 하면 됩니다. 시간 간격 후에는, 이 글에서 실습하는 stop_vm runbook은 매개 변수도 필요하기 때문에 다음과 같이 설정해 주어야 합니다. (참고로 아래와 같이 Azure Portal UI를 통해 json 유형의 값으로 매개 변수를 채우면 schedule로 인한 호출 시 오류가 발생합니다. 이유는 나중에 설명합니다.)

stop_vm_runbook_3.png

이후부터는, 설정된 시간 간격과 매개 변수로 runbook이 자동으로 실행됩니다.

당연히 이런 일정 등록 작업도 PowerShell이나 C#으로 가능합니다. 그런데, 위에서처럼 runbook에 schedule을 할당하는 방법은 없고 "Automation Account" 계정 수준에서 schedule을 만든 다음,

# Automation Account 내에 schedule 생성

$StartTime = Get-Date "10:25:00"
New-AzureRmAutomationSchedule -AutomationAccountName "testrunbook" -Name "Schedule01" -StartTime $StartTime -HourInterval 1  -ResourceGroupName "myservice2"

이것을 개별 runbook에 연결하는 방법으로 등록할 수 있도록 하고 있습니다.

# Automation Account 내에 생성해 두었던 schedule과 runbook을 연결

$vmParams = '{ "VMName": "vm1" }' | ConvertFrom-Json
$runbookParams = @{"targetVM"=$vmParams}
Register-AzureRmAutomationScheduledRunbook -AutomationAccountName "testrunbook" -RunbookName "stop_vm" -ScheduleName "Schedule01" -ResourceGroupName "myservice2" -Parameters $runbookParams

어찌 보면 schedule을 재사용할 수 있도록 해주므로 좋은 방법이긴 합니다. (아마도 runbook 단위로 생성하는 방법도 있겠지만, 제가 방법을 못 찾은 걸 수도 있습니다.)

참고로, Automation Account 단위로 재사용할 수 있는 변수도 추가할 수 있는데 다음과 같이 생성합니다.

$vmParams = '{ "VMName": "vm1" }' | ConvertFrom-Json
New-AzureRmAutomationVariable -AutomationAccountName "testrunbook" -Name "targetVM" -Encrypted $False -Value $vmParams -ResourceGroupName "myservice2"




이 외에도, VM의 성능 메트릭(Metrics)에 따라 WebHook이나 내부의 runbook을 호출하도록 설정하는 것이 가능합니다. 이에 대해서는 다음의 글에서 설명하고 있는데, 지금까지의 내용을 이해하셨다면 한번 훑어보기만 해도 금방 이해가 되실 것입니다.

Using Azure Automation to take action on Azure Alerts
; https://azure.microsoft.com/en-us/blog/using-azure-automation-to-take-actions-on-azure-alerts/




Stop-AzureRmVM 명령어는 기본적으로 명령어 실행 모드가 동기 방식입니다. 따라서, 대상 VM이 Azure에서 중지될 때까지 기다리게 되는데 이 시간이 꽤 깁니다. 따라서 여러 개의 VM을 중지하는 경우에는 비동기로 실행하는 것을 권장하며, 이를 위해 명령어에 -AsJob 옵션을 주면 됩니다.

Stop-AzureRmVM -Name "vm1" -ResourceGroupName "myservice2" -Force -AsJob

그런데 문제는, Azure runbook 실행 환경에서는 Stop-AzureRmVM에 대해 AsJob 옵션을 허용하지 않아 다음과 같은 오류가 발생합니다.

Stop-AzureRmVM : A parameter cannot be found that matches parameter name 'AsJob'.
At line:12 char:16
+ Stop-AzureRmVM -AsJob -Name "vm1" -ResourceGroupName ...
+                ~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [Stop-AzureRmVM], ParameterBindingException
    + FullyQualifiedErrorId : NamedParameterNotFound,Microsoft.Azure.Commands.Compute.StopAzureVMCommand

그래서, 만약 여러 개의 VM을 제어하고 싶다면 runbook 스크립트 내에서 for-each를 통해 VM을 제어해서는 안 되고 runbook을 실행하는 측에서 해당 runbook을 제어해야 할 VM의 이름을 넘겨, 이를 여러 번 호출하는 방식으로 구현해야 합니다.




일정 등록을 할 때, 다음과 같은 오류가 발생할 수 있습니다.

PS C:\WINDOWS\system32> New-AzureRmAutomationSchedule -AutomationAccountName "testrunbook" -Name "Schedule01" -StartTime $StartTime -HourInterval 1  -ResourceGroupName "myservice2"
New-AzureRmAutomationSchedule : BadRequest: Argument requestScheduleData with value Orchestrator.Schedules.DataAccess.Models.ScheduleAllData is not valid. Error message: The start time of the schedule must be at least 5 minutes after the time you create the schedule. 
At line:1 char:1
+ New-AzureRmAutomationSchedule -AutomationAccountName "testrunbook" -N ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : CloseError: (:) [New-AzureRmAutomationSchedule], CloudException
    + FullyQualifiedErrorId : Microsoft.Azure.Commands.Automation.Cmdlet.NewAzureAutomationSchedule

말 그대로, -StartTime의 인자로 전달한 시간이 현재 시간으로부터 5분 이내로 설정된 경우이기 때문입니다. 따라서, 다음과 같은 식으로 생성하면 안전하게 등록할 수 있습니다. (참고로, region에 따라 시간이 다릅니다. 제가 테스트 한 바로는 15분인 경우도 있었습니다.)

$StartTime = Get-Date
$StartTime = $StartTime.AddMinutes(6)
New-AzureRmAutomationSchedule -AutomationAccountName "testrunbook" -Name "Schedule01" -StartTime $StartTime -HourInterval 1  -ResourceGroupName "myservice2"




이 글을 실습하는 중에 다음과 같은 runbook 실행 오류 로그가 남을 수 있습니다.

Get-AzureRmVM : Cannot validate argument on parameter 'Name'. The argument is null or empty. Provide an argument that 
is not null or empty, and then try the command again.
At line:12 char:65
+ ... reRmVM -ResourceGroupName "myservice2" -Name $targetVM.VMName -Status
+                                                  ~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidData: (:) [Get-AzureRmVM], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.Azure.Commands.Compute.GetAzureVMCommand

말 그대로, runbook의 $targetVM에 전달하는 인자가 정상적으로 역직렬화가 안 된 것입니다. 이 부분이 좀 이상한데요. runbook에 등록하는 일정의 매개 변수를 다음과 같이 설정하면,

stop_vm_runbook_3.png

{ "VMName": "vm1" }

생성 후 다시 매개 변수를 확인했을 때 다음과 같이 출력됩니다.

{ \"VMName\": \"vm1\" }

재미있는 것은, 위와 같이 Azure Portal의 UI를 이용하지 않고 직접 프로그래밍을 통해 설정하는 경우에는 매개 변숫값이 겹따옴표에 대한 escape 문자 표현을 포함하고 있지 않다는 점입니다. 따라서 complex type에 대해 schedule의 매개 변수를 설정하려면 다음과 같이 코드로 설정해야 합니다.

# Automation Account 내에 생성해 두었던 schedule과 runbook을 연결

$vmParams = '{ "VMName": "vm1" }' | ConvertFrom-Json
$runbookParams = @{"targetVM"=$vmParams}
Register-AzureRmAutomationScheduledRunbook -AutomationAccountName "testrunbook" -RunbookName "stop_vm" -ScheduleName "Schedule01" -ResourceGroupName "myservice2" -Parameters $runbookParams

위와 같이 해서 scheduler를 생성하면 매개 변수를 확인했을 때 정상적으로 { "VMName": "vm1" }라고 나옵니다. 참고로, Azure portal UI 화면에서 다음과 같은 설정으로 입력했을 때 모두 실패했습니다.

{ 'VMName': 'vm1' }

{ ""VMName"": ""vm1"" }

{ VMName: vm1 }

{ "VMName": "vm1" }

아마도 azure의 입력 버그가 아닌가 싶은데... 두고 봐야겠습니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 5/10/2018]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2018-06-27 02시49분
이 글의 마지막 실습에서 { "VMName": "vm1" } 텍스트를 설정했는데도 오류가 나는 것은 해당 스크립트의 param 인자 타입을 다음과 같이 object에서 PSObject로 바꿔주면 해결됩니다.

param (
    [Parameter(Mandatory=$true)][PSObject]$user
)

관련해서 다음의 Form에서 질문해 답변을 들었습니다. ^^

How to pass complex typed argument in Azure runbook schedule's parameter?
; https://social.msdn.microsoft.com/Forums/azure/en-US/5d911ba5-6b7e-4182-9873-35c37b7012b7/how-to-pass-complex-typed-argument-in-azure-runbook-schedules-parameter?forum=azureautomation
정성태

... 16  17  18  19  20  21  22  23  24  25  26  27  28  [29]  30  ...
NoWriterDateCnt.TitleFile(s)
12904정성태1/8/20228697개발 환경 구성: 623. Visual Studio 2022 빌드 환경을 위한 github Actions 설정 [1]
12903정성태1/7/20227286.NET Framework: 1130. C# - ELEMENT_TYPE_INTERNAL 유형의 사용 예
12902정성태1/7/20227345오류 유형: 779. SQL 서버 로그인 에러 - provider: Shared Memory Provider, error: 0 - No process is on the other end of the pipe.
12901정성태1/5/20227384오류 유형: 778. C# - .NET 5+에서 warning CA1416: This call site is reachable on all platforms. '...' is only supported on: 'windows' 경고 발생
12900정성태1/5/20229059개발 환경 구성: 622. vcpkg로 ffmpeg를 빌드하는 경우 생성될 구성 요소 제어하는 방법
12899정성태1/3/20228552개발 환경 구성: 621. windbg에서 python 스크립트 실행하는 방법 - pykd (2)
12898정성태1/2/20229127.NET Framework: 1129. C# - ffmpeg(FFmpeg.AutoGen)를 이용한 비디오 인코딩 예제(encode_video.c) [1]파일 다운로드1
12897정성태1/2/20228008.NET Framework: 1128. C# - 화면 캡처한 이미지를 ffmpeg(FFmpeg.AutoGen)로 동영상 처리 [4]파일 다운로드1
12896정성태1/1/202210855.NET Framework: 1127. C# - FFmpeg.AutoGen 라이브러리를 이용한 기본 프로젝트 구성파일 다운로드1
12895정성태12/31/20219325.NET Framework: 1126. C# - snagit처럼 화면 캡처를 연속으로 수행해 동영상 제작 [1]파일 다운로드1
12894정성태12/30/20217272.NET Framework: 1125. C# - DefaultObjectPool<T>의 IDisposable 개체에 대한 풀링 문제 [3]파일 다운로드1
12893정성태12/27/20218847.NET Framework: 1124. C# - .NET Platform Extension의 ObjectPool<T> 사용법 소개파일 다운로드1
12892정성태12/26/20216836기타: 83. unsigned 형의 이전 값이 최댓값을 넘어 0을 지난 경우, 값의 차이를 계산하는 방법
12891정성태12/23/20216767스크립트: 38. 파이썬 - uwsgi의 --master 옵션
12890정성태12/23/20216917VC++: 152. Golang - (문자가 아닌) 바이트 위치를 반환하는 strings.IndexRune 함수
12889정성태12/22/20219326.NET Framework: 1123. C# - (SharpDX + DXGI) 화면 캡처한 이미지를 빠르게 JPG로 변환하는 방법파일 다운로드1
12888정성태12/21/20217483.NET Framework: 1122. C# - ImageCodecInfo 사용 시 System.Drawing.Image와 System.Drawing.Bitmap에 따른 Save 성능 차이파일 다운로드1
12887정성태12/21/20219558오류 유형: 777. OpenCVSharp4를 사용한 프로그램 실행 시 "The type initializer for 'OpenCvSharp.Internal.NativeMethods' threw an exception." 예외 발생
12886정성태12/20/20217490스크립트: 37. 파이썬 - uwsgi의 --enable-threads 옵션 [2]
12885정성태12/20/20217744오류 유형: 776. uwsgi-plugin-python3 환경에서 MySQLdb 사용 환경
12884정성태12/20/20216795개발 환경 구성: 620. Windows 10+에서 WMI root/Microsoft/Windows/WindowsUpdate 네임스페이스 제거
12883정성태12/19/20217645오류 유형: 775. uwsgi-plugin-python3 환경에서 "ModuleNotFoundError: No module named 'django'" 오류 발생
12882정성태12/18/20216756개발 환경 구성: 619. Windows Server에서 WSL을 위한 리눅스 배포본을 설치하는 방법
12881정성태12/17/20217261개발 환경 구성: 618. WSL Ubuntu 20.04에서 파이썬을 위한 uwsgi 설치 방법 (2)
12880정성태12/16/20217049VS.NET IDE: 170. Visual Studio에서 .NET Core/5+ 역어셈블 소스코드 확인하는 방법
12879정성태12/16/202113295오류 유형: 774. Windows Server 2022 + docker desktop 설치 시 WSL 2로 선택한 경우 "Failed to deploy distro docker-desktop to ..." 오류 발생
... 16  17  18  19  20  21  22  23  24  25  26  27  28  [29]  30  ...