Zeals TECH BLOG

チャットボットでネットにおもてなし革命を起こす、チャットコマース『Zeals』を開発する株式会社Zealsの技術やエンジニア文化について発信します。

Kubernetes Meetup Tokyo#24で「ZealsでのCI/CDパイプライン構築事例」について登壇しました!

https://connpass-tokyo.s3.amazonaws.com/thumbs/16/f1/16f13ae16413921089f49e9bb5087352.png

こんにちは!
普段はGolangでMicroservicesを開発しているぱんでぃーです!

今回のエントリーでは『Kubernetes Meetup Tokyo #24』でLT枠として登壇してきたレポートをお届けします!

k8sjp.connpass.com

続きを読む

Rails環境でセキュリティ向上のため、Brakeman gemを導入&脆弱性対策を実施しました

Brakemanのロゴ

こんにちは、ZealsでバックエンドエンジニアとしてRailsを使って開発をしている鈴木です。
Zealsでは、セキュリティ対策の一環としてBrakemanを使用しております。

今回はなぜ、Brakemanを導入することに至ったかの理由とBrakemanの導入、実際に活用してうまくいったノウハウを紹介させていただきます。
Railsのセキュリティ対策について調べている方や、Brakemanをどのように活用していくか調べている方の参考になれば幸いです。

  • そもそもBrakemanとは
  • Brakemanを導入した経緯
  • Brakeman活用までの流れ
    • 1.Brakemanの実行結果をエクスポートし、脆弱性の調査および相談
    • 2.脆弱性についての調査
    • 3.脆弱性の対応についてチームで方針を決定
    • 4 話した優先度や対応方針に従って修正
  • 今後やっていきたいこと
  • さいごに

そもそもBrakemanとは

Brakemanとはソースコードに、SQLインジェクションなどの脆弱性がないかを解析してくれるライブラリです。 SQLインジェクションなどの有名な脆弱性だけでなく、解析してくれる項目が20種類以上もあります。

github.com

これだけ解析をしてくれるため、URLを設定する際に危険なサイトのURLを登録できてしまう脆弱性に気づくことができました。現在のサービスの運営上では問題になっていませんが、将来的に大きなインシデントとなる可能性があります。

もちろん、 コードレビューだけでは気づけない箇所も指摘してくれますので、セキュリティに強いアプリケーションを開発していく上では必須のツールです。

実行についても、以下のようにコマンド一発で解析してくれるため非常に簡単です。

brakeman

実行すると、以下のように解析した結果を表示してくれます。

もちろん、Brakemanは他のGemと同様に簡単にインストールできます。
グローバルにGemをインストールする場合は、以下のコマンドを実行してください。

gem install brakeman

Gemfile にを用いる場合は以下のように記述してください。

group :development do
  gem 'brakeman', :require => false
end

Brakemanを導入した経緯

Zealsはクライアントである企業様の大切な情報を扱うアプリケーションです。 そのため、セキュリティ対策を万全にする必要があります。

今後より多くの機能を追加し事業を成長させる上で、セキュリティ強化は重要なテーマとなります。 そうなった場合に、数万行のコードをエンジニアがすべて解析することは、現実的ではありません。

そういった脆弱性の診断を効率的にするために、今回Brakemanを導入しました。

Brakeman活用までの流れ

1.Brakemanの実行結果をエクスポートし、脆弱性の調査および相談

最初のフローとして、Brakemanを用いた脆弱性の調査、相談をしていきます。

実行結果を見るために、その都度コマンドラインで実行するしていくのは非効率的です。
Brakemanには解析した結果を、HTMLなどにエクスポートしてくれる便利機能があります。

解析結果をエクスポートするためには、以下のコマンドを実行するだけOKです。

brakeman -o brakeman.html

Zealsでは、エクスポートされたHTMLを資料として、脆弱性の調査や相談を社内外のエンジニアと共同で行いました。

脆弱性を調査した箇所の一例としては、冒頭に説明した「URLを設定するところで危険なサイトのURLを登録できてしまう」という脆弱性です。こちらに関しては、コードレビューの時点では気づくことができませんでした。

詳細な内容としては、「アプリケーションの公開範囲が将来toC向けに広がった場合、不正なデータを登録される可能性がある」というものです。

