WESEEK Tech Blog

WESEEK のエンジニアブログです

コスト7割減!Kubernetes本番サービス環境の運用ノウハウ

この記事は、 2021/4/22 に行われた WESEEK Tech Conference の内容です。

Google Compute Engine のプリエンプティブルインスタンスを利用したクラウド利用料を節約するお話をしました。 Google Cloud Platform 上で稼働している GROWI.cloud を実例に上げつつ、節約額、稼働率の運用実績についてもご紹介しました。

目次

開始早々タイトル訂正

f:id:skomma:20210427155111j:plain

イベント開催を告知した時点では

「コスト8割減!Kubernetes本番サービス環境の運用ノウハウ」

というタイトル名でしたが、本資料を作成し、改めて価格を比較してみたところ以下のタイトルが適切であることが判明したため、タイトルを訂正しました(笑)

「コスト一部8割7割減!Kubernetes本番サービス環境の運用ノウハウ」

GROWI.cloud とは?

GROWI.cloud の利用状況

2021/04/14 現在、以下のような利用状況です。

  • 総 GROWI 数: 1022
    • うち約 20 % が課金プラン利用の GROWI
  • GROWI が載っているノード数: 32
    • うちプリエンプティブルノード数は 27 台

利用技術

アプリ

f:id:skomma:20210427163157j:plain

インフラ

f:id:skomma:20210427163215j:plain

なぜ GKE を利用しているか

以下の 4 点から、GROWI.cloud では GKE を採用しました。

  1. 利用実績があった
    • GROWI.cloud 開始前の別プロジェクトで利用していた
    • そのプロジェクトでは、アプリ・ミドルを載せるだけのシンプルな構成だった
    • GROWI.cloud では、もっといろいろな機能にチャレンジしている
  2. master がマネージドである
    • 自前で master を構築・運用するのは大変
    • ただ、これが原因でなかなかトラブルが解消されなかった例も…
      • トラブルの詳細は、5/27 の本イベントで話される予定です!
  3. ノードの増減が簡単である
    • GUI/CLI で簡単に増減できる
    • OS、kubernetes node として動かすためのディスクイメージが用意されているため、そこを考える必要がなくなる
    • オートスケールもできる
  4. ノードプールという単位で、ノードの構成が変えられる
    • これにより、ノード/プリエンプティブルの有効無効の切り替え、オートスケールの有無の切り替えなど、ノードの設定を塊ごとに管理できる
    • 1 つの GKE クラスタに様々な設定のノードを混在させることが可能となる

コストを下げたい!

上述の通り、GKE はクラウド上で Kubernetes クラスタを稼働させるのに、いろいろな便利な機能が付加されています。
しかし、GROWI.cloud のように SaaS 型で利用者が増えるたびにノードを増やす必要がある場合、料金はうなぎ上りに増えていきます…

そこで、どうにか安く利用する方法がないか模索したところ、今回ご紹介するプリエンプティブルノーにたどり着きました。

プリエンプティブルノードとは?

cloud.google.com

  • Google Compute Engine のノードの 1 種類
  • インスタンスタイプで、通常のノードと比較して約 70 %割引で使える
  • ただし、最大 24 時間しか連続で稼働できない
    • 「最大」なので、24 時間以内の停止ももちろんありうる
    • AWS だと EC2 スポットインスタンスが同じ概念の位置する
    • が、利用者同士でインスタンスの売買ができるほど賢くはない

プリエンプティブルノードの使いどころ

  • 突然停止しても問題ないワークロードが載っているノード

これらのアプリを、プリエンプティブルノード上で稼働させると利用料を抑えることができます。

GROWI.cloud クラスタ内のノード構成

大きく以下の括りでノードを構成しています。

  1. growi.cloud のシステムコンポーネントが載るノード
    • growi.cloud アプリ
    • GROWI を作るための workflow engine(brigade.js)
    • 管理用アプリ
  2. GROWI が載るノード
  3. GROWI の DB をバックアップするスクリプトが動くノード
    1. のうち特に分離しておきたいコンポーネントが載るノード
    2. メモリを極端に必要とするミドルウェアなど

上記のうち、2./3. についてプリエンプティブルノードを利用しています。

ノードの振り分け方

Kubernetes 上にある以下の機能を利用して実現しています。

  1. Node Affinity
  2. Taint/Toleration

Node Affinity

  • Kubernetes が Pod をスケジュールする際に、スケジュールされる先のノードを指定する機能
  • Pod 上の設定として記載する
  • ノードはノード名で指定するのではなく、key/value のラベルで設定する
  • 具体的な YAML 例(app=growi-app というラベルが指定されているノードに必ずスケジュールする)
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: app
            operator: In
            values:
            - growi-app

※参考 kubernetes.io

Taint/Toleration

  • Taint
    • 許容(tolerate)できない Pod のスケジュール/実行を禁止する機能
    • Node 側に、key/value/effect を設定する
    • 具体的な YAML
taints:
- effect: NoExecute
  key: app
  value: growi-app
  • Toleration
    • Taint が設定されたノードへ Pod をスケジュールするために、許容する機能
    • Pod 側に設定する
    • 具体的な YAML
tolerations:
- key: app
  operator: Equals
  value: growi-app
  effect: NoExecute

※参考 kubernetes.io

GROWI 作成時のフロー

フリープラン利用者が GROWI を起動するときのフローを例に、実際の動きをご紹介します。

f:id:skomma:20210427163524j:plain

まず、GROWI.cloud UI 上でユーザが GROWI を作成すると、

f:id:skomma:20210427163530j:plain

GROWI.cloud アプリからワークフローエンジンである brigade.js へ REST で作成リクエストが飛びます。

f:id:skomma:20210427163539j:plain

brigade.js では helm で GROWI マニフェストのデプロイを試みます。

f:id:skomma:20210427163552j:plain

helm によりデプロイされたマニフェストを元に、Kubernetes(kube-scheduler) がスケジューリング先ノードを決定します。

f:id:skomma:20210427163606j:plain

まず、Node Affinity で指定されたノードのラベルを比較し、真ん中のノードがスケジュール先候補として選ばれます。
フリープランはプリエンプティブルノードに配置されるプランのため、cloud.google.com/gke-preemptible=true をラベルとして持っているノードのみが選択されるようになっています。

f:id:skomma:20210427163618j:plain

その後、Node の taints と Pod 内の tolerations を比較し、スケジュール先が決定されます。

f:id:skomma:20210427163625j:plain

晴れて、GROWI.cloud 利用者が GROWI を利用できるようになります!

f:id:skomma:20210427170121j:plain

同様のフローで、通常ノードに配置されるべきスタンダートプラン利用者の GROWI は、通常のノードにスケジュールされます。

プリエンプティブルノードの罠と現時点での対策

コストが下がってみんなハッピー、というわけではなく、それなりに運用でカバーしないといけない点が出てきました。

必ず 24 時間動くとは限らない

公式ドキュメントに記載されてますね、はい…

