Dockerfileのベストプラクティスとセキュリティについて

こんにちは、主に検索周りを担当しているエンジニアの伊藤です。

この記事は Enigmo Advent Calendar 2020 の 17 日目の記事です。

みなさんは適切なDockerfileを書けていますか?とりあえずイメージのビルドが出来ればいいやとなっていませんか? 今回は自戒の意味も込めて、改めてDockefileのベストプラクティスについて触れつつ、 そもそもDockerfileを書かずにコンテナイメージをビルドする方法とコンテナセキュリティに関する内容についてまとめてみました。

Dockerfileのベストプラクティス

ご存知の方も多いと思いますが、こちらがDocker社が推奨するベストプラクティとなっています。 せっかくなので事例を交えていくつかピックアップしてみます。

イメージサイズは極力小さくしよう

  • 軽量なベースイメージを選択する
  • 不要なパッケージはインストールしない
  • レイヤはなるべく減らす
    • RUN/COPY/ADDだけがレイヤを増やすのでこれを使用するときに意識しましょう。
      • RUNで実行するコマンドは極力&&で連結する
      • 可能な場合はマルチステージビルドを利用する
      • ADDを使用したアンチパターン(下記の例ではADDによって圧縮ファイルを含んだレイヤが余計に作成されてしまう)
        • アンチパターン

          ADD http://example.com/big.tar.xz /usr/src/things/
          RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
          RUN make -C /usr/src/things all
          
        • 推奨例

          RUN mkdir -p /usr/src/things \
          && curl -SL http://example.com/big.tar.xz \
          | tar -xJC /usr/src/things \
          && make -C /usr/src/things all
          

ビルドキャッシュを活用しよう

イメージをビルドするとき、DockerはDockerfileに書かれた命令を上から順番に実施します。 その際、各命令毎にキャッシュ内で再利用できる既存のイメージを探しますが、なければ以降のキャッシュは破棄されます。 そのため、更新頻度が高いものをDockerfileの後ろの方に記載することが重要になります。 例えば下記はappというアプリケーションコードを含むディレクトリをコンテナにコピーし、 pip installによって必要なライブラリをインストールする例です。

COPY app /tmp/
RUN pip install --requirement /tmp/requirements.txt
  • 推奨例
COPY requirements.txt /tmp/
RUN pip install --requirement /tmp/requirements.txt
COPY app /tmp/

一見すると前者の方がレイヤが少ない分、良さそうに見えますが、 app配下のコードに変更が入るたびにライブラリのインストールも行われ、 その分ビルド時間が伸びてしまいます。

Dockerfileに関する悩みどころ

ここまでDockerfileに関するベストプラクティスについて触れてきましたが、Dockerfileを作成、メンテするのって大変ではないですか?

  • どのベースイメージを使用すべきか?
  • イメージサイズが大きくなりすぎる
    • イメージサイズの削減を頑張ってたら時間が溶けた(開発作業に専念したいのに。。。)
  • Dockerfile自体のメンテが辛い
    • イメージサイズを小さくしようと思うとDockerfile自体の可読性が下がるというつらみ
  • ベストプラクティスを意識することが自体が辛い
  • セキュリティ的な懸念
    • 使用するベースイメージに脆弱性が含まれていないかなど

Dockerfileを書かないという選択肢

そこで続いてのお話がBuildpackについてです。 こちらを利用することでDockerfileを書くことなく、ソースコードからコンテナイメージを生成することが可能になるというものです。

Buildpack

  • 2011年にHerokuが考案し、Cloud FoundryGitlabKnative等で採用されている仕組み
  • 様々な言語のBuildpackを使ってユーザのアプリケーションコードに対して、「判定」、「ビルド」、「イメージ化」といった一連の流れを実施する事によって、基盤上で動作可能な形にアプリケーションコードを組み立てる

Cloud Native Buildpacks

  • 上記のHerokuオリジナルと呼ばれるBuildpackが特定の実行基盤でしか動作しないというでデメリットがあったのに対し、Dockerの急速な普及を背景に、OCIイメージのようなコンテナ標準を採用したイメージを作成しようと始まったのがCloud Native Buildpacks(以降 CNBと略) Projectです。
  • HerokuとPivotalが中心となって2018年1月にCNCF傘下でスタートし、現時点でCNCFのSandboxプロジェクトという立ち位置になっています
  • 以降はこちらのCNBについての概要について記載します

CNBの仕組み

CNBを利用してイメージを生成する際はビルダーというものを指定します。 ビルダーはアプリのビルド方法に関するすべての部品と情報をバンドルしたイメージとなっており、複数のbuildpacklifecyclestackで構成されています。

f:id:pma1013:20201214095357p:plain
公式サイトから引用

  • buildpack
    • ソースコードを検査し、アプリケーションをどうビルドし実行するかを決める
  • lifecycle
    • buildpackの実行を調整し、最終的なイメージを組み立てる
  • stack
    • ビルド及び実行環境用のコンテナイメージのペア

デモ

基本的にCNBを利用して運用していく際には、自前のビルダーを作成することになると思います。 今回はお試しということで、すでにあるビルダーを使って試してみたいと思います。

  • 前提条件
    • ローカル環境にDocker及びBuildpackがインストール済みであること
  • サンプルコード
    • Flaskを利用したWebアプリケーション(単純にHello Worldと出力するだけのもの)
    • 構成としては下記の通りで最低限のファイルのみ配置しています。
              .
              ├── requirements.txt
              └── src
                  ├── __init__.py
                  ├── app.py
                  └── templates
                      └── index.html
  • ビルド
    それではpackコマンドを使ってビルドしてみましょう。
$ pack build sample-cnb:0.0.1
Please select a default builder with:

    pack set-default-builder <builder-image>

Suggested builders:
    Google:                gcr.io/buildpacks/builder:v1      Ubuntu 18 base image with buildpacks for .NET, Go, Java, Node.js, and Python
    Heroku:                heroku/buildpacks:18              heroku-18 base image with buildpacks for Ruby, Java, Node.js, Python, Golang, & PHP
    Paketo Buildpacks:     paketobuildpacks/builder:base     Ubuntu bionic base image with buildpacks for Java, .NET Core, NodeJS, Go, Ruby, NGINX and Procfile
    Paketo Buildpacks:     paketobuildpacks/builder:full     Ubuntu bionic base image with buildpacks for Java, .NET Core, NodeJS, Go, PHP, Ruby, Apache HTTPD, NGINX and Procfile
    Paketo Buildpacks:     paketobuildpacks/builder:tiny     Tiny base image (bionic build image, distroless-like run image) with buildpacks for Java Native Image and Go

Tip: Learn more about a specific builder with:
    pack inspect-builder <builder-image>

packコマンドを実行すると上記のようにビルダーを指定しろと言われます。 今回はここでおすすめされている Google Cloud Buildpacks を利用して実行します。

$ pack build sample-cnb:0.0.1 --builder gcr.io/buildpacks/builder:v1
v1: Pulling from buildpacks/builder
Digest: sha256:f0bb866219220921cbc094ca7ac2baf7ee4a7f32ed965ed2d5e2abbf20e2b255
Status: Image is up to date for gcr.io/buildpacks/builder:v1
v1: Pulling from buildpacks/gcp/run
Digest: sha256:83eb67ec38bb38c275d732b07775231e7289e0e2b076b12d5567a0c401873eb7
Status: Image is up to date for gcr.io/buildpacks/gcp/run:v1
===> DETECTING
google.python.runtime            0.9.1
google.python.missing-entrypoint 0.9.0
google.utils.label               0.0.1
===> ANALYZING
Previous image with name "sample-cnb:0.0.1" not found
===> RESTORING
===> BUILDING
=== Python - Runtime (google.python.runtime@0.9.1) ===
Using runtime version from .python-version: 3.7.8
Installing Python v3.7.8
Upgrading pip to the latest version and installing build tools
--------------------------------------------------------------------------------
Running "/layers/google.python.runtime/python/bin/python3 -m pip install --upgrade pip setuptools wheel"
Collecting pip
  Downloading pip-20.3.1-py2.py3-none-any.whl (1.5 MB)
Collecting setuptools
  Downloading setuptools-51.0.0-py3-none-any.whl (785 kB)
Collecting wheel
  Downloading wheel-0.36.2-py2.py3-none-any.whl (35 kB)
Installing collected packages: pip, setuptools, wheel
  Attempting uninstall: pip
    Found existing installation: pip 20.1.1
    Uninstalling pip-20.1.1:
      Successfully uninstalled pip-20.1.1
  Attempting uninstall: setuptools
    Found existing installation: setuptools 47.1.0
    Uninstalling setuptools-47.1.0:
      Successfully uninstalled setuptools-47.1.0
Successfully installed pip-20.3.1 setuptools-51.0.0 wheel-0.36.2
Done "/layers/google.python.runtime/python/bin/python3 -m pip inst..." (6.427479028s)
=== Python - pip (google.python.missing-entrypoint@0.9.0) ===
Failure: (ID: 194879d1) Failed to run /bin/build: for Python, an entrypoint must be manually set, either with "GOOGLE_ENTRYPOINT" env var or by creating a "Procfile" file
--------------------------------------------------------------------------------
Sorry your project couldn't be built.
Our documentation explains ways to configure Buildpacks to better recognise your project:
 -> https://github.com/GoogleCloudPlatform/buildpacks/blob/main/README.md
If you think you've found an issue, please report it:
 -> https://github.com/GoogleCloudPlatform/buildpacks/issues/new
--------------------------------------------------------------------------------
ERROR: failed to build: exit status 1
ERROR: failed to build: executing lifecycle: failed with status code: 145

今度は上記のようなエラーが出力されます。 どうやらDockerfileのentrypointに相当する GOOGLE_ENTRYPOINTを設定する必要があるようです。 該当のオプションを追加して下記の通り再トライしてみます。

$ pack build sample-cnb:0.0.1 --builder gcr.io/buildpacks/builder:v1 --env GOOGLE_ENTRYPOINT="flask run --host 0.0.0.0 --port 5000"
〜省略〜
Adding cache layer 'google.python.pip:pip'
Adding cache layer 'google.python.pip:pipcache'
Successfully built image sample-cnb:0.0.1

上記のようにSuccessfullyと出力されれば無事にコンテナイメージのビルドは完了しています。 作成されたイメージを確認してみましょう。

REPOSITORY              TAG           IMAGE ID       CREATED         SIZE
sample-cnb              0.0.1         4c60a192da62   40 years ago    289MB

sample-cnbというイメージが作成されていることが確認できました。 ここで気になるのは作成日が40 years agoとなっていることです。 これについては公式サイトに記載がありましたが、 どうやら再現可能なビルドを目的とした意図的な設計のようです。

  • コンテナ起動
    ビルドしたコンテナを起動して正常に動作することを確認します。 下記コマンドでコンテナを起動して、
    $ docker run --rm -p 5000:5000 -e FLASK_ENV=development sample-cnb:0.0.1
    こちらにアクセスすると、下記の画面が表示されることが確認できました。

f:id:pma1013:20201214165419p:plain

  • Dockerfileを使ったビルド
    最後に比較のためにDockerfileを利用したビルドも行います。

    • Dockerfileの準備
FROM python:3.7

WORKDIR /app

COPY requirements.txt /app

RUN pip install -r requirements.txt

COPY src /app/

ENV FLASK_APP=/app/app.py

ENTRYPOINT ["flask", "run"]
CMD ["--host", "0.0.0.0", "--port", "5000"]
  • ビルド
    $ docker build -t sample-df:0.0.1 .

  • 比較
    Dockerfileベースでビルドしたイメージは下記の通りとなります。 CNBで作成したイメージの方が軽量なOSが利用されていることが分かります