エクスポートしたHTMLをブラウザで開くと、このような画面が表示されます。

ここでの指摘内容の例として、以下のようにBrakemanが指摘してくれています。

  • RemoteFollowControllerのcreateメソッドで、ユーザーが入力した値をもとにリダイレクトする可能性がある
  • そのため、オープンリダイレクトにつながってしまう可能性がある

tooljp.com

2.脆弱性についての調査

Brakemanは、現在は脆弱性でなくとも、将来的に脆弱性となりえる箇所まで指摘してくれます。

脆弱性と指摘されたコードについても、何かの理由があってそのような実装になっている場合もありますので、その背景も調査してドキュメントに残しておくと、後から振り返ることができ非常に便利です。

ドキュメントがあることで第三者への脆弱性報告の資料として使えたり、実装方針について相談する資料にもなり、とても役に立ちました。

3.脆弱性の対応についてチームで方針を決定

調査した脆弱性ごとに対応方法を選択し、修正していく方針となりました。

しかし、すぐに修正可能な方法を採用すると、処理に時間がかかりサービスが使いづらくなる脆弱性対策が存在します。 そのため、「脆弱性対策として処理時間をどれぐらいかけて良いか」をもとに対応方法を考え、チーム内で相談と調査をしていきました。

先に調査を行ったことで、脆弱性対策によって処理が遅くなることが事前にわかり、実装の手戻りを防いだり、工数の見積を正確にした上で対策を進めることができたという点が非常に良かったですね。

4 話した優先度や対応方針に従って修正

チーム内で決定した優先度や対応方針に従って、プロダクトコードに修正を加えます。

Brakemanは、アプリケーション内でのユーザー入力の操作内容をもとに脆弱性を判断しているため、問題ない仕様でも指摘される場合があります。
そのような場合は、Brakemanの指摘を無視するコマンドを実行します。

以下のコマンドを実行すると、警告を無視する設定が可能です。

brakeman -I 

上記のコマンドを実行すると、以下の実行画面が表示されます。

最初に、警告を無視するignore設定をするファイル名を何にするか質問されます。

特にこだわりがなければ、デフォルトのままで問題ないのでEnterを押します。
次に、どの対話形式で進めていくかを確認されますが、最初は全件対応で問題ないので1 を入力してください。

さらに、挙げられた脆弱性ごとに、内容を無視するかを確認されます。
主に実行するコマンドは以下のコマンドです。

  • n :ignoreをする際、ノートにignoreする理由を書くことが可能です
    • iでもignore可能ですが、あとから見返すとignoreした理由が不明になるため、nの使用を推奨します
  • s:何もせずにスキップします
  • u:ignoreを取り消します

今後やっていきたいこと

CIを使ってBrakemanを実行していきます(Zealsでは、CIにCircle CIを利用しています)。

当然ですが、一度Brakemanの対応が終わった後にも、新しい脆弱性が生まれる可能性があります。

新しい脆弱性が生まれないようにするため、CIツールでBrakemanを実行し継続的に脆弱性を確認したほうが良いでしょう。
Zealsでは今後、初動の脆弱性対応の完了後、 CircleCIを用いたBrakeman運用をしていく予定です。

circleci.com

続きを読む

Gotanda.js#13をfreeeさんのオフィスで開催&登壇しました!

f:id:zeals-engineer:20191006001711j:plain

みなさんこんにちは!

10月4日に五反田の地域JavaScriptコミュニティGotanda.js』の第13回イベントが行われたのですが、Zealsエンジニアのぱんでぃーさんこと濱田が登壇したので、そちらの模様をお届けいたします。

Gotanda.jsはテックブログで今までに何度かイベントレポートを書いておりまして、前回はZealsオフィスで開催させていただきました。

tech.zeals.co.jp

今回もとても面白い会になりましたので、当日の模様をお届けできればと思います。

  • イベント全体の雰囲気
    • LT一覧
  • ぱんでぃーさんの登壇
  • さいごに

イベント全体の雰囲気

f:id:zeals-engineer:20191006000741j:plain

※奥に見える卓球台がいつも気になってます

今回はfreeeさんのオフィスにあるイベントスペースをお借りして、イベントを開催しました!めちゃくちゃキレイな会場で、音響やスクリーンなどの設備が整っていたのが印象的でした(羨ましい...)。

