Go言語でWindows,Linuxの常駐システムを開発する

https://cdn-ak.f.st-hatena.com/images/fotolife/o/optim-tech/20220426/20220426145622.jpg

こんにちは。AI・IoTサービス開発部の青木です。

弊社サービスの OPTiM IoT の開発チームに所属しており、 連携デバイス/ソリューション の拡充を進めています。

基本的にはLinuxが搭載されたデバイスをメインに開発を行っていますが、組み込みや他のOSの対応などはIoTをやっていく上で必須になってくるので そこで利用していた技術の一つであるWindows,Linuxの常駐システム化について執筆しようかと思います。

目次

システム常駐化の方法

システムをバックグラウンドで常駐化させる方法は様々です。 Windowsであれば サービス、Ubuntuだと systemdinit.d などがあります。 それぞれのOSのシステム稼働手法に則って、サービスを常駐化する必要があります。

また、IoTデバイスともなるとスペックも限られてくるので、なるべくOSに標準で搭載されているシステム常駐化の方法にしたいところです。

基となるアプリケーションを開発する

Go言語 を利用し、簡単なHTTPサーバを作ってみます。

package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":3000", nil)
}

実際に実行し、 Hello, World が表示されることを確認します。

$ go run main.go &
$ curl "http://localhost:3000"
Hello, World⏎

常駐システム化するライブラリを利用し、実装を行う

github.com

こちらのOSSを利用させていただきます。 Mac, Windows, Linuxそれぞれでどのような挙動になるかも併せて確認してみます。

main.go の変更

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"

    service "github.com/kardianos/service"
)

type exarvice struct {
    exit chan struct{}
}

var loggerOs service.Logger

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World")
}

func (e *exarvice) run() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":3000", nil)
}

func (e *exarvice) Start(s service.Service) error {
    if service.Interactive() {
        loggerOs.Info("Running in terminal.")
    } else {
        loggerOs.Info("Running under service manager.")
    }
    e.exit = make(chan struct{})

    go e.run()
    return nil
}

func (e *exarvice) Stop(s service.Service) error {
    close(e.exit)
    return nil
}

func main() {

    svcConfig := &service.Config{
        Name:        "optim_sample_agent",
        DisplayName: "OPTiM Sample Agent",
        Description: "",
    }

    // Create Exarvice service
    program := &exarvice{}
    s, err := service.New(program, svcConfig)
    if err != nil {
        log.Fatal(err)
    }

    // Setup the logger
    errs := make(chan error, 5)
    loggerOs, err = s.Logger(errs)
    if err != nil {
        log.Fatal()
    }

    if len(os.Args) > 1 {

        err = service.Control(s, os.Args[1])
        if err != nil {
            fmt.Printf("Failed (%s) : %s\n", os.Args[1], err)
            return
        }
        fmt.Printf("Succeeded (%s)\n", os.Args[1])
        return
    }

    // run in terminal
    s.Run()
}

func main() に記述した内容を func (e *exarvice) run() に移動し、常駐システム化のためのロジックを main() に書きます。

リポジトリのサンプルコードがあるので、そこを参考にすると良いでしょう。

試しに実行

普通に実行すると、常駐システム化されずにそのまま実行出来ます。

$ go run main.go
I: 13:45:40 Running in terminal.

install 引数を指定することで常駐システム化が出来ます。 検証環境ではMacを利用していますので、Mac用の常駐システム化ロジックが実行されています。

$ go run main.go install
Failed (install) : Failed to install OPTiM Sample Agent: open /Library/LaunchDaemons/optim_sample_agent.plist: permission denied

$ sudo go run main.go install
Password:
Succeeded (install)

$ sudo go run main.go start
Succeeded (start)

Mac では Launchd が利用されているみたいですね。

ただ、Goのrunコマンドではうまく動作しないようです。 ビルドしてから実行してみます。その前に、クリーンアップするために stopuninstall を実行します。

$ sudo go run main.go stop
Succeeded (stop)
$ sudo go run main.go uninstall
Succeeded (uninstall)

$ ls /Library/LaunchDaemons/ | grep optim
# 何も無いことを確認する