REPOSITORY              TAG           IMAGE ID       CREATED          SIZE
sample-df              0.0.1           9a5c14fd1846   14 seconds ago   928MB

CNBのメリット

CNBのメリットをざっとまとめると下記のような感じになるかと思います。

  • 開発に注力できる
    • 開発者はDockerfileを作成、メンテすることから開放される
  • 持続可能な運用
    • スケーラブルなセキュリティ対応
      • 散在しがちなDockerfileすべてにおいて脆弱性対応などしていくのは現実的ではない

セキュリティについて

私のコンテナセキュリティに対する知識としては、下記のようなレベルのものでした。

  • コンテナにおけるセキュリティって何すればいいの?
  • そもそもコンテナに限らず何をすればセキュリティちゃんとしてますって言えるの?

という訳でコンテナにおけるセキュリティ基準やツールとしてはどういったものがあるのかを調査した結果をまとめます。

概要

コンテナにおけるセキュリティ基準

コンテナの脆弱性スキャン

  • コンテナ環境もオンプレ同様にOSのライブラリやパッケージなどから構成されるため、これまで通り脆弱性対策が必須である
  • それに加えてコンテナイメージ、ランタイム環境の脆弱性にも配慮する必要がある

ツールの活用

とりあえず手軽に上記のセキュリティ基準チェックと脆弱性スキャンを行いたいというモチベーションの元、以前から気になるツールをピックアップしました。

dockle

https://github.com/goodwithtech/dockle

trivy

https://github.com/aquasecurity/trivy

  • 概要
    • コンテナの脆弱性スキャンツール
  • 使い方
    • trivy [イメージ名]

デモ

ここで上記で作成したコンテナイメージ(Dockerfileから作成したイメージとCNBで作成したイメージ)をそれぞれのツールにかけた場合にどういった結果になるか確認してみたいと思います。

Dockerfileベース

まずはDockerfileからビルドしたイメージの方です。

  • dockle
    • WARNレベルが1件検知されました。
$ dockle sample-df:0.0.1
WARN    - CIS-DI-0001: Create a user for the container
    * Last user should not be root
INFO    - CIS-DI-0005: Enable Content trust for Docker
    * export DOCKER_CONTENT_TRUST=1 before docker pull/build
INFO    - CIS-DI-0006: Add HEALTHCHECK instruction to the container image
    * not found HEALTHCHECK statement
INFO    - CIS-DI-0008: Confirm safety of setuid/setgid files
    * setuid file: usr/bin/chfn urwxr-xr-x
    * setgid file: usr/bin/ssh-agent grwxr-xr-x
    * setuid file: usr/lib/openssh/ssh-keysign urwxr-xr-x
    * setuid file: bin/umount urwxr-xr-x
    * setgid file: usr/bin/wall grwxr-xr-x
    * setuid file: bin/mount urwxr-xr-x
    * setuid file: usr/bin/gpasswd urwxr-xr-x
    * setuid file: usr/bin/passwd urwxr-xr-x
    * setgid file: usr/bin/chage grwxr-xr-x
    * setuid file: bin/su urwxr-xr-x
    * setuid file: bin/ping urwxr-xr-x
    * setgid file: usr/bin/expiry grwxr-xr-x
    * setuid file: usr/bin/newgrp urwxr-xr-x
    * setuid file: usr/bin/chsh urwxr-xr-x
    * setgid file: sbin/unix_chkpwd grwxr-xr-x
  • trivy
    • こちらは大量の出力結果が表示されるためサマリのみ貼っておきます。 CRITICALなものが69件検知されていることが分かります。
$ trivy sample-df:0.0.1

sample-df:0.0.1 (debian 10.2)
=============================
Total: 2401 (UNKNOWN: 23, LOW: 1291, MEDIUM: 520, HIGH: 498, CRITICAL: 69)

CNBベース

続いてCNBでビルドしたイメージの方を確認してみます。

  • dockle
    • こちらはWARNレベルのものは1件もなく、INFOレベルのものだけが検知されました。
$ dockle sample-cnb:0.0.1
INFO    - CIS-DI-0005: Enable Content trust for Docker
    * export DOCKER_CONTENT_TRUST=1 before docker pull/build
INFO    - CIS-DI-0006: Add HEALTHCHECK instruction to the container image
    * not found HEALTHCHECK statement
INFO    - CIS-DI-0008: Confirm safety of setuid/setgid files
    * setgid file: usr/bin/expiry grwxr-xr-x
    * setuid file: bin/umount urwxr-xr-x
    * setgid file: usr/bin/chage grwxr-xr-x
    * setuid file: usr/bin/newgrp urwxr-xr-x
    * setgid file: usr/bin/wall grwxr-xr-x
    * setuid file: usr/bin/chsh urwxr-xr-x
    * setuid file: bin/su urwxr-xr-x
    * setuid file: usr/bin/passwd urwxr-xr-x
    * setuid file: usr/bin/gpasswd urwxr-xr-x
    * setuid file: usr/bin/chfn urwxr-xr-x
    * setuid file: bin/mount urwxr-xr-x
    * setgid file: sbin/unix_chkpwd grwxr-xr-x
    * setgid file: sbin/pam_extrausers_chkpwd grwxr-xr-x
  • trivy
    • こちらもサマリのみ貼りますが、CRITICALに関しては0件となっています
$ trivy sample-cnb:0.0.1
2020-12-14T19:22:18.244+0900    INFO    Detecting Ubuntu vulnerabilities...

sample-cnb:0.0.1 (ubuntu 18.04)
===============================
Total: 75 (UNKNOWN: 0, LOW: 53, MEDIUM: 20, HIGH: 2, CRITICAL: 0)

この結果からもGoogle Cloud Buidpackを利用してビルドしたイメージの方が軽量かつセキュアな環境であることが分かると思います。

CIへの組み込み

上で紹介したツールはいずれもCIに組み込んで使用することも想定して作られています。 下記のようにオプションを指定して使うことで、CIのタイミングで実行&確認がしやすくなっています。

  • dockle
    • dockle --exit-code 1 [イメージ名]
  • trivy
    • trivy --exit-code 1 --severity CRITICAL --no-progress [イメージ名]

まとめ

今回はDockerfileのベストプラクティスのおさらいと、CNBを利用したコンテナイメージのビルド方法、セキュリティに関してさらっとまとめてみました。

今後もコンテナベースのアプリケーション開発が進むと、 これまで個人、チームレベルで任せていたDockerfileの作成、管理が破綻するのではと感じました。 CNBには組織として統制のとれたコンテナ作成やセキュリティ基準を継続的に満たすことの手段が提供されているので、 その辺りをうまく活用していく必要性を感じでいます。

セキュリティについても検知の仕組みだけでなく、日々の運用の中でいかに対応していくかということが大事だと思うので、 今後も試行錯誤しながら少しずつ前進していければと思っています。

明日の記事の担当はインフラエンジニアの山口さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co


  1. CIS(Center for Internet Security)とは、米国のNSA(National Security Agency/国家安全保障局)、DISA(Difense Informaton Systems Agency/国防情報システム局)、NIST(National Institute of Standards and Technology/米国立標準技術研究所)などの米国政府機関と、企業、学術機関などが協力して、インターネット・セキュリティ標準化に取り組む団体の名称

Rails + マイクロサービスでイベント駆動アーキテクチャを導入した話

はじめに

こんにちは、サーバーサイドエンジニアの@hokitaです。

この記事は Enigmo Advent Calendar 2020 の 16 日目の記事です。

弊社が運営するBUYMAは現状モノレポで管理されており、10年以上も運営しているサービスなのでソースも肥大化していて、メンテナンスが難しくなってきました。 そこで現在、本体から少しずつマイクロサービスに切り離していこうとしています。

その取組の中で配送処理の一部をマイクロサービス化する作業に携わることができました。今回はBUYMA本体と配送サービスとの通信にイベント駆動アーキテクチャを導入した話をしていきます。

イベント駆動アーキテクチャ

マイクロサービスでサービスを切り分ける場合、それぞれ責務が分かれるように分割するかと思います。 しかしサービス間の通信手段によっては各サービスが密になる恐れがあります。

そこでイベント駆動アーキテクチャを利用します。

f:id:hokita:20201215115957p:plain
PublisherとSubscriber

メリットとして

  • PublisherはSubscriberのことを知らない
  • Subscriberはイベントのこと以外知らない
  • Publisher1つに対し、複数のSubscriberを設置できる

このようにPublisherとSubscriberはお互い知らないので、疎の関係を保つことができます。

非同期メッセージングサービス

イベント駆動アーキテクチャを実現するために、非同期メッセージングサービスを利用します。

代表的なものとして

などがあります。

今回は Amazon SNS / Amazon SQS を使用しました。

Amazon SNS

docs.aws.amazon.com

発行者からサブスクライバー (プロデューサーおよびコンシューマーとも呼ばれます) へのメッセージ配信を提供するマネージド型サービスです。

メッセージを発信するものと思って頂ければと思います。メッセージの受け取り先(サブスクライバー)として今回はAmazon SQSですが、他にもAWS Lambdaなどでも取得することが可能です。

なぜSNSを利用するのか

次の図を見て頂ければ分かる通り、SNSがないとBUYMA本体が配送サービスを知っていることになります。つまりになってしまいます。

f:id:hokita:20201215120053p:plain
SNSなしの場合

Amazon SQS

docs.aws.amazon.com

メッセージキューイングサービスです。同じ非同期処理としてsidekiqやResqueを使っているサービスも多いかと思います。

主な特徴としては

  • "at least once"(最低1回)が保証されている
    • 逆に2回以上同じメッセージを取得する可能性がある
    • Redisのようにジョブを失うことがない
  • 順不同(それなりに担保されるのかと思っていましたが全く順不同でした。)

FIFO(First-In-First-Out)キュー

通常キューとは別でFIFOキューというものも使用することができます。

  • 受信する順序が保持される
  • 必ず1回処理される
  • 1つずつ処理される
  • 通常キューより処理が遅くなる

処理順序が決まっている処理(例えば製品価格の変更処理など)で便利かと思います。

ロングポーリング

ショートポーリングとロングポーリングというものがありますが、基本ロングポーリングが良いです。(ショートポーリングはいつ使われるのだろうという感じです。)

ロングポーリングですが最大20秒キューにメッセージがないか待機をして、あれば即座に実行します。(筆者は最初20秒間に溜まったメッセージを処理するのかと思っていましたが、勘違いでした。メッセージを取得したら即座に実行です。)

Shoryuken

github.com

RailsでSQSをジョブキューとして利用するときは現状Shoryuken一択です。Shoryukenを起動することでSQSのキューをポーリングし、キューを取得して処理を実行してくれます。エンキューももちろんできます。

一通りwikiに必要な情報が書かれているので、そちらを読めば問題なく実装できるかと思います。

以下設定ファイルや実装例を記載します。

shoryuken.yml

対象のキューの情報を記載します。

Shoryuken options · phstc/shoryuken Wiki · GitHub

# config/shoryuken.yml

groups:
  purchase_completed:
    concurrency: 1
    queues:
      - ['purchase_completed_queue', 1]
pidfile: ./tmp/pids/shoryuken.pid

※groupsで分けている理由

groups:
  group1:
    concurrency: 1
    queues:
      - ['a_queue', 2] # 重さ2
      - ['b_queue', 1] # 重さ1

このように同じグループに複数のキューを設定している場合、a_queueがメッセージを取得してもb_queueにメッセージがない場合はポーリング時間が終わるまでa_queueの処理は実行されないので注意が必要です。