当日は、後ほどお話するぱんでぃーさんを含め計7名の方にLTをしていただいたのですが、「実践的なテクニックやツールについての話」と「少しマニアックだけど面白いフレームワークやツールの話」とが半分ずつくらいでした。

学びもあり、笑いもありという、他にはない珍しい雰囲気の中で当日のイベントが進んでいきました。

f:id:zeals-engineer:20191006131213j:plain

LT一覧

・Code Generatorを作ってコンポーネントを自動生成しよう
・やっと Sprockets やめる話
・JavaScript で広げる xR の世界
・Web Audioで作る!快感エンターキー
・Gatsby.jsとCloud Functionsで毎週自動でコンテンツが更新され続けるフレームワーク比較サイトを作った話
・変化(へんげ)の術

音声を扱う『p5.js』といWeb Audio APIの話や、JSでxRを実装する話についての話は、普段のWeb開発ではなかなか触れることがなかったので、「そんなフレームワークまで存在するんだ」と、JSの対応範囲の広さやトレンドの移り変わりの速さを感じました。

一方で、Gatsby.jsなどのSSRのLTやsprockets(Rails)を含んだ話、あるいはコンポーネントについてのノウハウなど、とても実践的な話も多く、JSを触りたい欲求が強くなってしまいました。(以下一部資料)

speakerdeck.com

speakerdeck.com

 

ぱんでぃーさんの登壇

さて、Zealsエンジニアぱんでぃーさんの登壇です。

続きを読む

React+Redux環境に『Jest』と『 Enzyme』を導入し、フロントエンドでのテスト環境を構築しました

f:id:zeals-engineer:20190930124820p:plain みなさんこんにちは!
Zealsでフロントエンドエンジニアとしてインターンをしている栂瀬といいます。

この度Zealsで、テスティングフレームワークのJestを導入しました!
背景や導入にあたっての取り組みなど、何を行なったかを詳しくご紹介していきたいと思います!

  • Jest & Enzyme とは
  • 背景
  • 実際に動かしてみた
    • 【準備】React Componentをテストしよう
    • 実際のテストコードをご紹介
      • タグの有無をテスト
      • イベントのテスト
    • Reduxのテスト
      • reducerのテスト
    • テストコード勉強会
      • 勉強会のコンテンツ:shallow is faster than mount ??
        • 検証結果
      • 勉強会の成果
  • テスト導入のメリット
  • 今後の展望
  • まとめ

Jest & Enzyme とは

JestとはFacebookが開発しており、細かい設定なしで簡単にフロントエンドのユニットテストを行うことができるテスティングフレームワークです。

jestjs.io

EnzymeとはAirbnbが開発しており、Reactのテストコード記述を簡単にしてくれるテストユーティリティツールです。

Enzymeには、shallowレンダリングフルDOMレンダリングの機能が備わっています。
shallowレンダリングを行うことで、インスタンス化またはレンダーされていない子コンポーネントの振る舞いを心配することなくユニットテストを行うことができます。

airbnb.io

背景

フロントエンドにテストを導入した背景としては、以下の課題を解決することにありました。

  1. 手動でデバッグを行うのが大変で、本番にデプロイ後に発覚するバグが多かった
  2. デバッグの難しさゆえに、継続的・定期的にリファクタリングを行うことが難しかった

これまでは、エディタでコードを書き終わった後にブラウザでアプリを立ち上げ、実装の影響が及ぶ箇所を1つずつ手動でテストしていました。
リファクタリングに関しては特に難しく、リファクタリング前と後で挙動が全く同じになっていることを目視で確認しなければいけなかったため、抜け漏れが発生してエラーに繋がることもありました。

実際に動かしてみた

【準備】React Componentをテストしよう

ZealsではフロントエンドのフレームワークにReact.jsを使っており、1ファイル1Componentというルールでファイルシステムを構築しています。
そのため、テスト対象のComponentを決めたらそのファイルと同名の.test.js(ts)ファイルを作成し、そこにテストコードを書いています。

import React, { Component } from 'react';

class Sample extends Component {
  render() {
    return (
      <div>
        <h1> {title} </h1>
      </div>
    )
  }

}

export default Sample;
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from 'enzyme';
import Sample from './Sample';

Enzyme.configure({ adapter: new Adapter() });

