EatSmartシステム部ブログ

ウェブサイトの開発や運営に関する情報です。

バックアップストレージの運用について

各システムのインフラ担当の方は、万が一の事態に備えバックアップの運用をされている事と思います。

バックアップ運用をするに辺り、どのデータを、何世代までバックアップするか等を検討されると思いますが、ストレージが無限に存在すれば、何世代でもバックアップ可能ですが現実はそうもいかないです。

また、出来る限りバックアップに掛けるランニングコストを抑えたいというニーズも多いのではないでしょうか?

そこで、弊社で実施している、低コストで世代バックアップ残す形のストレージ運用について紹介したいと思います。

バックアップストレージの構成

バックアップストレージを検討するに辺り、(S3、Googleドライブ、DropBox等)外部のストレージを検討してみましたが、低コストで柔軟に運用を行うことが困難だったので、弊社はオンプレミス構成を採用しました。

また、バックアップは日次で行っており、バックアップ対象は、サービス利用画像/日次DBスナップショット/日次ログ等さまざまですが、その中でも、最も大容量な日次DBスナップショットデータに着目し、以下の構成をとる事にしました。

構成図

f:id:eatsmart:20190314202659j:plain

補足
  • バックアップサーバー1は、内臓で2Tbyteのストレージが存在しており、直近2週間分の日次DBスナップショット等が存在している。

  • バックアップサーバー2は、「バックアップサーバー1」で直近2週分からあふれたデータを外付け(4TBbyte)に保存していく。(現状は約半年分位バックアップ可能)

  • バックアップサーバー2の外付け(4TBbyte)のデスク容量が逼迫した場合、新規にHDDを購入し、外付ドライブのHDDを交換する。

  • 万が一過去分が必要になった場合、外付けドライブに交換前のHDDを入れて参照する。

上記構成を採用したポイント

  • バックアップサーバー(ストレージ)は2つの物理構成を採用し、利用頻度が高い直近のデータをバックアップサーバー1に、利用頻度が低いデータをバックアップサーバー2に置く事にしました。そうする事で、HDDを交換した際にバックアップデータが全て無くなることを回避しました。

  • バックアップサーバー2に関しては、HDDの交換のしやすさを考慮し、外付けのストレージをを採用しました。 懸念点として上がったのが、外付けストレージの転送速度の問題でした。 転送速度が遅いと、ギガ単位のデータを転送するのに時間がかかってしまい運用が成り立ちません。 そこで、外付けストレージの接続にはeSATAを採用しました。

www.elecom.co.jp

ランニングコストについて

  • ストレージの費用ですが、半年に一度 3.5インチSATA HDD(2Tbye×2)を購入しており、1万5千円前後かかります。

  • ストレージの交換作業ですが、こちらも半年に一度 1時間程度で交換可能です。

最後に

バックアップの運用は各システム構成に依存する為、一概には適用できないかと思いますが、何かの参考になれば幸いです。

PostgreSQLの関数でパラメータのチェックを行う

引き続きSQL関連のネタを書いてみたいと思います。

Javaでクラス外から参照出来るpublicなメソッドを作成する時は、値のチェックとエラーメッセージを行うように心がけています。 これは、クライアントへ想定外の利用を正しく伝えるためです。

public String doSomething(int id, String name) {
  if (id <= 0) {
    throw new IllegalArgumentException("idは0以上を指定して下さい。id:" + id);
  }
  if (name == null || name.isEmpty()) {
    throw new IllegalArgumentException("nameが空です。name:" + name);
  }
  if (name.length() < 8) {
    throw new IllegalArgumentException("nameは8文字以上を指定して下さい。name:" + name);
  }
  ....
}

正常な処理が出来ないことを伝える方法としてこの場合nullを返す等も考えられますが、クライアント側で正常な値であるか判別する必要があります。 仮に判別を行わなかった場合、nullが値として利用されてしまい思わぬ障害を引き起こす可能性があります。 この例ではIllegalArgumentExceptionをスローしていますが、独自の例外を定義する場合もあると思います。

これと同様のことを、SQLの関数で実装してみました。

CREATE OR REPLACE FUNCTION do_something(id NUMERIC, name VARCHAR)
RETURNS CHARACTER VARYING
AS '
BEGIN
  IF id <= 0 THEN
    RAISE EXCEPTION ''idは0以上を指定して下さい。id:%'', id;
  END IF;
  IF name IS NULL OR LENGTH(name) <= 8 THEN
    RAISE EXCEPTION ''nameは8文字以上を指定して下さい。name:%'', name;
  END IF;
  ....
END;
'
language 'plpgsql';

