AWS EC2で常駐するJavaアプリをデーモンとして実行する

AWS EC2（特にEC2に限定した話ではないですが）上で、常駐するJava（およびScalaなどのJVM上で動作する言語）アプリケーションをデーモンとして実行するのに苦労したので残しておきます。

「いまさらEC2??」ってのは置いといて...

## 要件

* EC2上でJavaアプリケーションを常駐させたい。
* EC2インスタンスの起動時に、アプリケーションを自動的に開始させたい。
* EC2インスタンスの終了／削除時に、アプリケーションを安全に（実行中の処理が終了するのを待ってから）停止させたい。
* JavaアプリケーションのJVMプロセスが落ちたら、自動的に再起動させたい。

## Javaアプリケーションの実装

* シャットダウンフック（[java.lang.Runtime#addShutdownHook()](https://docs.oracle.com/javase/9/docs/api/java/lang/Runtime.html#addShutdownHook-java.lang.Thread-)）を使って、プロセスが終了されたときに、実行中の処理がないかを確認して処理が完了するのを待つようにします。
  * Javaなら[Spring Boot](https://projects.spring.io/spring-boot/) を使えば簡単（と思う）が、Spring Boot を使わない場合などは自前で実装する必要あり。

## サービスのrcスクリプト

Javaアプリケーションを、Linuxサービスとして起動するためのrcスクリプトを作成します。

rcスクリプトにはいろいろな流儀がありますが、最低限このような感じで良いと思います。
（[Init.d and Start Scripts for Scala/Java Server Apps](https://orrsella.com/2014/11/06/initd-and-start-scripts-for-scala-java-server-apps/) で紹介されている内容をベースに少しカスタマイズしました）

* プロセスIDをファイルに書き出して、多重起動を防止します。
* EC2インスタンスの起動／停止時にサービスも自動的に起動／停止させるために、ロックファイルの作成が必要です。（参考:[CentOS で OS のシャットダウンや再起動時に何らかの処理をさせたい](http://inokara.hateblo.jp/entry/2017/12/16/070027)）
* EC2のデフォルトタイムゾーンは通常 `UTC` ですが、予期せずに変わることがあるので明示的に `-Duser.timezone=UTC` を指定しておいたほうが安全です。
* `stop` でサービスを停止するときは、JVMプロセスを強制的にkillしないようにします。（強制的にkillすると、シャットダウンフックが実行されません）

```shell:/etc/init.d/example
#!/bin/bash

### BEGIN INIT INFO
# Provides:          example
# chkconfig:         2345 99 99
# Required-Start:    $local_fs $network $named networking
# Required-Stop:     $local_fs $network $named networking
# Short-Description: example
# Description:       example
### END INIT INFO

DAEMON_NAME=example
DAEMON_DIR=/home/ec2-user/example
MODULE_NAME=example-server

START_SCRIPT="java -Duser.timezone=UTC -jar $DAEMON_DIR/$MODULE_NAME.jar"
# プロセスIDファイルは /var/run 下に作成する
PID_FILE=/var/run/$DAEMON_NAME.pid
# ロックファイルは /var/lock/subsys 下に作成する
# ロックファイルを作成しておかないと、マシンをシャットダウンするときにサービスの停止が行われない
LOCK_FILE=/var/lock/subsys/$DAEMON_NAME

# ***********************************************
# ***********************************************

ARGS="" # optional start script arguments
DAEMON=$START_SCRIPT

# colors
red='\e[0;31m'
green='\e[0;32m'
yellow='\e[0;33m'
reset='\e[0m'

echoRed() { echo -e "${red}$1${reset}"; }
echoGreen() { echo -e "${green}$1${reset}"; }
echoYellow() { echo -e "${yellow}$1${reset}"; }

start() {
  # $! で起動したプロセスのプロセスIDを退避しておく（あとでプロセスIDファイルに書き出す）
  PID=`$DAEMON $ARGS > /dev/null 2>&1 & echo $!`
}

case "$1" in
start)
    if [ -f $PID_FILE ]; then
        PID=`cat $PID_FILE`
        echo $PID
        if [ -z "`ps axf | grep -w ${PID} | grep -v grep`" ]; then
            start
        else
            echoYellow "Already running [$PID]"
            exit 0
        fi
    else
        start
    fi

    if [ -z $PID ]; then
        echoRed "Failed starting"
        exit 3
    else
        # サービスが正常に起動できたら、プロセスIDファイル、ロックファイルを作成する
        echo $PID > $PID_FILE
        touch $LOCK_FILE
        echoGreen "Started [$PID]"
        exit 0
    fi
;;

status)
    if [ -f $PID_FILE ]; then
        PID=`cat $PID_FILE`
        if [ -z "`ps axf | grep -w ${PID} | grep -v grep`" ]; then
            echoRed "Not running (process dead but pidfile exists)"
            exit 1
        else
            echoGreen "Running [$PID]"
            exit 0
        fi
    else
        echoRed "Not running"
        exit 3
    fi
;;

stop)
    if [ -f $PID_FILE ]; then
        PID=`cat $PID_FILE`
        if [ -z "`ps axf | grep -w ${PID} | grep -v grep`" ]; then
            echoRed "Not running (process dead but pidfile exists)"
            exit 1
        else
            PID=`cat $PID_FILE`
            # プロセスを停止するときは -9 で強制終了してはいけない
            # 強制終了すると、シャットダウンフックが実行されず、実行中の処理が途中で終わってしまう
            kill -HUP $PID
            echoGreen "Stopped [$PID]"
            # サービスが正常に停止できたら、プロセスIDファイルとロックファイルを削除する
            rm -f $PID_FILE
            rm -f $LOCK_FILE
            exit 0
        fi
    else
        echoRed "Not running (pid not found)"
        exit 3
    fi
;;

restart)
    $0 stop
    $0 start
;;

*)
    echo "Usage: $0 {status|start|stop|restart}"
    exit 1
esac
```

## サービスとして登録

実装したアプリケーションモジュール、rcスクリプトをEC2インスタンスにデプロイします。

ここでは [Cloud Formation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-init.html) でEC2を構築するテンプレートを例示します。

* EC2インスタンス起動時に、自動的にS3からモジュールを取得してデプロイ、アプリケーションを実行します。
* rcスクリプトを `chkconfig` に登録することで、EC2インスタンスの起動／停止時にアプリケーションも自動的に起動／停止されます。（参考:[EC2で起動時やterminate時にシェルを実行する](https://dev.classmethod.jp/cloud/ec2shell/)
）

```yaml
EC2AutoScalingConfig:
  Type: AWS::AutoScaling::LaunchConfiguration
  Metadata:
    # CloudFormation:Init で、S3からリソースを取得できるようにするための設定
    AWS::CloudFormation::Authentication:
      S3AccessCreds:
        type: S3
        roleName:
          Ref: EC2Role
        buckets:
          - モジュールなどを保存しているS3バケット名

    # EC2インスタンスの初期化処理。S3からモジュールを取得してデプロイします。
    AWS::CloudFormation::Init:
      configSets:
        Setup:
          - PreDeploy
          - DeployExampleServer
          - PostDeploy
          - StartApplication
      # 前処理。必要なパッケージのインストールなど。AMIにしておいたほうが望ましいです。
      PreDeploy:
        packages:
          yum:
            java-1.8.0-openjdk-devel: []
            awslogs: []
        files:
          /home/ec2-user/awslogs-agent-setup.py:
            source: https://s3.amazonaws.com/aws-cloudwatch/downloads/latest/awslogs-agent-setup.py
            mode: "000755"
          /home/ec2-user/awslogs.conf:
            content: |+
              [general]
              state_file = /var/lib/awslogs/agent-state

              [/var/log/messages]
              log_group_name = /example/ec2
              log_stream_name = {instance_id}-sysmsg
              file = /var/log/messages
              datetime_format = %b %d %H:%M:%S
              buffer_duration = 5000
              initial_position = start_of_file
        commands:
          SetDefaultJVM:
            command: alternatives --set java /usr/lib/jvm/jre-1.8.0-openjdk.x86_64/bin/java
      # アプリケーションのデプロイ
      DeployExampleServer:
        files:
          # S3からモジュールを取得
          /home/ec2-user/example/example-server.jar:
            source:
              Fn::Sub: https://s3-${AWS::Region}.amazonaws.com/モジュールなどを保存しているS3バケット名/example-server-1.0.0.jar
            owner: ec2-user
            group: ec2-user
          # S3からrcスクリプトを取得
          /home/ec2-user/example/example:
            source:
              Fn::Sub: https://s3-${AWS::Region}.amazonaws.com/モジュールなどを保存しているS3バケット名/example
            owner: ec2-user
            group: ec2-user
            mode: "000755"
          # アプリケーションが出力したログを、awslogs でCloudWatchに転送
          /home/ec2-user/example/conf/awslogs.conf:
            content: |+

              [example]
              log_group_name = /example/example-server
              log_stream_name = {instance_id}-{{name}}
              file = /root/logs/example.log
              encoding = utf_8
              datetime_format = %Y/%m/%d %H:%M:%S.%f
              time_zone = UTC
              buffer_duration = 5000
              initial_position = start_of_file
              multi_line_start_pattern =  {datetime_format}
            owner: ec2-user
            group: ec2-user
          # アプリケーションのJVMプロセスが落ちたときに、自動的に再起動させるcronスケジュール
          /home/ec2-user/example/conf/cron.conf:
            content: |+
              */5 * * * * /sbin/service example start > /dev/null 2>&1
            owner: ec2-user
            group: ec2-user
        # commandsに複数のコマンドを記述する場合は、アルファベット順に実行されることに注意
        commands:
          # rcスクリプトを配置（/etc/init.d 下にシンボリックリンク）
          A_RegisterService:
            command: ln -s /home/ec2-user/example /etc/init.d/example
          # chkconfigで追加して、EC2インスタンスの起動／停止時にアプリケーションが自動的に起動／停止するように設定
          B_AddChkconfig:
            command: chkconfig --add example && chkconfig example on
          C_AppendAwsLogsConf:
            command: cat /home/ec2-user/example/conf/awslogs.conf >> /home/ec2-user/awslogs.conf
          D_AppendCrontab:
            command: cat /home/ec2-user/example/conf/cron.conf >> /home/ec2-user/cron.conf

      PostDeploy:
        commands:
          A_PipUpgrade:
            command:
              pip install --upgrade pip
          B_ConfigureAwsLogs:
            command:
              Fn::Sub: python /home/ec2-user/awslogs-agent-setup.py -n -r ${AWS::Region} -c /home/ec2-user/awslogs.conf
          C_ConfigureCron:
            command: crontab /home/ec2-user/cron.conf
        services:
          sysvinit:
            awslogs:
              enabled: true
              ensureRunning: true
            crond:
              enabled: true
              ensureRunning: true

      StartApplication:
        commands:
          A_ManagedContentsSynchronizer:
            command: service example start
  Properties:
    :
    UserData:
      Fn::Base64:
        Fn::Sub: |+
          #!/bin/bash
          # 起動したEC2インスタンスにわかりやすい名前を付ける
          INSTANCE_ID=`curl -s http://169.254.169.254/latest/meta-data/instance-id`
          START_TIME=`date '+%Y%m%d%H%M'`
          aws ec2 create-tags --region ${AWS::Region} --resources ${!INSTANCE_ID} --tags Key=\"Name\",Value=example-${!START_TIME}

          # ---- Run cfn-init
          yum -y update
          yum update -y aws-cfn-bootstrap
          /opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource EC2AutoScalingConfig --configsets Setup
```

以上です。