describe('Sample.jsx', () => {
  const defaultValues = {
    title: 'hoge',
  };

  const makeComponent = (args = {}) => (<Sample {...Object.assign({}, defaultValues, args)} />);

  test('show h1', () => {
    const component = shallow(makeComponent());
    expect(component.find('h1').text()).toEqual('hoge');
  })

})

実際のテストコードをご紹介

それでは基本的なテストコードを紹介していきます。

タグの有無をテスト

以下はフラグ(ここでは isOpenModal )の true / false によって、タグの表示が切り替わっているかをテストするコードの例です。

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from 'enzyme';
import Sample from './Sample';

const defaultValues = {
  isOpenModal: false,
}

const makeComponent = (args = {}) => (<Sample {...Object.assign({}, defaultValues, args)} />); // 値の指定がなければdefaultValuesを設定

describe('When isOpenModal is true', () => {
  test('Modal should be displayed', () => {
    const wrappper = shallow(makeComponent({ isOpenModal: true })); // shallowを使用するためshallow wrapperを作成
       expect(wrappper.exists(Modal)).toBeTruthy();
  })
})

describe('When isOpenModal is false', () => {
  test('Modal should not be displayed', () => {
    const wrappper = shallow(makeComponent());
       expect(wrappper.exists(Modal)).toBeFalsy();
  })
})

イベントのテスト

eventのテストではMock Functionを使用します。
フロントエンドチームの方針で、イベントが正しくcallされているかどうかもテスト対象に含めています。

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from 'enzyme';
import Sample from './Sample';

const makeComponent = (args = {}) => (<Sample {...Object.assign({}, defaultValues, args)} />);

describe('execute event', () => {

  // onChangeイベントが呼ばれたかどうかをテストする
  test('execute onChange event', () => {
    const onChangeMock = jest.fn()
    const wrappper = shallow(makeComponent({ ValueIsChange: onChangeMock })); // テスト対象のComponentの関数にMock Functionを代入
     wrapper.find('input').simulate('change');
     expect(onChangeMock).toHaveBeenCalled();
  })

  // onClickイベントが呼ばれたかどうかをテストする
  test('execute onClick event', () => {
    const onClickMock = jest.fn()
    const wrapper = shallow(makeComponent({ toggleSomething: onClickMock }));
     wrapper.find('button').simulate('click');
     expect(onClickMock).toHaveBeenCalled();
  })

  // onClickイベントに `e.stopPropagation()` が効いているかのテスト
  test('execute onClick event', () => {
    const onClickMock = jest.fn()
    const wrapper = shallow(makeComponent());
     wrapper.find('button').simulate('click', { stopPropagation: () => { onClickMock() } });
     expect(onClickMock).toHaveBeenCalled();
  })

})

Reduxのテスト

Zealsではステート管理にReduxを使用しており、そちらのテストも行なっています。

reducerのテスト

reducerのテストコードが書かれたファイルに、initialStateとactionCreatorをimportをしているのがポイントです。

initialStateをimportしていることによりコードベースの変更による影響を防いでいます。
また、actionCreatorの責務は主にactionをreturnするというシンプルなものなので、reducerのテストに移譲して効率よくテストできるようにしています。

import { cancel, expand, setData } from './actions/sample' // action
import { initialState } from './reducers/sample'
import { sample as reducer } from './reducers'

// importしたinitialState
// export const initialState = {
//   isCanceled: false,
//   isExpanded: false,
//   data: {},
// };

