Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)
(시리즈 글이 3개 있습니다.)
Linux: 18. C# - .NET Core Console로 리눅스 daemon 프로그램 만드는 방법
; https://www.sysnet.pe.kr/2/0/11958

C/C++: 186. Golang - 콘솔 응용 프로그램을 NT 서비스를 지원하도록 변경
; https://www.sysnet.pe.kr/2/0/13854

C/C++: 187. Golang - 콘솔 응용 프로그램을 Linux 데몬 서비스를 지원하도록 변경
; https://www.sysnet.pe.kr/2/0/13857




Golang - 콘솔 응용 프로그램을 NT 서비스를 지원하도록 변경

이번 글은 아래의 내용을 베낀 정리한 것입니다.

Writing a windows service in Go
; https://dev.to/cosmic_predator/writing-a-windows-service-in-go-1d1m




go 언어로 단순한 유형의 콘솔 애플리케이션을 만들어 직접 실습해 볼 텐데요, 이를 위해 아래와 같은 초기화 작업을 먼저 진행하고,

c:\temp\go_sample> go mod init sample_svc
go: creating new go.mod: module sample_svc

C:\temp\go_sample> type go.mod
module go_sample

go 1.22

toolchain go1.22.10

require golang.org/x/sys v0.27.0

대충 main은 이런 식으로 만들겠습니다.

package main

import (
    "bufio"
    "log"
    "os"
)

func doMyWork() {
	tick := time.Tick(1 * time.Second)

	for {
		select {
		case <-tick:
			log.Println("Tick Handled (Console)...!")
		}
	}
}

func main() {
    go doMyWork(false)

    log.Println("Press any key to exit...")
    input := bufio.NewScanner(os.Stdin)
    input.Scan()
}

보는 바와 같이 doMyWork 함수가 1초마다 log를 출력하는 작업만 수행하는데 일단은 저걸 NT 서비스의 주요 작업이라고 보면 되겠습니다.

자, 그럼 이제 이 프로그램을 NT 서비스로 만들어 볼 텐데요, go 언어에서는 이를 위해 svc 패키지를 제공하므로 이에 준하는 규칙에 따라 작성하시면 됩니다.

우선, svc 패키지에는 Execute 함수 하나를 정의한 Handler 인터페이스가 구현돼 있는데요,

type Handler interface {
    Execute(args []string, r <-chan ChangeRequest, s chan<- Status) (svcSpecificEC bool, exitCode uint32)
}

따라서 우리는 저 인터페이스를 구현한 개체를 만들면 됩니다.

type myService struct{}

func (m *myService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
    const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue

    // if need long-init
    // status <- svc.Status{State: svc.StartPending}
    // ...init work...

    log.Print("Starting service...!")
    go doMyWork(false)

    status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}

loop:
    for {
        select {
        case c := <-r:
            switch c.Cmd {
            case svc.Interrogate:
                status <- c.CurrentStatus
            case svc.Stop, svc.Shutdown:
                log.Print("Shutting service...!")
                break loop
            case svc.Pause:
                status <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted}
            case svc.Continue:
                status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
            default:
                log.Printf("Unexpected service control request #%d", c)
            }
        }
    }

    // if need long-clean-up (참고: https://www.sysnet.pe.kr/2/0/13861#go_stop_pending)
    // status <- svc.Status{State: svc.StopPending}
    // ...cleanup...

    status <- svc.Status{State: svc.Stopped}
    return false, 1
}

Execute 함수의 내부 로직을 보면 대충 이렇게 나뉩니다.

  1. Execute가 수행된 것 자체가 이미 서비스를 "Start" 시킨 것으로 간주하고, "서비스 작업"을 (다른 스레드로) 수행한다.
  2. 이후, 무한 루프를 돌면서 SCM(Service Control Manager)으로부터 받은 명령에 따라 적절한 동작을 수행한다.
  3. "Stop" 명령을 받으면 무한 루프를 벗어나고, 서비스가 정상적으로 "Stopped" 상태로 빠졌음을 SCM에게 알린다.

위의 "1번" 작업에 해당하는 코드를 보면,

const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue

// if need long-init
// status <- svc.Status{State: svc.StartPending}
// ...init work...

log.Print("Starting service...!")
go doMyWork() // 사용자가 서비스를 "Start" 시킨 것이므로 서비스로써의 작업을 수행한다.

status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} // 서비스가 Running 상태임을 SCM에 알린다.

가장 중요한 것은 svc.Status를 이용해 서비스가 "Running" 상태로 전환했음을 알리는 것입니다. 만약, 이 코드를 누락하면 SCM은 해당 서비스가 시작됐는지 알 수 없게 돼 "진행 중"을 표시하는 progress bar가 무한히 돌아가게 됩니다.

