au Commerce&Life Tech Blog

au コマース&ライフ株式会社の開発ブログ

コネクションプーリングが効かない問題をコードリーディングから解決したお話

f:id:tomezo0914:20180611141642j:plain

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

初めまして。KDDIコマースフォワードでエンジニアをしております T.T. です。

みなさん、プロジェクトに参加したらまず何をされますか?既に動くシステムがあれば一通りさわってみて、フォームにスクリプトを混入してみたり、通信中にWi-Fiを切ってみたりしますよね?

今回、とあるシステムで MySQL の実行中プロセスを全部殺したらエラーを吐き続けて2度と復旧しなくなるという現象に遭遇しました。この現象、勘の良い方ならすぐにDBコネクション周りの問題だろうと気づくでしょう。答えを先に言うとコネクション・プーリングの取り扱い不備による不具合でした。

知っている人にとってはなんて事ないミスで解決も一瞬かと思いますが、原因の調査と解決までの過程を残しておくことで、これからプログラミングを学ぶ人へのコードリーディングのサンプルになるかなと思い今回話させていただきます。

ちなみに筆者のスキルですが、Javaを書いていたのは10年以上前(かろうじてJava5を触ったくらい)。Spring 等の Java 界隈の FW や ORM は使ったことがないお粗末なスキルです。以上予防線でした。

閑話休題

Connection Pooling

コネクション・プーリング(以降 CP)とは、データベースとの接続を予め一定数確立しておいてそれを使いまわす手法のことです。一度確立したコネクションを2回目以降も再利用することで、接続要求に発生するコストを削減することが期待されます。

メジャーなWebフレームワークには大抵組み込みでCPの機能が提供されているか、デフォルトで当該機能を提供するライブラリがバンドルされていると思いますので、この機能を使ってWebアプリケーションを実装した経験をお持ちの方は多いと思います。

その一方で、大変便利な機能ではありますが提供しているサービスの規模や内容によっては都度接続でも問題にならないと判断するケースもあり、CPを使わなかったり意識したことがない方もいらっしゃるかもしれません。かくいう私も今まで比較的大規模なサービスでもCPを使わず都度接続で処理を行っていたため、あまりこの機能に触れる機会がありませんでした。

環境情報

  • Spring Boot: 2.0.1.RELEASE
  • Kotlin
  • DB: MySQL
  • JDBC connection pool: HikariCP 2.7.8
  • JDBCドライバ: mysql-connecter-java 6.0.6
  • ORM: JOOQ 3.10.4

詳しくはこちらの記事にありますので、興味がある方はぜひご一読ください。

kcf-developers.hatenablog.jp

現象の確認

Spring Boot を起動しシステムの正常動作を確認。その後 MySQL のプロセスを全部 kill して動作確認をすると以下のエラーが出続けていました。

WARN    com.zaxxer.hikari.pool.ProxyConnection  HikariPool-2 - Connection com.mysql.cj.jdbc.ConnectionImpl@2faa43cc marked as broken because of SQLSTATE(08S01), ErrorCode(0)
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
Caused by: com.mysql.cj.core.exceptions.CJCommunicationsException: Communications link failure

本システムではCPを使用していることを事前に聞いていたので、プロセスが全て死んだとしてもCPの機能で再接続してコネクションをプールし直し、その中からコネクションを使用することですぐに復旧すると思っていました。しかしエラーの文言からはDBとの疎通で失敗している様子です。コネクションの再作成が動いてないのでしょうか?

おかしいなと思い、このエラーが出ているときの MySQL プロセスを確認すると予想に反してしっかりと新しいプロセスができていました。

mysql> show full processlist;
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| Id  | User  | Host            | db      | Command | Time | State    | Info                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| 206 | fizz  | localhost       | NULL    | Query   |    0 | starting | show full processlist |
| 217 | fizz  | localhost:48442 | buzz    | Sleep   |    3 |          | NULL                  |
| 218 | fizz  | localhost:48444 | buzz    | Sleep   |    3 |          | NULL                  |
| 219 | fizz  | localhost:48446 | buzz    | Sleep   |    3 |          | NULL                  |
| 220 | fizz  | localhost:48448 | buzz    | Sleep   |    3 |          | NULL                  |
| 221 | fizz  | localhost:48450 | buzz    | Sleep   |    2 |          | NULL                  |
| 222 | fizz  | localhost:48452 | buzz    | Sleep   |    2 |          | NULL                  |
| 223 | fizz  | localhost:48454 | buzz    | Sleep   |    2 |          | NULL                  |
| 224 | fizz  | localhost:48456 | buzz    | Sleep   |    2 |          | NULL                  |
| 225 | fizz  | localhost:48458 | buzz    | Sleep   |    2 |          | NULL                  |
| 226 | fizz  | localhost:48460 | buzz    | Sleep   |    2 |          | NULL                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+

プロセスが10個います。本システムで使用している Connection Pool 実装は HikariCP。デフォルトの最大プール数は10なのでこれが効いていると思われます。

調査開始

現象が確認できたので調査を開始。まずは本システムで DataSource の設定をしているコードを見てみました。

@Configuration
class DataBaseConfigure {
    @Configuration
    @ConfigurationProperties(prefix = PROPERTY_PREFIX)
    class DataSourceConfigure {
        var url = ""
        var username = ""
        var password = ""
        var driverClassName = ""

        @Bean(DATA_SOURCE_NAME)
        fun dataSource(): DataSource {
            return DataSourceBuilder.create()
                .driverClassName(driverClassName)
                .url(url)
                .username(username)
                .password(password)
                .build()
        }
    }
}

CPのサイズ諸々は特に指定していないみたいです。諸々調整したいので HikariCP の Config から HikariDataSource を作成して色々設定できるようにします。

@Configuration
class DataBaseConfigure {
    @Configuration
    @ConfigurationProperties(prefix = PROPERTY_PREFIX)
    class DataSourceConfigure {
        var url = ""
        var username = ""
        var password = ""
        var driverClassName = ""
        var connectionTimeout: Long = TimeUnit.SECONDS.toMillis(30)
        var minimumIdle = 5
        var maximumPoolSize = 5
        var idleTimeout: Long = TimeUnit.MINUTES.toMillis(10)
        var maxLifetime: Long = TimeUnit.MINUTES.toMillis(30)
        var connectionInitSql = "SELECT 1"