describe('reducer', () => {

  // initialStateがそのまま返ってくるか
  test('initialState should be returned', () => {
    expect(reducer(undefined, {})).toEqual(...initialState);
  });

  test('handle CANCEL', () => { // CANCELはcancel reducer自身が呼ばれたことを検知するための識別子
    const action = cancel()
    expect(reducer(initialState, action)).toEqual({
        ...initialState,
       isCanceled: !action.isCanceled, // cancelが呼ばれたらisCancelがtrueになる想定
    });
  });

  test('handle EXPAND', () => {
    const action = modalExpand(true) // action内では引数の値をisExpandedに代入している
    expect(reducer(initialState, action)).toEqual({
        ...initialState,
        isExpanded: !action.isExpanded,
    });
  });

  test('handle SET_DATA', () => {
    const value = 'testValue'
    const action = setData(value)
    expect(reducer(initialState, action)).toEqual(Object.assign({}, initialState, { data: Object.assign({}, initialState.data, { sampleData: action.value }) }));
  });

テストコード勉強会

Zealsではフロントエンド全体のレベルを底上げするため、毎週末にフロントエンドチームで集まって勉強会を開催しています。

今回のテスト導入にあたって、最近はテストに焦点を当てた勉強会を開催していました。
テスト導入時に丁寧にメンバーにノウハウを共有しながら進むことで、属人化の防止やテスト規約に対する共通認識をチーム内で得ることが可能です。

進め方としては、フロントエンドの各メンバーは、毎週コードベース内の小さなComponentや小さなロジックのテストを書いてプルリクエストを出します。
そして毎週末に、全員でプルリクエストをディスプレイに映しながら、お気に入りのテストや学んだことを説明し、議論します。

GitHub上のPRに対してレビュー

勉強会のコンテンツ:shallow is faster than mount ??

勉強会ではレビューのほか、ペアプログラミングを実施したり、より大きなComponentのテストに取り組むなど様々なことを行なっています。
例えば最近では、Enzymeのドキュメントには shallowmount より速いと書かれていましたが、それがどのくらい速いのかを検証してみました。

Difference between Shallow, Mount and render of Enzyme · GitHub

以下で実際のコードと共に詳しく紹介していきます。

▼Demo.test.js▼

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow, mount } from 'enzyme';
import Parent from './Parent'

Enzyme.configure({ adapter: new Adapter() });

const makeComponent = () => <Parent/>

describe('shallow', () => {
  test('test Parent by shallow', () => {
    const wrapper = shallow(makeComponent())
    for (let i=0, sum=10000; i<sum; i++) {
      expect(wrapper.find('h1').text()).toEqual('Parent');
    }
  });
});

describe('mount', () => {
  test('test Parent by mount', () => {
    const wrapper = mount(makeComponent())
    for (let i=0, sum=10000; i<sum; i++) {
      expect(wrapper.find('h1').text()).toEqual('Parent');
    }
  });
});

▼Parent.jsx▼

import React, { Component } from 'react';
import Child from './Child'

class Parent extends Component {
  render() {
    return (
      <div>
        <h1>Parent</h1>
        <Child></Child>
      </div>
    )
  }
}

export default Parent;

▼Child.jsx▼

import React, { Component } from 'react';

class Child extends Component {
  render() {
    return (
      <div>
        <h2>Child</h2>
      </div>
    )
  }
}

export default Child;

テスト結果1

mount の方が実行速度が早くなりました。

なので実験として、 shallowmount の実行速度の速さは実はComponentの構造に依存するのではないか という仮説のもと、稼働中のサービスで使われている複雑なComponent(複数のstyled Component・importしたComponent・関数・state・propsが含まれる)に対して shallowmount で同じテストを行って実行速度を比べてみました。

ソースコードは載せることはできないのですが、以下のようなテストを実行しました。

▼Component.test.js▼

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow, mount } from 'enzyme';
import Component from './Component';

Enzyme.configure({ adapter: new Adapter() });

const defaultValues = { title: '' };
const makeComponent = (args = {}) => (<Component {...Object.assign({}, defaultValues, args)} />);

describe('shallow', () => {
  test('shallow is faster', () => {
    const component = shallow(makeComponent({ title: 'my test' }));
    component.setState({ isOpen: true }); // isOpenがtrueの時だけtitleが表示される

    expect(component.find('.title').text()).toEqual('my test');
  })
})

describe('mount', () => {
  test('mount is slower', () => {
    const component = mount(makeComponent({ title: 'my test' }));
    component.setState({ isOpen: true });

    expect(component.find('.title').text()).toEqual('my test');
  })
})

すると以下のように shallow の方が速いという結果になりました。

テスト結果2

検証結果

単純なComponentのテストでは、速度だけで shallowmount を比べると(僅差ではありますが)なんと mount の方が速い結果が得られてしまいました。

しかし、アプリケーションにおいてはComponentが1番目の検証コード(Child.jsx)のような単純な構造ではないという前提のもと、ドキュメントでは shallow の方が速くなると書かれているのかもしれませんね。

勉強会の成果