値のチェックを行い、"RAISE EXCEPTION"でエラーメッセージを出力します。 Javaの実装と若干異なりますが、引数の範囲のチェックを行っています。

実行すると、以下のようになります。

postgres=> select do_something(1, '1234');
ERROR:  nameは8文字以上を指定して下さい。name:1234

SQLでエラーが発生することで、トランザクションロールバックさせる対処を取ることが出来ます。 これで、意図しない処理が行われることが無くなります。

PostgreSQLのPL/pgSQLで複数行の戻り値を返す

SQL関連の記事が続いているので、今回もそれに乗っかってみました。

サーバーサイドの実装をしていると、少しややこしいが共通的な処理について、どこに実装するのが良いか迷う時があります。

基本的にはビジネスロジックに関する内容であれば、アプリケーションサーバーで実装し、共通的な処理だったらモジュール化してサーバーへデプロイして使います。 ただ、プログラムからだけでなくSQLからも使用する場合や、処理速度のためにアプリケーションサーバーとDB間の通信を減らしたい場合などは、ビジネスロジックに深く関わらない内容なら、DBにViewや関数として実装することもあります。

今回は、弊社で使用しているPostgreSQLPL/pgSQL関数で複数行の結果を返す処理をを実装し、プログラムからは通常のテーブルをSELECTするように呼び出して使用した例を紹介します。

関数の作成

PL/pgSQLの関数は、以下の様に作成します。

CREATE OR REPLACE FUNCTION func_test(p_in_param varchar)
    RETURNS TABLE(
        id numeric(13,0),
        name varchar(128)
    ) AS $$
declare
    cur_food cursor(p_param varchar) for
        SELECT * FROM FOOD_TABLE WHERE FOOD_NAME ~ p_param
        ;
begin
    FOR rec_food IN cur_food(p_in_param) LOOP
        id := rec_food.food_id;
        name := rec_food.food_name;
        RETURN NEXT;
    END LOOP;
    RETURN;
end;
$$ LANGUAGE plpgsql;

ポイントは

  • RETURNS TABLE句

戻り値として複数の項目(列)を返すために、RETRUNS TABLEとして、戻り値の構造体を定義します。

  • RETURN NEXT句

RETURN TABLEで宣言した変数に値を入れてRETURN NEXTを呼ぶと、その列を戻り値として返すことができます。 この例では、カーソルでループする回数だけRETURN NEXTを呼ぶので、その件数の戻り値が返ります。

例では取得した値をそのまま戻り値の変数に入れていますが、ここに複雑な処理を実装することで、Viewでは複雑になりすぎるものも実装することができます。

クエリの実行

以下の様にSELECT句のFROMに関数を指定することで、クエリを実行できます。

database=> select * from func_test('ご飯');
   id   |                          name
--------+---------------------------------------------------------
 526583 | 人形町今半 牛炊き込みご飯 箱165g
 526673 | はごろも わかめご飯 鮭 袋250g
 526674 | はごろも わかめご飯 明太子昆布 袋250g
 526675 | はごろも わかめご飯 袋250g
 537351 | カネカ 本格ごはん 鶏肉のバジル炒めご飯 袋83g
 543441 | マルちゃん 炊き込みご飯の素 鶏五目 袋41.7g
 543442 | マルちゃん 炊き込みご飯の素 鶏五目 袋13.9g×10
 543443 | マルちゃん 炊き込みご飯の素 きのこ 袋13.1g×10
(8 rows)

通常のクエリとして実行できるので、ORマッピングフレームワーク等を使用して、簡単にオブジェクト化して使うことができると思います。

終わりに

個人的にはプログラムの実装はシンプルなのが好きなことと、アプリケーションサーバーとDB間のオーバーヘッドが気になるので、いわゆるビジネスロジックでは無いものをViewなどでDB側に置きたくなります。(処理が色々な所に分散されて管理が煩雑になることや、DBに負荷が集中してしまうデメリットも、もちろん気になりますが…。)

今回紹介した方法を使うと、クエリ時に少し複雑なロジックがあっても、ストアド関数として実装できて、選択の幅が広がると思いました。

イベント情報のテーブル設計で考えたことまとめ

タスクの趣旨

弊社サービスのもぐナビでは、様々なイベント、キャンペーンなどをオンライン、オフライン問わず実施しています。開催イベントの一覧が情報として見れるといいよねということで、新らたにイベント情報をもつテーブルの設計を考える機会がありました。
【過去の実施イベント例】
もぐナビベストフードアワード2018
カップ麺選手権
もぐナビ試食会

以上の背景を踏まえて、実際に作成したスキーマが下記になります。
(PostgreSQLを使用)