        @Bean(DATA_SOURCE_NAME)
        fun dataSource(): DataSource {
            val config = HikariConfig()
            config.driverClassName = driverClassName
            config.jdbcUrl = url
            config.username = username
            config.password = password
            config.minimumIdle = minimumIdle
            config.maximumPoolSize = maximumPoolSize
            config.idleTimeout = idleTimeout
            config.maxLifetime = maxLifetime
            config.connectionInitSql = connectionInitSql
            config.connectionTimeout = connectionTimeout
            return HikariDataSource(config)
        }
    }
}

Spring を再起動して MySQL プロセスを確認。プロセスが5個になっているので期待通り設定を読んでるみたいです。

mysql> show full processlist;
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| Id  | User  | Host            | db      | Command | Time | State    | Info                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| 206 | fizz  | localhost       | NULL    | Query   |    0 | starting | show full processlist |
| 248 | fizz  | localhost:48534 | buzz    | Sleep   |    1 |          | NULL                  |
| 249 | fizz  | localhost:48536 | buzz    | Sleep   |    1 |          | NULL                  |
| 250 | fizz  | localhost:48538 | buzz    | Sleep   |    1 |          | NULL                  |
| 251 | fizz  | localhost:48540 | buzz    | Sleep   |    1 |          | NULL                  |
| 252 | fizz  | localhost:48542 | buzz    | Sleep   |    1 |          | NULL                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+

画面にアクセス。正常に表示されていることを確認して再度プロセスを全部殺します。

mysql> kill 248;

プロセスが全部死んだのを確認。

mysql> show full processlist;
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| Id  | User  | Host            | db      | Command | Time | State    | Info                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| 206 | fizz  | localhost       | NULL    | Query   |    0 | starting | show full processlist |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+

もう一度画面にアクセスしてみると、やはりエラーでています。

WARN    com.zaxxer.hikari.pool.ProxyConnection  HikariPool-2 - Connection com.mysql.cj.jdbc.ConnectionImpl@2faa43cc marked as broken because of SQLSTATE(08S01), ErrorCode(0)
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
Caused by: com.mysql.cj.core.exceptions.CJCommunicationsException: Communications link failure

でも mysql のプロセスを見たらプロセスは5個あります。

mysql> show full processlist;
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| Id  | User  | Host            | db      | Command | Time | State    | Info                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+
| 206 | fizz  | localhost       | NULL    | Query   |    0 | starting | show full processlist |
| 253 | fizz  | localhost:48554 | buzz    | Sleep   |  467 |          | NULL                  |
| 254 | fizz  | localhost:48556 | buzz    | Sleep   |  467 |          | NULL                  |
| 255 | fizz  | localhost:48558 | buzz    | Sleep   |  467 |          | NULL                  |
| 256 | fizz  | localhost:48560 | buzz    | Sleep   |  467 |          | NULL                  |
| 257 | fizz  | localhost:48564 | buzz    | Sleep   |  467 |          | NULL                  |
+-----+-------+-----------------+---------+---------+------+----------+-----------------------+

ここまでで、CPは期待通りに設定値を見てコネクションを作成&プールし、コネクションが切断されても再作成&プールしていることがわかりました。そこからCPを利用する側がクローズしたコネクションを参照し続けているのだろうと推測できます。というわけで、以降はコネクションを利用する側のコードを読んでいきます。

コード・リーディング

コネクションを利用しているのは ORM の JOOQ です。本システムで JOOQ がコネクションを取得していそうな箇所を探します。JOOQでは org.jooq.DSLContext というのがクエリ実行の基点となるようです。

DSLContext (jOOQ 3.10.0 API)

Apart from the DSL, this contextual DSL is the main entry point for client code, to access jOOQ classes and functionality that are related to Query execution.

該当してそうなコードがあったのでそこを基点に JOOQ のコードを読んでいきます。Javadocのサンプルにあるコードそのままですね。

@Bean(DSL_NAME)
fun dslContext(@Qualifier(DATA_SOURCE_NAME) dataSource: DataSource): DSLContext {
    return DSL.using(dataSource.connection, SQLDialect.MYSQL)
}

ただ闇雲に読んでもしょうがないのでキーワードを探します。ログを見るとコネクションをもらっているときに acquire という単語が使われているので、この単語をキーに JOOQ のコードを読んでいきましょう。

DEBUG   org.springframework.jdbc.datasource.DataSourceTransactionManager        Acquired Connection [HikariProxyConnection@1843070619 wrapping com.mysql.cj.jdbc.ConnectionImpl@1990e95b] for JDBC transaction

コンストラクタを辿っていきます。

org/jooq/impl/DSL

public static DSLContext using(Connection connection, SQLDialect dialect) {
    return new DefaultDSLContext(connection, dialect, null);
}

org/jooq/impl/DefaultDSLContext

public DefaultDSLContext(Connection connection, SQLDialect dialect, Settings settings) {
    this(new DefaultConfiguration(new DefaultConnectionProvider(connection), null, null, null, null, null, null, null, null, null,  null,  dialect, settings, null));
}

org/jooq/impl/DefaultConnectionProvider

public DefaultConnectionProvider(Connection connection) {
    this(connection, false);
}

DefaultConnectionProvider(Connection connection, boolean finalize) {
    this.connection = connection;
    this.finalize = finalize;
}

@Override
public final Connection acquire() {
    return connection;
}

acquire 発見。コンストラクタでセットされたコネクションをただ返すだけのメソッドです。ということは、このコンストラクタを使うと最初にセットしたコネクションをずっと参照してしまうんじゃないでしょうか?

どうもCPで管理しているコネクションを使っていない気がします。DataSourceConfigure クラスにて DataSource として HikariDataSource を返すようにしているので、こちら経由でコネクションを返すような実装になっている箇所を探します。

DSL.java を新ためて読むとそれらしいメソッドがあるのでさらに読んでいきます。

org/jooq/impl/DSL

public static DSLContext using(DataSource datasource, SQLDialect dialect) {
    return new DefaultDSLContext(datasource, dialect);
}

org/jooq/impl/DefaultDSLContext

public DefaultDSLContext(DataSource datasource, SQLDialect dialect) {
    this(datasource, dialect, null);
}

public DefaultDSLContext(DataSource datasource, SQLDialect dialect, Settings settings) {
    this(new DefaultConfiguration(new DataSourceConnectionProvider(datasource), null, null, null, null, null, null, null, null, null,  null,  dialect, settings, null));
}