Processing Groups · phstc/shoryuken Wiki · GitHub

またa_queueb_queueで大量にメッセージがある場合、a_queueb_queueの重さの2倍なので、処理の優先度も2倍になります。

Polling strategies · phstc/shoryuken Wiki · GitHub

shoryuken.rb

SQSの情報を記載します。

# config/initializers/shoryuken.rb

# ロングポーリング
Shoryuken.sqs_client_receive_message_opts = { wait_time_seconds: 20 }

Shoryuken.sqs_client = Aws::SQS::Client.new(
  region: ENV['AWS_REGION'],
  access_key_id: ENV['AWS_ACCESS_KEY_ID'],
  secret_access_key: ENV['AWS_ACCESS_SECRET_KEY']
)

起動

$ bundle exec shoryuken -R -C config/shoryuken.yml

ジョブ

例)決済完了時に実行するジョブ

# app/jobs/purchase_complete_job.rb

# 決済完了時のジョブ
class PurchaseCompleteJob < ApplicationJob
  include Shoryuken::Worker

  shoryuken_options queue: '<キュー名>',
                    auto_delete: true,
                    body_parser: :json

  def perform(_sqs_msg, body)
    message = JSON.parse(body['Message'])

    # 保存処理
  end
end

エンキュー

エンキューも簡単にできます。

# 配送ステータスの通知ジョブにエンキュー
StatusNotificationJob.perform_async(
  status_data: 'some status data'
)

FIFOキュー

前述したFIFOキューですが、Shoryukenでも扱うことができます。 基本通常キューと記述は同じですが、メッセージグループIDを指定することができます。 同じメッセージグループIDだと厳密な順序で、常に1件ずつ処理をします。

例えば取引のステータスを変更したい場合、ステータス変更の順序は重要だが、他の取引同士の順序は気にしない場合はメッセージグループIDに取引IDを指定したら良さそうです。

SomeJob.perform_async(
  csv_id: csv.id,
  message_group_id: <MESSAGE_GROUP_ID>
)

イベント駆動は一筋縄ではいかない話

各技術の説明をしてきましたが、ここからはイベント駆動アーキテクチャ導入時に必ず考慮する点を書いていきます。

冪等性

同じ処理を何度実行しても同じ結果が得られる性質のことです。

冪等 - Wikipedia

Amazon SQSの通常キューでは2回以上同じメッセージを取得する可能性があるので、重複してメッセージを取得することを考慮する必要があります。

今回実装した決済完了イベントでは、取引ID(冪等キー)をUnique Keyにして、DB側で制御するようにしました。

# 配送サービスの配送情報保存処理
def save!
  Delivery.save!(params)
rescue ActiveRecord::RecordNotUnique => e
  # 念の為取引ID(冪等キー)で重複していることを確認
  raise e unless Delivery.exists?(order_id: params[:order_id])

  # ログだけ残して正常終了させる
  Rails.logger.info(
    "#{self.class}##{__method__} Message: #{e}"
  )
end

もしくはFIFOキューを導入することでも解決が可能です。

整合性

モノレポの場合はDBのtransaction機能を利用して、失敗したらロールバックすることが可能です。しかしマイクロサービスだとそう簡単にはできません。

一般的にはTCCパターンやSagaパターンを使用する必要がでてきます。 qiita.com

今回は下記理由で、ロールバック処理を行いませんでした。

  • 連携するサービス数が少ないので今のところ処理がシンプル
  • 外部からの入力事項は送信前にバリデーションされている
  • BUYMA本体から配送サービスに送られてくるメッセージは信頼する
  • BUYMA本体の取引データと配送データに不整合がないかを毎日チェックしている

現状は問題なく動作していますが、今後はもっと堅牢にするためにTCCパターンやSagaパターンを導入する必要がありそうです。

最後に

イベント駆動アーキテクチャに使用した技術と考慮するポイントを書いていきましたが、マイクロサービスは思っている以上に学習することが多く、また実際に作成して経験値を積んでいくことが大事だと実感しました。 今後も実践を経て、レガシーから脱却できるように精進していきたいです。

明日の記事の担当は検索エンジニアの伊藤さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

Figma Pluginの作り方

この記事は Enigmo Advent Calendar 2020の15日目の記事です。

エニグモでは、2日目の記事「デザインツールをXd→Figmaへした話 / プロトタイプ作るようになった話」にもあるように、UIに関わるメンバーは、Figmaを使用してデザインすることが多くなりました。

私もそのうちの一人で、SketchやXDに比べて便利なことが多く、デザインの確認・共有がしやすくなったように感じています。 日頃の業務でFigmaを使っていて、作ってみたい機能が出てきたため、Figma Pluginの開発を始めてみました。

記事について

本記事では、Figma Pluginの開発を始めるまでの作業や内容について説明します。

Figma Pluginの公式ドキュメントは大変分かりやすく書かれていますが、少し内容が古くなっている箇所もあり、そのまま進めても動作しないこともありましたので記していければと思います。

ちなみに、本記事は公開日直前に書いており、後述する作りたいプラグインは本記事内で完成しません。すみません。

作りたいもの

日頃の業務で、デザインを作成したときにUIの設計意図や説明をコメントとして、デザイン上に添えることがよくあります。(以下イメージ)

f:id:enigmo7:20201215130834p:plain

コメントする度に、「frame」を用意して、テキストを置いて、どこの部分にコメントしているのかを分かるようにする必要があり、少し手間だと感じておりました。 どこか効率化できないかと思い、Figma Pluginの作成を試みました。

想定したプラグインの概要は、まず選択した「Layer」の名前を取得し、プラグインのUI側でコメントを書いて、ボタンを押すと、コメント用の「Frame」が生成されるというものです。デザイン上のコメントは、時には邪魔になることもあるので、一括で表示・非表示なども制御できるとよいなと考えております。

Figmaのコメント機能でやればいいじゃないか』と思われるかもしれませんが、コメントするためにモードを切り替える必要があり面倒で、また、デザインを見たメンバーが確実にコメントまで見てくれているとは限らないため、今のところはデザイン上に「frame」としてコメントを置きたいと考えております。

用意しておくもの

Visual StudioCode

  • エディタは基本的に何でも大丈夫だと思いますが、TypeScriptを使用するため公式ではVSCodeが推奨されています。

Node.js

  • まだの方は以下のサイトからダウンロードしておきます

TypeScript

  • JavaScriptでも開発はできますが、変数がどんなプロパティを持っているのかすぐに知ることができるため推奨されています。

Figmaデスクトップアプリ

  • ブラウザのものではなく、デスクトップアプリが必要です。ローカルファイルを読み込みながら開発できます。

プラグインを動かすまで

Figmaアプリを起動

アプリケーションメニューの「Plugins」>「Manage Plugins...」を選択します。

f:id:enigmo7:20201215131331p:plain

表示された画面の「In Development」のセクションから「+」ボタンをクリックします。

f:id:enigmo7:20201215131335p:plain

プラグインのテンプレートを作成、ローカルへ保存

「Create a plugin」のモーダルが出てくるので、作りたいプラグインの名前を入力し、「Continue」ボタンをクリックします。

f:id:enigmo7:20201215131338p:plain

「Choose a template」の画面に進むので、自分が作りたいテンプレートの形式を選択します。今回私はUIを伴ったプラグインを作成したいので、一番右の「With UI & browser APIs」を選択しました。

f:id:enigmo7:20201215131343p:plain

「Save as...」からローカルに保存します。保存すると、先程の「In Development」のセクションに連携されます。

f:id:enigmo7:20201215131347p:plain

VSCodeでファイルを開く

7つのファイルで構成されています。それぞれの説明は、後述します。

f:id:enigmo7:20201215132608p:plain

TypeScriptの自動コンパイルの設定

「ターミナル」>「ビルド タスクの実行...」から「tsc:ウォッチ - sample/tsconfig.json」を選択し、クリックします。

f:id:enigmo7:20201215132613p:plain

これで編集を加える度に自動でコンパイルが実行されますが、TS2304のエラーが出るので、型定義ファイルをインストールしてください。

f:id:enigmo7:20201215133233p:plain

npm install --save-dev @figma/plugin-typings

これでエラーがなくなったと思います。 先程保存したプラグインが実際にFigmaアプリ上で動くようになります。

Figmaプラグインを実行

「Plugins」> 「Development」 > 「sample」をクリックするとプラグインが実行すると、長方形作成者が現れます。ちなみに、「Plugins」> 「Development」 > 「Open console」で見慣れたコンソールを表示することができます。

f:id:enigmo7:20201215134913p:plain

構成ファイルについて

manifest.json

詳しい説明はこちらに記載されております。

code.ts

先程の長方形を作成するプラグインでいうと、Figmaのページに長方形を作成する処理が書かれています。

figma.showUI(__html__);

figma.ui.onmessage = msg => {
  if (msg.type === 'create-rectangles') {
    const nodes: SceneNode[] = [];
    for (let i = 0; i < msg.count; i++) {
      const rect = figma.createRectangle();
      rect.x = i * 150;
      rect.fills = [{type: 'SOLID', color: {r: 1, g: 0.5, b: 0}}];
      figma.currentPage.appendChild(rect);
      nodes.push(rect);
    }
    figma.currentPage.selection = nodes;
    figma.viewport.scrollAndZoomIntoView(nodes);
  }
  figma.closePlugin();
};

code.js

  • 上記 code.tsコンパイルされた後の姿
  • manifest.jsonで読み込まれ、実行されるファイル

ui.html

先程の長方形を作成するプラグインでいうと、長方形の数を入力するプラグイン側のUI部分になります。

<h2>Rectangle Creator</h2>
<p>Count: <input id="count" value="5"></p>
<button id="create">Create</button>
<button id="cancel">Cancel</button>
<script>

document.getElementById('create').onclick = () => {
  const textbox = document.getElementById('count');
  const count = parseInt(textbox.value, 10);
  parent.postMessage({ pluginMessage: { type: 'create-rectangles', count } }, '*')
}

document.getElementById('cancel').onclick = () => {
  parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*')
}

</script>

HTMLファイルになっておりますが、<script> タグでJavaScriptのコードが埋め込まれています。

このファイル側、つまりUI側ではFigmaの操作を直接行うことができず、Web Messaging APIのpostMessageを介して、 code.ts とお互いにやり取りすることで連携しています。

f:id:enigmo7:20201215150946p:plain

※ その他にも構成ファイルには、 package.json tsconfig.json README.mdがあります。

Webpack + Reactの導入

せっかくなので、Reactを導入して開発していきたいと思います。 導入の仕方は自由ですが、今回は公式ドキュメントに沿って進めていきます。

インストール

公式のドキュメントには以下のインストール行うように書かれていますが、

npm install --save-dev css-loader html-webpack-inline-source-plugin@beta html-webpack-plugin style-loader ts-loader typescript url-loader webpack webpack-cli

この通りにインストールしてしまうと動かないため、Figmaが提供しているReactのサンプルの依存関係を参考にして、インストールします。

"dependencies": {
    "@types/react": "^16.8.23",
    "@types/react": "^16.8.23",
    "@types/react-dom": "^16.8.5",
    "css-loader": "^3.1.0",
    "html-webpack-inline-source-plugin": "0.0.10",
    "html-webpack-plugin": "^3.2.0",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "style-loader": "^0.23.1",
    "ts-loader": "^6.0.4",
    "typescript": "^4.0.3",
    "url-loader": "^2.1.0",
    "webpack": "^4.38.0",
    "webpack-cli": "^3.3.6"
  }

ファイル作成