勉強会を行なった結果として、エンジニアメンバーが新しいテスト文化に早く慣れることができました。

また、古いComponentと比較すると、ユニットテストを導入した新しいComponentでは事前に問題が可視化されてエラーが発生しにくいなどのテストの効果が目に見えて生まれていて、「テストコードなしでこれまでどうやってアプリケーション作ったのだろう」と思うほど、テストの効果が高いことを実感できました。

テスト導入のメリット

当初の課題についてですが、以下のように解決することができました。

  1. テストが自動化され、デバッグに必要なエンジニアの手間とバグが少なくなった
  2. リファクタリングが正しく行えたのかをエンジニアがプログラマブルに判断しやすくなった

実装後に挙動を確かめるにはコマンドを入力してテストを実行するだけで良いため、手動でテストする工数が大幅に減りました。

それにより次のタスクに取り掛かるのも早くなり、全体的な実装スピードの向上に繋がりました。
2に関しては、特に正確性の向上が著しかったですね。

今後の展望

今後のフロントエンドテストに関してですが、いわゆるTDD(テスト駆動開発) での開発をフロントエンドで進めていきたいと考えています。

そのために、勉強会やPR上での議論を繰り返すことで、エンジニアのテストに対する免疫をなくし、新しく入ってきていただいたエンジニアの方への積極的なノウハウ共有を行っていきたいと思います。

まとめ

今回はテストコードに関する取り組みを紹介しましたが、他にも課題にフロントエンダー全員で挑戦したりと、Zealsならではの取り組みがたくさんあります。今後もテックブログで、そのような取り組みを紹介していければと思います。

続きを読む

進化し続ける「Zeals」サービスを支えるアーキテクチャについて

f:id:zeals-engineer:20190922211042p:plain

こんにちは、Zeals CTOの島田です。
本日は、進化するZeals(旧fanp)サービスのアーキテクチャを、 リリース当初と現在で比較して紹介 します。

以下のようなアジェンダで解説していければと思います。

  • はじめに
  • リリース当初のアーキテクチャ
    • 技術選定
    • 新しくプロダクトの課題
  • 現在
  • 今後の展望
  • さいごに

Go(gRPC, Protocol Buffers)を用いたmicrosevicesやkubernetes(GKE)を用いた新しいインフラ環境など、これからのZealsを支えるコア技術について解説できればと思います。

続きを読む

Zshの起動を爆速にするプラグインマネージャーZplugin【コーナーで差をつけろ】

f:id:zeals-engineer:20190916134827p:plain


皆さんこんにちは!現在SREチームでエンジニアインターンをしており、4月から新卒入社する玉城です。
今回はZshプラグインマネージャーのZplugin導入のススメです!

  • Zpluginの紹介
  • Zpluginインストール方法
  • Zpluginを用いた.zshrcの設定例
    • はやくなった要因解説
    • zplugの起動時間も計測してみる
  • まとめ

Zpluginの紹介

Zpluginは柔軟で高速なZshellプラグインマネージャーで、zshの起動を50~73%高速化するTurboモードを備えた唯一のプラグインマネージャーです。

github.com

  • zplug使っているけど起動時間1秒以上かかるし辛い…
  • Zplugin使ってみたいけどドキュメントは英語だし日本語の情報も少ないし…
  • Zshを使ってみたいからこの記事を読んでみよう!

という方には、とてもおすすめです!

私も実際にZshとZpluginを使っていて、以前使っていたプラグインマネージャーzplugよりも約0.5秒ほど起動速度が早くなりました。これなしの生活は今や考えられません!(あやしいサプリをすすめているわけではないですよ)

また、ZEALSのエンジニアが使っているシェルはみんな多種多様です 。
良ければ御覧ください!

tech.zeals.co.jp

tech.zeals.co.jp

続きを読む

【週刊 Kubernetes 連載】ミニマリストのための CI/CD パイプライン!!

https://skaffold.dev/images/architecture.png

こんにちは!!

8月からZEALSにジョインしたぱんでぃーです!TypeScriptとAureliaを愛するバックエンドエンジニアとして、現在はGolangでMicroservices化を目指したAPIを開発しています!

このAPI開発のプロジェクトについても、近いうちにブログで公開していきたいのですが、今回はKubernetes環境でのCI/CDについてご紹介します。(おもにCD)