※⑴CREATE SEQUENCE seq_event;

CREATE TABLE m_event(
event_id        NUMERIC(13, 0) DEFAULT ※⑵nextval('seq_event') PRIMARY KEY,
event_title     character varying(128) NOT NULL,
event_url       character varying(128) NOT NULL,
information_start_date  TIMESTAMP WITHOUT TIME ZONE NOT NULL,
information_end_date    TIMESTAMP WITHOUT TIME ZONE ※⑶DEFAULT '2100/12/31',
※⑷new_end_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
event_label CHAR(2) NOT NULL DEFAULT '01',
picture_path1   character varying(128) NOT NULL,
picture_path2   character varying(128) NOT NULL,
upd_date        TIMESTAMP WITHOUT TIME ZONE,
ins_date        TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
del_kbn         CHAR(2) DEFAULT '01');

COMMENT ON COLUMN m_event.event_label IS '01:イベント';
COMMENT ON COLUMN picture_path1  IS '300x250';
COMMENT ON COLUMN picture_path2  IS '320x100';

※⑸CREATE INDEX idx_m_event ON m_event(information_start_date);

設計する過程で、考えたことについて内容を振り返ってみます。

INDEXについて

event_id(イベント固有に振られるID)が主キーの扱いとなるため、自動的にインデックスが貼られます。それに加えて、掲載した時期を元にイベント表示をしていくので掲載開始時期(information_start_date)についてもインデックスを貼ります※⑸。

シーケンスについて

event_idに対し、連番で固有の番号を割り振るため、シーケンスオブジェクトという特別なテーブルを作成します※⑴。 ※⑵でシーケンス関数を利用し、シーケンスオブジェクトの値を次に進めその値を返す、つまり自動で採番していく処理を行います。

NOT NULL制約について

イベントに関する基本情報は、デフォルトでNOT NULLにしておきます。イベントの掲載開始時期(information_start_date)は、全て必要になるためNOT NULL制約をつけますが掲載終了時期(information_end_date)は制約を外しました。これは、終了期間を未指定で掲載するケースもあるためです。何も入力しない場合には適当な年月日※⑶が自動で入るようにしてあります。

New!(新着アイコン)の表示について

新着のイベントにNEW!アイコンを表示します。掲載開始開始から一定期間経過後、非表示とする必要があるため、※⑷new_end_date(NEW!表示終了日)を設けて対応することにしました。当該期間とnew_end_dateの差が0以上なら、NEW!をつける、そうでないならNEW!をはずすというメソッドを作り、VIEW側でアイコンの出しわけを行いました。

truncate処理について

上記のイベント実装に関連し、テストコードの実装を行いました。その中でtruncate処理を知りました。 truncateは、deleteと違い、テーブルスキャンを行なわいため、テーブルの削除が高速で実行できます。下記※⑹で、イベントのテーブルデータを一度削除し、再作成するということをセットアップ処理の中で行っています。

public class EventBeanTest {
    @Before
    public void setUp() throws ServerException {
        TestManager.getInstance().initialize();
        DBConnectionManager.getInstance().getConnection().execute(※⑹"truncate m_event ");
    }

以上となります。

SQLのnullの扱いについて

あるカラムに文字列を追記したい為、update分で元のカラムにパイプ(||)を使って連結して更新を行った際、元カラムがNULLの場合意図しないNULLに更新されたので原因を調査してみました。

調査対象

Postgresql9.3.4

事象

例
update table_name 
set description = 'abcd' || description
where  
・・・
※descriptionがnullの場合、nullで更新されてしまう

原因

NULL値は不明の値を表しているため、不明な値との連結は行えない。 これは標準SQLに従った動作となります。

https://www.postgresql.jp/document/9.3/html/functions-comparison.html

解決策

COALESCE関数でNULLを置換する

COALESCE関数は、NULLでない自身の最初の引数を返します。全ての引数がNULLの場合にのみNULLが返されます。

COALESCE関数の使用例

SELECT COALESCE(description, short_description, '(none)') ...

descriptionがNULLでなければをそれ返します。そうでない場合(NULLの場合)は、
short_descriptionがNULLでなければそれを返します。そうでもない場合は(none)が帰ります。

結果、COALESCE関数で、NULLの場合スぺ―ス('')に置換することで解決しました。

update table_name 
set column_name = 'abcd' || coalesce(description,'')
where  
・・・
※descriptionがnullの場合、'abcd'として更新される

参考

COALESCE関数とは逆に、NULLに変換する必要がある場合、NULLIF関数をつかうと便利です。NULLIF関数の利用用途も交えて紹介します。

NULLIF関数の使用例

SELECT NULLIF(value1, value2) ...

value1がvalue2と等しい場合NULL値を返します。 等しくない場合はvalue1を返します。 

例 SQLの除算でゼロ除算エラーを回避する

例
SELECT
    column1 / column2
FROM
    table_name

※column2に0が設定されているとゼロ除算エラー(ERROR: division by zero)となる

ゼロ除算の回避方法

SELECT
    column1 / NULLIF(column2, 0)
FROM
    table_name

※column2が0の場合、ゼロ除算が回避され結果はNULLとなる

更に、結果にNULLを返したくない場合に、COALESCEと組み合わせることで回避できます

SELECT
    COALESCE(column1 / NULLIF(column2, 0), 0)
FROM
    table_name

※column2が0の場合、結果が0となる

よかったら、参考にしてみて下さい。

Tomcat8のjar読み込み順について

結論