ライブラリのインストールが終わったら、構成ファイルを変更します。新たに以下のファイルを作成し、中身はサンプルを参考にします。

  • src/code.ts
  • src/ui.html
  • src/ui.tsx
  • src/ui.css
  • src/logo.svg
  • webpack.config.js

ファイルの変更

manifest.jsonuimainを以下のように書き換えます。

{
  ...
  "main": "dist/code.js",
  "ui": "dist/ui.html",
  ...
}

tsconfig.jsonを以下のように書き換えます。

{
  "compilerOptions": {
    "target": "es6",
    "jsx": "react",
    "typeRoots": [
      "./node_modules/@types",
      "./node_modules/@figma"
    ]
  }
}

ファイルを削除

先程まで使用していた code.ts , ui.html ファイルは不要になるので削除します。

これで環境が整いました。 Figmaデスクトップアプリから実行すると、プラグインが実行されるようになりました。

プラグインのUIデザイン

最後になりましたが、プラグインのUIは、Figma Componentsが参考になりそうでした。 必ずしもFigmaに沿ったUIである必要はなさそうですが、Plugin Review GuidelinesでもFigmaに沿ったデザインが推奨されています。

We highly recommend matching your plugin to Figma's UI so we can create a seamless experience for our users.

終わりに

今回はFigma Plugin作成の準備についてまとめました。

デザイナーであっても自力で作成できる範囲だと思いましたので、これから冒頭で述べたプラグインを作っていければと思います。

明日の記事の担当は、サーバーサイドエンジニアの@hokitaさんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧 hrmos.co

SQL Server、BigQuery、Redshift 日付型の比較&リファレンス

この記事はEnigmo Advent Calendar 2020 の14日目の記事です。

はじめに

こんにちは、エニグモ 嘉松です。

簡単な自己紹介ですが、BUYMAのプロモーションやマーケティングを行っている事業部に所属して、その中のデータ活用推進室という部署で会社のデータ活用の推進やマーケティング・オートメーションツール(MAツール)を活用した販促支援、CRMなどを担当しています。(データ活用推進室、長らく私一人部署だったのですが、先月1名増えて2名体制になりました!)

背景

エニグモのデータ活用の大きな特徴として、エンジニアに限らず、マーケターやマーチャンダイザー(MD)、カスタマーサポートや役員まで、多くの社員、ほとんどの社員と言っても過言では無いくらいの人が自分でSQLを叩いてデータを見る、分析するという文化、カルチャーが根付いているということが言えると思います。

また、データ活用基盤の整備も積極的に進めており、クラウドで提供されているビックデータ向けのデータベースをデータレイクやデータウェアハウス(DWH)として利用しています。

このように複数のデータベースを活用してく中で出てくる問題点が、SQLシンタックスの違いです。 特にエニグモではエンジニアでは無いユーザもたくさんいるので、SQLの作成に多くの時間がとられてしまうと、本来の業務へも影響がでてきてしまいますし、データ活用は停滞してしまいます。

そこで、この記事ではMicrosoftが提供しているSQLServerGoogleが提供しているフルマネージド型分析データウェアハウスであるBigQuery、Amazon Web ServicesAWS)のクラウド型データウェアハウスであるRedshiftの3製品を対象として、特に混乱するであろう日付および時刻関連のデータ型について整理することで、今後のリファレンスになればと思っています。

f:id:enigmo7:20201212155053p:plain

日付および時刻関連のデータ型

まず、ここでは各データベースの日付および時刻関連のデータ型(の代表的なもの)を列挙します。

※データ型の表記(大文字小文字)、説明の内容については各データベースのマニュアルにおおよそ準拠しています。

SQL Server

データ型 説明 タイムゾーン
date 日付型 なし
datetime 日時型(タイムゾーンなし) なし
datetimeoffset 日時型(タイムゾーンあり) あり

BigQuery

データ型 説明 タイムゾーン
DATE 日付型 なし
DATETIME 日時型 なし
TIMESTAMP タイムスタンプ型 あり

Redshift

データ型 説明 タイムゾーン
DATE カレンダー日付 (年、月、日) なし
TIMESTAMP 日付と時刻 (タイムゾーンなし) なし
TIMESTAMPTZ 日付と時刻 (タイムゾーンあり) あり

タイムゾーンとは?

タイムゾーンについて言及すると、それだけで1本の記事になるくらいなので、簡単に説明します。

データーベースにおけるタイムゾーンのあり・なしとは、標準時間をUTCとするか、それとも個々のデータベースで決めるか、ということです。

タイムゾーンありのデータ型を使う場合は、当然、データを格納する時にもタイムゾーンを指定してデータを格納する必要があります。

また、タイムゾーンなしのデータ型を使う場合は、そのデーターベースにはどのタイムゾーンでデータが格納されているかを、意識して使う必要があります。 例えば日本の時間で格納したデータを、ニューヨークの時間帯で表示させるには、時間を14時間戻してあげるといったことを意識的に行う必要があります。

いずれにしても、ひとつのデータベースで時差のある地域の時間を扱う場合は、時差を意識することからは逃れられません。基準となる時間をUTCにするのか、どうかの違いです。 逆に日本時間だけで良いシステムであれば、扱う時間は常に日本時間なので、タイムゾーンなしのデータ型を使うことで、タイムゾーンを意識する必要がなくなります。

データ型まとめ

日付型 日時型(タイムゾーン無し) 日時型(タイムゾーン有り)
SQLServer date datetime datetimeoffset
BigQuery DATE DATETIME TIMESTAMP
Redshift DATE TIMESTAMP TIMESTAMPTZ

日付型は3データベースともDATEで分かりやすいですね。

日時型(タイムゾーン無し)はSQLServerとBigQueryがDATETIMEなのに対して、RedshiftがTIMESTAMP

日時型(タイムゾーン有り)は全てのデーターベースで異なります。

更にTIMESTAMPはRedshiftでは日時型(タイムゾーン有り)なのに対して、BigQueryでは日時型(タイムゾーン無し)となっています。

この時点で既にややこしくなってますね。

現在日時(日付と時間)の取得方法

次に、それぞれのデータベースで現在の日時(日付と時間)を取得する関数を見ていきます。 ここに挙げた関数以外もありますが、よく使う(であろう)ものを列挙しています。

SQL Server

GETDATE関数

戻り値の型:datetime

SELECT
 GETDATE()
;
------------
2020-12-09 08:20:17.645

BigQuery

CURRENT_TIMESTAMP関数

戻り値の型:TIMESTAMP

SELECT
 CURRENT_TIMESTAMP()
;
------------
2020-12-10 08:07:47.222776 UTC

括弧は省略可能です。 UTCで表示されます。 日本時間(JST)で表示させたい場合は以降の「日付型 → 文字型」を参照ください。

Redshift

SYSDATE関数

戻り値の型:TIMESTAMP

select
 sysdate
;
------------
2020-12-09T08:20:17.645728

GETDATE関数

戻り値の型:TIMESTAMP

select
 getdate()
;
------------
2020-12-09T08:20:17.645728

どちらもデフォルトではUTCが表示されるので、日本時間を表示したい場合はセッションのタイム ゾーン(デフォルトではUTC)を設定してあげる必要があります。

set timezone = 'Asia/Tokyo';
select
 sysdate
;
------------
2020-12-09T17:20:17.645728

 

set timezone = 'Asia/Tokyo';
select
 getdate()
;
------------
2020-12-09T17:20:17.645728

現在日付の取得方法

SQL Server

SQL Serverには単体で日付を取得する関数が無いので、GETDATE()で現在の日にち時刻を取得した後に、CONVERTを使ってdate型に変換してあげる必要があります。

SELECT
 CONVERT(date, GETDATE())
;
------------
2020-12-09

BigQuery

CURRENT_DATE関数

戻り値の型:DATE

SELECT
 CURRENT_DATE()
;
------------
2020-12-09

引数に何もして指定しないとUTCの日にちが返ってくるので、日本時間での日にちを取得する場合は、引数にタイムゾーンを指定してあげます。ここ注意ですね。

SELECT
 CURRENT_DATE("Asia/Tokyo")
;
------------
2020-12-09

Redshift

CURRENT_DATE関数

戻り値の型:DATE

select
 current_date
;
------------
2020-12-09

現在日時の取得方法まとめ

SQL Server BigQuery Redshift
日時 GETDATE() CURRENT_DATETIME() sydate
getdate()
日付 なし CURRENT_DATE() current_date

日付型 → 文字型

日付型のデータを文字列に変換する方法について記載します。

SQL Server

年月日(YYYY/MM/DD形式)

SELECT
 CONVERT(nvarchar, getdate(), 111)
;
------------
2020/12/10

年月日(YYYYMMDD形式)

SELECT
 CONVERT(nvarchar, getdate(), 112)
;
------------
20201210

年月日時分秒(yyyy-mm-dd hh:mi:ss.mmm (24h))

SELECT
 CONVERT(nvarchar, getdate(), 21)
;
------------
2020-12-10 16:34:37.837

年月日時分秒(yyyy-mm-ddThh:mi:ss.mmm形式(ISO8601標準))

SELECT
 CONVERT(nvarchar, getdate(), 126)
;
------------
2020-12-10T16:30:05.690

BigQuery

DATE型

FORMAT_DATE(format_string, date_expr)

指定されたformat_string(形式設定要素)に従ってdate_exprをフォーマットします。

DATE型でサポートされる形式設定要素

形式設定要素 説明
%Y 10 進数として表示される、世紀を含む年。
%y 10 進数(00-99)として表示される年。世紀は含みません。
%m 0 進数として表示される月(01~12)。
%d 10 進数として表示される、月内の日付(01~31)。
%F %Y-%m-%d 形式の日付。

年月日(YYYYMMDD形式)

SELECT
 FORMAT_DATE("%Y%m%d", CURRENT_DATE())
;
------------
20201210

年月日(YYYY-MM-DD形式)

SELECT
 FORMAT_DATE("%F", CURRENT_DATE())
;
------------
2020-12-10

TIMESTAMP型

FORMAT_TIMESTAMP(format_string, timestamp[, timezone])

指定されたformat_string(形式設定要素)に従ってtimestampをフォーマットします。

タイムゾーンを指定すると指定したタイムゾーンに変換されて表示されます。

タイムゾーン名

TIMESTAMP型でサポートされる形式設定要素

形式設定要素 説明
%Y 10 進数として表示される、世紀を含む年。
%y 10 進数(00-99)として表示される年。世紀は含みません。
%m 10 進数として表示される月(01~12)。
%d 10 進数として表示される、月内の日付(01~31)。
%H 10 進数で表示される時間(24 時間制)(00~23)。
%M 10 進数として表示される分(00~59)。
%S 10 進数として表示される秒(00~60)。
%F %Y-%m-%d 形式の日付。
%T %H:%M:%S 形式の時刻。
%Z タイムゾーンの名前。
%z 必要に応じて +HHMM または -HHMM の形式で示されるグリニッジ子午線からのオフセット。

年月日時分秒(yyyy-mm-dd hh:mi:ss.mmm (24h))

SELECT
 FORMAT_TIMESTAMP("%Y-%m-%d %H:%M:%S", CURRENT_TIMESTAMP(), "Asia/Tokyo")
;
------------
2020-12-10 17:13:13

Redshift

TO_CHAR (timestamp_expression, 'format')

日時形式の文字列

形式設定要素 説明
YYYY 4 桁の年数
MM 月番号 (01~12)
DD 日にちを数字表示 (01–31)
HH24 時 (24 時間制、00–23)
MI 分 (00–59)
SS 秒 (00–59)

年月日時分秒(yyyy-mm-dd hh:mi:ss.mmm (24h)) UTC