このトピックについては、以前、CTO島田のエントリーでも少し紹介していましたが、さらに進化させた内容となっております!

tech.zeals.co.jp

  • TL;DR
  • KubernetesエコシステムのCDツールの現状
  • できるだけミニマムスタックでスタートするためには?
    • Gitブランチ戦略
    • アプリケーションの実行環境
    • 目指すゴール
  • ZEALSの実際のCI/CDパイプラインを紹介
  • 今後の伸びしろ
  • さいごに

TL;DR

  • Kubernetes導入初期で最適なCDパイプラインを構築するハードルが高い・・・
  • できるだけミニマムスタックでラーニングコストの小さいCI/CDパイプラインとは!?
  • GitOps likeなCIOpsで安全かつ高速なデプロイサイクルを実現!!
続きを読む

行動ログをRailsからBigQueryに流す仕組みの導入

f:id:zeals-engineer:20190820120026p:plain

はじめに

こんにちは、分析基盤を担当している鍵本です。
本日は DB に保存されている行動ログを BigQuery に流すように修正 したお話をしようと思います。

  • はじめに
  • 背景
  • 非RDB化の方針
  • Rails 側の設定
    • logger の再定義
    • 出力データ用の Struct の定義
    • 出力処理の追加
  • Stackdriver Logging のシンク設定
  • BigQuery でのテーブル作成
  • Cloud Functions の設定
  • まとめ

背景

これまでチャットボットに流入させる「モーダルを開いた」とか「クリックした」といった行動ログは 直接DB に保存されていました。

レコード数が少ないサービス開始直後はそれでもよかったのですが、だんだんお客様の数が増えてくると、それに応じてモーダルにアクセスしてくださるユーザーさんの数も増え、DB への書き込み頻度が高まり、次第にアプリへの負荷が高くなってきました。
このままサービスを続けていくと、いずれDBへの書き込み処理が足枷になってしまうだろうということで、 このログを DB に書き込むのではなく、ログデータとして外部出力 することにしました。

非RDB化の方針

ZEALS では GKE でアプリケーションサーバを運用しているので、Rails アプリでは単に必要なデータを標準出力するだけとなります。
標準出力されたログは、同一クラスタ内で稼働している fluentd によって Stackdriver Logging に送られますので、それを Cloud Storage を経由させて BigQuery にロードするようにしました。

流れ図は以下の通りです。

f:id:zeals-engineer:20190819194127p:plain (ログ出力構成図)

Stackdriver Logging から直接 BigQuery にインポートすることはできますが、データとスキーマとの間の不一致によるインポートエラーが発生した場合に再取り込みが面倒なこと、リアルタイム性がそれほど必要ではなかったことから Cloud Storage 経由にしています。

なお Stackdriver Logging から Cloud Storage へのエクスポートは一時間に一回行われます。
したがって、BigQuery では約一時間遅れでデータの閲覧が可能となります。

Rails 側の設定

Rails 側では次の3つの対応をしました。

  • logger の再定義
  • 出力データ用の Struct の定義
  • 上記 logger による出力処理の追加

logger の再定義

Simma さんの GKE上RailsのアプリケーションログをStackdriver Loggingで運用する方法 で紹介されている Custom log を参考にさせていただきました。
修正した点は progname が定義されてなかった場合に rails_app を代入するという点のみです。

当初は Rails.logger を上書きして利用することを想定していたため、production.log が progname = rails_app で送られるようにして、行動ログと分離できるようにしたかったからです。

出力データ用の Struct の定義

ログ出力部分に勝手に項目を追加されないように、 出力データを構造体で縛る ことにしました。
以下のようなものを lib 以下に配置しています。

FooStruct = Struct.new(:foo, :bar, :baz) do
  def to_emptify
    to_h.map { |k, v| [k.to_s, v.to_s] }.to_h
  end
end

null を空文字にするためのメソッドを内部で定義しています。
ZEALS ではまだ ruby-2.5 系なので上ような書き方をしていますが、2.6 以降では前後の to_h が不要となります。

出力処理の追加

実際にDBに書き出している部分にログ出力を追加します。

JsonStdOutLogger.instance.logger.info(:foo_log) do
    FooStruct.new(
        foo_val,
        bar_val,
        baz_val
    ).to_emptify