org/jooq/impl/DataSourceConnectionProvider

public DataSourceConnectionProvider(DataSource dataSource) {
    this.dataSource = dataSource;
}

@Override
public Connection acquire() {
    try {
        return dataSource.getConnection();
    }
    catch (SQLException e) {
        throw new DataAccessException("Error getting connection from data source " + dataSource, e);
    }
}

こちらの acquire ですとデータソース経由でコネクションを取得していそうなので CPで管理するコネクションが返ってきそうです。ここまでくると原因と解決方法に関してははおおよそ検討がつきますので一安心できました。

心に余裕ができると折角なので HikariCP のコードも読んどくかとなりますよね。プールが枯渇していれば作成して、あればその中から返してるっぽいですね。

com/zaxxer/hikari/HikariDataSource

@Override
public Connection getConnection() throws SQLException
{
   if (isClosed()) {
      throw new SQLException("HikariDataSource " + this + " has been closed.");
   }

   if (fastPathPool != null) {
      return fastPathPool.getConnection();
   }

   // See http://en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java
   HikariPool result = pool;
   if (result == null) {
      synchronized (this) {
         result = pool;
         if (result == null) {
            validate();
            LOGGER.info("{} - Starting...", getPoolName());
            try {
               pool = result = new HikariPool(this);
               this.seal();
            }
            catch (PoolInitializationException pie) {
               if (pie.getCause() instanceof SQLException) {
                  throw (SQLException) pie.getCause();
               }
               else {
                  throw pie;
               }
            }
            LOGGER.info("{} - Start completed.", getPoolName());
         }
      }
   }

   return result.getConnection();
}

com/zaxxer/hikari/pool/HikariPool

public Connection getConnection(final long hardTimeout) throws SQLException
{
   suspendResumeLock.acquire();
   final long startTime = currentTime();

   try {
      long timeout = hardTimeout;
      do {
         PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
         if (poolEntry == null) {
            break; // We timed out... break and throw exception
         }

         final long now = currentTime();
         if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > ALIVE_BYPASS_WINDOW_MS && !isConnectionAlive(poolEntry.connection))) {
            closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE);
            timeout = hardTimeout - elapsedMillis(startTime);
         }
         else {
            metricsTracker.recordBorrowStats(poolEntry, startTime);
            return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry), now);
         }
      } while (timeout > 0L);

      metricsTracker.recordBorrowTimeoutStats(startTime);
      throw createTimeoutException(startTime);
   }
   catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new SQLException(poolName + " - Interrupted during connection acquisition", e);
   }
   finally {
      suspendResumeLock.release();
   }
}

解決

長々とコードを見てきましたが、原因は端的に言ってコネクションの取得先が違っていた、というものでした。というわけで直します。JOOQの DSLContext を返している箇所に戻って1行書き換えます。たったこれだけ!

@Bean(DSL_NAME)
fun dslContext(@Qualifier(DATA_SOURCE_NAME) dataSource: DataSource): DSLContext {
-    return DSL.using(dataSource.connection, SQLDialect.MYSQL)
+    return DSL.using(dataSource, SQLDialect.MYSQL)
}

再度テスト。Spring 起動後、MySQLのプロセスを全部killしてから画面にアクセスすると... connection のチェックをして...

WARN    com.zaxxer.hikari.pool.PoolBase HikariPool-2 - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@2919cabd (No operations allowed after connection closed.)

その後にコネクション作り直している。