ライト・バリュープランについては、以下のように対策を行いました。

  • 1 度ノードの再起動がかかると、5~10分程度 GROWI がダウンする
    • フリープランは SLO を設定していないため、問題ない
  • ライト・バリュープランに関しては、プリエンプティブルノードを利用していて安価だが、SLO 付き
    • いつ落ちるかわからないノードだと SLO まで稼働率を上げられない…
    • -> GROWI Pod を冗長化して対応した
    • 冗長化した後、稼働率の向上に成功

常に在庫があるとは限らない

これまでの経験上、1年に1回程度は発生するようです。

在庫不足に陥ったときは、日本近辺のリージョン(オーストラリア、シンガポールなど)でもプリエンプティブルノードが起動しなかったことを確認しました。
ML などで大量にノードを奪ってく人がいるんでしょうか…

そのようなケースについて、以下の対策を行いました。

  • affinity 内の nodeSelectorTerms 内に、在庫が足りなかったときのための設定を追加しておく
    • nodeSelectorTerms は array で指定でき、OR 判定になる
  • 実際に、プリエンプティブルノードが異常に DOWN したら、人手で適合するラベル/taint をつけた緊急配備用のノードプールを作成する
    • 新たに作成したノードに GROWI Pod が載る
    • プリエンプティブルノードを試しに起動してみて、起動できそうだったら緊急配備用のノードを drain する

f:id:skomma:20210427163754j:plain

通常スケジュール用の nodeSelectorTerm に加え、緊急配備用の nodeSelectorTerm を予め設定しています。

時々腐ったノードが出てくることがある(再現性不明)

載っている GROWI Pod が長期間(30分以上) healthy にならない事象が出るノードが時々出てくることがあります。
この事象に対しては、まだ効果的な対策が打てていない現状です。

再現性不明のため、アラートで長期間 down している GROWI が出てきたら、アラート発生時にエンジニアが手動対応しています。
具体的には、問題のあるノードを drain して、都度載っている GROWI を別ノードに移動しています。

ここも将来的には、ノード上のリソース利用状況を見つつ、自動化していきたい分野です。

プリエンプティブルノードの実績値

費用比較

割引適用なし

  • GROWI プリエンプティブルノード数: 28(2021/04/21 現在)
  • 通常ノードの場合: USD 2,902.86 per 1 month
  • プリエンプティブルノードの場合: USD 870.86 per 1 month

通常ノードを利用している分を単純にプリエンプティブルノードに置き換えると、約 70% の削減が可能です。

3年継続割引利用時

  • 通常ノードの場合: USD 1,306.29 per 1 month
  • プリエンプティブルノードの場合: USD 870.86 per 1 month

3年継続割引利用の通常ノードをプリエンプティブルノードに置き換えると、約 33% の削減が可能です。

GROWI.cloud 全体

  • 通常ノード(3年確約利用適用)だけで構成した場合
    • USD 2,303.26 per 1 month
  • GROWI が載るノードに、プリエンプティブルノードを利用した場合
    • USD 1,867.84 per 1 month

GROWI.cloud クラスタで利用しているノード全体のうち、プリエンプティブルを許容する GROWI が載るノードを プリエンプティブルノードに置き換えると、約 20% の削減ができました。

稼働率

実際の稼働率

  • 対象期間: 2021/04/14 ~ 2021/04/21
    • GROWI.cloud 全体: 99.53%
    • 通常ノード上の GROWI: 99.97%

プリエンプティブルノードを利用した場合でも、Pod を冗長化すると稼働率を大幅に向上させることができました!

プリエンプティブルノードの実際の再起動回数

  • 対象期間: 2021/04/14 ~ 2021/04/21
    • プリエンプティブルノード数: 28
    • 平均再起動回数: 8.64 回 (7回~12回)
    • 稼働時間ワースト3: 00:06:47, 00:08:21, 00:12:56

平均を取ると、ほぼ 1 日 1~2 回再起動するノードがほとんどでした。
しかし、稼働時間のワーストを取ると、やはり稼働時間が保証されていないノードであることを示すように、時々 10 分前後で再起動されるノードもいました。

まとめ

プリエンプティブルノードの活用方法

  • プリエンプティブルノードとは?
    • 最大 24 時間稼働するノード
      • 24 時間以内に再起動することもありうる
    • 以下のような用途向け
  • GROWI.cloud では以下の用途で活用している
    • バックアップスクリプトを動かすため
    • フリー/ライト/バリュープランの GROWI を動かすため
      • ライト/バリュープランの GROWI については Pod を冗長化することで稼働率が向上しました

今後

  • GROWI の稼働率向上(案)
    • プリエンプティブルノードでもノード再起動前に意図的に Pod or ノードの再起動を自動的に実施し、稼働率を向上させる
    • プリエンプティブルノード再起動時にノード上に載っている GROWI の起動タイミングを自動的にずらす
      • GROWI は起動時に一番 CPU を使うため、同一ノードに乗る GROWI 数を減らすことで、起動時間短縮を狙う
  • プリエンプティブルノード再起動時の 503 画面でユーザに原因をわかりやすくする
    • 現状 GROWI.cloud 上の GROWI 詳細画面では出るが、GROWI アクセスだけではわからないので、ユーザに不親切な状況です…

著者プロフィール

今間 俊介

株式会社WESEEK / バックエンドエンジニア

ISP に 2 年弱勤務した後、2013 年 3 月に WESEEK へ join。
現在は、GROWI.cloud などプロジェクト問わず、Kubernetes を中心としたインフラ/アプリの設計・構築・運用に携わる。
最近の業務時間外は、子供の面倒見、家事手伝い。

株式会社WESEEKについて

株式会社WESEEKは、システム開発のプロフェッショナル集団です。

【現在の主な事業】

  1. 通信大手企業の業務フロー自動化プロジェクト
  2. ソーシャルゲームの受託開発
  3. 自社発オープンソースプロダクト「GROWI」「GROWI.cloud」の開発

GROWI

GROWIは、Markdown記法でページを記述できるオープンソースWikiシステムです。

【主な特徴】

  • テキストも図表もどんどん書ける、強力な編集機能
  • チーム拡大に迅速に対応できる管理者向け機能を提供
  • 充実した機能・サポートでエンタープライズにも対応

GROWI.cloud

GROWI.cloudOSSのGROWIを専門的知識がなくても簡単に運用・管理できる、法人・個人向けの商用サービスです。

大手SierISPから中小企業・大学などの教育機関まで幅広くご利用いただき、さらに個人や大学サークルでもご利用いただいています。

【導入事例記事】
インターネットマルチフィード株式会社様 growi.cloud

株式会社HIKKY(VR法人HIKKY)様 growi.cloud

WESEEK Tech Conference

WESEEK Tech Conferenceは、株式会社WESEEKが主催するエンジニア向けの勉強会です。
月に2回ほど、WESEEKに所属するエンジニアが様々なテーマで発表を行う予定です。

次回は、4/22(木) 19:00~20:00に開催予定です。 ArgoCDを使ったGitOpsの利用について、インターンでエンジニアをしている岡がお話します。

現在、connpassやTECH PLAYで参加受付中です。皆様のご参加をお待ちしております! weseek.connpass.com TECH PLAYはこちらから

