KCF Labo Blog

KDDI Commerce Forwardの開発ブログ

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

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

f:id:seri_wb:20180508141306j:plain

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

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

私のチームで開発している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を検討してみて貰えると幸せになれるかもしれません😇