ビルドして、動作確認を行います。

$ go build main.go
$ ./main &
$ curl "http://localhost:3000"
Hello, World

OS毎の挙動を見てみる

OSによって常駐システム化実装方法を変えてくれているようなので、その挙動を確認してみます。

Macの場合

$ sudo ./main install
Succeeded (install)

$ sudo ./main start
Succeeded (start)

$ curl "http://localhost:3000"
Hello, World

しっかり常駐化されています。

Launchdに登録されているようなので、実際に登録されているか確認してみます。

$ ls /Library/LaunchDaemons/ | grep optim
optim_sample_agent.plist

Windowsの場合

クロスコンパイルしてWindowsで確認してみます。

$ env GOOS=windows GOARCH=amd64 go build -o main.exe main.go

Windows IoTが手元にあったので、バイナリを移動しアプリケーションが実行できるか確認します。

PS C:\Users\optim\Documents> ls


    ディレクトリ: C:\Users\optim\Documents


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2022/04/25     14:03        6574080 main.exe


PS C:\Users\optim\Documents> ./main.exe
I: 14:04:43 Running in terminal.
PS C:\Users\optim> curl "http://localhost:3000"


StatusCode        : 200
StatusDescription : OK
Content           : Hello, World
RawContent        : HTTP/1.1 200 OK
                    Content-Length: 12
                    Content-Type: text/plain; charset=utf-8
                    Date: Mon, 25 Apr 2022 05:05:13 GMT

                    Hello, World
Forms             : {}
Headers           : {[Content-Length, 12], [Content-Type, text/plain; charset=utf-8], [Date, Mon, 25 Apr 2022 05:05:13
                    GMT]}
Images            : {}
InputFields       : {}
Links             : {}
ParsedHtml        : System.__ComObject
RawContentLength  : 12

うまく動作してそうですね。 では、常駐システム化して確認します。

PS C:\Users\optim\Documents> ./main.exe install
Succeeded (install)
PS C:\Users\optim\Documents> ./main.exe start
Succeeded (start)
PS C:\Users\optim> curl "http://localhost:3000"

StatusCode        : 200
StatusDescription : OK
Content           : Hello, World
(...省略)

WindowsではWindowsサービスとして、LocalSystemに登録されるようです。

https://cdn-ak.f.st-hatena.com/images/fotolife/o/optim-tech/20220425/20220425150522.png

Linux(Ubuntu)の場合

Windows同様にクロスコンパイルします。

$ env GOOS=linux GOARCH=amd64 go build -o main.exe main.go

AWS EC2インスタンスを用意し、そこで実行してみます。

$ ./main install
Failed (install) : Failed to install OPTiM Sample Agent: open /etc/systemd/system/optim_sample_agent.service: permission denied

どうやらSystemdに登録されるようですね。 管理者権限で実行して挙動を確認してみます。

$ sudo ./main install
Succeeded (install)

$ sudo ./main start
Succeeded (start)

$ curl "http://localhost:3000"
Hello, World
$ systemctl status optim_sample_agent
● optim_sample_agent.service
     Loaded: loaded (/etc/systemd/system/optim_sample_agent.service; enabled; vendor preset: enabled)
     Active: active (running) since Mon 2022-04-25 05:14:50 UTC; 59s ago
   Main PID: 1266453 (main)
      Tasks: 7 (limit: 4637)
     Memory: 1.1M
     CGroup: /system.slice/optim_sample_agent.service
             └─1266453 /home/ubuntu/sample/main

Apr 25 05:14:50 ip-172-31-27-39 systemd[1]: Started optim_sample_agent.service.
Apr 25 05:14:50 ip-172-31-27-39 optim_sample_agent[1266453]: Running under service manager.

Alpine Linux(Docker)の場合

少し気になったので、Alpine Linuxかつ配布されているDockerImageで実行してみようかと思います。

$ docker run --rm --name test -v (pwd):/app -d alpine tail -f
$ docker exec -it test sh
/ # cd app/
/app # ls
Dockerfile  README.md   go.mod      go.sum      main        main.exe    main.go