一緒に働く仲間を募集しています

東京の高田馬場オフィス、大分にある別府サテライトオフィスにてエンジニアを募集しております。
中途採用だけではなく、インターンシップも積極的に受け入れています!

詳しい募集要項は、弊社HPの採用ページからご確認ください。

Raspberry Piで、コミュニケーションシンクロ率を上げる

この記事は、 2021/4/8 に行われた WESEEK Tech Conference の内容です。

Raspberry PiArduino を使ってデバイスを作成し、社内環境の改善をするお話をしました。今回のお話は、ハードウェアについてが多めです。時間の都合でソフトウェアの話まで広げられなかったので、それはまた後日記載したいと思います。

目次

  • 目次
  • WESEEK の 日常と課題
  • 理想のデバイス探し
  • バイス製作の工房探し
    • mokumoku 会とは
    • WESEEK の工房化
  • 賢いカメラ neighbo <ネイボ> 誕生
  • バイス製作の試行錯誤
    • 試行錯誤1: 試作・設計
      • 試行錯誤1: 試作・設計 の気づき
    • 試行錯誤2: チルトユニットの製作
      • 試行錯誤2: チルトユニットの製作 の気づき
    • その他の試行錯誤
    • neighbo の成長
  • 導入と効果
  • かかった費用
    • 気づき
  • まとめ
    • neighbo の展望
  • 著者プロフィール
  • 株式会社WESEEKについて
    • GROWI
    • GROWI.cloud
    • WESEEK Tech Conference
  • 一緒に働く仲間を募集しています
続きを読む

JavaScript/Node.jsエンジニア必見!MongoDB+Mongoose利用時のデータマイグレーション

こちらは 「JavaScript/node.jsエンジニア必見!MongoDB+Mongoose利用時のデータマイグレーション」からの転載です。


古くは「LAMP」に代表されるような技術スタック、JavaScript 界隈では「MEAN」スタックという用語もあるくらい、JavaScript(Node.js) との組み合わせでは MongoDB がよく使われるようです。

NoSQL ではスキーマレスのためデータマイグレーションを気にせず開発することも多いわけですが、例えば一度バージョン1をリリースして既にユーザーがついているシステムを更改するような場合、後方互換を保つためにもデータマイグレーション機構は重要です。

本記事では、Node.js 環境で MongoDB および Mongoose を利用しているようなシステムにデータマイグレーション機構を導入する Tips を紹介します。

データマイグレーション機構とは?

有名処で言えば、Ruby on RailsActiveRecord に搭載されている機能を想像するとよいでしょう。そこには以下のような特徴があります。

  • データベーススキーマを時系列に進化(またはロールバック)させることができる
    • Rails では rake タスクとして実行できる
  • migration ファイルを決まったスキームで実装できる
  • ActiveRecord のモデルを利用することができる

Node.js の世界でのデータマイグレーション事情

Ruby の世界では Rails, ActiveRecord というデファクトスタンダードが確立されていますが、Node.js の世界でのデファクトフレームワークは残念ながら Express であり、オールインワンのフレームワークでもフルスタックのフレームワークでもありません。

ORM/ODM にしても、ORM/ODM のデファクトは、RDBMS なら Sequelize、MongoDB なら Mongoose で固まりつつありますが、それぞれデータマイグレーション機構を備えているわけではなく、データマイグレーション機構が欲しい場合に「すぐ使える」状況ではないのが実情です。

そのため、ライブラリ選定から進める必要があります。

ライブラリ選定

npm でそれっぽいものを検索したところ、以下を見つけました。

npm パッケージ ライセンス npm weekly downloads
2018.10 時点
GitHub stars 直近1ヶ月でのコミット数
2018.10 時点
migrate-mongoose MIT 1405 86 0
mongoose-migrate (none) 506 63 0
migrate-mongo MIT 3515 97 25
db-migrate MIT 28399 1480 0
db-migrate-mongodb MIT 2604 17 2

weekly downloads と GitHub stars を見ると db-migrate がダントツなのですが、こちらは db-migrate-mongodb の親プロジェクトで、データベースの差異を吸収するような抽象化がなされています(子プロジェクトとして、MySQL 版や PostgreSQL 版が存在する)。

こと db-migrate-mongodb のみの数字で言えば、migrate-mongo が若干優勢といった感じです。今回は、直近コミット数で現在も開発が進んでいると見受けられる migrate-mongo を採用することにします。

Let's Try ~npm script の準備~

Rails の rake タスクのように使えるとなにかと便利なので、まず npm script を準備しましょう。

    "migrate": "npm run migrate:up",
    "migrate:create": "migrate-mongo create -f config/migrate.js -- ",
    "migrate:status": "migrate-mongo status -f config/migrate.js",
    "migrate:up": "migrate-mongo up -f config/migrate.js",
    "migrate:down": "migrate-mongo down -f config/migrate.js",

-f オプションで設定しているのはコンフィグファイルです。MongoDB への接続設定等が入ると思います。環境変数から接続先を取りたい場合は以下のようなファイルにするとよいでしょう。

/**
 * Configuration file for migrate-mongo
 * @see https://github.com/seppevs/migrate-mongo
 *
 * @author Yuki Takei <yuki@weseek.co.jp>
 */

function getMongoUri(env) {
  return env.MONGODB_URI ||
    env.MONGO_URI ||
    ((env.NODE_ENV === 'test') ? 'mongodb://localhost/myprj_test' : 'mongodb://localhost/myprj');
}

const mongoUri = getMongoUri(process.env);
const match = mongoUri.match(/^(.+)\/([^/]+)$/);
module.exports = {
  mongoUri,
  mongodb: {
    url: match[1],
    databaseName: match[2],
    options: {
      useNewUrlParser: true, // removes a deprecation warning when connecting
    },
  }
};

Let's Try ~初めての migration ファイル作成~

npm script を叩きましょう。

npm run migrate:create my-first-migration

Let's Try ~migration の実装1~

まずは Mongoose 対応の migration ファイルの書き方です。

my-first-migration.js

'use strict';

require('module-alias/register');
const logger = require('@alias/logger')('growi:migrate:my-first-migration');

const mongoose = require('mongoose');
const config = require('@root/config/migrate');

module.exports = {

  async up(db) {
    logger.info('Apply migration');
    mongoose.connect(config.mongoUri, config.mongodb.options);

    const User = require('@server/models/user');

    ...

    logger.info('Migration has successfully applied');
  },

  async down(db) {
    logger.info('Undo migration');
    mongoose.connect(config.mongoUri, config.mongodb.options);

    const User = require('@server/models/user');

    ...

    logger.info('Migration has successfully undoed');
  }

};

@alias@root@server 等は、module-alias パッケージで設定したエイリアスパスです。ご自身の環境に合わせて読み替えてください。

コード解説

up function が migration の適用のためのコード、 down function がロールバックのためのコードになります。それぞれ ES6 で記述可能な async function として定義しています。

User インスタンスは、 mongoose.model() で作成されるモデルインスタンスという想定です。