select
 to_char(sysdate, 'YYYY-MM-DD HH24:MI:SS') 
;
------------
2020-12-10 08:13:13

年月日時分秒(yyyy-mm-dd hh:mi:ss.mmm (24h)) JTC

select
,to_char(convert_timezone('Asia/Tokyo', sysdate), 'YYYY-MM-DD HH24:MI:SS') 
;
------------
2020-12-10 17:13:13

文字型 → 日付型

SQL Server

datetime型

日付だけ指定した場合は時分秒は0時0分0秒となります。

SELECT
 CONVERT(datetime, '2020/12/10')
;
------------
2020-12-10T00:00:00

日付のスラッシュ(/)は省略することもできます。

SELECT
 CONVERT(datetime, '20201210')
;
------------
2020-12-10T00:00:00

時分秒を指定したい場合は日付の後にスペースを開けて、時:分:秒を付けます。

SELECT
 CONVERT(datetime, '2020/12/10 12:15:30')
;
------------
2020-12-10T12:15:30

BigQuery

DATE型

CASTを使います。 年月日は-ハイフンで区切ります。 2020/12/10のように/スラッシュで区切ったり、20201210のように区切らない場合はエラーになります。

SELECT
 CAST('2020-12-10' AS DATE)
;
------------
2020-12-10

TIMESTAMP型

日付だけ指定した場合は時分秒は0時0分0秒となります。 また、タイムゾーンUTCになります。

SELECT
 CAST('2020-12-10' AS TIMESTAMP)
;
------------
2020-12-10 00:00:00 UTC

時分秒を指定したい場合は日付の後にスペースを開けて、時:分:秒を付けます。

SELECT
 CAST('2020-12-10 12:15:30' AS TIMESTAMP)
;
------------
2020-12-10 12:15:30 UTC

タイムゾーンを指定したい場合は+09のようにUTCからの時差を指定します。

SELECT
 CAST('2020-12-10 12:15:30+09' AS TIMESTAMP)
;
------------
2020-03-10 12:15:30 UTC

Redshift

DATE型

TO_DATE (string, format)

引数には、変換したい文字列とそのフォーマットを指定します。

SELECT
 TO_DATE('2020/12/10', 'YYYY/MM/DD')
;
------------
2020-12-10

フォーマットの方法によって変換したい文字列の形式を指定できます。

SELECT
 TO_DATE('2020-12-10', 'YYYY-MM-DD')
;
------------
2020-12-10

こんなことでも大丈夫です。

SELECT
 TO_DATE('2020###12$$$10', 'YYYY###MM$$$DD')
;
------------
2020-12-10

CASTを使うこともできます。

SELECT
 CAST('2020-12-10' AS DATE)
;
------------
2020-12-10

TIMESTAMP型

日付だけ指定した場合は時分秒は0時0分0秒となります。

SELECT
 CAST('2020-12-10' AS TIMESTAMP)
;
------------
2020-12-10 00:00:00

年月日の区切りは/でも大丈夫です。

SELECT
 CAST('2020/12/10' AS TIMESTAMP)
;
------------
2020-12-10 00:00:00

区切り文字がなくても大丈夫です。

SELECT
 CAST('20201210' AS TIMESTAMP)
;
------------
2020-12-10 00:00:00

時分秒を指定したい場合は日付の後にスペースを開けて、時:分:秒を付けます。

SELECT
 CAST('2020-12-10 12:15:30' AS TIMESTAMP)
;
------------
2020-12-10 12:15:30

f:id:enigmo7:20201212154555p:plain

最後に

この記事では、SQL Server、BigQuery、Redshiftの3つのデーターベースを対象に、日付および時刻関連のデータ型についてまとめました。 日頃、私は上記のデータベースを使い分けている、それもおおよそ均等に使っているような状況なので、特に日付型の関数についてはよく迷ったりしています。 何度bigquery 日付 文字列 変換でググったことか。 今回、このように整理することで、迷ったときはこの記事を参照することで、少しでも生産性を高められたらと思っています。 記載したSQLについては、実際に実行した上で確認していますが、データーベースのバージョンの違いなどによってエラーになったり、そもそも間違っていたりする可能性もあるので、その場合はコメントなどに記載いただければ、修正や補足など入れていきたいと思っています。


株式会社エニグモ 正社員の求人一覧

hrmos.co

Amazon EKS アップグレードにてこずった話

こんにちは。Enigmoインフラエンジニアの夏目です。

この記事は Enigmo Advent Calendar 2020 の13日目の記事です。

なんだか競馬関連のエントリがいっぱいですが、弊社の主要サービスは競馬予想サイトではありませんので誤解なきよう。僕は競馬のことはさっぱりわからないのですが、先月末のジャパンカップは大変熱いレース展開でしたね。着順自体はまったく面白みがなく収支マイナスになってしまいましたが。

さておき。1年前と同様、今年もKubernetesクラスタ運用に翻弄される日々を過ごしておりまして、今日の記事はそんなKubernetes...というかAmazon EKSクラスタに関するお話です。

Kubernetesのリリースサイクルに乗り遅れるな

皆さんご存知の通りKubernetesのマイナーバージョンはおよそ3ヶ月ごとにリリースされ、各マイナーバージョンは最新バージョンとの差異が3以上になった時点でコミュニティのサポート対象外となります。

https://github.com/kubernetes/community/blob/master/contributors/design-proposals/release/versioning.md#release-versioning

つまり、同一バージョンを1年以上利用することはほぼ不可能に近く、どうしても1年のうち最低でも1回はアップグレード作業が必要となります。これはKubernetesを本番環境で利用する上で避けることができない、インフラエンジニアの頭を悩ませる問題のひとつです。

(注:1.19以降は1年間のサポートがアナウンスされていますが、バージョン差異は4つも進んでしまうのでどのみちアップグレードしないわけにはいきません)

エニグモの一部サービスではAmazon EKSクラスタを利用しており、春先に1.15がリリースされたタイミングで1.13からアップグレードを実施しました。

EKSはKubernetes本家のリリースからおよそ半年遅れでリリースが行われ、本家よりも3ヶ月長い1年程度のサポートが保証されています。

そのため、1.15は2021年3月…よりも少し先の5月までがサポート予定となっており、年内はさらなるアップグレードは当面必要ないかな、と高を括っていました。

というのも、EKSは本家への追従に時間がかかりなかなかリリースされないこともあり、サポート期間はさておき年内に1.17や1.18がリリースされるかどうかも疑わしいものだ、と半ばAWS開発チームの対応スピードを侮っていました。

ところが、1.15のリリースから2ヶ月も経たないうちになんと1.16がリリースされました。3ヶ月ごとじゃないじゃん!話が違うよ!と憤慨しながらGithubのAWS Container Roadmapを眺めていると、こんな頼もしいコメントが寄せられているではありませんか。

https://github.com/aws/containers-roadmap/issues/487#issuecomment-597444626

but one of priorities for EKS this year is to reduce the gap between upstream releases and EKS support, which will require a temporary release schedule that is sooner than every 90 days.

ということで、本家リリースから10ヶ月も経ってからようやく公開されたEKS 1.15はたった1ヶ月半で旧バージョン扱いになってしまったのでした。ひどい。

1.16へアップグレードする前に

さて、本家で1.16がリリースされてからもう1年以上経過しているため、GKEなどで常に最新バージョンのKubernetesを利用されている方には遠い昔のことのように思われるかもしれませんが、1.16では一部のAPIが非推奨となりました。

https://github.com/kubernetes/kubernetes/blob/release-1.16/CHANGELOG/CHANGELOG-1.16.md#deprecations-and-removals

非推奨となったものの中でも最も広範かつ影響が大きいのは、Deployment , DaemonSet , ReplicaSet リソースが対象となる apiVersion:extensions/v1beta1 グループです。

これらのリソースを apps/v1 へ変更するために、現在クラスタで稼働しているアプリケーションを確認してmanifestを修正して……といった作業工数を考えると、1.15のサポート期間終了直前に慌てて1.16へアップグレードすることはあまり現実的ではないと判断し、早々に1.16へのアップグレード準備作業を始めることとなりました。

APIバージョン変更対応

対象リソースの洗い出し

1年前の記事でご紹介したように、アプリケーションのmanifestは基本的にGitで管理しているので、apiVersion:extensons/v1beta1 のリソースの有無はGitリポジトリを確認すればよいのですが、アプリケーション以外の一部のモジュールはGithubリポジトリのkustomizationファイルを直接参照してデプロイしているため、 kustomize build コマンドを実行しないとmanifestを確認することができません。

すべてのモジュールを確認して回るのも手間だしどうしたものか、と思っていたところkube-no-trouble:kubentという便利なスクリプトを見つけました。

https://github.com/doitintl/kube-no-trouble

$./kubent
6:25PM INF >>> Kube No Trouble `kubent` <<<
6:25PM INF Initializing collectors and retrieving data
6:25PM INF Retrieved 103 resources from collector name=Cluster
6:25PM INF Retrieved 132 resources from collector name="Helm v2"
6:25PM INF Retrieved 0 resources from collector name="Helm v3"
6:25PM INF Loaded ruleset name=deprecated-1-16.rego
6:25PM INF Loaded ruleset name=deprecated-1-20.rego
__________________________________________________________________________________________
>>> 1.16 Deprecated APIs <<<
------------------------------------------------------------------------------------------
KIND         NAMESPACE     NAME                    API_VERSION
Deployment   default       nginx-deployment-old    apps/v1beta1
Deployment   kube-system   event-exporter-v0.2.5   apps/v1beta1
Deployment   kube-system   k8s-snapshots           extensions/v1beta1
Deployment   kube-system   kube-dns                extensions/v1beta1
__________________________________________________________________________________________
>>> 1.20 Deprecated APIs <<<
------------------------------------------------------------------------------------------
KIND      NAMESPACE   NAME           API_VERSION
Ingress   default     test-ingress   extensions/v1beta1

このスクリプトapiVerison:extensions/v1beta1 のリソースを洗い出して順次APIバージョンの変更を実施しました。

安全にリソースAPIバージョンを変更するには

リソースのAPIバージョン変更と言っても、単純に apiVersion:apps/v1 へ変更するだけではありません。 spec.selector フィールドの追加も必要なのですが、このフィールドはimmutableとして定義されているため、既存のリソースに追加しようとしても以下のようなエラーが出力されてしまいます。

The Deployment "sample-application" is invalid: spec.selector: Invalid value: v1.LabelSelector{MatchLabels:map[string]string{"app":"sample-application", "app.kubernetes.io/name":"sample-application"}, MatchExpressions:[]v1.LabelSelectorRequirement(nil)}: field is immutable

このエラーを無視して kubectl apply --force コマンドで強制的にmanifestの変更を適用することはできるものの、整合性が取れないため既存のリソースは一度完全に削除されてから、 apps/v1 のリソースが新たに作成される形になります。つまり、稼働Pod数が一時的に完全に0になってしまうのです。

Pod数が0になってしまえば当然サービスに影響が生じてしまうため、以下のようなフローでAPIバージョンを変更しました。

  1. 既存のリソースと同一構成のmanifestを作成し、 metadata.name のみ変更してクラスタへデプロイ
    • 例) Deployment: sample-app を複製し、 Deployment: sample-app-temp を作成
  2. 複製したリソースが Service に紐づき、トラフィックが振り分けられていることを確認
    • kubectl get endpoints コマンドで、ServiceとDeploymentの紐付けを確認できます
  3. 既存のリソースに対してAPIバージョン変更及び spec.selector を追加するmanifestを強制的に適用
  4. 既存リソースのDeploymentが削除され、 apps/v1 で再作成されてアプリケーションが正常に動作していることを確認
  5. 一時的に作成したリソースを削除