  • Tomcat8ではjarの読み込み順が決まっていない

Tomcat7以前のようにアルファベット順に読み込まれると思うと痛い目に遭います。

きっかけ

昨年末からサーバの増強を進めています。サーバの構築は約一年ぶりなので不安も有りましたが、以前作成したAnsibleのplaybookを利用することで問題なく作業も完了しました。 サーバの動作確認も問題無く終え、Webアプリの移行を行い本番環境に投入しました。 ところが、移行したTomcat8で動くアプリの特定のページでエラーが発生しているという報告を受けました。 実際にアクセスしてエラーが発生していることを確認し、エラーログを確認すると該当ページで"java.lang.NoSuchMethodError"が発生していました。

java.lang.NoSuchMethodError発生の原因

このエラーは、クラスにメソッドの定義がない場合に発生します。このエラー自体は知っていましたが、あまりお目にかからないものです。 メソッドの定義が無い場合、該当ページのクラスをビルドするタイミングで発見されるハズです。実行時にエラーが発生するということが不思議です。 もっと不思議なのは、そもそもこのアプリは別のサーバでは正常に動くのです。 気になるのが、該当のメソッドが定義されているクラスが複数のjarに存在することです。

xxx.yyy.Zzz

このようなクラスが aaa.jar と bbb.jar に存在し、該当メソッドは aaa.jar に含まれるクラスにのみ定義されていました。 長年Tomcatを利用したWebアプリを開発している経験では、名前の順で上になる aaa.jar のクラスが利用されるという認識です。 今回も当然 aaa.jar のクラスが利用されていると考えたのですが、念の為クラスが含まれるjarをログに出力させたところ、 bbb.jar となっていました。

jarの読み込み順

Tomcatのクラスローダでは、Webアプリでの優先度が以下のようになります。