end

上記のように Rails.logger を上書きするのではなく、JsonStdOutLogger.instance.logger を直接呼ぶようにしています。
なので「当初は…」なんて言い方を先ほどいたしました。

Rails.logger = JsonStdOutLogger.instance.logger

を記述して上書きしたところ ActiveSupport::TaggedLogger が継承されてしまいました。
その結果 ActiveSupport::TaggedLogger::Formatter#call が先に呼ばれ、その内部で Hash データが文字列化され想定通りの出力にならなかったため JsonStdOutLogger.instance.logger を直接呼ぶ方法を採用しました。

Stackdriver Logging のシンク設定

高度なフィルタに変更し、以下を設定します。

resource.type="container"
resource.labels.cluster_name="クラスタ名"
resource.labels.namespace_id="ネームスペース名"
logName="projects/プロジェクト名/logs/サービス名"
jsonPayload.progname="foo_log"

これによって抽出されたログに対してシンクの設定をします。

冒頭でご説明しましたように、シンクサービスには Cloud Storage を選択します。
同時に必要ならばバケットも作成しますが、その際の注意点は以下の二点です。

  • アクセス制御モデルとして オブジェクトレベルを選択 する
  • ストレージの場所はアプリケーション、BigQuery とで 全て統一 する

BigQuery でのテーブル作成

Stackdriver Logging のログには timestamp が存在するので、これをログが生成された日時として BigQuery にも取り込むことにしました。

また、この値を用いてテーブルパーティションすることにしました。
これは常に全件検索をしていると、有名な BigQuery で破産しました みたいなことになってしまうからです。

qiita.com

テーブルは以下のようなクエリで生成しています。

CREATE TABLE app_logs.foo_logs (
  foo int64,
  bar string,
  baz string,
  timestamp timestamp
) PARTITION BY DATE(timestamp);

Cloud Functions の設定

以下のような Python スクリプトを作成して、google.storage.object.finalize をトリガーとして実行されるようにデプロイします。

import os
import sys
import json

import gcsfs
from google.cloud import bigquery

MAX_REC_LENGTH = 1000
bigquery_client = bigquery.Client()

def bq_insert(bq_client, table, data):
    errors = bq_client.insert_rows(table, data)
    if errors != []:
        print("BQインポートエラー({})".format(errors))
        sys.exit(1)
    return len(data)

def gcs_placement_handler(data, context):
    gcs_fs = gcsfs.GCSFileSystem(project = os.environ['PROJECT_ID'])
    table_ref = bigquery_client.dataset(os.environ['DATASET_ID']) \
                               .table(os.environ['TABLE_ID'])
    table = bigquery_client.get_table(table_ref)

    total_length = 0
    rows_to_insert = []
    with gcs_fs.open('/'.join([data['bucket'], data['name']]), 'r') as fd:
        for line in fd:
            line_dict = json.loads(line.rstrip('\n'))
            payload = line_dict['jsonPayload']
            # foo が空の場合に NULL に変換する
            if payload['foo'] == '':
                payload['foo'] = None
            payload.update({'timestamp': line_dict['timestamp']})
            rows_to_insert.append(payload)

            # rows_to_insert のサイズが MAX_REC_LENGTH に達したら BigQuery にインサートする
            if len(rows_to_insert) >= MAX_REC_LENGTH:
                total_length += bq_insert(bigquery_client, table, rows_to_insert)
                rows_to_insert = []

    # バッファリングした残りを BigQuery にインサートする
    if len(rows_to_insert) > 0:
        total_length += bq_insert(bigquery_client, table, rows_to_insert)

    return 'Successfully insert {} records.'.format(total_length)

Rails から出力されたログが jsonPayload に入っており、BigQuery に取り込みたいものがjsonPayload の中身と timestamp の値なので、それらをマージしてインポートするのが上記スクリプトの役割です。

また整数値 foo が null となる可能性があるのですが、Rails 側から空文字で送るようにしているので、BigQuery にインポートできるよう null に設定する処理も入れています。
(ここは Rails から直接 null で出力しても良かったのかもしれません)

また一時間に一回とはいえ、レコード数が多かった場合にメモリが足りなくなる可能性もあるので、1000件ずつインポートするようにしています。

続きを読む