はじめに
様々なアーキテクチャ選択肢が生まれた現在でも、バッチ処理を作るにあたり考慮しておきたい(と個人的に思う)事項を経験則的に羅列してみました。個々の技術的な記事はたくさんあるものの、共通的/抽象的な把握はあまりされにくい領域の印象があります。
サーバ+シェルスクリプトだけでなく、考慮しておくという意味ではどのようなアーキテクチャでも共通的に必要になると思っています。
具体的な実装ではない抽象的な表現が多く、文字ばかりになってしまい恐れ入ります。
すべてのシチュエーションで必要になるわけではありませんが、なるべく「考慮しないとどうなるか」を添えるようにしていますので、思い当たるところがあれば参考になれば幸いです。
想定読者
「フロントエンドばかりやっててバッチ処理あまり作ったことない」
「データ分析で前処理をよく書くものの自動化するにあたり不安」
「ついつい簡単につくってしまい運用が大変で後悔している」(わたし)
考慮しておきたいこと
処理の単位/分割
処理単位あたりどこまでのタスクを行うか、どう組み合わせられるようにしておくか。
運用で手動起動されうる単位で分割する
処理時間が無視できない単位で分割する
アトミックである単位はまとめる/まとめて実行できるようにする
必ず トラブル等で再実行することになることを考慮 します。
できるだけ小さな単位で実行できるようにしておき、組み合わせて構成する のが基本の考え方になりますが、実行単位を小さくしていくと、こんどは実装工数が大きくなるだけでなく、システム上の不整合も起こりやすくなります。
システム、要件に合わせた適切なさじ加減が必要になってきます。
要件例
日次で出力されるwebサーバの処理前ログがあり、前日のログを処理し、DWHにimportする。
運用で手動起動されうる単位は分割する
「3日前の分を再実行」は容易に想定されるでしょうから、本処理は「N日の処理を行う」にしておき、「前日を指定する」部分は切り出しましょう。
本処理の引数に対象日付(絶対日付)を受け取れるようにします。
相対日付(-3d等)は後述の理由であまり推奨しません。
処理時間が無視できない単位で分割する
「N日前から昨日までのDWHimportだけを再実行」もありそうです。
ログの処理、DWHimportがそれぞれ相応の時間がかかる場合、どちらか片方が要因の再実行で無駄な時間をかけないように分割しましょう。
ただしその場合、処理済みのデータを一旦永続領域に格納し、DWHimportだけが単体で行えるようにする必要があります。
これはこれで面倒なので、「DWHimportを行うときは必ず処理もセットで行う」ことが許容できる処理時間であればまとめても良いでしょう。
アトミックである単位はまとめる/まとめて実行できるようにする
トランザクション処理の基本ですが、バッチ処理でもシステム上不整合が起きうる中途半端な成立状態を起こさないようにします。
本来1日で行うべき処理群を分割している時点で、一時的に広い意味での不整合を許容している状態になりますので、運用でできるだけカバーできるようにしたいところです。
「1日の一連の処理を順次行う起動単位」を持っておくと便利です。
実装例
daily_system.sh
前日を算出
処理ログの出力を指定
処理ログを監視して通知
oneday.sh
処理日を引数で受領
proc_data.sh
import_data.sh
proc_data.sh
処理日を引数で受領
(データ処理してどっかに格納)
import_data.sh
処理日を引数で受領
(処理済みデータをDWHにimport)
データ処理、データimportをそれぞれ任意の日付で実施できるようにしつつ、まとめて行えるonedayでwrapすることで、「特定処理のみの連続実行」も「特定日付のみの全体実行」も可能になります。
また、処理ログの規定出力と監視/通知は自動起動対象となる最上位のところ(daily_system)でのみ実装します。
運用による手動起動が必要な場合はおおよそログを分けたい場合が多く、また実施結果も運用により確認することが多いでしょう。
更新処理の注意
追加処理は対象の削除を処理に入れること
更新処理は処理前状態の戻す手段を用意すること
dry-runオプションでより安全な事前確認を
広い意味で 冪等性の確保 が必要といえます。
運用による再実行を行う際に、不整合が起こさない配慮が必要です。
追加処理は対象の削除を処理に入れること
再実行時に手動で対象ファイル/レコードを削除する運用は危険を伴います。処理の中で確実に削除するようにしましょう。
またDB/DWHに格納するような処理の場合、実行単位で削除対象の指定ができる 必要があります。
実行単位を格納するカラムを明示的に設けるのが確実です。
トランザクション処理であれば insert ... or update
が使えますが、大量データを扱う場合は適切でない場合が多いでしょう。
また、vacuumが必要なDBの場合は 削除 -> vacuum -> 追加 としておくと再実行時に無駄が生じません。
更新処理は処理前状態の戻す手段を用意すること
マスタ更新などupdateを伴うものは、当処理(もしくはそれを含む一連の処理)直前にバックアップを取得しておきましょう。
本番の運用においても必要になりますが、実装時に用意しておくことでテストが捗ります。
dry-runオプションでより安全な事前確認を
実装・テスト時もそうですが、本番運用での再実行時に活躍します。
「本命の更新処理を行わずに、件数や対象レコードを別の場所に出力する」などの実装を用意することで、リカバリ時もより安全に実行時の確認を行うことができます。別途手動で検証するのではなく、同じ処理で対象を判定させる ことに意味があります。
処理時間/性能
I/Oや通信はできるだけ繰り返しの外に出す
DBは一括コミット
長時間かかる処理は分割/並列を検討
大量のデータを順次処理するパターンが多いバッチ処理では、「1件あたりの時間」x「件数」が基本的な処理時間となります。
「1億件の場合、1件あたり0.1ミリ秒(0.0001秒)の差で3時間の差が生まれる」
「1億件を1時間で処理するには秒間30,000件の処理が必要」
という感覚値をもっておきましょう。
I/Oや通信は繰り返しの外に出す
I/Oや通信はソースで書く部分よりOSレイヤのオーバーヘッドが大きく、1件毎に発生させるとかなりの時間コストを要します。
繰り返し中はなるべく発生させず、どうしても必要な場合は並列処理を検討しましょう。
ファイル入出力はバッファを用いる (BufferedReader, BufferedWriter等)
都度参照が必要なマスタはできるだけ事前にメモリに乗せる
ことで回避できることが多いです。後者は特に都度DBを参照することが散見されますが、大きな改善ポイントになる可能性があります。
DBは一括コミット
前項の一部ではありますが、DBコミットもオーバーヘッドが大きいため、1件ずつやらずに一括でまとめて行いましょう。
また性能面以外でも、「アトミックであるべき」観点からも必要な考慮になります。
処理途中までDBに反映されてしまって異常終了した、はシステム不整合以外の何物でもありません。
長時間かかる処理は分割/並列を検討
まず処理を(量的に)単純に分割するだけで、通常の合計処理時間は変わらなくても 再実行時の単位を小さくする ことが可能になるため、定常の処理時間が許容範囲であっても、検討の価値があります。
さらに並列で処理を行うことでマシンリソースを分散させることができ、お金さえ払えばいくらでも速くできる ことになります。
(昨今のイケてる大規模バッチ処理アーキテクチャは並列が前提)
1台のマシンでシェルで行う小規模なものであっても、現在は大抵複数コアCPUでしょうから、その分程度の並列処理にするだけで大幅な速度改善が期待できます。
ジョブ制御
適切な処理開始トリガを設定
多重起動がマズければロック機構を
無駄なマシン起動時間を抑える(クラウドの場合)
ファイル配置系トリガを外部システム間で用いるのは注意
ベタにバッチ処理を書き出すとcronで起動設定してしまいがちですが、単発ではなく「一連の処理」になってくる場合はジョブ制御プロダクト・サービス(JP/1、Hinemos等)を取り入れることを推奨します。上記考慮点がすでに組み込まれており、単に順次処理するだけでも様々な恩恵に与れます。(関係者ではありません)
適切な処理開始トリガを設定
「並列で実行されているAとBの処理がどちらも正常終了の場合Cを実行」という制御要件はよく発生しますが、cronとシェルだけで実装するのはそれなりに面倒で、cronだけでサボるとリカバリ時に痛い目に遭います。
ジョブ制御プロダクトではこのパターンだけでなく、任意のリターンコード条件や複数サーバ/サービスにまたがった並行実施など、かなり柔軟な要件に対応できます。
また、途中で異常終了した場合「そこから続きを再実行」もできるのが大きな強みです。一連の処理が多くなるほど恩恵も大きくなります。
多重起動がマズければロック機構を
バッチ処理内部ではあまり「他に同じ処理が動いているかもしれない」ことを考慮しづらく、外側のジョブ制御で制約するほうが簡単、確実です。
特にDBのupdateを伴うようなものはロックファイルなどで制御するようにしましょう。
ベタなやり方ですが、現在でもさまざまな箇所でこの手法は用いられています。
無駄なマシン起動時間を抑える(クラウドの場合)
(できるだけLambdaに寄せたいところではありますが)EC2などの時間課金インフラを用いる場合、処理を行っている時間以外はお金の無駄ですから停止しましょう。一連のジョブの先頭と末尾に組み込むだけです。実行時間によっては、リザーブドを購入するよりよっぽど節約できます。
単発の処理であれば @reboot
のcron設定を組み込むことで起動とともに処理を開始できますが、これを行う場合はサーバ以外のリソースでのロック機構を必ず併用しましょう(どこどこにどのフラグが立っていれば処理を行わない、等)。
ファイル配置系トリガを外部システム間で用いるのは注意
例えばLambdaでS3のファイル配置をトリガにするとか、SCP受信をトリガにするとかはよく用いられますが、外部システム間との連携でこれを設定するのは注意が必要です。特に「N時までにファイルを配置」という条件を取り交わしている場合、認識のズレが発生し、エラーのあるファイルを先に処理してしまう、など発生しがちです。
その他のジョブ制御プロダクトの恩恵
「(処理中における)処理時間超過アラート」などは自前の実装がかなり面倒な部類ですが、設定しておくとかなり早めに異常を検知できます。処理が終わった結果でのアラートは自前で実装できますが、それだと遅い場合が多く、処理中にこれを検知できることに意味があります。
処理ログ/エラーハンドリング
エラー相当のログレベルも2段階用意
適度に進捗情報ログを出力
終了時に処理時間を出力
エラー相当のログレベルも2段階用意
異常時に ERROR
などの特定文字列を出す制御はよく行われますが、それより上の EMERGENCY
などを用意しておきましょう。
ログレベルとしては INFO
WARN
ERROR
EMERGENCY
あたりを用意しておきたいところです。
バッチ処理群の処理結果に応じたシチュエーションとして以下を想定するためです。
「正常な部類だがあとでログを確認したり発生件数を把握したりしたい」:WARN
「異常終了だが一連の処理は継続する、あとで対応が必要」:ERROR
「異常終了で一連の処理も停止する、すぐに対応が必要」:EMERGENCY
適度に進捗情報ログを出力
意外とサボられがちです。通常時は問題なかったりしますが、バグやちょっとしたトラブルで処理時間が激増することはあります。何も出力がないといつ終わるかが予測できず、止めるに止められない状況に陥ります。
「適度」は通常時に1秒以上10分以内の間隔で、ログサイズが10MB以下であれば特に考慮無く設定してよい範囲かと思います。処理件数に応じたものを出力するのが一般的です。
終了時に処理時間を出力
専用のサービスやジョブ制御プロダクトを用いる場合は勝手に出力されますが、ベタに書くとこれもサボられがちです。
開始/終了の時刻だけでなく、開始から終了までの秒数や分数を出しておくと日々の傾向把握に役立ちます。
bashの場合、SECONDS=0
を処理開始時に定義したのち、最後に ${SECONDS}
を出力するだけで経過秒数を取得できます。
監視/通知
ログレベルと頻度に応じた通知設定
マシンリソースを定常取得
ログレベルと頻度に応じた通知設定
上述のとおりログレベルを適切に設定したら、相応の通知設定を行いましょう。
EMERGENCY
はできるだけ早い対応を要するため、モバイル端末で検知できるのが望ましいです(望ましくありませんが)。
また、せっかくの通知もあまりにも多く発生すると見過ごされます。運用当初は通知を多めにしつつも、発生パターンを細分化しつつ、ログレベルを下げていくメンテナンスをしておきたいところです。
マシンリソースを定常取得
CPU、メモリ使用量、I/Oなどのほか、SI/SO(スワップイン、スワップアウト)を取得しておくことを推奨します。
処理データ量の増加等で、メモリ確保が追いつかず、いつのまにかSI/SOが多発していて処理時間が大幅に伸びた、など起こりがちです。
(昔に比べHDD->SSDとなり劣化が軽減されたとはいっても)
この際これらのデータ取得が無いとボトルネックの特定が難しくなり、的はずれな対応をしてしまうことがあります。
取得だけでなく、SIの大量発生や(特にI/Oが激しい処理の場合)ロードアベレージの値で通知設定しておくと安心です。
その他
実日付でなく処理日付で制御
環境差分は自明/不変なもので判定
一時ファイル/フォルダパスを適切に
実日付でなく処理日付で制御
「一連の処理」が相応に長くなる場合、「前日の日付分を処理」している間に実日付が変わることがあります。
また、運用当初は問題無くても、経年での状況変化に応じてその日付変更のタイミングが変わることもあります。
これもジョブ制御プロダクトでは標準的な機能ですが、処理途中の日付変化に耐えうる制御を用いる必要があります。
上述した、「相対日付での引数制御は推奨しない」のはこのためです。
環境差分は自明/不変なもので判定
テスト環境と本番環境は何らかプログラム上の差分が発生することが多いです。
差分の吸収手法は様々ありますが、ビルドやデプロイ時の運用による対応よりは、ホスト名、セグメント、それに準じた環境変数など、環境構築した時点で自明/不変なもので判定させるのが安全です。
一時ファイル/フォルダパスを適切に
「排他的である単位で唯一となる」パス名称にしましょう。並列起動できるのに処理名だけで作られることの無いように。「処理名+引数」などで一意になるとよいでしょう。
さらに実行時刻などを入れて細かくすればこの観点では安全ですが、「そもそも同時実行されるべきではない」単位であればエラーになるべきです。
おわりに
現在は一概にバッチ処理と言っても実行環境/アーキテクチャが様々存在するため、上記のような考慮は勝手に組み込まれているものも多いでしょう。しかし こういったサービス が発表されるということで、まだまだシェルでゴリゴリ書いていく現場は多いのだと思われます。
どういった構成にするにせよ、便利な現代だからこそ「どうあったほうが望ましい」ことを認識した上で使っていきたいものです。
お読みいただき、ありがとうございました。