それぞれの function の2行目の mongoose.connect() を呼ばなければ Mongoose のモデルインスタンスのメソッドを呼んでも MongoDB にアクセスされませんので注意してください。

Let's Try ~migration の実装2~

次に、 MongoDB Driver を直に利用するような Example を紹介します。例えば既存の Mongoose モデルを廃棄するような場合です。

abolish-myrelations.js

'use strict';

require('module-alias/register');
const logger = require('@alias/logger')('growi:migrate:abolish-myrelations');

const mongoose = require('mongoose');
const config = require('@root/config/migrate');

/**
 * BEFORE
 *   - 'myrelations' collection exists (related to models/myrelations.js)
 *     - schema:
 *       {
 *         "_id" : ObjectId("5bc9de4d745e137e0424ed89"),
 *         "group" : ObjectId("5b028f13c1f7ba2e58d2fd21"),
 *         "user" : ObjectId("5b07e6e6929bad5d3cce9995"),
 *         "__v" : 0
 *       }
 * AFTER
 *   - 'myrelations' collection is dropped and models/myrelations.js is removed
 */
module.exports = {

  async up(db) {
    logger.info('Apply migration');
    mongoose.connect(config.mongoUri, config.mongodb.options);

    const User = require('@server/models/user')();
    const UserGroup = require('@server/models/user-group')();

    // retrieve all documents from 'myrelations'
    const relations = await db.collection('myrelations').find().toArray();

    for (let relation of relations) {
      const user = await User.findOne({ _id: relation.user });
      const group = await UserGroup.findOne({ _id: relation.group });

      ...

      await user.save();
      await group.save();
    }

    // drop collection
    await db.collection('myrelations').drop();

    logger.info('Migration has successfully applied');
  },

  async down(db) {
    logger.info('Undo migration');
    mongoose.connect(config.mongoUri, config.mongodb.options);

    const User = require('@server/models/user')();
    const UserGroup = require('@server/models/user-group')();

    const insertDocs = ...;
    ...

    await db.collection('myrelations').insertMany(insertDocs);

    logger.info('Migration has successfully undoed');
  }

};

コード解説

上記コードでは、 myrelations という関連テーブル風のコレクションで管理していた情報を破棄する、というシナリオを想定しました。myrelations コレクションは当初は Mongoose によって管理されていたモデルに対応する形で生成されたはずですが、モデルを廃止する場合は当然モデルクラス・モジュールも削除されますので、スキーマファイルを require してアクセスすることができません。そのため、Raw な MongoDB Driver API を用いてデータマイグレーションを行う必要があります。

まとめ

まだまだ発展途上の JavaScript、Node.js の世界では、MongoDB を利用しながらデータマイグレーション機構をプロジェクトに導入できるデファクトスタンダードがまだありません。しかしながらコミュニティは精力的に npm ライブラリを開発・追加し続けていますので、今回のように慎重に選定を行い、時にはいくつかの実装を実際に試しながら導入することで、他の枯れた言語・エコシステムに劣らないプロジェクト構成を実現できます。

今回の Tips が、JavaScript と MongoDB を好むプロジェクトたちの一助となれば幸いです。

Dev in Container on WSL2 でボリュームのパフォーマンスを比べてみる

f:id:yukitakei:20200613040453j:plain

こんにちは。武井です。

2020年5月末に待望の Windows 10 May 2020 Update が公開され、WESEEK, Inc. で開発中の GROWI でも WSL2 と Docker Desktop を利用して Dev in Container を実現した新しい開発スタートアップ を採用しました。

本日のエントリーでは、その開発時に利用するストレージ(Docker volume)の種類によってパフォーマンスがどれくらい変わるのかを実験した際の結果を紹介します。

続きを読む

Raspberry Piと温度・湿度・気圧センサーと天気予報APIでペット環境を見守る

こんにちは、 takayuki です。

最近、知り合いのインコ部屋にRaspberry Piと温度・湿度・気圧センサーを設置し、天気予報APIも使用して、お留守番中の環境を確認できるようにしました。

動機

インコはもともと暖かく湿気のある地域に生息しており、寒さに弱い生き物です。
日中に家を空けていると、インコ部屋の温度・湿度が保たれているか、心配になります。
以前、部屋の温度を数日低くしてしまったことで、インコが体調を崩してしまったことがありました。

現在は、家電メーカーから発売されているペットカメラを設置していますが、これは微妙にかゆいところに手が届かないと感じていました。
ペットカメラには、標準カメラ、赤外線カメラ、温度センサー、動作センサー、音センサーが搭載されています。

標準カメラ、赤外線カメラは、外出先でも様子が見られるため、非常に便利です。
温度センサーは、ペットカメラの画角の関係で距離を取る必要があり、インコ部屋の外に置いています ※。
そのため、計測される温度は人がいるところの室温で、インコが過ごしているケージ内の室温はわかりません。また、温度センサーしかないため、湿度はわかりません。

※インコ部屋はアクリルケージで囲い、ペット用のヒーターで暖めています。

そこで今回、温度・湿度・気圧センサーをインコ部屋の中に設置し、環境を計測できるようにしました。

構成

今回構築した構成は下記の通りです。

f:id:Aqutam:20200303020524p:plain

ハードウェア

# 品名 数量
1 Raspberry Pi 4 Mlodel B 1
2 OKdoブラック2ピース構造スライドケース 1
3 TOSHIBA microSDHC EXCERIA 16GB 100MB/s 1
4 BME280 温湿度・気圧センサ 2
5 タカチ SWプラケース SW-50B 2
6 6極4芯モジュラーケーブル 2m 3
7 モジュラーローゼット MJ-4S-MG 1
8 モジュラーソケット6極4芯 TM5RJ3-64 3

ソフトウェア

ネットワーク

  • インコの家は、 LTE を経由してインターネットに接続しています。
  • インコの家と自宅は相互に通信できるよう、 IPsec で拠点間接続しています。

センサーの製作

温度・湿度・気圧センサーは、秋月電子通商で販売されている BME280 を使用しています。このセンサーは、I2Cで制御できます。

Raspberry Pi と BME280 を下記のように配線していきます。

f:id:Aqutam:20200309234805p:plain

BME280は、ジャンパを設定することで、 0x760x77 の2つのI2Cアドレスを使用できます。
今回、2つセンサーを接続するため、それぞれのアドレスになるようにジャンパをはんだ付けします。

I2Cは、電源に2本、信号に2本 (SCL, SDA) の合計4本の配線が必要になります。
Raspberry PiとセンサーをつなぐI2Cバスは、容易に取り回しができるようにしたいと考え、4芯モジュラーケーブルを採用しました。
モジュラーケーブルは家電量販店で調達できるものなので、最適な長さのものに交換して、センサーの位置を自由に変更できます。

タカチ SWプラケースを削り、センサーと2つのモジュラージャックを収めました。
2つのモジュラージャックは導通しているため、他のセンサーをデイジーチェーンできるようにしています。
写真では穴が空いていませんが、温度・湿度・気圧を計測できるよう、ケースの前面には通気孔を空けています。