  1. WEB-INF/classes
  2. WEB-INF/lib

この挙動を利用して、優先させたいクラスやプロパティファイルを WEB-INF/classes に配置することもありました。 WEB-INF/lib は、上で書いたようにアルファベット順に読み込まれるので、jarのファイル名を変更したこともありました。 ですが、その理解が間違っていたようです。検索していたところ、このような書き込みがありました。

https://stackoverflow.com/questions/5474765/order-of-loading-jar-files-from-lib-directory

Order for loaded jars in WEb-INF/lib folder.

For tomcat 5-7 the order is alphabetical. It uses sort.

For tomcat 8 is random decided by underlying file system.

Tomcat8から、ファイルシステムに依存するとあります。これを確認するため、ログにクラスローダが読み込んだjarを出力させてみました。

org.apache.catalina.loader.WebappClassLoader {context-class-loader=true, id=1539094878}
    file:/usr/local/tomcat/webapps/mognavi-backend/WEB-INF/classes/
    file:/usr/local/tomcat/webapps/mognavi-backend/WEB-INF/lib/bbb.jar
    file:/usr/local/tomcat/webapps/mognavi-backend/WEB-INF/lib/eee.jar
    file:/usr/local/tomcat/webapps/mognavi-backend/WEB-INF/lib/ccc.jar
    file:/usr/local/tomcat/webapps/mognavi-backend/WEB-INF/lib/aaa.jar
    file:/usr/local/tomcat/webapps/mognavi-backend/WEB-INF/lib/ddd.jar

実際とは異なりますが、上記のように aaa.jar が bbb.jar より後に読み込まれていました。このため、メソッドの定義がないためエラーが発生していました。 ちなみに、移行前の環境でも同様のログを出力させたところ、 aaa.jar が bbb.jar より先に読み込まれていました。このため、いままこの問題に気付かなかったようです。

解決

解決方法としては、利用しない bbb.jar に該当するクラスを含まないようにビルドすることにしました。(bbb.jar は社内で制作したライブラリのため、この方法を採ることが出来ました) PreResourcesを利用する、ファイルシステムを変更する、など他の手段もあるようでしたが、そもそも該当クラスが利用されていないことと今後も同様のトラブルが他で発生する可能性があるので、このような対処をとりました。 これらはまた別の機会に調査したいと思います。

プライベートmavenリポジトリでのバージョンの扱いについて

弊社では、Webアプリケーションのビルド用に、かなり昔に作ったAntのスクリプトを使用しているところがあるのですが、JARのバージョン管理や、新しい開発者の環境構築の容易さを考えて、(今更ながら)Mavenを使うように試してみました。

プライベートリポジトリ構築

JARのバージョン管理と言っても、外部のJARについては頻繁にバージョンアップをすることは無いのですが、自社プログラムの共通モジュールは日々更新されているので、自社モジュールのバージョン管理が主目的となります。

そのため、自社環境内にmavenのプライベートリポジトリを構築して、依存関係の配布とモジュールのデプロイを行う必要があります。

すでに、社内向けに閉じたapacheがあるので、今回はWebDAVリポジトリを構築しました。 WANからはアクセスできないので、以下の設定にしています。

DavLockDB /var/lib/dav/lockdb

Alias /repository /var/mvn/repository/
<Location /repository/>
    DAV on
    Options +Indexes
    AuthType None
    Require all granted
</Location>

<Directory /var/mvn/repository/>
    Options Indexes FollowSymLinks
    Satisfy Any
    Allow from all
</Directory>

あとは、使う側のpom.xml

<repositories>
    <repository>
        <id>mvn.eatsmart.jp</id>
        <name>eatsmart.jp mvn repository</name>
        <url>http://mvn.eatsmart.jp/repository/</url>
    </repository>
</repositories>

を追加して、自社モジュールの依存関係を使えるようになりました。

WebDAVリポジトリへのデプロイ

モジュールをデプロイする側のプロジェクトでは、まず以下のようにpom.xmlにデプロイ先の定義をします。

<distributionManagement>
    <repository>
        <id>mvn.eatsmart.jp</id>
        <name>eatsmart.jp maven repository</name>
        <url>dav:http://mvn.eatsmart.jp/repository</url>
    </repository>
</distributionManagement>

次に、maven3でWebDAVへデプロイするために、wagonというextensionを使う必要がある(Maven3でWebDavでjarをdeploy:deploy-fileしたら失敗する件)とのことで、以下の定義を追加します。

<build>
    …
    <extensions>
        <extension>
            <groupId>org.apache.maven.wagon</groupId>
            <artifactId>wagon-webdav-jackrabbit</artifactId>
            <version>2.12</version>
        </extension>
    </extensions>
</build>

以上で、maven deployで自社リポジトリへデプロイできるようになりました。

モジュールのバージョン管理について

自社モジュールのバージョン管理と言っても、外部のJARと同様にすぐにバージョンアップに追随しない種類のものもあります。

大まかに分けると

  1. ユーティリティやフレームワークなど、複数のアプリケーション間で共通に使っているモジュール
  2. バイスごとのサイトを実装する際に、アプリケーションの共通のビジネスロジックをモジュール化したもの

1については、外部のJARと同様に、アプリケーションごとにある程度固定のバージョンを使用します。モジュールもバージョンによって機能やインターフェースに微妙な差異があり、それをバージョン管理する必要があります。

2については、デバイスごとで処理内容が異なることはあまり無い(ある場合は共通ロジックとしては使用しない)ので、特にバージョン情報で世代管理をする必要は無く、むしろ常に最新のバージョンに追随する必要があります。

そのため、1のためには通常のmavenの依存性管理を用いて、バージョン番号で世代管理をしようと考えています。バージョン番号としてはこちら(セマンティック バージョニング 2.0.0 | Semantic Versioning)に則り、互換性を保たないバージョンはメジャー番号、互換性を保つバージョンはマイナー番号、バグ修正やパッチ対応はリビジョン番号で表現したいと思います。ただ、社内の共通モジュールでは、そこそこ頻繁に機能追加が行われ、後方互換性とは関係無く新しいインターフェースが追加されるので、その範囲ではリビジョン番号の繰り上げで対応しても良いかなと考えています。

2については、通常のバージョン番号で管理する範囲の更新頻度ではありませんし、そもそも常に最新バージョンに追随する必要があるので、リビジョン 番号だけを延々と繰り上げていく運用か、あえてSNAPSHOTで運用しても良いかなとも考えています。この辺で何か知見があれば良いなあと思っています。