Ubuntuと同じように実行してみます。

/app # ./main install
Succeeded (install)
/app # ./main start
Failed (start) : Failed to start OPTiM Sample Agent: "service" failed: exec: "service": executable file not found in $PATH

/app # curl
sh: curl: not found
/app # apk add curl
fetch https://dl-cdn.alpinelinux.org/alpine/v3.15/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.15/community/x86_64/APKINDEX.tar.gz
(1/5) Installing ca-certificates (20211220-r0)
(2/5) Installing brotli-libs (1.0.9-r5)
(3/5) Installing nghttp2-libs (1.46.0-r0)
(4/5) Installing libcurl (7.80.0-r0)
(5/5) Installing curl (7.80.0-r0)
Executing busybox-1.34.1-r4.trigger
Executing ca-certificates-20211220-r0.trigger
OK: 8 MiB in 19 packages
/app # curl "http://localhost:3000"
curl: (7) Failed to connect to localhost port 3000 after 0 ms: Connection refused

もちろんですが systemd は無いので、他に探してみます。

# cd /etc/init.d/
/etc/init.d # ls
optim_sample_agent
/etc/init.d # cat optim_sample_agent 
#!/bin/sh
# For RedHat and cousins:
# chkconfig: - 99 01
# description: 
# processname: /app/main

### BEGIN INIT INFO
# Provides:          /app/main
# Required-Start:
# Required-Stop:
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: OPTiM Sample Agent
# Description:       
### END INIT INFO

cmd="/app/main"

name=$(basename $(readlink -f $0))
pid_file="/var/run/$name.pid"
stdout_log="/var/log/$name.log"
stderr_log="/var/log/$name.err"

[ -e /etc/sysconfig/$name ] && . /etc/sysconfig/$name

get_pid() {
    cat "$pid_file"
}

is_running() {
    [ -f "$pid_file" ] && ps $(get_pid) > /dev/null 2>&1
}

case "$1" in
    start)
        if is_running; then
            echo "Already started"
        else
            echo "Starting $name"
            
            $cmd >> "$stdout_log" 2>> "$stderr_log" &
            echo $! > "$pid_file"
            if ! is_running; then
                echo "Unable to start, see $stdout_log and $stderr_log"
                exit 1
            fi
        fi
    ;;
    stop)
        if is_running; then
            echo -n "Stopping $name.."
            kill $(get_pid)
            for i in $(seq 1 10)
            do
                if ! is_running; then
                    break
                fi
                echo -n "."
                sleep 1
            done
            echo
            if is_running; then
                echo "Not stopped; may still be shutting down or shutdown may have failed"
                exit 1
            else
                echo "Stopped"
                if [ -f "$pid_file" ]; then
                    rm "$pid_file"
                fi
            fi
        else
            echo "Not running"
        fi
    ;;
    restart)
        $0 stop
        if is_running; then
            echo "Unable to stop, will not attempt to start"
            exit 1
        fi
        $0 start
    ;;
    status)
        if is_running; then
            echo "Running"
        else
            echo "Stopped"
            exit 1
        fi
    ;;
    *)
    echo "Usage: $0 {start|stop|restart|status}"
    exit 1
    ;;
esac
exit 0

init.d に登録されていましたね。Startコマンドを init.d から直接叩いてみましょう。

/etc/init.d # ./optim_sample_agent 
Usage: ./optim_sample_agent {start|stop|restart|status}
/etc/init.d # ./optim_sample_agent start
Starting optim_sample_agent

curl "http://localhost:3000"
curl: (7) Failed to connect to localhost port 3000 after 0 ms: Connection refused

うまく動作していませんね....

/app # ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 tail -f
    7 root      0:00 sh
   48 root      0:00 [main]
   84 root      0:00 ps aux

直接実行した場合は正常に動作しているようなのでおそらく別の原因があるかと思いますが、今回は原因追求はやめておきます。

/app # ./main & 
/app # I: 05:25:54 Running in terminal.

/app # curl "http://localhost:3000"
Hello, World