f:id:Aqutam:20200302232643j:plain f:id:Aqutam:20200302232624j:plain

Raspberry Pi からはI2Cで使用する4本の線を引き回し、モジュラーローゼットを配置しています。

f:id:Aqutam:20200302232435j:plain f:id:Aqutam:20200302232512j:plain

設置

下記の写真のように、インコ部屋に温度センサーを設置しています。
手作業で穴を空けたため、ガタガタになってしまっています。

f:id:Aqutam:20200302232558j:plain f:id:Aqutam:20200302232609j:plain

実装

センサーからのデータ取得

センサーからのデータの取得は、こちらのコードを参考にさせていただきました。 samplecodes/BME280 at master · SWITCHSCIENCE/samplecodes · GitHub

こちらのコードをベースに、下記CSVを出力するように実装します。

Date,GR Temp,GR Press,GR Hum,OR Temp,OR Press,OR Hum
2020-03-01 17:00:00,23.94,1004.81,53.46,21.53,1003.36,62.13
2020-03-01 17:30:00,25.75,1005.25,50.85,22.10,1003.73,62.95
2020-03-01 18:00:00,26.55,1005.34,48.78,22.22,1003.83,62.06
2020-03-01 18:30:00,26.58,1005.70,47.49,22.00,1004.25,62.20
2020-03-01 19:00:00,26.41,1005.88,42.79,21.68,1004.29,50.83
2020-03-01 19:30:00,26.18,1006.09,40.66,21.65,1004.61,51.54

天気予報APIからの実績と予報データの取得

天気予報データは、 Rakuten RapidAPI を使用し、このマーケットプレイスで公開されている、 Dark Sky API を使用しました。
Rakuten RapidAPI は、1つのAPIキーを使用して世界中の様々なAPIを利用できるようにした、プロキシサービスです。

様々なAPIをWebで簡単にテストでき、Pythonをはじめいろいろな言語に対応したAPIをコールするためのコードスニペットも用意されているため、結構便利です。

f:id:Aqutam:20200310003313p:plain

Pythonコードスニペットをダウンロードし、パースして下記CSVを出力するためのコードを追加実装しました。

Date,WT Temp,WT Press,WT Hum,WT Sum,WT PrecipIntensity,WT PrecipProbability,WT ApparentTemperature,WT WindSpeed,WT WindBearing,WT CloudCover
2020-03-01 17:00:00,14.79,1015.4,42.0,曇り,0,0,14.79,2.66,167,77
2020-03-01 17:30:00,14.2,1015.7,44.0,曇り,0,0,14.2,2.74,180,90
2020-03-01 18:00:00,13.65,1016,47.0,曇り,0,0,13.65,2.89,191,94
2020-03-01 18:30:00,13.14,1016.6,50.0,曇り,0,0,13.14,3.13,187,96.0
2020-03-01 19:00:00,12.73,1017.2,52.0,曇り,0,0,12.73,3.51,180,92.0
2020-03-01 19:30:00,12.46,1017.6,55,曇り,0,0,12.46,3.93,177,91.0

グラフの描画

グラフの描画は、まず Jupyter Notebook で行いました。
Jupyter Notebook はブラウザ上で Python のコードを記述でき、グラフなど視覚的な結果を確認しながらプログラミングできるツールです。

センサーからのデータ取得天気予報APIからの実績と予報データの取得 で出力していた2つのCSVを読み込ませ、 Pandas で加工していきます。
そのデータを Matplotlib でグラフを描画するようにしました。

f:id:Aqutam:20200310004559p:plain f:id:Aqutam:20200310004610p:plain

Slackへのアップロード

Jupyter Notebook でグラフが描画できたら、ここから Python のコードに落とし込みます。
Pythonコード上で、 Slack files.upload API を使用して、Slack の特定 channel にグラフをアップロードするようにします。
cron に登録し、定期的にグラフをアップロードするようにします。

動作画面

下記のように、定期的に1週間ごと、1日ごとの温度・湿度・気圧の推移をグラフに描画しています。
緑色、黄色の線はセンサーのデータで、グレーの線は DarkSky API から取得した天気データです。

f:id:Aqutam:20200314120352p:plain

DarkSky API は、1時間ごとの天気予報も取得することが可能です。
これを使用して、1日先の1時間ごとの温度・湿度・気圧・降水量・降水確率を取得して、下記のようにグラフをプロットしています。
毎晩1回Slackにアップロードし、翌日のインコ部屋の温度管理の計画を立てるための情報として使用しています。

f:id:Aqutam:20200314120359p:plain

使用してみての感想

今回は温度・湿度・気圧センサーと天気予報APIから取得したデータをグラフに描画する仕組みを構築しました。

使用を開始してからは1ヶ月弱が過ぎました。
今までは、インコ部屋に温湿度計を立てて、ペットカメラ越しに確認しに行っていたのですが、このシステムを構築してからは定期的にSlackに情報が通知されるため、だいぶ把握が楽になりました。
湿度・温度・気圧・降水確率・降水量のグラフは、それぞれ別の画像で書き出しSlackにアップロードしているのですが、短時間に連続してアップロードしているためか、たまにアップロードに失敗します。
そんなに頻度は多くないので、失敗したときは手動でグラフ描画を再実行しています。

製作時の感想は、ケースの加工がなかなか大変でした。バンドソーやボール盤など電動工具がなく手作業で削ったり穴を空けていたので結構疲れました。
ソフトウェアの実装は、参考にできるコードがすでにあったり、便利なAPIサービスがあったりと、すんなり実装できました。

インコ部屋の環境は、今後下記のように改良してみたいと考えています。

  • Prometheus・Grafana 環境の構築
  • 温度のしきい値に応じて Slack への通知 (Prometheus Alertmanager)
  • 温度に応じて赤外線でエアコンの電源ONにする
  • I2Cバスのノイズ対策

CircleCI 2.1 による kubernetes で動作するアプリケーションの CI/CD 事始め (Ruby on Railsアプリケーション)

はじめに

この記事は CircleCI Advent Calendar 2019 の 19 日目の記事です。

拙稿となりますが Ruby on Rails, Vue.js によるモダン WEB アプリケーション 実践編 (その2) にて GitHub Action を使って k8s 上で動作する Ruby on Rails アプリケーションを CI/CD する Workflow を作ったので、同じ機能を CircleCI で作ってみることにします。

尚、Rails アプリケーションを前提としてますが、k8s の deployment manifest で定義されたアプリケーションであれば概ね流用できると思います。

なぜ CircleCI?

アドベントカレンダーのネタとして何かないかと探したことがきっかけです。(笑)

Wercker, CircleCI, GitHub Actions, GitLab CI/CD ではどれも YAML を定義することで CI/CD 環境を作ることが出来ます。 (CI/CD をオンプレ環境で構築する場合は Jenkins を使う環境があると思います)

そこで、1つ処理の実例を題材として GitHub Actions, CircleCI のそれぞれで違いを探してみようと思い立ったため CircleCI を使うことにしました。

前提事項

CircleCI が VCS リポジトリと連携できるよう初期設定が既に完了していることを前提としています。

