파이썬으로 윈도우 서비스 만들기#

윈도우나 리눅스와 같은 운영체제는 운영체제 시작과 함께 백그라운드로 동작하는 프로그램이 있습니다. 일반적으로 이런 프로그램이 하는 일은 운영체제가 시작하면서 반드시 해야 하는 일이 많습니다. 예를 들면 네트워크 통신을 할 수 있도록 통신 기능을 활성화하거나 웹 페이지나 데이터베이스 서버 프로그램을 실행하는 기능을 가지고 있습니다.

리눅스와 맥은 이렇게 시작되는 프로그램을 데몬(Daemon)이라고 하고 윈도우는 서비스(Service)라고 합니다.

제가 이 글을 작성하게 된 것은 Trac(https://trac.edgewall.org) User 메일에 올라온 다음 메일 때문이었습니다.

Hi all,
Does anyone have experience with running trac as a windows service?

I've tried various methods including the python script provided in trac hacks, .bat files etc.

The issue I hit is that it starts trac ok, but stopping it stops the service but tracd keeps running in the background thus trac doesn't stop.

Any thoughts on how to fix this or working examples would be appreciated. Just need a way to ensure trac starts and stops as a service rather than just using cmd. Stand alone server btw.

tracd 명령으로 윈도우에서 시작했는데 tracd를 실행한 터미널을 종료했음에도 tracd가 종료되지 않는데 어떻게 하면 좋을지에 대한 의견을 구하는 것이었습니다.

그래서 직접 만들어 보기로 했습니다.

윈도우가 시작하면서 실행하는 많은 서비스가 있지만 여기서 그 서비스를 다 설명할 것은 아니고, 이 글에선 파이썬으로 윈도우 서비스를 만들고 동작시키는 방법을 살펴보겠습니다.

준비물#

이 글을 시작하기에 앞서 다음과 같은 준비가 필요합니다.

  • 윈도우 10 이상

  • 파이썬 3.9 이상

  • 명령 프롬프트 또는 파워셸

파이썬은 https://www.python.org 에서 배포하는 python-버전-amd64.exe 실행 파일로 설치되어 있고 Python 실행 경로가 PATH 환경 변수에 포함되어 있어야 합니다.

파이썬 유틸리티 설치#

이 문서의 내용을 따라하기 위해 몇 가지 파이썬 프로그래밍 유틸리티 설치가 필요합니다. 설치할 유틸리티는 pipx와 pipenv 입니다.

PS > Invoke-WebRequest -Uri https://bit.ly/4ctb7ek | iex
PS > Invoke-WebRequest -Uri https://bit.ly/3y1qTgL | iex
PS > Add-EnvPath $Env:AppData\$PY_VER\Scripts User

이 명령의 실행이 끝나면 다음과 같이 pipx를 설치합니다.

PS > pip install pipx
PS > pipx ensurepath

이 명령의 실행이 끝나면 파워셸을 종료했다가 다시 실행합니다. 파워셸을 다시 실행했으면 pipenv를 설치합니다.

PS > pipx install pipenv

프로젝트 폴더 및 파이썬 개발 환경 구성#

우선 윈도우 서비스 프로그램을 만들기 위한 프로젝트 폴더를 만듭니다. 이 글에서는 trac_service라는 이름을 사용합니다.

PS > New-Item -ItemType Directory -Path "$env:HOMEPATH\trac_service"

그리고 trac_service 폴더로 이동합니다. 그리고 pipenv를 사용해 파이썬 가상 환경을 생성하면서 윈도우 서비스 개발에 필요한 라이브러리를 설치합니다.

PS > cd "$env:HOMEPATH\trac_service"
PS > pipenv install pywin32
PS > pipenv install pyinstaller

이로서 파이썬 가상 환경 생성까지 마쳤습니다.

커스텀 윈도우 서비스 클래스 만들기#

윈도우 서비스의 기본 클래스를 생성했으면 다음으로 실제 우리가 등록할 서비스 클래스를 만듭니다. 우리가 만들 서비스 클래스는 앞에서 만든 SMWinservice 클래스를 상속받아 만듭니다.

여기에서 만드는 커스텀 윈도우 서비스 클래스는 Trac(https://trac.edgewall.org) 데몬을 실행하는 tracd 명령을 실행합니다.

그렇기 때문에 여기에서 재정의하는 하는 것은 클래스 변수 _svc_name_, _svc_display_name_, _svc_description_와 start, main, stop 메서드입니다. 예시로 명명한 파일 이름은 tracservice.py 입니다.

tracservice.py#
from smservice import SMWinservice

class TracService(SMWinservice):
    _svc_name_ = "TracWinService"
    _svc_display_name_ = "Trac on Windows"
    _svc_description_ = "Trac Windows Service"

    def start(self):
        pass

    def stop(self):
        pass

    def main(self):
        pass

_svc_name_, _svc_display_name_, _svc_description_ 는 적절한 내용으로 입력하시면 되는데 여기서는 Trac을 띄울 것이므로 Trac에 관한 내용으로 채웠습니다.

여기에서 재정의 하는 main 메서드가 호출되면 프로그램이 무한 루프로 실행되어야 합니다. 예를 들면 다음과 같은 무한 루프 로직이 동작해야 합니다.

while True:
    # 여기에서 무한 루프 내에서 실행할 문장 기술
    pass

main 메서드 내부에 무한 루프로 동작하는 코드를 직접 추가해도 되지만 “윈도우 서비스”는 어디까지나 서비스 시작시 무한 루프로 실행되도록 프로그램은 별도의 실행 파일로 분리하는 것이 좋습니다.

별도의 실행 파일로 로직을 분리하지 않으면 나중에 프로그램이 업데이트 되었을 때 여러 어려움이 발생합니다.

  1. 윈도우 서비스를 다시 인스톨하거나 서비스 업데이트 필요

  2. 프로그램의 실제 로직 추가로 프로그램 용량이 매우 커지고 서비스 프로그램의 유지보수가 어려워짐

그리고 tracd 명령은 한 번 실행되면 메모리에서 무한 루프로 실행되기 때문에 여기에서는 main 메서드 내에 따로 while 문을 사용하지 않았습니다.

실제 코드를 구현하기에 앞서 Trac은 다음과 같이 설치되어 있다고 가정합니다. 이 글에서는 Trac의 설치 및 환경 구성은 살펴보지 않습니다.

  • Trac 패키지가 설치된 파이썬 가상 환경 경로 : %HOMEPATH%trac-venv

  • Trac 환경 : %HOMEPATH%tracreposproject

이렇게 설치되어 있는 trac 환경은 다음 명령으로 실행합니다.

PS > $env:HOMEPATH\trac-venv\tracd --port 8000 $env:HOMEPATH\trac\repos\project

이렇게 하면 8000번 포트로 trac 서버가 실행됩니다.

start 메서드부터 살펴보겠습니다.

def start(self):
    self.exec_process = None
    daemon_path = Path(sys.argv[0])

    self._env_path_ = daemon_path.parent / "config.json"

start 메서드에서는 main 메서드를 호출하기 전에 tracd 프로그램을 실행하기 위한 사전 작업을 준비합니다. tracd 명령은 파이썬 subprocess 모듈의 Popen 클래스를 사용해 실행합니다. Popen 클래스는 초기화되면 운영체제를 대신해 실행중인 프로세스를 제어할 수 있도록 Popen 객체를 반환합니다.

이에 따라 Popen 클래스를 초기화 하기 전에 Popen 객체를 담아놓을 필드 변수 exec_process를 start 메서드 안에서 선언합니다. 이렇게 만든 exec_process 필드 변수는 윈도우 서비스가 중지 요청되면 stop 메서드 안에서 사용됩니다.

start 메서드에서 하나 더 유심히 볼 부분은 _env_path_를 선언한 부분입니다.

사실 서비스 클래스를 윈도우 서비스로 등록하고 나면 서비스 클래스는 main 메서드 안에서 tracd 명령이 어떤 경로에 있는지, trac 환경이 어떤 경로에 있는지 알 수 없습니다.

그래서 tracd 명령이 있는 경로와 trac 환경을 파이썬 환경에 하드 코딩하는 방법이 사용되기도 합니다. 하지만 이 경우 나중에 시간이 지나고 나면 trac 패키지를 설치한 가상 환경이 어디에 있는지 Trac 환경이 어디에 있는지 알기 어렵습니다.

그렇기 때문에 여기에서는 파이썬 가상 환경이 설치된 경로와 tracd 명령을 config.json으로 담아놓고 start 메서드 실행시 config.json이 있는 경로를 절대 경로로 고정해뒀습니다.

config.json 파일의 경로는 커스텀 윈도우 서비스 파일(ex. tracservice.exe)이 있는 경로에 있다고 가정합니다.

설정 파일 만들기#

config.json#
{
    "exec_start": "tracd --port 8000 C:\\Users\\gildong\\trac\\repos\\project",
    "python_home": "C:\\Users\\gildong\\trac-venv"
}

config.json 파일에서는 exec_start 키와 python_home 키를 가지고 있으며 python_home은 파이썬 가상 환경의 경로, exec_start는 tracd 실행 명령을 적었습니다. 참고로 경로는 역슬래시()를 2개씩 사용해야 합니다.

이제 main 메서드를 살펴봅니다. main 메서드는 config.json 파일을 읽어들여 exec_start 키의 내용으로 프로그램을 실행합니다.

def main(self):
    if not self._env_path_.exists():
        raise ValueError(f'{self._env_path_} File Not Found')

    config_obj = json.load(open(self._env_path_))

    python_home = config_obj.get("python_home")
    exec_start = config_obj.get("exec_start")
    exec_command = f"{python_home}\\Scripts\\{exec_start}"

    exec_env = os.environ.copy()
    exec_env.update(config_obj.get("env", {}))

    self.exec_process = subprocess.Popen(
        shlex.split(exec_command, posix=False),
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        env=exec_env)
    self.exec_process.wait()

main 메서드가 처음 실행되면 우선 config.json이 있는지 검사합니다. 만약 config.json을 찾을 수 없다면 ValueError 예외를 발생시키고 윈도우 서비스를 시작하지 않습니다. 커스텀 서비스 클래스가 실행할 정보를 확인할 수 없으니 당연한 수순입니다.

파일을 찾았으면 config_obj에 config.json을 읽어 파이썬 딕셔너리로 보관해둡니다.

그런 다음 config_obj에 저장해둔 python_home과 exec_start 키를 읽어들인 다음 최종적으로 실행할 tracd 실행 명령을 만들어둡니다. 여기에서는 tracd가 [python_home]Scripts에 있다고 생각하기 때문에 경로는 [python_home]Scripts[exec_start]가 됩니다.

혹시라도 tracd가 참고하는 윈도우 환경 변수가 필요할 수 있기 때문에 운영체제 환경 변수를 복사하고 config.json에 env 키(JSON Object)의 내용을 병합해서 exec_env에 담아둡니다.

실행할 경로도 다 만들어졌겠다 이제 Popen 클래스를 사용해 exec_command를 실행합니다. subprocess는 첫 번째 인자의 값은 특별한 일이 없으면 실행 명령에 있는 공백을 구분 문자로 해서 MutableSequence로 제공해야 합니다.

위에 있는 config.json을 예시로 든다면 Popen에 전달해야 하는 값은 ["C:\Users\gildong\trac-venv\Scripts\tracd", "--port", "8000", "C:\Users\gildong\trac\repos\project"] 형태로 전달해야 합니다. 그런데 이렇게 명령을 분할해주는건 아무래도 불편하기 때문에 shlex 모듈의 split 함수를 사용해서 하나의 긴 문자열을 MutableSequence로 변경해줄 수 있습니다.

단, 이 글에서는 윈도우을 실행 환경으로 두고 있기 때문에 shlex.split 함수를 실행할 때 posix 인자에 False를 따로 제공했습니다. 윈도우에서 Popen를 실행한다면 꼭 잊지 않으셔야 합니다. 나머지 인자에 대한 설명은 파이썬 공식 문서를 참조하세요.

main 메서드의 마지막입니다. 서두에서 언급한 것처럼 main 메서드는 무한 루프 상태로 시작해야 하는데 subprocess로 tracd를 실행해서 self.exec_process에 담아놓는다고 끝난게 아닙니다. Popen 클래스로 실행한 명령이 어떤식으로든 끝날 때까지 파이썬 프로그램이 기다리게 해야 합니다.

바로 이 때, Popen 객체의 wait 메서드를 호출합니다. wait 메서드는 Popen 클래스로 실행한 명령이 끝날 때까지 파이썬 프로그램을 끝내지 말고 대기하라는 메서드입니다.

결과적으로 main 메서드에서 우리가 무한 루프를 돌리지 않아도 wait 메서드가 대신 tracd가 내부적으로 실행하는 무한 루프가 끝날때까지 기다립니다.

마지막으로 stop 메서드를 구현합니다. stop 메서드는 SvcStop 메서드가 처음 호출하도록 되어 있는거 기억하시죠?

def stop(self):
    self.exec_process.terminate()

stop 메서드에서는 단순히 self.exec_process에 담아놓은 Popen 객체의 terminate 메서드를 호출하는 것 뿐입니다. terminate 메서드는 Popen이 잡고 있는 프로세스에게 종료 명령을 내리는 것입니다.

마지막으로 tracservice.py 파일의 끝에 다음 내용으로 커스텀 윈도우 서비스 클래스를 윈도우 서비스 관리자가 제어할 수 있도록 해줘야 합니다.

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
                    prog=__file__,
                    description='Windows Service Register')
    parser.add_argument('mode', nargs='?')
    args = parser.parse_args()

    if args.mode:
        TracService.parse_command_line()
    else:
        servicemanager.Initialize()
        servicemanager.PrepareToHostSingle(TracService)
        servicemanager.StartServiceCtrlDispatcher()

argparse 명령의 첫 번째 실행 인자가 전달되면 앞에서 만든 parse_command_line 메서드를 호출하게 하고 윈도우 서비스 관리자가 직접 실행하게 하는거면 servicemanager 모듈의 몇 개 함수를 순차적으로 호출시켜줍니다.

완성된 tracservice.py 파일 구현이 끝나면 아래와 같아야 합니다(물론 완성된 코드엔 import 해야 하는 코드 및 로깅을 위한 코드나 주석이 일부 포함되어 있습니다).

tracservice.py#
import argparse
import subprocess
import json
import os
import shlex
import sys
from pathlib import Path

from smservice import SMWinservice

import win32serviceutil
import servicemanager


class TracService(SMWinservice):
    _svc_name_ = "TracWinService"
    _svc_display_name_ = "Trac on Windows"
    _svc_description_ = "Trac Windows Service"

    def logging(self, type_, message):
        event_type = getattr(servicemanager, type_)

        servicemanager.LogMsg(event_type, 0x1000, (message, ''))

    def logging_info(self, message):
        self.logging('EVENTLOG_INFORMATION_TYPE', message)

    def logging_error(self, message):
        self.logging('EVENTLOG_ERROR_TYPE', message)

    def logging_audit(self, message):
        self.logging('EVENTLOG_AUDIT_SUCCESS', message)

    def start(self):
        self.exec_process = None
        daemon_path = Path(sys.argv[0])

        self._env_path_ = daemon_path.parent / "config.json"

    def stop(self):
        self.exec_process.terminate()

        self.logging_info(f"{self._svc_name_} Service Stopped")

    def main(self):
        if not self._env_path_.exists():
            self.logging_error(f"{self._svc_name_} Service Execution Failed")
            raise ValueError(f'{self._env_path_} File Not Found')

        config_obj = json.load(open(self._env_path_))

        python_home = config_obj.get("python_home")
        exec_start = config_obj.get("exec_start")
        exec_command = f"{python_home}\\Scripts\\{exec_start}"

        # self.logging_audit(exec_command)

        exec_env = os.environ.copy()
        exec_env.update(config_obj.get("env", {}))

        self.exec_process = subprocess.Popen(
            shlex.split(exec_command, posix=False),
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            env=exec_env)
        self.logging_audit(f"StdOut: {self.exec_process.stdout.read()}")
        self.logging_audit(f"StdErr: {self.exec_process.stderr.read()}")
        self.exec_process.wait()


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
                    prog=__file__,
                    description='Windows Service Register')
    parser.add_argument('mode', nargs='?')
    args = parser.parse_args()

    if args.mode:
        TracService.parse_command_line()
    else:
        servicemanager.Initialize()
        servicemanager.PrepareToHostSingle(TracService)
        servicemanager.StartServiceCtrlDispatcher()