DockerImageで常駐プロセス化するケースは無い(というよりも、アンチパターンな気がします)と思いますので、問題は無いと思います。

Docker Imageで該当のアプリケーションを実行したい場合は、Dockerfileに Entrypointとして登録するかCMDとし、 Docker Containerが実行されたときに自動実行されるような仕組みがベストプラクティスだと思います。

Appendix. OS毎の個別設定方法について

https://github.com/kardianos/service/blob/master/service.go#L165-L198

//  * OS X
//    - LaunchdConfig string ()                 - Use custom launchd config.
//    - KeepAlive     bool   (true)             - Prevent the system from stopping the service automatically.
//    - RunAtLoad     bool   (false)            - Run the service after its job > has been loaded.
//    - SessionCreate bool   (false)            - Create a full user session.
//
//  * Solaris
//    - Prefix        string ("application")    - Service FMRI prefix.
//
//  * POSIX
//    - UserService   bool   (false)            - Install as a current user service.
//    - SystemdScript string ()                 - Use custom systemd script.
//    - UpstartScript string ()                 - Use custom upstart script.
//    - SysvScript    string ()                 - Use custom sysv script.
//    - OpenRCScript  string ()                 - Use custom OpenRC script.
//    - RunWait       func() (wait for SIGNAL)  - Do not install signal but wait for this function to return.
//    - ReloadSignal  string () [USR1, ...]     - Signal to send on reload.
//    - PIDFile       string () [/run/prog.pid] - Location of the PID file.
//    - LogOutput     bool   (false)            - Redirect StdErr & StandardOutPath to files.
//    - Restart       string (always)           - How shall service be restarted.
//    - SuccessExitStatus string ()             - The list of exit status that shall be considered as successful,
//                                                in addition to the default ones.
//  * Linux (systemd)
//    - LimitNOFILE   int    (-1)               - Maximum open files (ulimit -n)
//                                                (https://serverfault.com/questions/628610/increasing-nproc-for-processes-launched-by-systemd-on-centos-7)
//  * Windows
//    - DelayedAutoStart  bool (false)                - After booting, start this service after some delay.
//    - Password  string ()                           - Password to use when interfacing with the system service manager.
//    - Interactive       bool (false)                - The service can interact with the desktop. (more information https://docs.microsoft.com/en-us/windows/win32/services/interactive-services)
//    - DelayedAutoStart        bool (false)          - after booting start this service after some delay.
//    - StartType               string ("automatic")  - Start service type. (automatic | manual | disabled)
//    - OnFailure               string ("restart" )   - Action to perform on service failure. (restart | reboot | noaction)
//    - OnFailureDelayDuration  string ( "1s" )       - Delay before restarting the service, time.Duration string.
//    - OnFailureResetPeriod    int ( 10 )            - Reset period for errors, seconds.

プロセス再起動時の設定や、プロセスファイルの格納場所などOSによって異なる設定ができるものに関してはこちらで変更できるようです。

まとめ

以下のシステム構成では正常にシステム常駐化が出来ました。

  • macOS Monterey v12.3.1
    • Launchd によるシステム常駐化
  • Windows10 IoT
    • Windowsサービスによるシステム常駐化
  • Ubuntu 20.04LTS
    • systemd によるシステム常駐化

Windowsはシステムの都合上ファイルなどを操作する場合など考慮すべき点があるかと思いますが、 OSが異なっていても同じソースで管理できるというのは汎用デバイスエージェントを開発する身としてとてもありがたい存在です。

バイナリで提供されるため、各種インストーラを用意してインストーラの中で installコマンドなどを実行すれば簡単に常駐させることが出来ます。

便利な半面、すべてのOSに対応させる汎用的なコーディングとOS個別の実装が含まれた際のソースコード管理などの課題も出てくるかと思います。実際にOPTiM IoTで一部利用した際も個別実装が必要な箇所があり、バグ発生をなるべく小さくおさえて実装をしています。

謝辞

github.com

こちらのリポジトリをメインに紹介させていただきました。ありがとうございます。


オプティムでは、一緒に働く仲間を募集しています。興味のある方は、こちらをご覧ください。

www.optim.co.jp