初めて CircleCI の Workflow を追加する場合は、Getting Started を参考にして Workflow を設定してみて下さい。

構築する CI/CD の動作説明

CI では VCS レポジトリの全ブランチを対象に、コミットされた時点のコードに対してテストを実行するものとします。

CD では VCS リポジトリのデプロイ用ブランチを対象に、アプリケーションの Docker container image をビルド及び、k8s へデプロイするものとします。

CI の動作説明

CIの動作図
CIの動作図

CI の動作は上図にあるように次のとおりです。

  1. 開発者VCSリポジトリ(ex.GitHub) に git push を実行
  2. VCSリポジトリ(ex.GitHub)CIツール(ex.CircleCI) に PUSHイベントを通知
  3. CIツール(ex.CircleCI) がテストを実行
  4. CIツール(ex.CircleCI)VCSリポジトリ(ex.GitHub) CI結果(テスト結果)を通知
  5. (opt) CIツール(ex.CircleCI)開発者 にCI結果(テスト結果)を通知

※ 今回「5. CIツール(ex.CircleCI)開発者 にCI結果(テスト結果)を通知」は設定しません。

CD の動作説明

CDの動作図
CDの動作図

CD の動作は上図にあるように次のとおりです。

  1. 開発者VCSリポジトリ(ex.GitHub) にてPR/MRをマージ
  2. VCSリポジトリ(ex.GitHub)CDツール(ex.CircleCI) にマージ(又はブランチ更新)イベントを通知
  3. CDツール(ex.CircleCI) にてDockerイメージをビルド
  4. CDツール(ex.CircleCI)Dockerイメージレジストリ(ex.Docker Hub) にDockerイメージをプッシュ
  5. CDツール(ex.CircleCI)Kubernetesk8sマニフェストを更新
  6. Kubernetes にてローリングアップデート
  7. CDツール(ex.CircleCI)Kubernetes にてローリングアップデートの成功を確認
  8. (opt) CIツール(ex.CircleCI)開発者 にCI結果(テスト結果)を通知

CircleCI の Workflow / Job を作成する

GitHub Actions から CircleCI に移行する際に参考になるドキュメントが migrating-from-github にあります。

GitHub Actions では 1 Workflow は 1 ファイルであり、Workflow の中では複数の Job が定義できます。Workflow 設定は .github/workflows 配下に保存します。 一方で CircleCI では複数の Workflow が 1 ファイルに保存され、複数の Job が定義できます。Workflow 設定は .circleci/config.yml に保存します。

CI 環境

まずは CI 環境を構築することにします。

GitHub Actions の CI Workflow

# .github/workflows/test.yml
name: Test

on: push

jobs:

  test:

    runs-on: ubuntu-latest
    container: ruby:2.5.3

    steps:
    - uses: actions/checkout@v1

    - name: Initialize
      env:
        RAILS_ENV: test
        DISABLE_SPRING: 1
      run: |
        # install tools
        curl -sL https://deb.nodesource.com/setup_10.x | bash -
        apt-get install -y nodejs
        npm install yarn@1.13.0
        gem install bundler -v 1.17.3
        # initialize DB
        bundle install
        bundle exec rails db:migrate

    - name: Test
      run: bundle exec rails test

CircleCI の CI Workflow

# .circleci/config.yml
version: 2.1
jobs:
  test:
    docker:
      - image: ruby:2.5.3

    steps:
      - checkout

      - run:
          name: Initialize
          environment:
            RAILS_ENV: test
            DISABLE_SPRING: 1
          command: |
            # install tools
            curl -sL https://deb.nodesource.com/setup_10.x | bash -
            apt-get install -y nodejs
            npm install yarn@1.13.0
            gem install bundler -v 1.17.3
            # initialize DB
            bundle install
            bundle exec rails db:migrate

      - run:
          name: Test
          command: bundle exec rails test

workflows:
  ci:
    jobs:
      - test

GitHub Actions と CircleCI の比較

GitHub Actions と CircleCI のそれぞれの Workflow で CI 環境を実現する YAML ファイルを見てみましたが、内容はほぼ同じに記載することが出来ました。

記載内容に違いが出るのは、並列実行、キャッシュ利用、実行ホストのOS/リソース指定などを行おうとした時だと思われます。 CI として紹介した Workflow は非常に単純なものであったため差異は出ませんでした。

CD 環境

次に CD 環境を構築することにします。

GitHub Actions の CD Workflow

# .github/workflows/docker_build_and_push.yml
name: Docker Image CI

on:
  push:
    branches:
    - stable

jobs:

  docker_build_and_push:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v1
    - name: Build the Docker image
      env:
        # [TODO] ${{ github.repository }} から repository 名だけ抽出する
        IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/vue_practice_app:${{ github.sha }}
      run: docker build . --file Dockerfile --tag $IMAGE_NAME

    - name: Login to Docker hub
      run: docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} -p ${{ secrets.DOCKER_HUB_PASSWORD }}

    - name: Push the Docker image to Docker hub
      env:
        # [TODO] ${{ github.repository }} から repository 名だけ抽出する
        IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/vue_practice_app:${{ github.sha }}
      run: docker push $IMAGE_NAME
# .github/workflows/deploy.yml
name: Deploy docker container to kubernetes

on:
  release:
    types: [published]

jobs:

  deploy:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@master

    - name: migrate on cluster
      uses: steebchen/kubectl@master
      env:
        KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
      with:
        args: >
          run tmp-migrate -i --generator=run-pod/v1 --rm
          --image ${{ secrets.DOCKER_HUB_USERNAME }}/vue_practice_app:${{ github.sha }}
          --overrides='{
            \"spec\":{
              \"containers\":[{
                \"name\":\"app\",
                \"image\":\"${{ secrets.DOCKER_HUB_USERNAME }}/vue_practice_app:${{ github.sha }}\",
                \"command\":[\"bash\"],
                \"args\":[\"-c\",\"rails db:migrate SECRET_KEY_BASE=$(rails secret)\"],
                \"envFrom\":[{\"secretRef\":{\"name\":\"vue-practice\"}}]
              }]
            }
          }'

    - name: deploy to cluster
      uses: steebchen/kubectl@master
      env:
        KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
        # [TODO] ${{ github.repository }} から repository 名だけ抽出する
        IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/vue_practice_app:${{ github.sha }}
        K8S_NAMESPACE: vue-practice
        K8S_DEPLOYMENT_NAME: vue-practice
        K8S_CONTAINER_NAME: app
      with:
        args: set image --namespace $K8S_NAMESPACE --record deployment/$K8S_DEPLOYMENT_NAME $K8S_CONTAINER_NAME=$IMAGE_NAME

    - name: verify deployment
      uses: steebchen/kubectl@master
      env:
        KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
        K8S_NAMESPACE: vue-practice
        K8S_DEPLOYMENT_NAME: vue-practice
      with:
        args: rollout status --namespace $K8S_NAMESPACE deployment/$K8S_DEPLOYMENT_NAME

CircleCI の CD Workflow

Settings >> BUILD SETTINGS >> Environment Variables にて、環境変数を以下のとおり設定する必要があります。