少々泥臭いですが、ブルーグリーンデプロイメントの亜種のようなやりかたですね。サービスに影響なく安全にリリースできる安心感はありますが、同様の要件が発生した際に都度こういった対応をするのも手間なので、今後はArgo Rolloutなどのモジュールも試してみたいところです。

ノードグループのマネージド化

非推奨APIバージョンのリソースは一新できたため、あとはコントロールプレーンとノードグループをそれぞれ1.16へアップグレードするだけです。

ただ、これまで利用していたノードグループはEKS 1.13の頃にAWSドキュメントに従ってEC2 LaunchTemplate と AutoScalingGroupで作成していたため、実際にアップグレードしようとすると、以下のように複数フェーズで対応をする必要がありました。

  1. 新規バージョンのAMIを利用するLaunchTemplate, AutoScalingGroupリソースを作成し、コントロールプレーンへ紐付け
  2. 既存のノードグループにTaintを付与し、Drainを実行してPodを新規ノードグループへ退避させる
  3. 既存のノードグループを削除

さきほどのAPIバージョン変更時と同じような作業ですが、これをCloudFormationで対応しようとすると、3度もスタックの変更セットを適用するはめになり大変面倒です。

このため、EKS 1.15へアップグレードしたタイミングでマネージドノードグループへ移行しようとしたのですが、リリース当初のマネージドノードグループはSecurityGroupの割当てをすることができず、RDSやElastiCache, ALBなどのAWSリソースとPod間の疎通設定ができないため踏み切ることができませんでした。

ノード単位でSecurityGroupを割り当てなくてもSecurityGroup for Podを利用すれば良いでしょ?と思っても、対応クラスタバージョンはEKS 1.17以降のため使うこともできない、といった具合で数カ月間スタック状態でした。

それがこの夏ようやくLaunchTemplateに対応し、SecurityGroupを自由に割り当てられるようになったため、1.16へのアップグレード前にマネージドノードグループへ移行することにしました。

移行作業は前述のアップグレードフローとほぼ同様で、マネージドノードグループを新規作成して旧ノードグループからPodを退避させたうえで旧ノードグループを削除する、という流れですんなり終わりました。

これでアップグレード作業もスムーズに……と思いきや、マネージドノードグループに移行したタイミングでちょっとしたトラブルが発生しました。

Podが停止できない!

マネージドノードグループを作成し既存のノードグループからPodを移行してから2,3日経ったところで、唐突にPodが再起動を繰り返したり、Podが正常に停止できずにいつまでも残り続けたりと不安定な状態になり、ノードのCPUやメモリが高負荷となってクラスタ上からもノードが利用できなくなってしまいました。

厄介なことにPodの移行が済んだことで既存のノードグループは削除済み、という状況のためPodを切り戻すこともできず、騙し騙しPodを動かしていたところ以下のバグを踏んでいたことが判明しました。

Pods stuck in terminating state after AMI amazon-eks-node-1.16.15-20201112

つまるところバグが含まれるAMIでマネージドノードグループを作成したことが原因だったため、慌ててCloudFormationで旧バージョンのAMIに変更しようとしたところ、今度は以下のようなエラーが。

Requested Nodegroup release version 1.15.11-20201007 is invalid. Allowed release version is 1.15.12-20201112 (Service: AmazonEKS; Status Code: 400; Error Code: InvalidParameterException; Request ID: ----; Proxy: null)

なんとマネージドノードグループは最新バージョンのAMIしか利用できないという仕様のため、最新バージョンのAMIにバグがあるとどうすることもできないのです。詰んだ。

結局そうこうしているうちに修正バージョンのAMIがリリースされたため、マネージドノードグループのAMIバージョンを変更して一件落着……と思いきや、そうは問屋がおろしません。

前述したとおり、Podが正常に停止できない ということは、すなわちマネージドノードグループの更新処理における Tainの付与Drainの実行PodのEviction の流れで、最終的にPodのEvictionに失敗してしまい、ノードグループの更新処理も失敗してしまうのです。そんな。ひどい。ひどすぎる。

二重に詰んだ状況になってしまったため、最終的に取った手段は

  1. CloudFormationでマネージドノードグループの更新を実行
  2. kubectl get pods --all-namespaces でPodの稼働状況を注視
  3. Terminating のまま一定時間変化がないPodを見つけたら kubectl delete pods <pod name> --grace-period=0 --force コマンドで殺して回る

という、いったいこれのどこが マネージド なんですか?と聞きたくなるような対応をする羽目になりました。

いたずらに過去バージョンのAMIを使うことで不要なトラブルが発生することを防ぐ、という目的であれば最新バージョンのAMIしか利用できないという方針もわからないでもないですが、じゃあちゃんと動くものをリリースしてくれ……という気持ちでいっぱいです。

バージョンスキップができない!

前記した問題が解消し、ではKubernetesnのバージョンアップをしましょうということでコントロールプレーンをEKS 1.16へアップグレードしました。

ここでマネージドノードグループも1.16へアップグレードすると、最新バージョンであるEKS 1.18まで3回も更新処理が必要となり、都度PodのEvictionが発生します。サービスに直接影響が出ないようReplicaSetで冗長化はしているものの、そう何度も実行したいタイプの作業ではありません。

幸い、コントロールプレーンとノードグループ間のバージョン差異は2バージョンまで許容されるため、コントロールプレーンをEKS 1.17へアップグレードし、ノードグループは1.15から1.17へスキップさせようと考えました。

さきほど1.16へアップグレードしたばかりのコントロールプレーンにCloudFormationで再度アップグレード処理を実行しようとしたところ、以下のようなエラーが発生しました。

Update failed because of Nodegroups EKSNodeGroup-,EKSNodeGroup-,EKSNodeGroup- must be updated to match cluster version 1.16 before updating cluster version (Service: AmazonEKS; Status Code: 400; Error Code: InvalidParameterException; Request ID: -----; Proxy: null)

なるほどなるほど、コントロールプレーンとマネージドノードグループのバージョンがね、一致してないからクラスタのアップグレードはできませんと。なるほどなるほど。えっ、なんで?????非マネージドノードグループを使っているときは1.13から1.15へアップグレードできたのに?なんで????

といった具合で理屈はわかるものの、ドキュメントのどこにも書いていない制約にひっかかり、エラーの内容からするとマネージドノードを使っている以上は避けられないようなので渋々マネージドノードもアップグレードをすることになりました。仕方がないこととはいえ、やはり少々納得がいっていません。

アップグレードをイベント化しないために

こんな調子で、ほぼマネージドなはずのAmazon EKSのアップグレード作業にずいぶんと工数をかける形になってしまいました。

ただ、Amazon EKSないしKubernetesを利用している限り、クラスタ本体はもちろんモジュール類(Ingress Controllerなど)のアップグレードは常に意識し続けなければなりません。安定稼働しているからといってしばらく放置していると、唐突に破壊的変更を含むリリースがアナウンスされることも珍しくありません。

四半期ごとや月次のタイミングなどで各種モジュールの最新リリースをチェックし、定期的にアップグレード作業を実施することで、サービスへの影響を最小限にして運用作業を行う方法を模索していけたら良いなと考えています。


明日の記事の担当は ハイアマチュアトレイルランナー の 嘉松 さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

自社サービスのプロジェクトを推進する上で大事にしていること

こんにちは、ディレクターの神吉です!
この記事は Enigmo Advent Calendar 2020 の12日目の記事です。

エニグモでは様々なプロジェクトに関わることが多いですが、自社サービスのプロジェクトを推進する上で大事にしていることを書いていきたいと思います。

今回はプロジェクトマネジメントの体系的なテクニックの話ではなく、プロジェクトを推進する上でのマインド面中心の記事になります。
またクライアントワークのプロジェクトとは違う部分もあるかと思います。

今思っていることや感じていることなので、今後変わっていくかもしれないのでそこはご了承ください。

f:id:enigmo7:20201207231035p:plain

そもそもプロジェクトとは?

プロジェクトって何か大変そう、難しそうみたいイメージがあるかもしれませんがプロジェクトの定義をWikipediaで確認してみました。

プロジェクトマネジメント協会が制定しているPMBOK(第5版)の定義では、「プロジェクトとは、独自のプロダクト、サービス、所産を創造するために実施する有期性のある業務」とされている。つまり、会社などの通常業務や、継続的な運用管理、あるいは改善活動などは、特に開始と終了が定義されていないので、「プロジェクト」とは呼ばない。ただし、特定の期限までに特定の建築を行う、製品を開発する、システムを構築する、などは個々のプロジェクトになりうる。

独自の目標がある、期限がある業務のことなのかなと思います。
いつもやっているルーティンワークは該当しないことになります。

今までやったことない業務でスケジュールも決まっていて多部署、または社外も巻き込んだ大きなプロジェクトを推進するとなるとなかなか大変です。

そんな中大事にしていることは以下になります。

大事にしていること

1. どうすべきか常に考える。

AなのかBなのか選択を迫られることが多いです。
また関係者全員が100%満足するような決定ができないことも多く、バランスをとることも必要とされます。
自分だけで判断できないことも多くありますが、どうすべきかどうあるべきか常に検討し積み重ね、小さなことでも丸投げしないで一つ一つ考えることは大事です。

2. 自分ごと化する。

他に企画者がいる場合でも自分の企画ぐらい真剣に考えることは必要だと思います。
これをやらないと検討事項も浅くなり、関係者と話す時につじつまが合わなくなったり情熱を伝えることができません。
かなり厳しい状態でプロジェクトにアサインされることもありますがそれをどうするかが腕のみせどころだと思います。
私自身、最初は自分ごと化できずに苦しんでしまうこともよくありました。。

3. 逃げないこと。

逃げないこと、あきらめないことは非常に大事です。
プロジェクトへの情熱が失われると一気に物事が進まなくなります。
あー失敗したーと思うこともありますがだいたい大丈夫です。
プロジェクトは日々の積み重ねなので地道にやるしかないです。

4.最前線にいってみる。

何が課題なのか人から聞いたり報告書を見たりするより、実際に経験してみるほうが良いと思います。
意外と聞いていたこととは違う課題でつまずいていたり、すごく重要なことを汲み取れていないこともあります。

5.他のプロジェクトにも協力する。

人が進めているプロジェクトにも協力することは大事なことです。
自分以外に情熱をもって行動してくれる人がいると諦めずプロジェクトを進めることができたりもします。
いつか自分が大変な時に助けてくれるかもしれません。

6.細かいところも覗いてみる。

開発、デザイン、データ分析、ユーザからの声 などなど細かいところも覗いたり、また人にまかせていた業務もたまには自分でやってみたりしています。
やっぱり業務の基本的なところは大事。
地味で細かいところも多いですがこうしたところ見ていると何かあった時の瞬発力につながると思っています。
私の周りではマネジメント層の方でも細かいタスクを大事にしている方も多いので尊敬です!

7.よく分からなくなったらとりあえず寝る。疲れてきたらとりあえず寝る。

いろいろ考え過ぎてどうして良いか分からなかった時は寝ましょう。
寝てみると意外と頭がすっきりして解決することも多いです。
プロジェクトは長期間になることもあるので持久力も大事です。

f:id:enigmo7:20201209112229p:plain

いろいろ上げてみましたが、キリがなさそうなのでこれぐらいにしておこうと思います。

私も最初は分からないことだらけで失敗ばかりでしたが(今でもまだまだ勉強することばかりですが。。)いろんな経験の積み重ねで少しずつプロジェクトを推進していけるようになってきたかなーと思っています。