DEBUG   org.springframework.jdbc.datasource.DataSourceTransactionManager        Acquired Connection [HikariProxyConnection@1381647274 wrapping com.mysql.cj.jdbc.Con

そして問題なく画面表示されました!

最後に

以上でCPのコネクションが使われない不具合の原因と解決は終了です。

冒頭にも書きましたが Java や JOOQ を知っている人にとっては、何てことのないミスで解決も一瞬なんだろうと想像します。しかし知識がない人にとっては手探りで調査し解決を探るしかありません。そんな時に一番身近で手っ取り早く確実な情報源はコードです。初学者にとってはライブラリのコードを読むことに抵抗があるかもしれません。ですがそこを乗り越えてコードを読むことを習慣付けられれば、問題に遭遇した時でも最後はコードを読めば何とかなるだろう、と楽観的に構えられる余裕ができると思います。

偉そうなことを書いていますが、これは自分への戒めでもあります。困ったらググる、も正しいですが、コードを読む習慣も忘れずに日々精進していきたいものです。

KPT以外を使って振り返りを行った話

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

はじめまして! 🐷

2018年5月からKCFのクーポンチームに
スクラムマスターとしてJoinしている兼平です。

入社して1月経ちましたが、 周りのメンバーがとてもフランクで優しい為、
入社6日目で勉強会用のハイボールを濃いめに作って配る位には
職場に慣れる事が出来ています。

さて、KCFラボ内に広がりつつあるのは
おやつ神社だけではなく、スクラムの導入です。

kcf-developers.hatenablog.jp

スクラムのメリット

スクラムという開発手法に関しては、ご存知の方も多いかと思います。

スプリントと呼ばれる一定期間のサイクルごとに、

  • 計画(プランニング)
  • レビュー
  • 振り返り(レトロスペクティブ)

というイベントを繰り返し実施することで、
早い段階で問題を検知し、軌道修正ができる
というメリットがあります。

私のいるクーポンチームは、率先してスクラムを導入しており、
ブログを書いている時点では、 1週間で回して29スプリント目となっています。

振り返りの重要性

イベントの中でも 振り返り は、とても重要なものです。
次にもっとうまくやる為に、
良い点は伸ばし、悪い点は直す必要があるからです。
その為には、スプリント内に何が起き、
チームメンバーがどう思ったのかをうまく可視化する必要があります。

前回の振り返りは、私が初めて準備をして振り返りのファシリテートをしたので、 その内容を共有したいと思います。

フローとしては、 振り返りで有名な森さんの下記スライド

書籍アジャイルレトロスペクティブズを参考にしたり、

YASUI Tsutomu (@yattom) on Twitter さんに相談したりして
下記の計画を立てました。(個人メモなので走り書き失礼します)

f:id:yumihira:20180604171506j:plain

実施計画

課題の解決と成功の継続 を今回の振り返りの実施目的として、 下記のアプローチを選択しました。

  1. 場の設定:Good&New
  2. データの収集:Timeline
  3. アイディアを出す:学習マトリクス
  4. アクション決め:ドット投票
  5. 振り返りを終わる:振り返りの振り返り

それぞれ、

  1. 1人30秒程度で、24時間以内にあった新しい発見、良かった事を声に出して言う。
  2. 感情ごとに色分けされたシートに対して、スプリント内に起きた出来事を記入して、タイムラインを作る。
    • 個人の感情の変化がわかるようにイニシャルなどを書いてもらう。
  3. 4つの観点(良かったこと・変えたいこと・新しいアイディア・感謝)に対してポストイットに書き出してもらう。
  4. 1人3ポイントを実施したいポストイットに投票して上位を決める。
  5. 今回の振り返りに意見を出してもらう。

という内容です。

実施結果

Timeline f:id:yumihira:20180604172851j:plain

学習マトリクス f:id:yumihira:20180604173022j:plain

  • メンバーの意外な感性を知れる(振り返り以降のコミュニケーションのきっかけになった)
  • ストレスなどもあったが最終的にはピンクやオレンジのポジティブな感情で終われたことがわかった。
  • 感謝の付箋が沢山でて、お互いに感謝する場を作るのは良かった。
  • タイムキープに気を使った結果、予想より大きくはずれなかった。
  • 振り返りの振り返りは、時間の関係で未実施だったが、あとで個別に楽しかったという意見がもらえた!

まとめ

KPT=振り返りだと思っている方も多いかもしれませんが、
振り返りの目的ごとに、様々なアプローチがあるので、
ぜひ上記スライドや書籍を参考にしていただければと思います。

目的とタイムボックスを明確にしつつ、
チーム内で様々なアプローチを使って楽しくカイゼンのサイクルを楽しく回して行きましょう!!!

【システムリプレイス】なぜリファクタリングではなかったか

f:id:kazumaryu:20180604115645j:plain:w100

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

こんにちは。エンジニアの倉井です。普段わりとアフロです。

土田が私物化しそうな勢いの当ブログですが、他にもエンジニアいるんだよということで僕も記事を書いておきます。

概要

弊社KCFが運営するサービスの「Wowma!」は2017年に生まれたばかりですが、システム自体は古くから存在したものがベースとなっています。

なんと20年近く前のコードがまだ動いているという噂も。

私の所属するチームは商品を表示する部分の改善をミッションとし結成されましたが、このエントリーではリファクタリング or システムリプレイスの2択からシステムリプレイスを選択した経緯なり理由なりを書いてみます。

リファクタリングについて

実はほぼほぼ議論の余地なくリプレイスの選択に至ったのですが、リファクタリングにも一定のメリットがありますので挙げてみます。

リファクタリングのメリット
  • インフラやフレームワークなど既存のリソースを使える
  • 仕様をすべて把握しなくてもすぐに小さく着手できる

リファクタリング可能であればそれで進めればよいのですが、以下のようなことが明らかになってくるはずです。

リファクタリング辛いあるある
  • アーキテクチャが古すぎる
  • ソースコードが複雑すぎる
  • 開発環境構築やビルドの自動化、CI/CDなど開発フローに手を入れにくい
  • テストコードが十分でなくプログラム変更時のリスクが大きい
  • そもそも開発を外注しており権限的に手をつけられない

弊社がどれに該当したか、あるいは全部だったのかは想像にお任せします。

システムリプレイスについて

一般的に言ってシステムリプレイスの主なメリットはこちらになるでしょう。

システムリプレイスのメリット
  • 新しいアーキテクチャを採用できる
  • 初めから自動化、CI/CDを意識してモダンな(表現古い?)開発フローを構築できる

ちなみに一括リプレイスはさすがに恐ろしいので、私のチームではページ単位、API単位のリプレイスを方針としています。

新しい機能については当然新システム側に載せていくことになります。

メリットの一方、当然デメリットもありますね。

システムリプレイスのデメリット
  • 現行の仕様のすべてを把握しなくてはならない
  • リプレイスが終わるまで現行と新システム、二重運用になる

いずれもクリアできない課題ではないですが、マインド的にもコスト的にも覚悟が必要です。

システムリプレイスにおける注意事項

実際に手を動かしていくなかで気がついた点があるので挙げてみます。

仕様は綺麗にならない

現行のソース調査していくと、起こりうるのか定かではない特定条件の対応、IDベタ書きによる条件分岐等、引き継ぎたくない仕様が目に入ります。

そこは見て見ぬふりです。じゃなくて、きちんと然るべき人に確認が必要になりますが、少なくない確率で引き継ぐことになります。

結局リファクタリングも必要

リプレイスしてデプロイした瞬間から、そこで動くコードも古いコードの仲間入りです。

特に1から作り上げた場合などはソースコードがまだ十分な汎用性を備えておらず、次の改修でまた根っこの方に手を入れるというのはザラにあることです。

テストをしっかり書いて自動化しましょう。

システムリプレイスを採用したキモ

弊社KCFは2016年12月に設立したばかりで、これからエンジニアリングによって事業をドリブンさせていこうとしている会社です。

であれば当然エンジニアのテンションが上がる技術を採用しなくてはなりません。なんだかんだでこれが最終的な決め手だったと個人的には考えています。

私のチームで採用しているアーキテクチャは以下のエントリーで紹介しています。 kcf-developers.hatenablog.jp

またMSA(マイクロサービスアーキテクチャ)的なところを全体の方針にしてますので、チームによってはRoRgolangなどを採用しています。

もし興味を持たれたエンジニアの方がいればぜひ話を聞きにきてください〜。

https://hrmos.co/pages/kddi-cf/jobs/15100001hrmos.co

次回

システムリプレイスのプロジェクトを進めるときに開発手法としてスクラムを採用したのですが、その相性とか、困ったことよかったことなど書いてみようかなと思います。

【おやつ神社パターン】お菓子がもたらす、その効果(体重を除く

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

前回「私のチームではー」みたいなことを書いておきながら、既にチームが変わっている土田です。

前のチームではお菓子ボックスが導入されていて、小腹が空いたときに、もといコミュニケーションのツールに良かったので、 移ったチームにも導入しました。

そこで、ふと他のチームの様子も気になり、ぷらぷら写真を取って回ったのが、今回の記事内容です。

各チームのお菓子状況

まずは私が見たところ最初に『ボックス』でお菓子が置かれていたチームのところへ。

f:id:seri_wb:20180521012513j:plain:w600

スクラムイベント用グッズと一緒に置いてありました。今は飴の種類が多いみたいですね。飴はイベントしながらでも食べやすいので、備えてあると一息入れるきっかけに良さそうです。

そんな理由もあってか、テーブルに飴を置いているところもありました。

f:id:seri_wb:20180521012524j:plain:w600

スッキリさせつつもテーブルに集まる心理的負担を減らしているところにチームのセンスを感じます。

次のチームもお菓子導入が早かったところで、今は在庫少なめですが、目線の集まるディスプレイ前に置いてあるあたり、お菓子を使いこなしている感があります。

f:id:seri_wb:20180521012546j:plain:w600

このチームはサービスのドックフーディングにも積極的で、冬にWowma!でみかんを買ったりなどして、周りにイベント感を提供してくれるので、雰囲気作りに欠かせないチームになっています。

各チームのお土産などは、普段開発のサポートをしてくれている人達のところに集まっています。

f:id:seri_wb:20180521012607j:plain:w600

このWowma!の大きい袋の中にも入っているので、なかなかの充実ぶりになっています。

さて、満を持して前のチームのお菓子ボックスを見てみましょう。
ちょうどお菓子ボックスの周りでミーティング中だったので、ちょっとだけお邪魔して撮ってきました。

f:id:seri_wb:20180521012622j:plain:w600

急いで撮ったので、全体が写らなかったのですが、甘いものからしょっぱいものまで、一通り押さえられていて良い感じです。

あれ、でもバナナなんてどこから・・・

f:id:seri_wb:20180521012631j:plain:w600

あ、私達のところからですね!

はい、これが私が負けじと導入したお菓子ボックスになります。
ちょうどお土産やいただきものなどが被って、ものすごく豪華になっています。

しかもこの赤ペンの下を見てみると・・・

f:id:seri_wb:20180521012644j:plain:w400

まだ在庫がこんなに!!

ちょっと張り切りすぎてしまったかもしれないですね・・・

ちなみにバナナはなんだかんだ言われながらも、その日のうちに無くなっていました。

この後、Slackでフロア全体に「コインを握りしめてお菓子食べに来てください」という旨の周知をし、貯金箱も配備しました。

f:id:seri_wb:20180521155407j:plain:w200

今のところ、割と好評に感じています。

効果の話

さて、ただお菓子を食べたいだけであれば、個人で買って食べればいいだけであり、 こうしてお菓子ボックスを置いているのは、先に述べたように、コミュニケーションツールとしての側面が大きいです。

好きなお菓子の話題から、その人の人となりをつかめたり、 普段席にいないマネージャーもお菓子につられてやってきたりするので、 導入前には発生しづらかったコミュニケーションが生まれました。

おやつ神社パターン

弊社は今、スクラムコーチとしてやっとむさんに来て頂いているのですが、

同僚と一緒に、

  • コミュニケーションを増やすにはどうしたらいいか
  • お菓子をもっと置いておけばいいのでは?

と話していたとき、通りがかったやっとむさんから「それを真剣に考えた人が、おやつ神社パターンとして公開している」というお話を聞きました。

調べてみると確かにあり、

説明のスライドを見ながら引き続き話をしていたところ、

「これ良くない?」「賽銭箱結構高い」「とりあえず明日菓子買ってくるわ」

というやり取りがあり、結果があの在庫あふれるお菓子ボックスになりました。

私達のパターンに

各チームのお菓子ボックスは、おやつ神社パターンほどの効果を狙ったものではないにしろ、 言われているような効果は一定出ているように感じます。 とりあえずやってみるは大事ですね。

募金も今はそこそこ集まっているので、別で導入したコーヒーマシンほどの赤字を抱え込みはしないのではないかなぁどうかなぁと思いながら運用しています。 今のところこのお菓子ボックスは良い感じがしているので、引き続き楽しく仕事ができる仕組みを模索していきたいと思います。

さて、そろそろまた買い出しに行かないといけないのですが、次はどんなお菓子がいいですかね?

Spring Bootアプリケーションのデプロイ方法(Fabric利用)

エンジニアの土田です。
先日のJapan Container Days v18.04でシールを貰ったのを契機にSpringアピールを始めたので、 今回もSpringに関連した話を書いていこうと思います。

f:id:seri_wb:20180508141306j:plain

前回のビルドから続いて、今回はデプロイの話になります。

Spring Bootアプリケーションのデプロイ

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

私のチームで開発しているSpring Bootのアプリケーションは、デプロイのタスクとして、以下の作業が必要になっています。

  1. TypeScriptのJSトランスパイル
  2. 暗号化された設定ファイルの復号
  3. リリースする環境に合わせて、kotlinアプリケーションをビルド
  4. jarとconfを各サーバに配置してアプリケーションの再起動(デプロイ)

これらの作業をCDツールから実施できるように、デプロイツールのFabricを利用しています。

前回の記事でAnsibleでのサービス登録という項がありましたが、 このデプロイに関する一連の作業はAnsibleで実施しないようにしています(理由は後述)。

Fabricの利用

FabricのタスクはPythonスクリプトで書くのですが、公式のドキュメントが日本語化されていたり、 実際にはデプロイ手順で実施するシェルコマンドがそのまま書けたりするので、 Pythonの経験がなくても、利用に踏み切りやすいのが特徴です。

概要とチュートリアル — Fabric ドキュメント

ちなみに、私は以下の理由でFabricをデプロイツールとして採用しました。

  • Pythonシェルスクリプトよりも高度な処理が書きやすく、理解しやすい
  • Fabricにはデプロイに便利な関数が用意されている
  • デプロイ対象のサーバ側にエージェントが必要ない
  • PHPのプロジェクトで使って割とよかった(重要)
使い方

JenkinsからFabricのタスクを呼び出して実施しています。 これを行うため、事前にJenkinsサーバにFabricをインストールし、fabコマンドがJenkinsの実行ユーザから使えるようにしてあります。

開発中のアプリケーションは、デプロイする環境が開発、ステージング、本番と別れており、 デプロイするブランチも異なるので、デプロイタスクは以下のような指定で実行できるようにしました。

fab <サーバ識別子>.<タスク名>:<環境識別子>,<ブランチ名>,<サーバ名>
サーバ識別子 説明
sample_app サンプル用アプリケーション
タスク名 動作内容
deploy アプリケーションのデプロイを行う
rollback ロールバックを実施する(この記事では触れない)
環境識別子 対象環境
local ローカル(動作確認用)
dev 開発
stg ステージング
prd 本番
stress 負荷試験
  • ブランチ名はデフォルトがdevelop
  • 個別でデプロイサーバを設定する場合は、サーバ名をセミコロン区切りで指定する
サンプル

ステージング環境にwebサーバが2台ある場合の実行例を記載すると、以下のようになります。

# ステージング環境にデプロイ
fab sample_app.deploy:stg,staging
または
fab sample_app.deploy:stg,staging,"web01;web02"

# WEBサーバのWEB01のみにdevelopブランチをデプロイ
fab sample_app.deploy:stg,develop,web01

Fabricによるデプロイタスクの作り方

では実際にこのデプロイタスクを作っていきます。

Fabricのファイル構成

Fabricはfabfile.pyの内容を読み取ってタスクを実行するのですが、 fabfile.pyの代わりにfabfileディレクトリを配置することも可能です。

その場合はfabfileディレクトリの中に、必要な処理を書いたPythonファイルを配置することになるのですが、 それらのファイルを読み込ませるため、同じくfabfileディレクトリの中に__init__.pyファイルを作成し、当該ファイルのimport処理を記述します。

例えばsample-appをデプロイするsample_app.pyを作成した場合の__init__.pyは、以下のようになります。

import sample_app

こうしておくと、sample-app以外のアプリケーションをデプロイするスクリプトを書きたい場合に、 既存のデプロイスクリプトを修正することなく、専用のファイルとして作成することができる上、 import分を追加するだけでFabricから認識されるようになる利点があります。

Fabricによるデプロイタスクの解説

デプロイタスクでは、

  1. 必要な変数を設定し、
  2. 作業ディレクトリを準備し、
  3. リリース資材を作成して、
  4. 各ホストに配置後、
  5. アプリケーションを再起動し、
  6. 作業ディレクトリを削除し、復号した設定ファイルをCIサーバ上に残さないようにしています。

これをsample_app.pyのdeployタスクとして記述すると、このようになります。

@task
def deploy(stage, branch='develop', hosts=[]):

    # ①変数の設定
    set_env(stage, branch)
    if hosts != []:
        # セミコロン区切りのhostsを分解して、値が存在するものだけをリスト化
        env.hosts = [host.strip() for host in hosts.split(';') if not host.strip() == '']

    # ②作業ディレクトリを作成
    import os
    if not os.path.exists(env.local_tmpdir):
        os.makedirs(env.local_tmpdir)

    # ③リリース資材の準備(ビルドタスク)
    execute(pack)

    # ④各ホストに資材を配置
    execute(common.update)

    # ⑤アプリケーションを再起動
    execute(chown)
    execute(restart)

    # ⑥作業ディレクトリの削除
    local('rm -rf %s' % env.local_tmpdir)

executeは他のタスクを呼び出す関数で、 各ホストで指定のタスクを実行します。 ビルドタスクのように、1度だけ実行したタスクは、@serialと@runs_onceのデコレータをタスクに付与して定義してください。

Spring Bootアプリケーションのデプロイ手順の1〜3が、ここでいうビルドタスクに相当します。

sample_app.py

Spring Bootアプリケーションのデプロイ用スクリプトを定義したファイルの全体像です。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from fabric.api import *
from fabric.decorators import *
import common

def set_env(stage, branch='develop'):
    env.stage = stage
    env.repo_url = 'Gitリポジトリの取得先'
    env.local_tmpdir = '.tmp/sample_app'
    env.destfile = "sample-app-%s.tgz" % env.current_release

    if stage == 'local':
        # ローカル環境
        env.user = 'vagrant'
        env.password = 'vagrant'
        env.branch = branch
        env.deploy_to = '/var/lib/app/sample_app'
        env.app_user = 'vagrant'
        env.path = './'
        env.hosts = ['localhost']
        env.yarn_script = 'devlocal'

    elif stage == 'dev':
        # 開発環境
        # 省略

    elif stage == 'stg':
        # ステージング環境
        env.user = 'deploy'
        env.branch = branch
        env.deploy_to = '/var/lib/app/sample_app'
        env.app_user = 'webapp'
        env.path = './'
        env.hosts = [
            'web01',
            'web02',
        ]
        env.key_filename = [
            '~/.ssh/stg_id_rsa'
        ]
        env.yarn_script = 'staging'

    elif stage == 'prd':
        # 本番環境
        # 省略

    elif stage == 'stress':
        # 負荷試験環境
        # 省略

    else:
        abort('Undefined stage %s.' % stage)

@task
def deploy(stage, branch='develop', hosts=[]):
    set_env(stage, branch)
    if hosts != []:
        env.hosts = [host.strip() for host in hosts.split(';') if not host.strip() == '']

    import os
    if not os.path.exists(env.local_tmpdir):
        os.makedirs(env.local_tmpdir)

    execute(pack)
    execute(common.update)
    execute(chown)
    execute(restart)

    local('rm -rf %s' % env.local_tmpdir)

@serial
@runs_once
def pack():
    with lcd(env.local_tmpdir):
        local("git clone --recursive -q -b %s %s %s" % ( env.branch, env.repo_url, env.current_release))

        # 設定ファイルの暗号化を解除
        if env.stage == 'stg':
            common.decrypt('%s/src/main/resources/application-stg.properties' % env.current_release)
        elif env.stage == 'prd':
            common.decrypt('%s/src/main/resources/application-prd.properties' % env.current_release)
        elif env.stage == 'stress':
            common.decrypt('%s/src/main/resources/application-stress.properties' % env.current_release)

        # JSトランスパイル
        with lcd('%s/client' % env.current_release):
            local('yarn')
            local('yarn %s' % env.yarn_script)

        with lcd(env.current_release):
            # スタブファイルの配置
            if env.stage == 'stress':
                local('cp -rp stub src/main/webapp/public/')

            # ビルド
            local('export SPRING_PROFILES_ACTIVE=%s' % env.stage)
            local('./gradlew build -x test')

            # 資材作成
            local('mkdir %s' % env.current_release)
            local('mv build/libs/sample-app.jar %s/' % env.current_release)
            local('cp conf/sample-app-%(stage)s.conf %(release)s/sample-app.conf' % {
                'release': env.current_release,
                'stage': env.stage
            })

        # tar zipの作成
        local('tar -zcf %(filename)s -C %(release)s %(release)s' % {
            'filename': env.destfile,
            'release': env.current_release
        })

def chown():
    with cd(env.deploy_to):
        sudo('chown -R %(app_user)s:%(app_user)s releases/%(release)s' % {
            'release': env.current_release,
            'app_user': env.app_user
        })

def restart():
    with cd(env.deploy_to):
        sudo('systemctl restart sample-app')

@task
def rollback(stage, hosts=[]):
    set_env(stage)
    if hosts != []:
        env.hosts = [host.strip() for host in hosts.split(';') if not host.strip() == '']

    execute(common.rollback)
common.py

他のアプリケーションからでも共通で使えるような処理は、別ファイルに切り出しておいて、 デプロイスクリプトでimportするようにしています。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from fabric.api import *
from fabric.contrib.files import exists
from datetime import datetime

env.current_release = datetime.now().strftime('%Y%m%d%H%M%S')

@parallel(pool_size=4)
def update():
    with lcd(env.local_tmpdir):
        put(env.destfile, '/tmp')

    releases = 'releases' if len(env.deploy_to) == 0 else env.deploy_to + '/releases'
    if not exists(releases):
        run('mkdir -p %s' % releases)

    with cd(env.deploy_to):
        run('tar xf /tmp/%(dist)s -C releases && rm -f /tmp/%(dist)s' % { 'dist': env.destfile} )
        run('ln -nfs releases/%s current' % env.current_release)
        # 5世代より前のファイルを削除
        run('ls -dr releases/* | tail -n +6 | xargs rm -rf')

@parallel(pool_size=4)
def rollback():
    with cd(env.deploy_to):
        releases = run('ls releases').split()

        if 2 <= len(releases):
            current = releases.pop()
            previous = releases.pop()
            run('ln -nfs releases/%s current' % previous)
            run('rm -rf releases/%s' % current)

def decrypt(path):
    local('ansible-vault decrypt --vault-password-file パスワードを書いたファイルの場所 %s' % path)

updateで配置されたリリース資材は、以下のパスに配置され、 現在のアプリケーションはリリース資材からのシンボリックリンクになります。

リリース資材 パス
現在のアプリケーション env.deploy_to/current
過去のリリース資材 env.deploy_to/release/リリース日時

またdecryptでは、ansible-vaultの復号処理をしていますが、 パスワードを細かく設定したい場合は、パラメータにパスワードファイルのパスを追加して実現してください。

JenkinsからFabricの実行

リリース環境ごとにJenkinsのジョブを作成するのであれば、対象のブランチ名($BRANCH_NAME)とリリース対象サーバ名($HOST_NAME)を指定できるパラメータ付きビルドのジョブを作成します。

ここでもステージング環境を例にすると、Jenkinsのビルドスクリプトは以下のようになります。

if [ -z "$HOST_NAME" ]; then
    fab sample_app.deploy:stg,$BRANCH_NAME
else
    fab sample_app.deploy:stg,$BRANCH_NAME,$HOST_NAME
fi

Slack通知をする場合は、カスタムメッセージも付与したほうが便利です。

STG環境に${BRANCH_NAME}をデプロイしました(👍・∀・) 👍
実行ユーザ:$BUILD_USER

$BUILD_USERは、Build User Vars Pluginを入れ、Set jenkins user build variablesにチェックをつけることで利用可能になります。

本番環境用設定

本番環境は、共通のリリース資材をタイミングをずらしてサーバにデプロイしたい(ローリングリリースしたい)という要望があるので、 sample_app.pyには記載していませんが、packの処理のみを行うbuildタスクと、資材配置と再起動を行うdeploy_fileタスクを追加で作成してあります。

Fabricのタスクが分かれたので、Jenkinsのジョブもビルドとデプロイで分けて作成します。
どちらも資材を識別する値をパラメータ($RELEASE)として渡せるジョブにしておくことで、ジョブが分かれても実行できるようにしています。

一応$RELEASEが未指定でも、ディレクトリ情報からいい感じに動くように工夫してあります。

ビルドジョブのシェル
if [ -z "$RELEASE" ]; then
    RELEASE=`date +%Y%m%d%H%M%S`
fi
fab sample_app.build:$RELEASE,prd,master

echo $RELEASE
デプロイジョブのシェル
if [ -z "$RELEASE" ]; then
    cd /tmp/sample_app
    export RELEASE = `ls --ignore=*.tgz -r | head -1`
fi

fab sample_app.deploy_file:$RELEASE,prd,web01

Ansibleをデプロイツールとして使わなかった理由

最後に、なぜAnsibleをデプロイツールに使わなかったのかを記載して終わろうと思います。

もしアプリケーションを動かすためのサーバ環境が、CIサーバからAnsibleで構築したものであれば、 特段の設定を必要とせず、Ansibleを使ってアプリケーションのビルド・デプロイを行うことができます。

Ansibleが十全に使えるのであれば、『Fabricでは環境別にSSHの鍵が異なる場合、env.key_filenameで鍵を指定しなければならない』ということに詰まることもないでしょう😭

ですがそうした場合、Ansibleの担当する領域が、サーバの構成管理とアプリケーションのデプロイになり、 インフラの領域とアプリケーションの領域の2つを持つようになってしまいます。

個人的に、各アプリケーションやツールが受け持つ領域は、なるべく小さいほうがわかりやすく、管理しやすいと思っているため、

  • Ansibleはサーバの構成管理
  • Fabricはアプリケーションのデプロイ

というように用途を分けて使うことにしました。

担当領域が分かれていれば、誤って不要な実行をしてしまうリスクも減らせ、 どちらかをすべて変えたいとき(例えばRailsアプリケーションのデプロイはAnsibleではなくCapistranoにしたいなど)に、他方に影響を与えることもないので、 アプリケーションのデプロイはFabricに任せることにしました。

単純に、AnsibleよりもPythonをそのまま書けるFabricの方が、 条件分岐の多い処理を書きやすいので、シェルスクリプトで頑張ろうとする前にFabricを検討してみて貰えると幸せになれるかもしれません😇

Spring Boot 2 アプリケーションのデーモン化

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

KCF Laboの紹介

はじめまして。エンジニアの土田です。

本日からKCF Laboメンバーの知見やLaboの様子などを、ブログを通してお伝えしていきますので、お付き合いいただけると幸いです!

ちなみに、KCFは私達が所属する会社であるKDDIコマースフォワードの略称で、

Laboは2018年2月にできた、開発部隊の拠点のことを指しています。

Spring Boot 2 アプリケーションの起動

現在、私のチームではSpring Boot 2を利用して、現行システムのリプレイスを進めています。

ローカルでの開発環境は以下のようになっており、プロジェクトの構成はGradleで管理しているため、動作を確認する場合はgradle bootRunでサーバを起動して実施しています。

f:id:seri_wb:20180413161556p:plain

しかし、アプリケーションをサーバで動作させるには、アプリケーションをデーモンプロセスとして起動する必要があるので、 ローカルと同じように、というわけにはいかなくなります。

今回はそのSpring Boot 2 アプリケーションのデーモン化方法についてお話したいと思います。

sample-appの環境情報

今回の話に関連する環境情報は、以下になります。

  • CentOS: 7系
  • Spring Boot: 2.0.1.RELEASE
  • Gradle: 4.6
  • Kotlin: 1.2.31
  • Java: 1.8.0.161

また、アプリケーションの名前は「sample-app」として進めていきます。

デーモン化可能なjarを作成

Spring BootのGradleプロジェクトでbuildコマンドを実施すると、java -jarでアプリケーションとして起動可能なjarファイルが作成されますが、デーモン化も可能なjarにするには、以下の記述をbuild.gradleに追記して、ビルドする必要があります。

bootJar {
    launchScript()
}

ちなみにbuild.gradleは、Spring Initializrをベースに作成するのがおすすめです

アプリケーションの配置

jarファイルを作成したら、アプリケーションを起動させるサーバに配置します。

ここでは/var/lib/app/sample-app/currentディレクトリ配下に配置するものとします。

jarファイルを配置したら、jarファイル名と同じ名前のconfファイルを作成します。

sample-app.jarという名前ならば、sample-app.confという名前になります

confファイルにはアプリケーションを起動させるオプションを記述します。 Spring Profilesを使って環境毎に設定を切り替えている場合は、RUN_ARGSに--spring.profiles.active=環境名を渡すと指定の環境で起動することができます。

  • sample-app.conf
JAVA_OPTS="-Xms4096M -Xmx4096M -Xloggc:/var/log/app/sample-app/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/sample-app/heap/dump.log -XX:ErrorFile=/var/log/app/sample-app/java_error%p.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=10M"
RUN_ARGS="--spring.profiles.active=prd"

JAVA_OPTSに書いている各種オプションは必須ではないのですが、このようなオプションを付けておくと障害時の解析に役立つと思います。

1点気をつけるところがあるとすれば、LOG_FOLDERというログファイルの出力先オプションがあるのですが、 これはアプリケーション内のLogback等の設定に負けるので、Logger使用時にはLoggerの設定でログ出力先を指定するようにしてください。

アプリケーションのサービス登録

次に、アプリケーションをCentOSサーバにサービス登録するため、serviceファイルを作成します。

  • /etc/systemd/system/sample-app.service
[Unit]
Description = Sample SpringBoot WebApplication daemon
After = syslog.target

[Service]
ExecStart = /var/lib/app/sample-app/current/sample-app.jar
Restart = always
Type = simple
User = webapp
Group = webapp
SuccessExitStatus = 143

[Install]
WantedBy = multi-user.target

各項目の内容は任意ですが、UserとGroupにはアプリケーションを動作させるユーザの情報を記載してください。

アプリケーションの管理

ここまでの作業を終えれば、systemctlでアプリケーションを管理することができるようになります。 systemctl start サービス名でアプリケーションを起動すると、アプリケーションはデーモンプロセスとして実行されます。

■ 起動
sudo systemctl start sample-app
■ 停止
sudo systemctl stop sample-app
■ 状態確認
sudo systemctl status sample-app
■ 再起動
sudo systemctl restart sample-app

以上でデーモン化完了です。

Ansibleでのサービス登録

これまでの内容を各サーバに手動で実施するのは面倒なので、実際には以下のようなAnsibleのRoleを作成して利用しています。

  • roles/app/sample-app/tasks/main.yml
---
- name: check and create application directory
  file: path="{{ application_dir }}/sample-app" state=directory recurse=yes owner={{ app_user }} group={{ app_user }} mode=0777
  become: yes
  tags:
    - app
    - sample-app

- name: add sample-app service at system
  template: src=sample-app.service.j2 dest=/etc/systemd/system/sample-app.service owner=root group=root mode=0755
  become: yes
  tags:
    - app
    - sample-app

- name: check and create application log directory
  file: path="{{ app_log_dir }}/sample-app" state=directory recurse=yes owner={{ app_user }} group={{ app_user }} mode=0755
  become: yes
  tags:
    - app
    - sample-app
    - log

- name: check and create heap dump log directory
  file: path="{{ app_log_dir }}/sample-app/heap" state=directory recurse=yes owner={{ app_user }} group={{ app_user }} mode=0755
  become: yes
  tags:
    - app
    - sample-app
    - log

- name: enable automatically start sample-app.service
  systemd:
    name: sample-app.service
    enabled: yes
  become: yes
  tags:
    - app
    - sample-app
    - systemd
  • roles/app/sample-app/temlates/sample-app.service.j2
[Unit]
Description = Sample SpringBoot WebApplication daemon
After = syslog.target

[Service]
ExecStart = {{ application_dir }}/sample-app/current/sample-app.jar
Restart = always
Type = simple
User = {{ app_user }}
Group = {{ app_user }}
SuccessExitStatus = 143

[Install]
WantedBy = multi-user.target

パラメータ値はgroup_varsに環境毎のファイルを作成し、以下のように定義しています。

application_dir: /var/lib/app
app_log_dir: /var/log/app
app_user: webapp

デーモン化完了の次は

これでSpring Boot 2のアプリケーションをサーバで起動することができましたね!お疲れ様です。

バージョン2からデーモン化の方法が少し変わっていたので、記事の話題にしてみました。 まだまだリリースされたばかりで2系の情報は少ないですが、Java界隈では使い勝手の良いフレームワークなので、KCFでもネタにして盛り上げていきたいです。

次は本稿ではさらっと流したデプロイの話を書く予定ですので、そちらもよろしくお願いします。