名前 説明
DOCKER_HUB_USERNAME Docker Hub アカウントのユーザ名
DOCKER_HUB_PASSWORD Docker Hub アカウントのパスワード
KUBECONFIG_STR ※1 kubectl config の内容を base64 変換したもの ※2

※1 circleci/kubernetes の install-kubeconfig コマンドでは設定ファイルの内容を KUBECONFIG 環境変数で設定することが default なのですが、kubectl は KUBECONFIG 環境変数を設定ファイルへのパスとして使用するため KUBECONFIG 以外の名前にしましょう。(参考)

※2 echo -n "$KUBECONFIG" | base64 -d により復元できる値にすること

# .circleci/config.yml
version: 2.1

orbs:
  docker-orb: circleci/docker@0.5.19
  kube-orb: circleci/kubernetes@0.10.1

commands:
  migrate_vue_practice:
    parameters:
      image_name:
        type: string
    steps:
      - run: |
          kubectl run tmp-migrate -i --generator=run-pod/v1 --rm -n vue-practice --image << parameters.image_name >> \
            --overrides="'{ \
              \"spec\":{ \
                \"containers\":[{ \
                  \"name\":\"app\", \
                  \"image\":\"<< parameters.image_name >>\", \
                  \"command\":[\"bash\"], \
                  \"args\":[\"-c\",\"rails db:migrate SECRET_KEY_BASE=\$(rails secret)\"], \
                  \"envFrom\":[{ \
                    \"secretRef\":{ \
                      \"name\":\"vue-practice\" \
                    } \
                  }] \
                }] \
              } \
            }'"

jobs:
  test:
    docker:
      - image: ruby:2.5.3

    steps:
      - checkout

      - run:
          name: Initialize
          environment:
            RAILS_ENV: test
            DISABLE_SPRING: 1
          command: |
            # install tools
            curl -sL https://deb.nodesource.com/setup_10.x | bash -
            apt-get install -y nodejs
            npm install yarn@1.13.0
            gem install bundler -v 1.17.3
            # initialize DB
            bundle install
            bundle exec rails db:migrate

      - run:
          name: Test
          command: bundle exec rails test

  docker_build_and_push:
    machine:
      image: ubuntu-1604:201903-01

    steps:
      - checkout

      - run:
          name: Initialize
          command: |
            GIT_HASH=$(git rev-parse HEAD)
            echo "export GIT_HASH=${GIT_HASH}" >> $BASH_ENV
            echo "export IMAGE_NAME=${DOCKER_HUB_USERNAME}/vue_practice_app" >> $BASH_ENV
            source $BASH_ENV

      - run:
          name: docker login
          command: echo "${DOCKER_HUB_PASSWORD}" | docker login -u ${DOCKER_HUB_USERNAME} --password-stdin

      - docker-orb/build:
          image: $IMAGE_NAME
          tag: $GIT_HASH

      - docker-orb/push:
          image: $IMAGE_NAME
          tag: $GIT_HASH

  deploy:
    machine:
      image: ubuntu-1604:201903-01

    steps:
      - checkout

      - run:
          name: Initialize
          command: |
            GIT_HASH=$(git rev-parse HEAD)
            echo "export IMAGE_NAME=${DOCKER_HUB_USERNAME}/vue_practice_app:${GIT_HASH}" >> $BASH_ENV
            source $BASH_ENV

      - kube-orb/install-kubectl

      - kube-orb/install-kubeconfig:
          kubeconfig: KUBECONFIG_STR

      - migrate_vue_practice:
          image_name: $IMAGE_NAME

      - kube-orb/update-container-image:
          namespace: vue-practice
          resource-name: deployment/vue-practice
          container-image-updates: app=$IMAGE_NAME
          record: true

      - kube-orb/get-rollout-status:
          namespace: vue-practice
          resource-name: deployment/vue-practice

workflows:
  ci:
    jobs:
      - test

  cd:
    jobs:
      - test:
          filters:
            branches:
              only: stable
      - docker_build_and_push:
          requires:
            - test
      - deploy:
          requires:
            - docker_build_and_push

CircleCI Orbs を使う方法には .circleci/config.yml にて以下のようにします。

  • version: 2.1 以上にする
  • orbs の値として使用したい Orb を Hash 形式で指定する
    • Hash の key は job 内で使用する名前となる
    • Hash の value は公開された Orb のパスを指定する
      • @ をつけると Orb のバージョンを指定できる
    • 例) docker-orb: circleci/docker@0.5.19 を指定すると circleci/docker の version 0.5.19 が定義したコマンドが使えるようになり、Job の中で docker-orb/build のようにコマンドが指定できる

また、commands も version: 2.1 から使えるようになった設定です。 Job の中で run 等の代わりに使えるコマンドを定義できます。 今回 commands を使った理由は、kubectl run の override させる JSON パスに $IMAGE_NAME を展開しつつ生成できなかったため使うことにしました。

GitHub Actions と CircleCI の比較

GitHub Actions と CircleCI のそれぞれの Workflow で CD 環境を実現する YAML ファイルを見てみましたが、CI と同様に内容はほぼ同じに記載することが出来ました。

GitHub Actions も CircleCI も外部の Workflow/Job を流用できるため、同様の機能であれば、それらの使い方の違いはあるもののほぼ同じに記載することが出来ました。

作成した CI/CD の改善ポイント

  • npm, gem パッケージインストール後にキャッシュを有効にする
  • Docker イメージのビルドキャッシュを有効にする
  • CI/CD の実行結果をメールの代わりに Slack 通知する
  • CD にてデプロイする前に継続して実行するかどうかを人に尋ねる

キャッシュを有効にすることで Workflow を実行する時間が短縮できるようになることが期待できます。

最後に - GitHub Actions と CircleCI の違いについての所感

今回はせっかくなので version 2.1 から使えるようになった機能である CircleCI Orbs, Commands を使ってみました。

CircleCI の Marketplace と同様の仕組みとして CircleCI Orbs なるものがあると記事を執筆していて知りました。

CircleCI Orbs を知る前は GitHub Actions との大きな差異として、外部の Workflow が利用できる点だと思っていたので、version 2.1 の新機能を知ったことで GitHub Actions とはほぼ差異はなく移行できる印象を持ちました。

敢えて違いとなることを挙げると次のとおりかと感じてます。

GitHub Actions の Marketplace の手軽さ、GitHub Actions では JavaScript を使ってアクションを実装することが出来るため自由度やメンテナンス製が高いように感じます。

一方で CircleCI には CLI があるため、開発をする際にはわざわざ VCS リポジトリに commit/push せずに開発・デバッグ出来るメリットがあると思います。

イマドキの JavaScript 開発で使える、リモートデバッグとロガーの Tips (2018年版-後編)

こちらは 「イマドキの JavaScript 開発で使える、リモートデバッグとロガーの Tips (2018年版-後編)」からの転載です。

SEROKU の開発を例に、弊社で使っているリモートデバッグとロガーの Tips をご紹介します。 当記事は 2018 年、と過去の記事ですが、現在でも応用可能な Tips になっています。