今までないものを形にするのは非常に楽しいことなので何か新しくやりたいことがある方は失敗を恐れずぜひそのプロジェクトに挑戦してみてください!!

明日の記事の担当はインフラエンジニアの夏目さんです。宜しくお願いします!


株式会社エニグモ 正社員の求人一覧

hrmos.co

複雑さを相手に抽象化を盾にしましょう

こんにちは、サーバーサイドエンジニアの Steven です。

この記事は Enigmo Advent Calendar 2020 の 11 日目の記事です。

抽象化という単語とその議論をそれほど目にすることがありませんが、設計においては極めて重要な概念だと思いますので、ここで抽象化は何を指すのか、何のためのものなのか、どうやるのかを説明してみます。

ソフトウェア・エンジニアリングとは

それが明確となっていないと、どうして抽象化が必要なのかは曖昧となってしまうこともあるかと思いますので、まずは方針にしていることについて語ります。

解釈は複数あると思いますが、一つの文章で表すと、ソフトウェア・エンジニアリングとは人間のアイデアアルゴリズムに変換することだと思います。 人間の観点で、不確定で無限とも取れるアイデアを、限り有る計算関数の組み合わせで有限なものに変えるとも取れます。 決まった特定な目的を果たすために、有限なものだけを使って何かを作ることから、問題解決とも取れると思います。レゴ作りやパズル解決という比喩は気に入ってます。 以上の説明のキーワードは「変換」と「問題解決」です。

一つ一つ個別なものと捉えると、ウェブ業界の通常の仕事では実に難しい問題はそうそうないと思います。業界として今まで見たことのない新しいアルゴリズムの発明はまず必要ないです。 ただし、仕事で求められるのは人間の言葉で表現される高レベルな問題の解決がほとんどで、その一つの大きな問題を解けるにはお互いに影響し合うたくさんな小さな問題を同時に解決しないといけないです。 なので、そういう問題は本質的に難しいというより、複雑という方が相応しいと思います。 したがって、エンジニアの主な評価基準はどれだけ難しい問題を解決できるかというより、如何に複雑さを抑えて、大きい問題を簡単に解決する・保つかの方です。

高校時代に歴史と地理学の先生から聞いた言葉ですが、「難しくするのは簡単。簡単にするのは難しい。」というのが印象的で今にも覚えています。パラダイムシフトとも言えたかもしれません。 「やっぱりわからないから、すごい」のではなく、「思ってたより、ぜんぜん簡単でわかりやすい」の方を目指すべきです。 解決策が簡単だったのはもともとの問題が簡単だったからというのはまずなく、エンジニアが頑張ったから、最終的には簡単な解決になったという方が正しい解釈だと思います。

その中で抽象化はものを簡単に保つための手段となります。

抽象化とは

抽象化という概念自体は抽象的なので、一つの文章で具体的に説明しきるのは難しいですが、以下のように解釈しています。 抽象化とは、特定の問題を概念として分析と分割し、単一の要素として扱えるようにした上で、その要素を組み合わせることでより大きい問題の解決に汎用的に使えるようにすることです。 抽象化をトピックごとにより細かく説明します。

抽象化の目的

大きい問題はいつもより細かい問題で構成されてます。 最上層にある、ユーザーに提供したい高レベルな結果(ボタンで操作できるカート画面)と、最下層にある、実装上の低レベルな詳細(カートの SQL テーブルに適用ポイントを保存する)は両方意識しやすくて、それだけをベースにそのまま開発に入ることがあったかもしれません。 ただし、その両端の間にはとんでもない距離があり、最下層からそのまま最上層を実装しようとすると、結果が凝ったものであればあるほど、開発の効率が下がり、目標は達成しにくくなります。 なぜなら、最下層から最上層まで一気に何かを実装しようとすると、さまざまな、関係のない詳細を一気にかつ同時に気にしないといけなくなるからです。開発の負担が単純に大きすぎるものになってしまいます。

なので、その残念な、非効率な開発環境を避けるために、大きい問題をより小さい問題に分割し、問題を部分的に解決できるようにします。 解決の負担が減った小さい問題を解けられたら、結果の要素を繋ぐことで大きい問題は簡単に解決可能となります。

馴染みのある例に例えるなら、車を作るのが目的な場合、構成が曖昧なまま車を一気につくるより、動力機関、燃料容器、収納スペースなどと概念として分析してから、それぞれの要素を設計・作成し、最終的に全部を組み合わせる方が効率的です。 以上で問題をものに例えたのですが、ことにも例えられます。 生き物としてエネルギーを得るということを分析するのでしたら、食べ物の探し、入手、調理、飲食、分解、摂取などと、ステップにも分けられます。

それが抽象化です。大きい問題をそれぞれより小さい問題に分けて、構造を見出すことです。 どうしても解決できない問題が相手の場合、その問題をそれぞれの概念として分析し、より細かい問題として分割した上で、再度挑戦するのがいいかと思います。 そうすれば、実に難しい問題は意外と少ないかもしれません。

抽象化の特徴

役に立ついい抽象化にはいくつかの特徴があります。大きく分けて、以下の2つにまとめてみました。

問題の構造化

問題を分割するとしても、正しいやり方と正しくないやり方があります。

純化が目的なので、そもそも簡単というのはどういうものなのかを明確にする必要があります。 簡単なものは本質までさかのぼった場合、一つだけなものとして考えるものです。一つの概念、一つのパターン、一つの責任など。複数な概念を合わせることで初めて成立するものであれば、もはや簡単ではないです。 ただし、その一つなものがより小さい複数なもので構成されていても、簡単じゃなくなるわけではないです。一つとして考えられれば、その時点で簡単です。

なので、問題を分割するに当たって、同じように、分割されたものをそれぞれ独立した、一つ一つとして考えられるものにするべきです。 複数な概念がオーバーラップするような、曖昧なものが分割の結果でしたら、それほど問題の単純化には貢献しないものとなるからです。

大きい問題を分割する際は、木構造の要領でものを分けて、それぞれの部分の大きさを抑えながら、大きいものからどんどん小さいものに構造化するのが望ましいです。 そうすることで、一つ一つの問題の解決は同程度の難易度になって、全体の単純化に繋がります。高レベルな問題は低レベルな問題と同じぐらいの努力で解決可能となります。 同時に、特定の細かい問題がどの問題の一部となってるのかも明白になって、把握がしやすくなります。

木の一つのノードを分けるとして、枝の間に共通点が少ない場合は、枝の数も抑えるべきです。 なぜなら、実装で5つの枝を一つのノードに集約するのがそれほど難しくなくても、共通点のない枝が 20本もあれば、集約がそれなりに難しくなります。 どうしても枝の数が多い場合は、共通点となる概念をベースに、一部の枝を一つにまとめて、新しい子ノードで問題をまた分割すれば大丈夫です。

最初は少なくても、改修で一つのノードの枝の数が少しずつ増えないようにするには、最初から分割の結果を、元の問題の 100% をカバーする、同じ抽象レベルのものにするのがいいと思います。 商品の購入過程はかならず選択、購入、受け取りの3つのステップに分けられますので、はじめからそうと分割すれば、後から枝の数が増える可能性が低いです。

まとめると、問題の構造化において、いい抽象化なら、問題は - 独立した概念として分割される - 木構造として構造化される - 同程度の大きさとして分けられる - 木として各ノードの枝の数が抑えられる - 木として同じノードの枝は同じ抽象レベルにある

そんな風に問題を分割すれば、それぞれの問題の解決は実装しやすくなります。

インターフェイスの単純化

問題がうまく分割されれば、その時点で簡単になります。 ただし、それだけではそれぞれの問題の解決策のつなぎ方が簡単になるとは限らないので、インターフェイスの面でも複雑さを抑える必要があります。 この項目ではより具体的な説明になるので、問題の解決として「機能」という単語を使います。

特に考慮せず、機能一つ一つをそのまま実装するだけだと、その機能を使うためのインターフェイスは機能よりになってしまいます。 ただし、そのそれぞれの機能は皆違いますので、インターフェイスの間の互換性がいいものにならず、機能を繋ぐだけでかなりな努力が必要となります。 なので、それを避けるため、インターフェイス自体を簡単なものに保ち、共通言語でそれぞれの機能をつなげるようにする必要があります。

いいインターフェイスには以下の特徴があります。

  • インターフェイスは包まれてる機能と同じ抽象レベルで表現されてます
    • 名称(クラス名、メソッド名、引数名など)がその抽象レベルに合わされてます
    • その抽象レベルに合わない実装詳細は表に出ません
  • インターフェイスのエンドポイントは最小限に抑えられてます
    • 機能を活用するために必要なオペレーションのみが公開されてて、利用方法が明白です
  • インターフェイスが必要とする引数の数が抑えられて、少ない加工でもその引数を簡単に提供できます
    • 必要のないデータまでを求めませんが、呼び出し元で準備が必要となってしまう細かすぎるデータも求めません
    • 機能と同じ抽象レベルのデータを引数にします
      • たとえば、商品の価格を計算する機能では、商品モデルを受け取るだけでも問題ありません
  • インターフェイスの返り値も引数と同じルールに従って、他の機能でそのまま活用できます
  • インターフェイスは基本的にステートレスです
    • メソッドをどんな形でどれだけ呼んでも、内部ステートが変わらず、機能の結果に影響しません
    • 最終的のステートを格納するモデルクラスは例外です
  • インターフェイスの実装詳細が隠蔽されてます
    • 呼び出し元が実装の詳細を気にする必要がありません
    • 後から実装が変わっても、変更なく機能をそのまま利用できます(実装の詳細が漏洩しません)
  • インターフェイスはコンテキストには必要以上に依存せず、他のコンテキストでも再利用できます
    • 活用する場合に、必ず他の機能と併用しないと使えない状況に陥ることがありません
    • コンテキストがなくても機能をそのまま理解できます

その特徴を持つインターフェイスを実装するのが難しい時がありますが、どれだけインターフェイスを高レベルなものに保てたかによって複雑さが決まることが多いです。 インターフェイス設計の過程で機能の実装自体が難しくなることがありますが、難しい実装と比べて難しいインターフェイスの方は影響が大きいので、選ぶ必要がある時は実装よりインターフェイスの方を簡単に保つべきです。

抽象化のメリット

うまく抽象化できれば、様々なメリットが現れます。

単純性

  • 全体的にわかりやすくなるので、調査にかかる時間が短縮されます
    • もともと実装した人にとっても、触ったことがない新人にとっても
  • 抽象化を考慮する時間が必要となりますが、実装自体にかかる時間は減ります
    • 問題をそれぞれ個別として扱えるようになるので、一つの問題のみに集中できるようになります

柔軟性

  • 各機能は明確に隔離されるので、一つの機能の修正が他の機能に与える影響が減ります
    • ものをより自由に変更できるようになります
  • 各機能は高レベルなインターフェイスで包まれるので、機能の間に新しい機能を追加するのが簡単になります
  • 各機能のコンテキストへの依存も抑えられるので、リファクタリングがよりやりやすくなります
    • 機能の再編など
    • 機能の実行順番の変更など

保守性

  • 単純性と柔軟性の改善から、保守性もそのまま向上されます
    • バグ発生時にどこを修正すればいいのかがより早くわかります
    • 該当箇所を修正したら、漏れが発生しにくくなります
      • 一つの問題が一つのところで対応されるので
    • 密結合状態が避けられるので、リファクタリングの必要性も減ります

安定性

  • 同じ理由で安定性も改善されます
    • 問題の分割で漏れにはより早く気づくので、仕様漏れやバグの発生率は減ります

