こんにちは。AI・IoTサービス開発部の青木です。
弊社サービスの OPTiM IoT の開発チームに所属しており、 連携デバイス/ソリューション の拡充を進めています。
基本的にはLinuxが搭載されたデバイスをメインに開発を行っていますが、組み込みや他のOSの対応などはIoTをやっていく上で必須になってくるので そこで利用していた技術の一つであるWindows,Linuxの常駐システム化について執筆しようかと思います。
目次
- 目次
- システム常駐化の方法
- 基となるアプリケーションを開発する
- 常駐システム化するライブラリを利用し、実装を行う
- OS毎の挙動を見てみる
- Appendix. OS毎の個別設定方法について
- まとめ
- 謝辞
システム常駐化の方法
システムをバックグラウンドで常駐化させる方法は様々です。
Windowsであれば サービス
、Ubuntuだと systemd
や init.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⏎
常駐システム化するライブラリを利用し、実装を行う
こちらの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
コマンドではうまく動作しないようです。
ビルドしてから実行してみます。その前に、クリーンアップするために stop
と uninstall
を実行します。
$ 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
に登録されるようです。
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で一部利用した際も個別実装が必要な箇所があり、バグ発生をなるべく小さくおさえて実装をしています。
謝辞
こちらのリポジトリをメインに紹介させていただきました。ありがとうございます。
オプティムでは、一緒に働く仲間を募集しています。興味のある方は、こちらをご覧ください。