커스텀 서비스 클래스 구현까지 모두 끝났습니다. 이제 윈도우 서비스 관리자에 커스텀 서비스 클래스를 등록시킬 시간입니다.

윈도우 서비스 관리자에 커스텀 서비스 클래스를 등록하려면 반드시 exe 파일로 변환해야 합니다.

커스텀 서비스 클래스 파일을 exe로 만들기#

커스텀 서비스 클래스 파일을 exe로 만들기 위한 사전 작업은 앞에서 다했기 때문에 여기에서 아래 명령을 내려 exe 파일을 만듭니다.

PS > pipenv run pyinstaller -F onefile --hidden-import win32timzone --uac-admin tracservice.py

이렇게 만들어진 파일은 명령을 실행한 디렉터리 아래의 dist 폴더에 만들어집니다. 앞에서 살펴본 config.json은 여기 dist 폴더에 넣어놓으면 됩니다.

단, dist 폴더의 내용은 시스템 관리자가 관리하는 폴더임을 나타낼 수 있는 곳에 옮겨놓는게 좋습니다.

윈도우 서비스 관리자 사용하기#

윈도우 서비스 관리자에 등록하는 명령은 아래와 같습니다.

PS > tracservice.exe install

새로 업데이트된 윈도우 커스텀 서비스 클래스 파일(exe 파일)로 기존 윈도우 서비스 관리자의 내용을 변경하려면 다음 명령을 입력합니다.

PS > tracservice.exe update

더 이상 윈도우 커스텀 서비스를 사용하지 않는다면 다음 명령으로 윈도우 서비스 관리자에서 삭제합니다.

PS > tracservice.exe remove

윈도우 커스텀 서비스 클래스를 시작하려면 다음 명령을 입력합니다.

PS > tracservice.exe start

윈도우 커스텀 서비스 클래스를 중지하려면 다음 명령을 입력합니다.

PS > tracservice.exe stop

이 글에서 윈도우 커스텀 서비스를 파이썬으로 만드는 방법에 대해 살펴봤습니다. 당연히 어려우셨으리가 생각하지만 이런 것 하나하나가 여러분의 실력을 향상시키는데 도움이 되길 희망합니다.

from. 파이썬 수다장이 날다의 아저씨

Comments

comments powered by Disqus