案件としても OSS 成果物としても、JavaScript を利用するシチュエーションは増え続けています。まだまだ枯れた言語とは言い難い状況で、使われるバージョンも ES5 から ES7 まで進化を続け、新しい文字列リテラルや async/await のような「イマドキの JavaScript の書き方」を紹介する記事は多い中、デバッグはこうあるべきという情報は比較的少ないように思います。

本記事の前編、中編では、システム開発に於けるデバッガ、ロガーの大切さと、他の言語・フレームワークと比べた際の JavaScript 開発環境に於けるビハインドについて説明し、実際に理想的なロガーを利用する為の設定方法を紹介してきました。

本記事では JavaScript 開発時のデバッガー利用のための具体的な設定方法を紹介します。

理想の世界

まずは前編で掲げたゴールの再掲です。

対象システム

以下の3つのシナリオをカバーすることにします。

  • シナリオA
    • node.js (Express)
  • シナリオB
    • node.js (Express) + webpack/babel によるクライアントビルド
  • シナリオC
    • next.js on Express

やりたいこと

  • SourceMap の利用
    • ブラウザの開発者ツールでエラーを追う場合、トランスパイル前のソースの行数が分かる
  • リモートデバッグ

Let's Try! ― シナリオA

シナリオAは、node.js (Express) 環境向けのロガー設定です。
必要なことは以下 2 つです。

npm 経由のプロセスをデバッグ

node.js のプロセスは、 node コマンドに --inspect あるいは --inspect-brk を渡すことでリモートデバッグに必要な inspector の利用が可能になります。node コマンドをそのまま叩くことは少ないので、まずは npm 経由で立ち上げた node プロセスをデバッグできるようにしましょう。

VSCode公式ページによれば、以下のようなコードで、npm から起動したプロセスをデバッグできるようになります。

(..snip..)

"scripts": {
  "debug": "node --nolazy --inspect-brk=9229 app.js"
},

(..snip..)

9229 ポートは inspector 利用時のデフォルトポートなので、単に --inspect でも構いません。

次に VSCode の設定ファイルです。

launch.json

(..snip..)

{
    "name": "Launch via NPM",
    "type": "node",
    "request": "launch",
    "cwd": "${workspaceFolder}",
    "runtimeExecutable": "npm",
    "runtimeArgs": [
        "run-script", "debug"
    ],
    "port": 9229
}

(..snip..)

これだけで、VSCode のデバッガが利用可能になります。

個人的には以下のように若干カスタムしたものを使っています。

launch.json

(..snip..)

{
    "type": "node",
    "request": "launch",
    "name": "Debug: Server",
    "runtimeExecutable": "npm",
    "runtimeArgs": [
      "run", "debug"
    ],
    "port": 9229,
    "restart": true,
    "console": "integratedTerminal",
    "internalConsoleOptions": "neverOpen"
},

(..snip..)

node-dev を使おう

さて、debug script では node をそのまま起動していますが、開発中にソースコード変更を検知して Express サーバーが再起動してくれると更に楽です。巷では nodemon が有名ですが、筆者が推すのは断然 node-dev です。コンフィグレスで require されたものだけを watch してくれる上に、デスクトップ通知があって再起動したことがわかりやすいのが特徴です。

debug script を修正し、以下のようにしておきましょう。

(..snip..)

"scripts": {
  "debug": "node-dev --nolazy --inspect app.js"
},

(..snip..)

追加の watch 設定

開発環境で、js だけではなく json 等が変更されたときでも node-dev による再起動が働いて欲しい場合もあると思います。そんなときは、追加で require する機構を用意し、 development 環境でのみ働くようにしておきましょう。

dev.js

const fs = require('fs');
const path = require('path');

const localeDir = 'src/locales';

class Dev {

  init() {
    this.requireForAutoReloadServer();
  }

  /**
   * require files for node-dev auto reloading
   */
  requireForAutoReloadServer() {
    // load all json files for live reloading
    fs.readdirSync(localeDir)
      .filter(filename => {
        return fs.statSync(path.join(localeDir, filename)).isDirectory();
      })
      .map((dirname) => {
        require(path.join(localeDir, dirname, 'translation.json'));
      });
  }

}

module.exports = Dev;

app.js

(..snip..)

if (self.node_env === 'development') {
  const Dev = require('./dev');
  const dev = new Dev();
  dev.init();
}

(..snip..)

上記のサンプルコードは、 src/locales/* 下にある translation.json ファイルが変更された場合に再起動を行います。

Let's Try! ― シナリオB

シナリオBは、node.js (Express) + webpack/babel によるクライアントビルド両方で利用できる設定です。

ここまでに作ったファイルは完全に流用できます。
webpack の設定と、VSCode の設定を追加しましょう。

webpack.js

(..snip..)

devtool: 'cheap-module-eval-source-map',

(..snip..)

devtool にどのような値を設定可能かは webpack の公式ドキュメントを参照してください。ここでは速度を重視し、 cheap-module-eval-source-map を選びました。

次に VSCode の設定ファイルです。

launch.json

(..snip..)
{
    "type": "chrome",
    "request": "launch",
    "name": "Debug: Chrome",
    "sourceMaps": true,
    "webRoot": "${workspaceFolder}",
    "sourceMapPathOverrides": {
      "webpack:///*": "${workspaceFolder}/*"
    },
    "url": "http://localhost:3000",
}
(..snip..)

特に重要なのは webRootsourceMapPathOverrides です。

webRoot は、URL ベースで root がどこを指し示すか、
sourceMapPathOverrides はブラウザの開発者ツールで source map の参照先の webpack:/// から始まるパスが、VSCodeワークスペース上でどこを指し示すべきかのマッピングを行います。ブレークポイントがうまく設定できない場合は、この2つを見直しましょう。

Let's Try! ― シナリオC

シナリオCは、next.js 向けの設定です。ディレクトリ構成がほぼ決まっているので、シナリオBより簡単かもしれませんね。

launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Next: Node",
      "runtimeExecutable": "npm",
      "runtimeArgs": [
        "run",
        "debug"
      ],
      "port": 9229,
      "restart": true,
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "sourceMapPathOverrides": {
        "webpack:///*": "${workspaceFolder}/*"
      },
    },
    {
      "type": "chrome",
      "request": "launch",
      "name": "Next: Chrome",
      "url": "http://localhost:3000",
      "webRoot": "${workspaceFolder}",
      "sourceMapPathOverrides": {
        "webpack:///*": "${webRoot}/*"
      },
    }
  ],
  "compounds": [
    {
      "name": "Next: Both",
      "configurations": ["Next: Node", "Next: Chrome"]
    },
  ],
}

まとめ

後編ではロギングに関して実際のコードを紹介し、デバッガ利用時の「理想の世界」を実現するコードを紹介しました。デバッガや node-dev のような自動で再起動するツールを使うことで、開発時の効率は数倍になります。

関連記事(前編・中編)と合わせて、是非自身とチームの開発をより効率的に、より楽しいものにしていただければと思います。

関連記事

tech.weseek.co.jp

tech.weseek.co.jp