그리고 사용자 작업을 "동기" 방식으로 실행해서는 안 된다는 점이 중요합니다. 보통 다른 언어(C/C++)로 작성한 경우라면 저 부분을 CreateThread를 이용해 별도의 스레드로 작업을 맡기지만 go 언어의 경우 고루틴을 이용하면 됩니다.

여기까지 진행이 되었으면 이제 SCM은 서비스가 시작 중임을 인지하게 됐고, 서비스 입장에서도 자신의 고유 작업을 수행하게 됐습니다.

참고로, 위의 코드에서 주석 처리한 StartPending은 서비스를 시작할 때 초기화 작업이 길어지는 경우 SCM 측에 중간 상태를 알려주기 위해 필요합니다. 이번 예제에서는 작업자 코드가 곧바로 수행될 것이므로 굳이 필요하지는 않은 경우입니다.




자, 그럼 2번 단계로 가볼까요?

loop:
    for {
        select {
        case c := <-r:
            switch c.Cmd {
            case svc.Interrogate:
                status <- c.CurrentStatus
            case svc.Stop, svc.Shutdown:
                log.Print("Shutting service...!")
                break loop
            case svc.Pause:
                status <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted}
            case svc.Continue:
                status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
            default:
                log.Printf("Unexpected service control request #%d", c)
            }
        }
    }

우선, Pause/Continue는 의미하는 바에 따라 해당 신호를 받은 경우에는 doMyWork 내부에서 작업을 skip/continue하도록 반응해 주면 됩니다.

그리고 가장 중요한 Stop/Shutdown의 경우 응용 프로그램을 종료하면 되므로 저 루프를 벗어나게 하면 됩니다. 그럼 3번 단계로 바뀌는데요,

// if need long-clean-up
// status <- svc.Status{State: svc.StopPending}
// ...cleanup...

status <- svc.Status{State: svc.Stopped}

이것 역시 이전에 설명했던 StartPending / Running 관계처럼 다루면 됩니다. 즉, 서비스를 종료하는 작업이 오래 걸릴 것 같으면 StopPending으로 우선 SCM에게 알려주고, 이후 완전히 서비스가 종료했을 때 Stopped를 통보하면 됩니다.

딱히 어렵지 않죠? ^^




위와 같이 SCM에 의해 callback을 처리하는 Handler를 만들었으면 이제 main 함수에 이를 반영하면 됩니다. 단지 Console에서 직접 실행하는 경우는 go doMyWork()를 호출한 이후 응용 프로그램 종료를 막기 위해 Scanner.Scan 함수를 일부러 호출했었는데요, svc 패키지의 경우 이런 블록을 svc.Run 함수에서 해주기 때문에 다음과 같이 간단하게 처리할 수 있습니다.

func main() {
    
    err := svc.Run("go_sample_svc", &myService{}) // 블록킹 함수
    if err != nil { // 서비스 "stop"을 한 경우에만 제어가 반환돼 이 코드를 실행
        log.Println("Error running service in Service Control mode.")
    }
}

끝입니다. ^^ 이제 저 프로그램을 빌드해 go_svc_sample.exe를 만들고 이를 NT 서비스로 등록하는 것으로 마무리를 합니다.

c:\temp> go build -ldflags "-s -w" -o go_svc_sample.exe

// 관리자 권한 필요
c:\temp> sc create go_sample_svc binPath= "C:\temp\go_sample\go_svc_sample.exe" start= auto 

이후, "서비스 관리자"를 열어 "go_sample_svc" 서비스를 시작/중지하는 식으로 테스트할 수 있습니다.




그런데, 서비스 프로그램을 만들어 보신 분은 알겠지만 이렇게 프로그램을 만드는 경우 SCM으로만 실행할 수 있으므로 개발 및 디버깅이 여간 불편한 것이 아닙니다.

따라서, 개발 편의를 위해 콘솔 모드로 실행하는 옵션을 추가하는 것이 좋은데요, 가령, 위의 예제는 다음과 같은 식으로 변경할 수 있습니다.

package main

import (
    "bufio"
    "fmt"
    "github.com/alecthomas/kingpin"
    "golang.org/x/sys/windows/svc"
    "log"
    "os"
    "time"
)


var (
    console_mode = kingpin.Flag("c", "Run in console").Default("false").Bool()
)

func main() {
    kingpin.Parse()

    if *console_mode == true {
        go doMyWork()

        log.Println("Press any key to exit...")
        input := bufio.NewScanner(os.Stdin)
        input.Scan()
    } else {
        err := svc.Run("go_sample_svc", &myService{})
        if err != nil {
            log.Println("Error running service in Service Control mode.")
        }
    }
}