テスト性

  • 機能一つ一つは独立するので、ユニットテストも実装しやすくなります
    • コンテキストとテストデータの準備で必要となる努力は減ります
    • 単一責任に重点が置かれるので、複数の関係のないものを同時にテストする頻度も減ります

抽象化をするには

うまく抽象化をするには何を気にするべきか、どのステップを取るべきかを紹介してみます。

概念の分析

抽象化と関係がないことですが、まずするべきなのは対象の案件を具体的なものにすることだと思います。 道標となるメインな仕様があるとして、エッジケースがあるのか、コンテキストが何なのか、未定なところがあるのか、というところを洗い出します。

それができたら、その案件を概念として分析します。 問題を解決するために必要となるデータ(モデルなど)には何があるのかを、「問題の構造化」で紹介した問題の分け方を活かして、分析します。 データを細かく分析できたら、その次に処理(関数など)の分析をします。 問題解決のためにどの処理が必要なのかを洗い出します。

ドメイン層(ビジネスロジック)とアプリケーション層(フレームワーク)を明確に分けることも望ましいです。 うまく分けて、問題をそれぞれの層の独立したものとして分析できれば、全体が単純化されることが多いです。

簡単に実装できそうな大きさの、曖昧なところのない、一つ一つな要素になるまで、データと処理の分析を繰り返します。 抽象化の一番難しい作業は以上の分析になるので、クリアできたら、残りのステップは簡単です。

Tips

分析結果で不可分と見える一つのデータか処理がやはり大きいという印象を抱くことがあるかもしれません。 一見では不可分ですが、大きいと見えたなら、おそらく複数な違う要素でさらに構成されてます。 その要素を暴き出すために、質問を問いて、そのデータか処理の本質を探し出すのがいいかと思います。 そのものは何なのか、何が目的か、実装するには何が必要かなど。

同じく、分析でうまく表現できないデータか処理が現れるかもしれません。 そのものをどう実装できるのかがよくわからない時は新しい概念の導入を検討します。 商品というのはそのまま概念として成り立ちますが、一部の商品のみを扱える処理があるとわかったなら、商品には種類という概念を導入する必要があるかもしれません。 扱い方が全然変わってしまうなら、商品モデルにステートを表すメソッドかカラムを追加するだけのではなく、ラッパークラスを通して、モデルを抽象化するのが妥当な可能性があります(たとえば、購入できない商品対購入できる商品など)。 当然、処理のほうにも新しい概念の導入が必要となる場合があります。

概念としては、ステート、ポリシー、イベント、エラー、アクション、プレゼンター、ストラテジー、エクストラクター、ノーマライザー、セレクター、ヒストリーなど、ものとことのどちらにも無限とあります。 プログラムに自由に新しい概念を導入しましょう。

インターフェイスの用意

その次に、分析されたものに一つ一つインターフェイスを与えます。

インターフェイスの単純化」で紹介した特徴を意識して、簡単なインターフェイスの設計を目標とします。 簡潔にまとめると、以下の特徴を目指します。

  • 用途が伝わる抽象的な名称
  • 数の抑えられたエンドポイント
    • パブリックな関数やメソッドなど
  • 単純な引数と返り値
  • ステートレスなインターフェイス
  • 実装の詳細を隠蔽したカプセル化
  • 抑えられたコンテキストへの依存

最初から完璧なインターフェイスを設計することが難しい時があります。 そういう時はまず用途を果たすものを作ってから、そのインターフェイスを少しずつ改善していく方が効率的です。

基本的にインターフェイスの設計が終わってから、実装に入るべきです。 そうすれば、実装に左右されず、簡単なものが作りやすくなります。 ただし、実装で曖昧なところが多い時は実装をある程度進めてから、インターフェイスを設計するのもありです。

Tips

名称としては、要素の実装を必要以上に具体的に表さないながら、用途や目的を明確にした、周りと同じ抽象レベルなものが望ましいです。 クラス名、メソッド名、変数名など、どのものにも以上のルールを適用します。 高レベルなコンテキストで、WriteProductIdToRedisAddProductToCartの間で後者の方が望ましいでしょう。 なぜかというと、WriteToRedisProductIdは実装を直接表すものでありながら、その用途を表してないです。 クラスの実装と呼び出し元を調べないと、用途が何なのかがわからないという問題もあれば、実装が変わった場合、クラス名がその実装と合わなくなります。

簡単なインターフェイスを作るには、実装の詳細とコンテキストを一旦全部忘れて、設計したいデータや処理をブラックボックスとして考えるのがいいと思います。 そのインターフェイスでしたら、触ったことのない、コンテキストに疎い新人にとって、そのまま意味をなすものなのかを確認します。 本当に簡単なものであれば、インターフェイスを見るだけで、大体なことは理解できるはずです。

QA

実装が一つ完成したら、結果を振り返って、抽象化としての質を確かめるのがいいでしょう。

  • インターフェイスも実装もわかりやすいか
  • 用途と使い方に関してどこかに違和感がないか
  • 単一責任が保たれてるか
  • 実装が顕になってないか
  • その抽象レベルで不可分であるか
  • コンテキストへの依存が少ないか

何かよくないところを発見したら、概念の分析を確認するか、インターフェイスを調整します。

適用例

初期状況

現在進めている React プロジェクトでは、アナウンスという、特定の条件下で画面に表示される注意事項というものがあります。 同時に、エラーという、サーバーから受け取る動的に変わる説明事項もあります。 画面のデザイン上では、色を除いて、アナウンスとエラーは大体一緒です。

エラーの仕組みはすでに実装されていて、React コンポーネント内でエラー配列から該当エラーをタグでフィルターして、そのままレンダーするようになっていました。

const renderErrors = () =>
  errors.filter(Error.match({ tag: 'totals' }))
        .map((error, index) => <Error key={index} error={error} />)

return (
  <div>
    {renderErrors()}
    ...
  </div>
)

一方で、アナウンスはコンポーネント内で直接表示すべきかを計算して、そのままレンダーするようになっていました。

const renderCashOnDeliveryMethodAnnounce = () => {
  if (!(hasDeliveryMethodWithPrepaidFees && isPayOnReceipt)) {
    return
  }
  return <Announce title="着払いを選択しました。" details="..." />
}

ただし、そのやり方だと、コンポーネント一つ一つに表示条件とメッセージの定義を行わないといけなくて、DRY ではないところから保守性が下がります。 エラーの仕組みと似てるところも複数あったので、共通化ができるのではないかと思いました。

改善策

メッセージという新しい概念を導入

まず気づいたのは<Error /><Announce />というコンポーネントが大体一緒だったということです。 もとを辿れば、エラーとアナウンスはユーザーに何かを伝えるためのものなので、抽象化して<Message />としてエラーとアナウンスを再定義しました。 <Message />はただのメッセージであって、エラーやアナウンスの用途を考慮しないものなので、インターフェイスは汎用的です。

<Message importance={} title={} details={} />

importanceはメッセージの重要度を表しています。値としてはinfodangerがあります。 そのimportanceを使って、メッセージの色が決まりますので、<Message />が特に考慮していなくても、呼び出し元でアナウンスとエラーの両方をそのまま表せます。 概念にオーバーラップがないので、疎結合となります。

アナウンスという概念を明確に

このままでは、アナウンスという概念はコードには明確に現れず、表示条件と組み合わされたメッセージ以上のものにはならないです。 それだと、保守性は上がらず、すべてのアナウンスの改修が必要となれば、箇所の一つ一つを修正しないといけなくなるのと、アナウンスに関するルールも明確になりません。 アナウンスはどこからどこまでのものなのかが曖昧になってしまいます。

なので、その状況でアナウンスを明確なものにするため、アナウンスの定義、略してアナウンスの概念を導入しました。 アナウンスには重要度、文章、表示箇所と表示条件がありますので、定義でそれを明示的に表現します。

// announces.js

const ANNNOUNCES = {
  cash_on_delivery_method_selected: {
    importance: 'warning',
    title: '着払いを選択しました。',
    details:
      '商品価格に含まれていた送料分が引かれますが、別途、着払い料金が必要です。',
    tags: ['totals'],
    when: ({
      product: { hasDeliveryMethodWithPrepaidFees },
      deliveryMethod: { isPayOnReceipt }
    }) => hasDeliveryMethodWithPrepaidFees && isPayOnReceipt
  },
  // ...
}

ANNOUNCESオブジェクトのバリューはアナウンスの定義となります。

  • 重要度はimportance
  • 文章はtitledetails
  • 表示箇所はtagsで対象オブジェクトを間接的に指定します
  • 表示条件はwhenでカート商品というモデルを引数に定義します

アナウンスはすべて一つのファイル内で定義されてるので、アナウンス横断の修正は簡単になります。 タグでアナウンスが対象にするエリアを定義していますが、どのタグがどのエリアに当たるのかを決めるのはコンポーネントなので、疎結合です。 表示条件も、特定のコンポーネントでのみアクセスできるデータを使わず、どのアナウンスにも渡される汎用モデルを引数としているので、コンテキストには依存しません。 あとからアナウンスのタグや表示条件を変えても、コンポネントの方で何も変更なく、アナウンスがそのまま更新されます。

この修正でアナウンス機能は他のものから独立して、一つのものとして扱えるようになりました。

アナウンスとエラーのレンダリングを抽象化

アナウンスとエラーは両方メッセージとなりました。 または、アナウンスの表示箇所はエラーと同じくタグを使って指定できるようになりました。 なので、レンダー処理はアナウンスとエラーの間で抽象化可能となります。

return (
  <div>
    {renderMessages(cartItem, { tag: 'totals' })}
    ...
  </div>
)

以上のコードでは結局エラーかアナウンスかを意識せず、ただカート商品の、特定のタグのメッセージをレンダーするように単純化されました。 エラーはcartItem内にあるerrors配列が使われて、メッセージがレンダーされます。 アナウンスはannounces.js内の定義を対象に、cartItemtagを使うことでマッチするものを抽出して、メッセージがレンダーされます。 ただし、呼び出し元ではその内部処理を意識せず、より高い抽象レベルでメッセージをレンダーしてるだけです。 あとから、また違う種類のメッセージを自由に追加できます。

低レベルの詳細が抽象化されて、プログラムの単純性と柔軟性が改善されました。

終わりに

誰もが、ある程度の抽象化は意識せずにできてしまいます。ただし最大までにその概念を活かすには努力と経験が必要となります。 抽象化の目的でライブラリーを活かすのも重要であれば、ビジネスロジックの抽象化も必要不可欠です。

抽象化をうまく活かせたプロジェクトはリリース後でも修正が容易で、時間が経っても追加開発で特に難しくならないです。 ただし、抽象化されたものが少しずつ具体化して、どんどん変更しにくくならないように、気をつけて常に努力する必要があります。

バランスにも気をつけないといけないです。最大まで抽象化したものが逆に理解しにくくなることもあります。 抽象化と具体化の間のいい中間点を見つけるのが目的となります。ただし、高い抽象レベルでも名称がしっかりしていれば、大体問題にならないと思います。

抽象化は科学的な手順に沿って行うのも可能でしょうが、感に頼って抽象化するのが基本だと思います。 その感を育てるには経験を重ねないといけないですが、メリットが実に大きいので、コストパーフォーマンスがいいです。 新人と経験者の違いの一つは、どれだけ抽象化をうまくできるかというところにあると思います。

最後に、一見では難しいと見えた問題は、抽象化をうまく活かせれば、意外と簡単になります。 概ね、対応中の実装で一つや2つの概念が見えていないからこそ、複雑と感じてしまいます。 プログラム内でその概念を明確に表せれば、複雑さは大体解消されます。

明日の記事の担当はディレクターの神吉さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co