이렇게 해주면 개발 시에는 명령행 인자를 줘서 편리하게 F5 디버깅을 할 수 있습니다.

c:\temp> go_svc_sample.exe --console

물론, 저렇게 콘솔로 실행하는 경우의 Interactive 세션 환경과 SCM에 의해 실행되는 Service 세션 환경의 보안이 다르므로 그런 부분은 주의해서 테스트를 해야 합니다.

아울러, NT 서비스로 실행하는 동안 문제가 발생하는 경우를 진단하기 위해 로그 파일을 남기는 것도 좋은 방법이므로,

func main() {
    kingpin.Parse()

    if *console_mode == true {
        // ...[생략]...
    } else {
        f, err := os.OpenFile("C:/temp/debug.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
        if err != nil {
            log.Fatalln(fmt.Errorf("error opening file: %v", err))
        }
        defer func(f *os.File) {
            _ = f.Close()
        }(f)

        log.SetOutput(f)

        err = svc.Run("go_sample_svc", &myService{})
        if err != nil {
            log.Println("Error running service in Service Control mode.")
        }
    }
}

위와 같이 log.SetOutput 함수를 사용하는 것이 필수입니다. 좋습니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




원문의 소스코드에 버그가 살짝 있는데요, Execute의 마지막 단계에서 StopPending 호출만 하고 있습니다.

func (m *myService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {

    // ...[생략]...
    status <- svc.Status{State: svc.StopPending}
    return false, 1
}

저렇게 종료하면 SCM은 정상적으로 서비스가 Stopped 상태로 전환됐다고 인지하지 못하므로 "서비스 관리자"에서 중지시켜 보면 이런 오류가 발생합니다.

Windows could not stop the go_sample_svc service on Local Computer.

Error 1: Incorrect function.

따라서 제가 본문에서 설명한 것처럼 "status <- svc.Status{State: svc.Stopped}" 코드로 끝맺음해야 합니다.




참고로, Go 언어의 결과물은 1.20 버전까지만 Windows 7/2008 R2를 지원합니다.

Cannot run go programs built with 1.21.5 on Windows Server anymore #64602
; https://github.com/golang/go/issues/64602

그 이상의 버전으로 빌드한 결과물을 Windows 8/2012 미만의 환경에서 실행하면 이런 결과가 나옵니다.

C:\temp> testapp.exe
Exception 0xc0000005 0x8 0x0 0x0
PC=0x0

runtime.asmstdcall(0x2d0700060009)
        runtime/sys_windows_amd64.s:76 +0x89 fp=0xf5f680 sp=0xf5f660 pc=0x397609
rax     0x0
rbx     0x920100
rcx     0x965f28
rdx     0x20
rdi     0x7fffffde000
rsi     0xf5f878
rbp     0xf5f7c0
rsp     0xf5f658
r8      0x91fe20
r9      0x965a4a
r10     0xc7a2e8
r11     0xc000004000
r12     0xf5f8c8
r13     0x0
r14     0x91ef80
r15     0x3
rip     0x0
rflags  0x10246
cs      0x33
fs      0x53
gs      0x2b

별 의미는 없지만 오류가 발생한 소스코드를 보면,

go/src/runtime/sys_windows_amd64.s
; https://github.com/golang/go/blob/master/src/runtime/sys_windows_amd64.s#L74

아래의 "CALL AX"에서 오류가 발생한 것이고,

...[생략]...

// void runtime·asmstdcall(void *c);
TEXT runtime·asmstdcall(SB),NOSPLIT,$16
    MOVQ    SP, AX
    ANDQ    $~15, SP    // alignment as per Windows requirement
    MOVQ    AX, 8(SP)
    MOVQ    CX, 0(SP)   // asmcgocall will put first argument into CX.

    MOVQ    libcall_fn(CX), AX
    MOVQ    libcall_args(CX), SI
    MOVQ    libcall_n(CX), CX

    // SetLastError(0).
    
    ...[생략]...
_1args:
    MOVQ    0(SI), CX
    MOVQ    CX, X0
_0args:

    // Call stdcall function.
    CALL    AX

    ADDQ    $(const_maxArgs*8), SP

    ...[생략]...

    RET
...[생략]...

AX에 기록된 값이 libcall_fn(CX)에서 읽어온 값인데, 이 값이 0이라서 오류가 발생한 것입니다. 그래서 call로 인해 점프한 곳도 0이고, 그때의 rax 값도 0입니다.

Exception 0xc0000005 0x8 0x0 0x0
PC=0x0 // Program Counter: RIP

runtime.asmstdcall(0x2d0700060009)
        runtime/sys_windows_amd64.s:76 +0x89 fp=0xf5f680 sp=0xf5f660 pc=0x397609
rax     0x0
rbx     0x920100
...[생략]...




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 1/11/2025]

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

비밀번호

댓글 작성자
 




... 91  92  93  94  95  96  97  98  99  100  101  [102]  103  104  105  ...
NoWriterDateCnt.TitleFile(s)
11383정성태12/4/201723396디버깅 기술: 110. 비동기 코드 실행 중 예외로 인한 ASP.NET 프로세스 비정상 종료 현상 [1]
11382정성태12/4/201721930오류 유형: 436. System.Data.SqlClient.SqlException (0x80131904): Connection Timeout Expired 예외 발생 시 "[Pre-Login] initialization=48; handshake=1944;" 값의 의미
11381정성태11/30/201718420.NET Framework: 702. 한글이 포함된 바이트 배열을 나눈 경우 한글이 깨지지 않도록 다시 조합하는 방법(두 번째 이야기)파일 다운로드1
11380정성태11/30/201718446디버깅 기술: 109. windbg - (x64에서의 인자 값 추적을 이용한) Thread.Abort 시 대상이 되는 스레드를 식별하는 방법
11379정성태11/30/201719136오류 유형: 435. System.Web.HttpException - Session state has created a session id, but cannot save it because the response was already flushed by the application.
11378정성태11/29/201720618.NET Framework: 701. 한글이 포함된 바이트 배열을 나눈 경우 한글이 깨지지 않도록 다시 조합하는 방법 [1]파일 다운로드1
11377정성태11/29/201719880.NET Framework: 700. CommonOpenFileDialog 사용 시 사용자가 선택한 파일 목록을 구하는 방법 [3]파일 다운로드1
11376정성태11/28/201724272VS.NET IDE: 123. Visual Studio 편집기의 \r\n (crlf) 개행을 \n으로 폴더 단위로 설정하는 방법
11375정성태11/28/201719066오류 유형: 434. Visual Studio로 ASP.NET 디버깅 중 System.Web.HttpException - Could not load type 오류
11374정성태11/27/201724164사물인터넷: 14. 라즈베리 파이 - (윈도우의 NT 서비스처럼) 부팅 시 시작하는 프로그램 설정 [1]
11373정성태11/27/201723152오류 유형: 433. Raspberry Pi/Windows 다중 플랫폼 지원 컴파일 관련 오류 기록
11372정성태11/25/201726130사물인터넷: 13. 윈도우즈 사용자를 위한 라즈베리 파이 제로 W 모델을 설정하는 방법 [4]
11371정성태11/25/201719814오류 유형: 432. Hyper-V 가상 스위치 생성 시 Failed to connect Ethernet switch port 0x80070002 오류 발생
11370정성태11/25/201719819오류 유형: 431. Hyper-V의 Virtual Switch 생성 시 "External network" 목록에 특정 네트워크 어댑터 항목이 없는 경우
11369정성태11/25/201721787사물인터넷: 12. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 키보드 및 마우스로 쓰는 방법 (절대 좌표, 상대 좌표, 휠) [1]
11368정성태11/25/201727418.NET Framework: 699. UDP 브로드캐스트 주소 255.255.255.255와 192.168.0.255의 차이점과 이를 고려한 C# UDP 서버/클라이언트 예제 [2]파일 다운로드1
11367정성태11/25/201727487개발 환경 구성: 337. 윈도우 운영체제의 route 명령어 사용법
11366정성태11/25/201719134오류 유형: 430. 이벤트 로그 - Cryptographic Services failed while processing the OnIdentity() call in the System Writer Object.
11365정성태11/25/201721379오류 유형: 429. 이벤트 로그 - User Policy could not be updated successfully
11364정성태11/24/201723330사물인터넷: 11. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스로 쓰는 방법 (절대 좌표) [2]
11363정성태11/23/201723347사물인터넷: 10. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스 + 키보드로 쓰는 방법 (두 번째 이야기)
11362정성태11/22/201719744오류 유형: 428. 윈도우 업데이트 KB4048953 - 0x800705b4 [2]
11361정성태11/22/201722545오류 유형: 427. 이벤트 로그 - Filter Manager failed to attach to volume '\Device\HarddiskVolume??' 0xC03A001C
11360정성태11/22/201722389오류 유형: 426. 이벤트 로그 - The kernel power manager has initiated a shutdown transition.
11359정성태11/16/201721898오류 유형: 425. 윈도우 10 Version 1709 (OS Build 16299.64) 업그레이드 시 발생한 문제 2가지
11358정성태11/15/201726690사물인터넷: 9. Visual Studio 2017에서 Raspberry Pi C++ 응용 프로그램 제작 [1]
... 91  92  93  94  95  96  97  98  99  100  101  [102]  103  104  105  ...