TECH PLAY

BASE株式会社

BASE株式会社 の技術ブログ

576

この記事はBASE Advent Calendar 2020の16日目の記事です。 devblog.thebase.in はじめに こんにちは。BASE株式会社フロントエンドチームに所属している田中です。 こちらの記事にもあるように、BASEではここ一年弱の間、リモートワークメインの働き方でした。 devblog.thebase.in そんな中、数ヶ月前にスタートしたあるプロジェクトに、私含め2名のフロントエンドエンジニアがアサインされました。アサインされた方はその月にJoinされたばかりの方だったことと、プロジェクトスタート時は私が別のプロジェクトを並行して進めていたこともあり、メインで関わることができていないプロジェクトに対して、何かよいアプローチはないだろうかと模索していました。その1つの手段として、実験的にモブプログラミングをやってみた様子をご紹介したいと思います。 事前に行ったこと モブプログラミングについておおまかな概要を知っておく 必要な情報の共有 現在進行中のプロジェクトで取り組めそうなタスクをピックアップし、仕様概要やデザインとともに共有 作業中のブランチを共有し、ローカルで動く状態にしておいてもらう 今回私以外の他のメンバーはモブプロをやるぞと言ってモブプロをやったことはあまりなかったそうですが、気づいたらその状態になっていたことはあったということでした。私自身はモブプロを全く経験したことがなかったため、モブプロの概要について、どのような役割があるのかや、実施したレポート記事などを読んでおきました。 実施概要 メンバーは3名 プロジェクトメンバーのフロントエンドエンジニア2名、プロジェクトメンバーではないフロントエンドエンジニア1名 時間は各回60~90分程 タイピスト(1名)とモブ(タイピスト以外)を時間交代制 全員リモートワークでの実施だったため、タイピストがエディタとブラウザを画面共有する形式 メンバー数に関しては、プロジェクトメンバーだけのペアプロでも良かったのですが、実験的にプロジェクト外のメンバーともやってみたいと思い3名で行いました。 結果と所感 進め方について リモートとなると交代時にpushやpull、開発環境を整える時間などがある程度かかった。 今回プロジェクトメンバー2名とプロジェクトメンバーではない方1名での実施で、実施前は仕様の共有や前提条件の説明などが難しいかもしれない、という懸念があったが、大きな問題はなく進められた。 時間の区切りが難しく、ある程度キリのよいところで交代とはいえもう少し…と延長してしまいがちだった。 時間配分については改善の余地がありそう。 プロジェクト外のメンバーも含めたことの懸念については、どちらにせよ担当プロジェクト外のものもチーム内でコードレビューを行うということと、むしろその場でコードを見ながら説明し、レビューが行われているような状態になるため、コードレビューの効率化にも繋がりそうだなと思いました。また、フロントエンド開発ではブラウザとエディタとデザインを行き来するが、ディスプレイ1枚の場合は画面共有で埋まってしまうのでモブの役割の場合は難しそうと感じた。 心理的な面について コードと作業を共有することでチームで働いている感を感じられたという意見が出た 新しく入ったメンバーへのオンボーディング的に行うのも良さそうという意見も リモートワーク環境下で、まだ直接会ったことがないメンバーとも仕事を進める中で、上記のような意見があり実施して良かったです。新しく入ったメンバーに関しても、タイピストの役割であれば、モブに指示されたことをタイピングしつつ、分からないことをその場で質問できるという環境になるのでよさそうですね。 タスクについて 今回用意していたタスクが比較的小規模の単純なコンポーネント作成やAPI連携だったため、いくつかタスクを終わらせることはできた。 正直各回作業できた量はそこまで多くはなかった。 タスクの数や難易度に関しては、あまり最初からやり方を固めすぎず、ゆるく始めて改善していければいいなと思っていたので、今後ある程度の回数を実施し、慣れてくればこなせるタスク数は自然と増えていくのではないかなと思いました。今後は試行錯誤が必要な複雑なタスクに取り組んでみるのもおもしろそうです。 その他 タイピストは自分の意志を反映させずにモブに言われたとおりタイピングするだけという役割になれるコードを客観的に見ることができ、対象箇所以外のところまで思考をめぐらせることができたという意見があり、新鮮でした。また、バックエンドエンジニア、またはエンジニア以外の方がオブザーバー的に参加するのもよさそうという声もありました。 まとめ 新メンバーも多い中のリモートワーク環境下で、プロジェクトを円滑に進めるための取り組みとして、フロントエンドメンバー数名でモブプロを実施してみました。まだ数回しか実施できておらず、よりよい進め方は模索中ですが、今後定期的に実施し改善を重ねていくことで質を上げていけたらと思います。 明日は、情シスチームの猪股さんです! もしBASEで働くことに興味を持っていただけた方は、ぜひご連絡ください! 採用情報はこちらから
アバター
この記事はBASE Advent Calendar 2020 15日目の記事です。 devblog.thebase.in こんにちは、Native Application Groupの大木です。最近React.jsを使ったフロントエンドアプリケーションの開発に取り組んでいますが、プロジェクトをmonorepoで管理しています。 今回は、monorepo管理にしたはいいが、Visual Studio Codeエディター(以下vscode)で、TypeScriptのモジュールのautoimportのパス解決に悩まされてやったことを、Next.jsというReact Frameworkを使った例でご紹介します。 Monorepo? monorepoは、次のうち1つまたは複数当てはまる場合に採用するのが効果的かと思います。 Web/ネイティブなど複数プラットフォームのアプリケーションが存在 メイン/管理画面など同じデータソースにアクセスする別々の役割を持つ複数のアプリケーションが存在 それらのアプリの実行環境に依存せず同じように利用する機能が存在 API Clientや共通UIコンポーネントなど特定アプリの環境に依存しない独立した共有する機能 最後の 実行環境に依存せず同じように利用する機能 というのは、過去に書いた ガワネイティブアプリ(Creator)を、React Nativeで置き換えてみての一年間戦いの記録 で、React Nativeの複数の実行環境を共存する試みで紹介しました。 さて、フロントエンドアプリケーション開発で適切に機能分割したパッケージを、manyrepoなど機能ごとにリポジトリが分割されたものではなく、monorepoで管理する利点はなんでしょうか? Guide to Monorepos for Front-end Code という記事によると、次の5つが挙げられています。 全ての設定とテストを一つの場所で 一連の関連するコミットをまとめてグローバルに機能を簡単にリファクタリング 簡略化されたパッケージのバブリッシング より簡単な依存関係管理 分離された状態を維持したまま、共有パッケージのコードを再利用可能 1.は運用の話で、パッケージごとにCIやテストの構成を一つにしておけば、それらの設定を追加する必要はなく、すぐに起動することが可能となります。 また、4.と5.の話の意味するところは、依存関係を適切に保てるし、新たにパッケージをmonorepo内に追加しても、共有パッケージを参照することが簡単になるということです。 Multi-root Workspace vscodeの Multi-root Workspaces は、複数のプロジェクトフォルダーをまとめて操作できる機能です。 記事の解説によると、manyrepoのような機能ごとにリポジトリが分割されたものに対して有効な機能であるという認識をもちますが、今回は次のようなフォルダー構成を持つmonorepoに適用しました。 . ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .stylelintrc ├── .vscode/ │   └── settings.json ├── README.md ├── multi-root-ts-monorepo.code-workspace ├── package.json ├── packages/ │   ├── admin/ │   │   ├── .vscode/ │   │   │   └── settings.json │   │   ├── next-env.d.ts │   │   ├── next.config.js │   │   ├── package.json │   │   ├── pages/ │   │   │   ├── _error.tsx │   │   │   └── index.tsx │   │   └── tsconfig.json │   ├── storybook/ │   │   ├── .storybook/ │   │   │   ├── addons.js │   │   │   ├── config.js │   │   │   └── preview-head.html │   │   ├── package.json │   │   └── stories/ │   │   └── Button.stories.js │   ├── ui/ │   │   ├── dist/ │   │   │   ├── components/ │   │   │   ├── constants/ │   │   │   ├── hooks/ │   │   │   ├── index.d.ts │   │   │   └── index.js │   │   ├── package.json │   │   ├── src/ │   │   │   ├── components/ │   │   │   ├── constants/ │   │   │   ├── hooks/ │   │   │   └── index.ts │   │   └── tsconfig.json │   └── web/ │   ├── .vscode/ │   │   └── settings.json │   ├── next-env.d.ts │   ├── next.config.js │   ├── package.json │   ├── pages/ │   │   ├── _error.tsx │   │   └── index.tsx │   └── tsconfig.json └── yarn.lock 26 directories, 37 files monorepo内には、4つのパッケージが存在します。 - admin: 管理画面のNextアプリケーション - storybook: 共有UIコンポーネントに対するStorybook - ui: Nextアプリケーションで利用する共有UIに関するコードを管理するパッケージ - web: 本体のNextアプリケーション これら一つ一つをworkspaceに追加すると、エディターのファイルエクスプローラーでは次のように見えます。 なぜ、monorepoにこの機能を適用するの? という話ですが、monorepoプロジェクト全体のvscodeの設定(workspace設定)の他に、それぞれのフォルダごとにvscodeの設定をカスタマイズできるようになります。それによってvscodeの機能を利用するのにいくつか利点があり、特にvscodeのautoimportに関して助かったので、紹介していきます。 vscodeのautoimport vscodeでTypeScript/JavaScriptファイルにコードを記述していると、次のようなサジェストが表示されることがあります。 サジェストを適用すると、下記のように参照先を自動でimportしてくれます。 import * as React from 'react' ; // **↓自動で挿入** import { SIZES } from '../../constants' ; export const useSize = ( size: keyof typeof SIZES ) => { const [ value , setValue ] = React.useState < number >(); React.useEffect (() => { setValue ( SIZES [ size ] ); } , [] ); return value ; } ; この自動インポートなのですが、vscodeの設定ファイルに次の設定を追加することにより、サジェストされるパスに変化が生まれることになります。 " typescript.preferences.importModuleSpecifier ": " auto " 設定値は全部で3つありそれぞれ下記の通りです。 設定値 説明 auto インポート パス スタイルを自動的に選択します。 non-relative jsconfig.json / tsconfig.json で構成されている baseUrl に基づきます。 relative ファイルの場所を基準にします。 上のコード例は、アプリケーションからライブラリのように利用する共有パッケージの実装コードでしたが、 Nextアプリケーションのパッケージではどのようにこの機能を利用するのが良いでしょう? アプリケーションパッケージの例 アプリケーションでは複数のパッケージを組み合わせて多くのコードを実装するため、フォルダー階層が深くなりがちです。そのため相対パスでimportするという設定だと、import文が長くなる可能性があり好ましくありません。また、リファクタリングの際にソースファイルの配置を頻繁に移動する可能性もあり、階層がズレるとimport文にある相対パスの ../ を増やしたり減らしたりする作業が発生します。 それに対する解決案の一つとしては、モジュールバンドラーと tsconfig.json の paths を使うというものです。 Nextでは、Webpackというモジュールバンドラーを使っているため、Webpackの機能 resolve.alias を利用して、パスのaliasを作成することができ、指定するimportパスを柔軟に変更することができます。 const withPlugins = require('next-compose-plugins'); const withTM = require('next-transpile-modules'); module.exports = withPlugins( [ withTM(['@multi-root-ts-monorepo/ui']), ], { reactStrictMode: true, webpack(config, options) { // [...] config.resolve.alias['~web/i18nextConfig'] = path.join( __dirname, 'i18next.config.js' ); config.resolve.alias['~web/defaultConfig'] = path.join( __dirname, 'default-config.js' ); config.resolve.alias['~web'] = path.join(__dirname, 'src'); return config; } } ); 次に、このエイリアスをTypeScriptで使えるように tsconfig.json の paths にも定義するようにします。参照先のルートが必要なため、 baseUrl も併せて指定します。 { "extends": "../../tsconfig.json", "compilerOptions": { "target": "es5", "lib": ["es2016", "dom", "dom.iterable", "esnext", "webworker"], "noEmit": true, "jsx": "preserve", "baseUrl": ".", "paths": { "~web/i18nextConfig": ["i18next.config.js"], "~web/defaultConfig": ["default-config.js"], "~web/*": ["src/*"] } }, "exclude": ["node_modules"], "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", "../../@types/worker-loader/index.d.ts" ] } このエイリアスを利用することによって、相対パスやソースコードを格納したフォルダーの外のパッケージルートにある i18next.config.js のようなファイルの実際のパスを、隠蔽することが可能となるわけです。 // 相対パスの代わりにエイリアスを使う例 import { FC , FormHTMLAttributes } from 'react' ; import { FormProvider , UseFormMethods } from 'react-hook-form' ; //import { FormDevTool } from "../../lib/react-hook-form/devtool"; <- この代わりにエイリアスを使う import FormDevTool from '~web/lib/react-hook-form/devtool' ; type FormProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any methods: UseFormMethods < any >; } ; type Props = FormHTMLAttributes < HTMLFormElement > & FormProps ; const Form: FC < Readonly < Props >> = ( { id , methods , children , onSubmit , className , } ) => ( < FormProvider { ...methods } > < FormDevTool control = { methods.control } / > < form id = { id } className = { className } onSubmit = { onSubmit } > { children } < /form > < /FormProvider > ); export default Form ; // `src/` フォルダーを跨いだ位置にあるファイルのimportで `src/`を隠蔽する例 import { useTranslation } from '~web/i18nextConfig' ; import Editor from './editor' ; type Props = { editorName: EditorName ; onDismiss?: () => void ; } ; const CheckoutEditModal: FC < Props > = ( { editorName , onDismiss , } ) => { useKeydown ( 'Escape' , onDismiss ); const { t } = useTranslation (); vscodeの設定 さて、アプリケーションパッケージにおいて、柔軟にimportパスを設定できることは説明しましたが、結局vscodeの設定についてあまり触れておりませんでした。 フロントエンドのmonorepoプロジェクト構成で、パッケージのimportがどう動いて欲しいのかをまとめますと、次の通りです。 1. monorepo内の他のパッケージをimportする際には、npmの他のライブラリと同じく、 package.json に定義した name でimportパスが解決される { "name": "@multi-root-ts-monorepo/ui", // [...] } // 参照するパッケージのnameで解決 import { Button } from '@multi-root-ts-monorepo/ui' ; import * as React from 'react' ; const App: React.FC = () => { return ( < div > Hello , World ! < Button onClick = { () => null } m = {[ '0' , '0 1rem' ]} label = "a test button" > Test ! < /Button > < /div > ); } ; export default App ; 2. 共有パッケージ内に属するソースファイル同士のimportは、相対パスでimportが解決される import * as React from 'react' ; // 同じパッケージ内のソースファイルの相対パスで解決 import { SIZES } from '../../constants' ; export const useSize = ( size: keyof typeof SIZES ) => { const [ value , setValue ] = React.useState < number >(); React.useEffect (() => { setValue ( SIZES [ size ] ); } , [] ); return value ; } ; 3. アプリケーションパッケージ内のソースファイル同士のimportは、webpackを利用するため、エイリアスでimportが解決される import { FC , FormHTMLAttributes } from 'react' ; import { FormProvider , UseFormMethods } from 'react-hook-form' ; // 相対パスの代わりにエイリアスを使う import FormDevTool from '~web/lib/react-hook-form/devtool' ; type FormProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any methods: UseFormMethods < any >; } ; type Props = FormHTMLAttributes < HTMLFormElement > & FormProps ; const Form: FC < Readonly < Props >> = ( { id , methods , children , onSubmit , className , } ) => ( < FormProvider { ...methods } > < FormDevTool control = { methods.control } / > < form id = { id } className = { className } onSubmit = { onSubmit } > { children } < /form > < /FormProvider > ); export default Form ; 1.に関しては特別な設定は必要ないため考えることはないのですが、2.と3.に関しては異なるimportパスを解決する必要があるため、全体と個別の設定を適切に定義する必要があります。 Multi-root Workspacesを利用すると、全体のworkspace設定と個別に追加したフォルダーごとの設定の2種類の設定を定義することができます。3.の場合、webpackというモジュールバンドラーのサポートが必要なことから、2.の方式を全体で設定、3.の方式を個別に設定するのが良さそうということで出来上がったのが次の設定です。 全体のworkspace設定(multi-root-ts-monorepo.code-workspace) 2.の方式を優先する設定を追加 { " folders ": [ { " name ": " project-root ", " path ": " . " } , { " path ": " packages/admin " } , { " path ": " packages/storybook " } , { " path ": " packages/ui " } , { " path ": " packages/web " } ] , " settings ": { " typescript.tsdk ": " ./node_modules/typescript/lib ", // 相対パス方式 " typescript.preferences.importModuleSpecifier ": " relative ", " typescript.preferences.importModuleSpecifierEnding ": " minimal ", " eslint.alwaysShowStatus ": true , " eslint.packageManager ": " yarn ", " eslint.validate ": [ " javascript ", " javascriptreact ", " typescript ", " typescriptreact " ] , " editor.codeActionsOnSave ": { " source.fixAll.eslint ": true , " source.fixAll.stylelint ": true } } } } アプリケーションパッケージでの個別設定(settings.json) "non-relative" を指定すると、monorepo内の他パッケージのimportパスまでも、 "baseUrl" からみた相対パスに変更されてしまうようなので、 "auto" を設定。 実際に試してみたところ、importしようとしているソースファイルが、親フォルダまでの位置にある場合は相対パスが選択され、それ以上に離れている場合は、エイリアス設定が優先されるようです。 { "typescript.preferences.importModuleSpecifier": "auto", } その他考慮すべきこと はじめの方に紹介した Guide to Monorepos for Front-end Code で出てきた「全ての設定とテストを一つの場所で」に関連する話です。 上の説明に従い、テスト実行環境やESLint環境は、パッケージが増えても追加設定しなくてもいいように、プロジェクトルートに用意していました。そのため、これらのツールからはWebpackで定義したエイリアスなどを認識できるように追加設定が必要となります。 テストやESLintのために必要な追加設定 Webpackで定義したエイリアス設定が、プロジェクトルートからみたら何処にあたるのか認識できるようにするようにする必要があります。 { "extends": "./tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "paths": { "~web/i18nextConfig": ["packages/web/i18next.config.js"], "~web/defaultConfig": ["packages/web/default-config.js"], "~web/*": ["packages/web/src/*"] // [...] } }, "exclude": ["**/dist", "**/build", "node_modules"] } まとめ アプリケーションの開発に必ずしも必要ではないが、開発効率に大きく貢献する機能が提供されていることを知ることは重要だなと改めて思いました。また良さそうな機能を見つけたら紹介していきたいと思っております。 明日は、フロントエンドチームの田中さんです!お楽しみに! 参考 ガワネイティブアプリ(Creator)を、React Nativeで置き換えてみての一年間戦いの記録 Guide to Monorepos for Front-end Code Multi-root Workspaces
アバター
この記事はBASE Advent Calendar 2020の14日目の記事です。 devblog.thebase.in こんにちは。BASE株式会社 デザインチームの北村( id:lllitchi ) です。 「BASE」は今年の10月に、デザイン編集機能のフルリニューアルを行いました。 binc.jp baseu.jp デザイン編集はサービスリリース当初からほぼ改修されず、「BASE」の管理画面の中でもかなり古いUIの画面でした。ショップデザインを作るメイン機能であるにもかかわらず、多すぎる課題を抱えていたため、なかなかフルリニューアルが行えなかった機能です。 今まで関わった改修のなかでも最長の開発期間になったこのデザイン編集リニューアルの、どんな部分が大変だったかをプロジェクトに関わった人間の目線で振り返ってみようと思います。 HTML編集Appと、ショップテーマの関係 「BASE」のショップデザインは、いくつかあるオフィシャルテーマから好きなものを選択して、自分のショップデザインに反映することができます。また、「 デザインマーケット 」でデザイナーが制作した高品質なテーマをお手ごろな価格で購入することもできます。 さらに、「BASE」の拡張機能である「 HTML編集App 」というAppを提供しており、このAppをインストールすると、利用しているテーマのHTMLとCSSを直接自由に編集できるようになります。 デザイン編集が抱えていた、多すぎる課題 課題1: 「HTML編集 App」で編集したテンプレートは更新することができない 「HTML編集 App」でテーマを利用すると、前述のとおり、テーマのHTMLとCSSを直接自由に編集できるようになります。オーナーさんには、ショップのトップページにバナーを配置したり、ナビゲーションにブランドサイトへのリンクを追加したりと、さまざまな用途で利用して頂いてました。 ですが、 1度でもコード変更をしてしまうとBASEによるショップページの更新(Appをふくむ新規機能の追加、不具合修正など)が自動で適用されず 、オーナーさん自らメンテナンスをしなくてはいけないという大きな問題点を抱えていました。 最近では、商品にギフトラッピングや名入れオーダーなどを追加できる「 商品オプションApp 」や、商品ページに動画やスライドショーなどの表現を追加できる「 商品説明カスタム 」といった、ショップ情報の充実に役立つAppがリリースされているのですが、こういった新規機能も追従できなくなってしまいます。 Appを利用したい場合、テーマを選び直してイチから再編集する必要があり、オーナーさんにはとても大きな負担になっていました。 課題2: HTML編集せざるを得ない、シンプルすぎるショップテーマ 「BASE」が提供しているオフィシャルテーマは、必要最低限のナビゲーションと画面があるだけの非常にシンプルなページ構成になっています。ブログや商品検索やカテゴリー表示を利用したいオーナーさんは、「BASE Apps」で機能を追加するというのがBASEのショップテーマの作りになっています。 さらに、設定できるデザイン項目は「ロゴ」と「背景」と「ナビゲーションカラー」のみという自由度の少なさでした。ちょっとした工夫にも、HTML編集Appの利用が必要でした。 課題3: サポート業務の圧迫 設定できる項目が少なすぎるため、ショップのブランド表現を高めるためには、前述のようにHTML編集Appを利用して直接テーマを編集せざるを得ない作りになっています。ですが、気軽にインストールできるとはいえ、すべてのショップオーナーさんがHTMLやCSSを使いこなせるわけではありません。 基本的なサポートやリファレンス・ヘルプはほぼなく、CSには日々テーマ編集に関する問い合わせが届いていました。主にフロントエンドチームとデザインチームがこのお問い合わせへ対応しなければならず、日々の業務を圧迫するという大きな課題も抱えていました。 課題4: PC表示用のテーマとスマートフォン表示のテーマ オフィシャルテーマはレスポンシブ対応されておらず、 PC表示用のテーマとスマートフォン表示のテーマが別々で用意 されてきました。スマートフォン用のテーマはもともとHTML編集Appに対応していないため、スマートフォン用のテーマは独自で開発されてきました。 スマートフォン用テーマ独自機能の例 例えば、スマートフォンでは「最近チェックした商品」や「関連商品」の欄が表示されますが、PCでは表示されません。PC表示とスマートフォン表示では情報に差異が生まれてしまい、ショップのブランディングを統一するのが難しくなっていました。 HTML編集Appという大きな足かせ 自由度の少なさをHTML編集Appでカバーしているが、HTML編集を利用すると新規機能のアップデートに追従できない…という負のループをBASEのショップデザインはずっと抱えていました。 手軽に編集できる機能としてHTML編集Appを提供している以上、長年積み上げた負債を返済しなければなりませんでした。 新しいデザイン編集が目指したこと 新しいオフィシャルテーマ 今までの「BASE」のデザイン編集に欠けていた、「多様なデザイン選択肢」を提供すること、HTML編集Appに頼らなくても「直感的な操作」で誰でもかんたんにイメージ通りのショップをデザインできる機能を目指しました。 特にデザインの上で大変だったのは、テーマのレイアウトを新しく刷新することです。既存のオーナーさんがスムースに移行できるように、レイアウトは既存を踏襲しつつ、より利用シーンが分かるようなクリエイティブにアップデートしました。オフィシャルテーマはデザインチームのみんなに手伝ってもらって、以前の倍のテーマ数を用意することができました。 すべてのオフィシャルテーマがスマートフォン対応デザインに 新しいオフィシャルテーマ 無料で利用できるオフィシャルテーマが、すべてスマートフォン対応デザインとなりました。ショップデザイン機能からデザインした内容は、スマートフォンページ・PCページに同時に反映されます。 ノーコードで直感的にデザインを編集 「ショップデザイン機能」では、HTMLやコードの編集を必要とせず、ノーコードで直感的に編集することができます。 さらに、フォントの変更や、ナビゲーションの追加や並び替え、「BASE」以外のサイトURLの追加など、今回新たに追加した編集機能も全て無料で利用することができます。 また、お知らせやピックアップ商品、スライドショーやSNSアイコンなど、20種類以上あるパーツの中から必要なものをネットショップに追加することが可能です。パーツを追加する位置も調整できるので、伝えたい情報や商品などの構成をご自身で決めることが可能です。 「HTML編集 App」をアップグレード 「HTML編集 App」を、より本格的なショップページ制作にも対応できるエディタにアップグレードしました。新しい「HTML編集 App」は、画面全体を使ってのコーディングとプレビューができるようになり、大規模な編集もより快適におこなうことができるようになりました。 テキストの変更や画像の追加など、デザインパーツを活用して手軽にデザインをおこないたい場合は、「ショップデザイン機能」を。専門知識を活かして本格的な編集をおこないたい方は、「HTML編集 App」を、と目的に応じて使い分けてもらうような機能として提供しています。 ショップテーマをリニューアルして 結果的に長期プロジェクトにはなりましたが、今後の「BASE」のショップデザインにおいて負債になっている部分を解決できる開発に関わることができたことの達成感は大きく、1年間このプロジェクトに携われてよかったなと思っています。 去年のアドベントカレンダーでも自社の開発について振り返ったのですが( https://devblog.thebase.in/entry/2019/12/13/110000_1 )、過去の開発において、そのときにくだした意思決定は圧倒的に正しくて(あとから状況が変わってそれが最適ではなくなることももちろんありますが)、現在のBASEはそれを積み重ねてきた結果なんだなと思っています。 デザイン編集リニューアルの開発のさなか、コロナの影響で弊社も全社員がリモートワークへ移行しました。プロジェクトの「Times」チャンネルを作り、日々の雑談もOKというチャンネルを作って積極的にオンラインコミュニケーションを取ろうという施策をおこなったところ、30日でのチャット投稿数が全チャンネル内でも2位という結果になり、リモートであってもこのプロジェクトを進めていく熱量のようなものを感じられて、個人的にとても良かったです。 1ヶ月間のSlackチャンネル投稿数 新しいデザイン編集、ぜひ触ってみてください。 明日はネイティブアプリチームの roothy さんです!
アバター
TDDのTips
前置き この記事はBASE Advent Calendar 2020 13日目の記事です。 devblog.thebase.in こんにちは、BASE株式会社 Product Dev Division でバックエンドエンジニアを務めている元木です。 以前、社内で同僚のエンジニアと話していたとき、 「TDDって頭では分かっているけど、テストから書くってなかなか難しいよね」 という話がありました。 そこで、自分がTDDでプログラムを書くときに行なっているTips的なものを紹介してみたいと思います。 あくまで 「自分はこういう感じで実践している」 というものであり、 「これが正しいTDDだ!」 と主張するものではありませんので、軽い気持ちで読んでいただけたら幸いです。 そもそも、TDDとは? テスト駆動開発 (Test Driven Development) のことです。いいね? 本題 前置きが長くなりましたが、自分が実践しているやりかたは 「テストコードを書く前に、メソッドコメントを書く」 です。 テストを書こうとしても手が進まない場合、 「実装しようとしているメソッドの仕様が、曖昧なままだから」 ということが原因の一つにあるように思います。 そのため、まずは実装しようとしているメソッドの仕様をメソッドコメント(と、メソッドのシグニチャ)という形で明確にしてみると、テストケースの洗い出しがしやすくなるかも知れません。 以下、サンプルコードを例に説明します。 今、PHPで電話帳アプリを開発していて、 「氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す」 というメソッド ( PhoneBook::search() ) を実装しようとしているとします。 何もない状態からいきなりテストを書こうとすると、 <?php class PhoneBookTest extends TestCase { public function test_氏名の一部を入力したら、一致する人の電話番号を返す () { } } という 1つくらいしかテストケースが思い浮かばなかったりします。 そこでまず、メソッドのシグニチャと大まかなメソッドコメントだけ先に書いてみます。 <?php class PhoneBook { /** * 氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す。 * * @param string $name * @return array */ public function search ( string $ name ) : array { } } このメソッドコメントを充実させていくことが、テストケースを洗い出すことにつながるわけです。 例えば、 <?php class PhoneBook { /** * 氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * @return array */ public function search ( string $ name ) : array { } } と書けば、テストケースは <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } } の 2つになります。 次に、戻り値に <?php class PhoneBook { /** * 氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * @return array 入力した氏名の一部に一致する人の電話番号の配列。 * 一致する人がいない場合、空の配列を返す。 */ public function search ( string $ name ) : array { } } と書けば、テストケースも <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } public function testSearch_一致する人がいない () { } } となります。 さて、ここで 「引数$nameが空文字列だった場合は、どうしようか?」 と思いつきました。 何も考慮せずに実装すると、電話帳に登録されているすべての人の電話番号を返してしまいます。 そこで、以下のような仕様に決めてみました。 <?php class PhoneBook { /** * 氏名の一部を入力したら、一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * * @return array 入力した氏名の一部に一致する人の電話番号の配列。 * 一致する人がいない場合、空の配列を返す。 * * 引数 $name が空文字列の場合も、空の配列を返す。 */ public function search ( string $ name ) : array { } } テストケースも 1つ、追加されました。 <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } public function testSearch_一致する人がいない () { } public function testSearch_渡された氏名が空文字列 () { } } ところでこの電話帳アプリ、データは CSVファイルで管理するのですが、後日、データベースに載せ替えことになるかもしれません。 そのため、メソッドの呼び出し元にはメソッド内でファイルを操作していることを隠蔽しておきたいと思いました。 そこで、CSVファイル操作時にエラーが発生した場合は独自に定義した例外を投げるように仕様を決めておきます。 <?php class PhoneBook { /** * 氏名の一部を入力したら、一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * * @return array 入力した氏名の一部に一致する人の電話番号の配列。 * 一致する人がいない場合、空の配列を返す。 * * 引数 $name が空文字列の場合も、空の配列を返す。 * * @throws PhoneBookException 検索中にエラーが発生した場合。 */ public function search ( string $ name ) : array { } } 例外のテストケースも追加されました。 <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } public function testSearch_一致する人がいない () { } public function testSearch_渡された氏名が空文字列 () { } /** * @expectedException PhoneBookException */ public function testSearch_エラーが発生 () { } } いかがでしたでしょうか? TDDではテストコードという形で仕様を記述しますが、何も決まっていない状態からいきなりテストコードを書くのは、なかなか難しいです。 やはり、自然言語で仕様を記述してからテストコードを書いたほうが、アイデアをまとめやすいと思います。 仕様書を書くのと何が違うの? メソッドの仕様を自然言語で書いていては、 「仕様書を書くのと何が違うの?」 と思うかもしれません。 実際、これほど詳細にメソッドコメントを書くのと仕様書を書くのでは、かかるコストは同じくらいかもしれません。 しかし、仕様を(仕様書ではなく)メソッドコメントという形で記述することには、以下のようなメリットがあると考えます。 そのメソッドの仕様が、そのまま他の人にも読める形でソースコード内に残る。 ある程度、決まった書式に沿って書くことになるので、考えを整理しやすい。 また、コメントの書式は phpDocumentor などのドキュメンテーションツールのそれに合わせておくことをお勧めします。 そうすれば、人間だけでなくIDEにも解釈可能になり、プログラミング作業を助けてくれる可能性が高くなるからです。 最後に 今回は、TDDでプログラミングする際に自分が実践しているTIPSをご紹介しました。 皆さんのプログラミング作業の一助になりましたら、幸いです。 明日はデザインチームの北村さん( id:lllitchi )です。 お楽しみに!
アバター
この記事はBASE Advent Calendar 2020の12日目の記事です。 devblog.thebase.in こんにちは!BASE株式会社 ServiceDevのShopグループ所属でエンジニアをしている炭田( @tanden )です。 「BASE」の裏側で動いているアプリケーションはCakePHP 2を使っています。そのCakePHP 2にプルリクエストを送ったけど先を越されてしまった話をします。 過去にも弊社の田中( @tenkoma )が同じような記事を書いていたので、そちらも合わせてご覧いただけると嬉しいです! devblog.thebase.in プルリクエストの内容 今回自分がプルリクエストを送ったのは、Validation::time()の不具合の修正です。Validation::time()は渡された文字列が妥当な時刻の形式になっているかどうかをチェックします。 渡された文字列が妥当な時刻かどうかを検証します。時刻は 24時間形式 (HH:MM) または am/pm ([H]H:MM[a|p]m) です。秒までは検査できません。 データバリデーション - 2.x から引用 しかし、ある開発作業のコードレビューの中で、Validation::time()を使おうとなり、実際にValidation::time()を使ったコードをテストをしていたところ「12:00のようなHH:MMの形式以外は受け付けないはずなのに、12のような『:MM』がない文字列でもパスしてしまう」現象が起きてしまいました。なぜかと思い、CakePHP 2のValidation::time()のコードを確認してみると、以下のようになっていました。 /** * Time validation, determines if the string passed is a valid time. * Validates time as 24hr (HH:MM) or am/pm ([H]H:MM[a|p]m) * Does not allow/validate seconds. * * @param string $check a valid time string * @return bool Success */ public static function time($check) { return static::_check($check, '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3])(:[0-5]\d){0,2}$%'); } https://github.com/cakephp/cakephp/blob/2.x/lib/Cake/Utility/Validation.php#L386-L396 原因となる正規表現 せっかくなので原因となる正規表現を詳しく見ていきます。前半部分の ((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m)) は「AM/PM形式の時刻のチェックをしている」部分になります。今回の問題点は24時間表記(HH:MM)の部分なので飛ばして | ORで区切られた後半部分を確認していきます。 後半部分は以下のようになっています。 ^([01]\d|2[0-3])(:[0-5]\d){0,2}$ ^ は ^ の次に指定された文字列で始まっていることを示すメタ文字で、 $ は $ の前に指定された文字列で終っていることを示すメタ文字です。なので、その間の ([01]\d|2[0-3])(:[0-5]\d){0,2} を見ていけば、今回の24時間表記の時間のバリデーションの問題の原因がわかりそうです。前半の ([01]\d|2[0-3]) では「0もしくは1が先頭にきて、次に任意の数字(0から9)がくる OR( | ) 20, 21, 22, 23のいずれかがくる」文字列にマッチさせていることがわかります。これは24時間表記のHHの「00 - 23」までを表し特に問題なさそうです。 次に時間の「分」の部分をマッチさせる後半の (:[0-5]\d){0,2} では「まず : がきて、0から5のいずれかの次に任意の数字がくる文字列( (:[0-5]\d) )の0回以上2回以下の繰り返し( {0,2} )」になっています。ここで問題になるのは「0以上2回以上の繰り返し」が指定されているので「 : 以下が無し、 :00 、 :00:00 」の3パターンの文字列にマッチしてしまいます。ドキュメントだと、24時間表記の場合はHH:MMのみで秒はチェックしないとあるので、ドキュメントとの相違が2つ含まれていることがわかります。 HHのみの形式でもtrue HH:MM:SSの形式でもtrue なので、 {0,2} の部分を外してしまって、以下にような正規表現にするとドキュメントとの相違点が無くなりそうです。 - '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3])(:[0-5]\d){0,2}$%' + '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3]):[0-5]\d$%' この部分を修正して、テストも一緒にプルリクエストとして出せば取り込まれて直せそうだな、めでたしめでたし(※)と思い、CakePHP 2向けに以下のプルリクエストを作成して送った( 最初に出したプルリクエスト )のですが、早速以下のコメントをいただきました。 2.x is at security maintenance mode only now. バージョン2系はもうセキュリティ面以外のメンテナンスはされていないのですね...(知りませんでした)。Validationメソッドはできるだけカスタムメソッドを使って実装したくなかったので、できれば本体に取り込んだものを使いたかったのに...残念。 ※ 秒数を受けないようにする対応はAM/PM表記用の正規表現においても対応する必要がありました。 CakePHP 4にもプルリクエストを出してみる CakePHP2での変更はかないませんでしたが、せっかくなのでバージョン4系のValidation::time()ではこの問題が修正されているのか見てみることにしました。 以下は修正前の古いコードです。 /** * Time validation, determines if the string passed is a valid time. * Validates time as 24hr (HH:MM[:SS][.FFFFFF]) or am/pm ([H]H:MM[a|p]m) * * Seconds and fractional seconds (microseconds) are allowed but optional * in 24hr format. * * @param mixed $check a valid time string/object * @return bool Success */ public static function time($check): bool { if ($check instanceof DateTimeInterface) { return true; } if (is_array($check)) { $check = static::_getDateString($check); } if (!is_scalar($check)) { return false; } $meridianClockRegex = '^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$'; $standardClockRegex = '^([01]\d|2[0-3])((:[0-5]\d){0,2}|(:[0-5]\d){2}\.\d{0,6})$'; return static::_check($check, '%' . $meridianClockRegex . '|' . $standardClockRegex . '%'); } https://github.com/cakephp/cakephp/blob/4.next/src/Validation/Validation.php#L628-L655 starndardClockの正規表現を見てみると... ([01]\d|2[0-3])((:[0-5]\d){0,2} 時間の「分」をマッチさせる箇所で「0回以上2回以下」の指定がそのまま残っていました。これでは、HHのみの文字列でもtrueが返ってしまいます。なので、以下のように修正する プルリクエスト をテストと一緒に出してみました。 - '^([01]\d|2[0-3])((:[0-5]\d){0,2}|(:[0-5]\d){2}\.\d{0,6})$'; + '^([01]\d|2[0-3])((:[0-5]\d){1,2}|(:[0-5]\d){2}\.\d{0,6})$'; 時間の「分」をマッチさせる箇所で「0回以上2回以下」のところを「1回以上2回以下」に変更しています。しかし、コメントで"We should update the documentation instead. Treating 12 as 12:00 seems like a reasonable thing to me."(HHの形式でも違和感ないから、仕様が書いてあるコメントの方を変更しよう)となり、「あれ、仕様の方を変えるのか...」と一瞬思いましたが「たしかに、00から23をtimeとして扱うのはわからなくもない」と考え、修正用のプルリクエストはクローズして メソッドのコメントだけ変えるプルリクエスト を作成し、こちらは無事に取り込まれました。 さらにコメントを貰うが放置してしまう メソッドのコメントを修正するプルリクエストを作っているときに別の方から"I dont think TimeType will marshal HH only time if that matters."とコメントをいただきました。自分は最初このコメントの意味がよくわからず(marshal?)、またちょうど業務で忙しくなってきたタイミングだったので、メソッドのコメントを修正したプルリクエストを出したままでそのまま放置していました。しかし、後日CakePHPのリポジトリを覗いてみると以下のプルリクエストがマージされていました。 Restrict Validation::time() so it requires minutes not just hours 「実装の方を修正してる!"marshal"(things in order)ってそういうことかー!」と悔しい思いをしました(せっかくなら実装に差分があるコミットを取り込まれたかった...笑)。ですが、「OSSでも日頃の業務と同じくプルリクエスト上で色々議論しながら作っていくんだな」と当たり前のことを体験することができました。こちらの修正は4.2.0でリリースされるようです( 4.2.0のマイルストーン )。とりあえずプルリクエストを出しておくのは大事ですね! ちなみに:CakePHP 2でValidation::time()の不具合に対応するには CakePHP 2をお使いの場合、Validation::time()の不具合に対応するには以下の正規表現でカスタムのバリデーションメソッドを作成すれば、「HHのみの形式」や「秒数付きの文字列」は受けつけないようにすることが可能です。 '%^((0?[1-9]|1[012])(:[0-5]\d){0,1} ?([AP]M|[ap]m))$|^([01]\d|2[0-3]):[0-5]\d$%' まとめ 日頃お世話になっているフレームワークに少しは貢献できたかなと思いつつ、今度は実装を修正したコミットが取り込まれるよう機会があれば再チャレンジしたいと思います! 明日は同じServiceDevのShopグループの元木さんです! 「BASE Advent Calendar」の記事一覧はこちら。 追記 この記事を書いていて、「そういえばCakePHP 3の方はまだ対応されていない気がするな」と思い、CakePHP 3のコードを見てみると、やはり同じような実装になっていたのでプルリクエストをとりあえず出してみました。どうなるかわかりませんが、今回は最後までやりきりたいと思います! CakePHP 3に出したプルリクエスト
アバター
この記事はBASE Advent Calendar 2020の11日目の記事です。 devblog.thebase.in BASE株式会社 Data Strategy チームの @tawamura です。 BASEではオーナーの皆様や購入者様のお問い合わせに対して、Customer Supportチームが主となって対応をしています。その中でもいくつかの技術的なお問い合わせに対しては、以下のようにSlackの専用チャンネルを通して開発エンジニアに質問を投げて回答を作成することになっています。 CSチームから調査を依頼されるお問い合わせの例 これらのCS問い合わせ対応は日々いくつも発生しており、 CSお問い合わせ対応を当番制にして運用してみた話 でもあるように週ごとに持ち回り制で各部門のエンジニアが対応しているのですが、どうしても調査や対応に時間が取られてしまうという問題が発生していました。 devblog.thebase.in ただ、いくつかの新規問い合わせに関しては過去に同様・類似のお問い合わせ事例があり、調査や回答の参考になる場合もありました。それならば、過去の類似の投稿を自動で取ってきてBotが提示することで、問い合わせ対応の一助となるかと思いました。 Botの提示例(社内情報が多く、マスクばかりで恐縮です) 過去の類似するお問い合わせ調査 関連する社内ドキュメント 今回は、その問い合わせ対応半自動化のシステム構築についてお話しさせていただきます。ちなみに、この内容は Data Strategy チームの HackWeek の導入とその効果 で行なった1週間の実施タスクでして、今回はここでの結果にもう少し機能追加などを行い整えた記事になります。 devblog.thebase.in 技術選定、システム概観 Slack、API Gatewayなどの連携設定 過去のSlack投稿の取得と解析、Elasticsearchへの保存 Slack投稿の取得 問い合わせ文書の解析 Elasticsearchに過去問い合わせを保存 処理Lambdaの実装 Slack周り 検索クエリの抽出ロジック Kibela検索クエリについて デプロイ 終わりに 技術選定、システム概観 弊社はAWSで環境構築を行なっている部分が多いため、今回もAWS環境を前提に進めます。 結果的に以下のようなシステムを構築しました。 参考情報自動投稿システム まず、特定チャンネルにおいてSlackに新規問い合わせがあった時にそれをイベントとして拾います。そこで拾ったイベントをAWS上で活用しやすいようにAPI Gatewayを使用します。 API Gatewayでイベントを受け取った後に、実際にその内容に対して処理を行うのですが、問い合わせは多いと言ってもデイリーで数十件には上らない程度の発生頻度であり、あまり重い処理を行うわけではないことから、AWS Lambdaを使用することにしました。 Lambda内ではお問い合わせ内容について類似する過去の問い合わせの検出と、弊社でドキュメントサービスとして使用しているKibelaの記事のうち関連する記事を取得する処理を行います。過去の問い合わせ検出には、大量のSlackドキュメントから全文検索により取得することを考えてElasticsearchを使用しました。 受け取った情報からレスポンスを整形し、Slackの特定のチャンネルに投稿します。今回はあくまでサブ機能としての提供で考えていたので、別途自動回答チャンネルを設けてそちらに書き込みをさせました。 弊社の場合、Slackに投稿する部分については、SNSに特定のイベントを投げることで完結するようなシステムがすでにありますので、具体的には以下のようなフローになっています。こちらのSlack投稿システムについては本記事では割愛し、直接Slackに投稿できるようなものを紹介します。 参考情報自動投稿システム(Slack投稿部分分離) Slack、API Gatewayなどの連携設定 Slackの投稿内容をLambdaで解析するためには、Slackの投稿イベントを拾いAPI Gatewayを通してAWS内で扱えるようにし、Lambdaに紐つけることでイベント内容を処理するという一連の連携の設定が必要になります。 こちらの内容については、以下の記事で同様の連携設定がまとめてありますので、そちらを参考に構築していただければと思います。図解も詳しくとてもわかりやすいです。 qiita.com 上記連携を行うことで、Botを追加したチャンネルでの新規投稿について、Lambdaでの個別処理を行うことができます。 今回はPythonでの処理を想定しているので、Lambda作成時のランタイムですが、記事にあるようにPython 3.6やPython 3.7などに指定してください。 過去のSlack投稿の取得と解析、Elasticsearchへの保存 Slack投稿の取得 作成したBotのtokenを利用して、まずは過去のSlackの問い合わせ投稿の取得を行います。Jupyter Notebookなどで実施するのをお勧めします。 headers = { "Content-type" : "application/json" , "Authorization" : f "Bearer {token}" # 取得したslack botのtoken } # 決めで2018年以降の投稿を取得 def fetch_messages_by_channel (channel_id): oldest_ts = None start_date = pd.to_datetime( '2018-01-01' ) endpoint = 'https://slack.com/api/conversations.history' ls_messages = [] while True : payload = { 'channel' : channel_id, 'latest' : oldest_ts, 'count' : 1000 } data = requests.get(endpoint, headers=headers, params=payload).json() messages = data[ 'messages' ] ls_messages.extend(messages) if data[ 'has_more' ]: time.sleep( 1 ) oldest_ts = messages[- 1 ][ 'ts' ] oldest_datetime = pd.to_datetime(oldest_ts, unit= 's' ) sys.stdout.write(f " \r {oldest_datetime}" ) sys.stdout.flush() if oldest_datetime < start_date: sys.stdout.write(f " \r finish!" + ' ' * 50 ) break else : break df = pd.DataFrame(ls_messages) df[ 'channel_id' ] = channel_id return df 対象となるチャンネルのIDは以下のエンドポイントから取得可能(チャンネル数が1000を超える場合は、適宜ループ取得をしてください)。以下は対象チャンネルが #お問い合わせチャンネル の場合の例です。 endpoint = 'https://slack.com/api/conversations.list' payload = { "limit" : 1000 } data = requests.get(endpoint, headers=headers, params=payload).json() channel_df = pd.DataFrame(data[ 'channels' ]) display(channel_df.query( "name == 'お問い合わせチャンネル'" )) 取得したいチャンネルのIDがわかったら、先ほどの関数に渡すことで再帰的に過去の投稿を取得できます。 messages_df = fetch_messages_by_channel(channel_id) 2018年以降の投稿を取得するのですが、1000件ずつ取得していって2018年以前になっていたら終了するというループなので、厳密には2017年後半に一部も含まれます。 続いて、取得したデータを整形していきます。同時に該当の投稿へリダイレクトできるリンクを取得情報から作成します。 # typeの選別 not_message_types = [ 'channel_join' , 'channel_leave' , 'channel_topic' , 'channel_archive' , 'channel_purpose' , 'sh_room_created' , 'channel_name' , 'pinned_item' , 'reminder_add' , 'app_conversation_join' ] messages = messages[~messages[ 'subtype' ].isin(not_message_types)] # slack linkの作成(ドメイン名は適宜変更してください) messages[ "link" ] = "https://xxx.slack.com/archives/" + messages[ "channel_id" ] + "/p" + messages[ "ts" ].str.replace( '.' , '' ) 次に問い合わせ投稿の抽出を行います。弊社の場合、 @cs_dev_team というメンショングループで投稿されているものがそれに該当するため、それらの投稿を抽出します。 # subteamのIDはmessages_dfの中身を確認するなどして代入してください cs_res_df = messages[messages[ "text" ].str.contains( "<!subteam^*********|@cs_dev_team>" )] これで期間中の全投稿 messages_df と問い合わせ投稿の cs_res_df が作られたことになります。 問い合わせ文書の解析 問い合わせ投稿の類似投稿の検索や、社内ドキュメントの検索のために、新規問い合わせの文章を適切に処理して行えるよう事前に解析しておきます。 import re import mojimoji # 事前に不要な項目をtextから削除する関数 def filter_contents (text): # タグ・emojiは削除 subed = re.sub( "<.*?>" , "" , text) subed = re.sub( ":.*?:" , "" , subed) # その他、社内独自ID系などの削除処理 # ******** return subed # 単語の正規化 def normalize (text): text = text.strip() text = mojimoji.zen_to_han(text, kana= False ) text = mojimoji.han_to_zen(text, digit= False , ascii = False ) text = text.lower() return text 全投稿文から名詞のみ抽出します。形態素解析器はJanomeを使用しました。あとでも触れますが、Janomeは辞書も含めてpipで簡単にインストールすることができるので、Lambdaのような環境で簡単に形態素解析を行うのに適しています。 from janome.tokenizer import Tokenizer t = Tokenizer() ndocs = [] for line in tqdm(messages_df[ "text" ]): parsed = [] subed = normalize(filter_contents(line)) # janomeは改行を無視できない for token in t.tokenize( " " .join(subed.split( " \n " ))): tok = token.surface hinsi, sub, _ = token.part_of_speech.split( "," , 2 ) # 数字のみは名詞だがスキップ if tok.isdecimal(): continue if hinsi == "名詞" : parsed.append(tok) ndocs.append( " " .join(parsed)) これで ndocs に名詞列のスペース区切り文字列が投稿数分だけlistとして格納されます。 これを利用してチャンネル全体での単語のTFIDF値(重要度のようなもの)を計算します。TDIDFについては以下の記事などをご参照ください。今回は簡単のためにscikit-learnに含まれるTfidfVectorizerを計算に使用しました。 qiita.com from sklearn.feature_extraction.text import TfidfVectorizer # 文書全体の90%以上で出現する単語は無視、語彙数は10000のみ vectorizer = TfidfVectorizer(max_df= 0.9 , max_features= 10000 ) X = vectorizer.fit_transform(ndocs) words = noun_vectorizer.get_feature_names() idf_list = [ " \t " .join( map ( str , line)) for line in list ( zip (vectorizer.get_feature_names(), vectorizer.idf_))] pd.DataFrame(idf_list).to_csv( "./idf.tsv" , index= False , header= False ) これで idf.tsv というIDF辞書が作成できます。 単品 9.973116006811027 匿名 9.839584614186505 カーソル 9.21097595476413 ショップ 3.3278392845417772 : TF値は新規問い合わせ時にわかりますので、これで新規問い合わせについての名詞のTFIDF値が計算できることになります。 Elasticsearchに過去問い合わせを保存 過去の問い合わせをElasticsearchに保存して、新規問い合わせが来た時に検索をかけられるようにします。まずElasticsearchに過去問い合わせ用のインデックスを作成します。事前にboto3で使用するcredentialsを以下の記事など参考に取得できるよう設定しておく必要があります。 qiita.com import io import boto3 from requests_aws4auth import AWS4Auth from elasticsearch import Elasticsearch, RequestsHttpConnection region = 'ap-northeast-1' service = 'es' credentials = boto3.Session().get_credentials() awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token) es = Elasticsearch( hosts = [{ 'host' : '{Elasticsearchのhost}' , 'port' : 443 }], http_auth = awsauth, use_ssl = True , verify_certs = True , connection_class = RequestsHttpConnection ) index_name = "autores_index" mappings = { 'properties' : { 'orig_text' : { 'type' : 'text' }, 'text' : { 'type' : 'text' , 'analyzer' : 'autores_kuromoji_analyzer' }, 'keyword' : { 'type' : 'text' , 'analyzer' : 'autores_kuromoji_analyzer' }, 'ts' : { 'type' : 'text' }, 'thread_ts' : { 'type' : 'text' }, 'link' : { 'type' : 'text' }, } } settings = { 'analysis' : { 'analyzer' : { 'autores_kuromoji_analyzer' : { 'type' : 'custom' , 'tokenizer' : 'kuromoji_tokenizer' } }, 'autores_kuromoji_tokenizer' : { 'kuromoji' : { 'type' : 'kuromoji_tokenizer' } } } } es.indices.create(index=index_name, body={ 'settings' : settings, 'mappings' : mappings}) 登録するtextなどについて、analyzerに kuromoji_tokenizer を使用することで、全文検索時に形態素解析を活用した検索をすることができます。 過去の問い合わせ投稿について、Elasticsearch用の事前処理を行います。 bulk_dataset = [] for index, row in tqdm(cs_res_df.iterrows(), total=cs_res_df.shape[ 0 ]): text = row[ "text" ] ts = row[ "ts" ] thread_ts = row[ "thread_ts" ] link = row[ "link" ] # タグ除去(メンションやリンク) subed_text = filter_contents(text) # tokenize keyword = " " .join(tp.make_tokens(subed_text)) tfidf_score, unique_keys, shop_ids, phrase_list = tp.process(text) # skip too short message if len (subed_text) < 30 : continue # bulk bulk_dataset.append({ "index" : { "_index" : index_name}}) bulk_dataset.append({ "orig_text" : text, "text" : subed_text, "keyword" : keyword, "ts" : ts, "thread_ts" : thread_ts, "link" : link, }) def bulk (data, dry= False ): if len (data) == 0 : return buf = io.StringIO() for d in data: buf.write(json.dumps(d, ensure_ascii= False )) buf.write( " \n " ) buf.seek( 0 ) if dry: print (buf.read()) else : body = buf.read() es.bulk(body) 以下で200件ずつデータをinsertしていきます。 rest_dataset= bulk_dataset insert_rows_len = 200 while len (rest_dataset) > 0 : data = rest_dataset[:insert_rows_len] bulk(data) rest_dataset = rest_dataset[insert_rows_len:] これでElasticsearchの autores_index にデータが追加されます。以下のクエリなどでデータを確認できます。 es.search( scroll= '5m' , index=index_name, body={ "query" : { "match_all" : { } }, "_source" : [ "orig_text" , "link" ], }, ) 処理Lambdaの実装 Lambdaで実装するのは主に以下の処理です。 API Gatewayから流れてきたイベントを正しく受け取って処理可能な状態にする テキスト解析を行いElasticsearch、Kibelaの検索を行う BotとしてSlackへ回答を投稿する いくつかLambdaで新しく導入する必要のあるモジュールがありますが、それはLambda Layerという形で事前に基盤のようなものを用意しておくことになります。 docs.aws.amazon.com requirements.txtに追加で必要なモジュールを記載し、それらのファイルを一つのzipとしてまとめる必要があります。同時に、事前に作成したIDF辞書もここでLayer内に含めます。以下のようなMakefileを同階層に作って構築すると楽です。 janome requests_aws4auth elasticsearch mojimoji build-layer: rm -rf python && mkdir -p python docker run --rm -v ${PWD} :/var/task lambci/lambda:build-python3.7 \ pip install -r requirements.txt -t python cp idf.tsv python zip -r autores_layer.zip python rm -rf python 以下のコマンドで、Layerに適用可能なzipファイル autores_layer.zip が作成されます。これをLayerとして新規登録しましょう。Webでもできますし、CLIツールでも可能だと思います。 $ make build-layer 今回、Lambda上で形態素解析を行うためにJanomeを採用したと言いましたが、理由として動作が軽いのと、シンプルなpipによるインストールで完結していることが理由として挙げられます。他の形態素解析器でもLayerやEFSなどを活用することでLambda上でも使用できるようなのですが、手っ取り早く扱いづらかったため今回は見送りました。 LambdaのWeb管理画面に行き、新規関数を作成していきます。これは前に説明したAPI Gatewayとの連携設定をしていれば、そのLambda関数を選択するので問題ないです。 まずLayerとして先ほど新規作成したLayerを使用します。 Layerの設定 デザイナータブ内のLayersをクリックすると、下部にレイヤーを管理するタブが出ますので、そこでレイヤーの追加を行います。Layer追加時に互換性のあるランタイムでPython3.7などを指定していればカスタムレイヤーで選択できますが、直接LayerのARNを指定するのでも問題ありません(ARNはLayerの画面右上などにあります)。 Lambdaの設定ですが、使用メモリを512MBとしています。Janomeでの形態素解析を行うのに少し余裕が必要になるためですが、このくらい確保しておけば大抵は動作するかと思います。 次にfunction.pyの中身を実装します。 import boto3 from requests_aws4auth import AWS4Auth from elasticsearch import Elasticsearch, RequestsHttpConnection import json import logging import io import requests import urllib import mojimoji import re from janome.tokenizer import Tokenizer import collections import time import hmac import hashlib logger = logging.getLogger() logger.setLevel(logging.INFO) KIBELA_DOMAIN = "****.kibe.la" # Kibelaのドメイン KIBELA_KEY = "secret/***************" # KibelaのAPIキー ES_DOMAIN = "********.es.amazonaws.com" # Elasticsearchのドメイン SLACK_DOMAIN = "******.slack.com" # Slackのドメイン SLACK_SIGNING_SECRET = "************" # SlackのSigning Secret BOT_TOKEN = "xoxb-********************" # Slack Botのtoken BOT_USER_ID = "U*********" # Slack Botのuser_id INDEX_NAME = "autores_index" # elasticsearchのインデックス名 REGION = "us-east-1" # 利用中のregion OUT_CHANNEL = "#autores_bot" # Botの投稿先チャンネル service = 'es' credentials = boto3.Session().get_credentials() awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, REGION, service, session_token=credentials.token) es = Elasticsearch( hosts=[{ 'host' : ES_DOMAIN, 'port' : 443 }], http_auth=awsauth, use_ssl= True , verify_certs= True , connection_class=RequestsHttpConnection ) def is_valid (event): if SLACK_SIGNING_SECRET is None : return False if "x-slack-signature" not in event[ "headers" ] or "x-slack-request-timestamp" not in event[ "headers" ]: return False timestamp = int (event[ "headers" ][ "x-slack-request-timestamp" ]) if abs (time.time() - timestamp) > 60 * 5 : return False request_body = event[ "body" ] sig_basestring = f "v0:{timestamp}:{request_body}" digest = hmac.new( SLACK_SIGNING_SECRET.encode(), sig_basestring.encode( "utf-8" ), hashlib.sha256).hexdigest() my_sig = f "v0={digest}" if hmac.compare_digest(my_sig, event[ "headers" ][ "x-slack-signature" ]): return True else : return False def event_to_json (event): # API Gatewayから流れてきたイベントから本文を抽出 if 'body' in event: body = json.loads(event.get( 'body' )) return body elif 'token' in event: body = event return body else : logger.error( 'unexpected event format' ) exit class TfidfProcessor (): def __init__ (self): self.word2idf = {} self.subed = "" self.tokens = [] self.tfidf_score = {} self.phrase_list = [] # Layer内のIDF辞書を読み込み with open ( "/opt/python/idf.tsv" ) as f: for line in f: word, score = line.rstrip().split( " \t " ) self.word2idf[word] = score self.t = Tokenizer() def process (self, text): self.filter_contents(text) self.make_tokens() self.calc_tfidf() self.extract_phrase() return self.phrase_list def normalize (self, text): text = text.strip() text = mojimoji.zen_to_han(text, kana= False ) text = mojimoji.han_to_zen(text, digit= False , ascii = False ) text = text.lower() return text def filter_contents (self, text): # タグ・emojiは削除 subed = re.sub( "<.*?>" , "" , text) subed = re.sub( ":.*?:" , "" , subed) # その他、社内独自ID系などの削除処理 # ******** self.subed = self.normalize(subed) def make_tokens (self): self.tokens = list (self.t.tokenize( " " .join(self.subed.split( " \n " )), wakati= True )) def calc_tfidf (self): tf_dic = self.calc_tf() idf_dic = self.calc_idf() for token, idf_score in idf_dic.items(): self.tfidf_score[token] = float (tf_dic[token]) * float (idf_score) def calc_tf (self): tf_tokens = {} tokens_len = len (self.tokens) counts = collections.Counter(self.tokens) for token, freq in counts.items(): tf_tokens[token] = freq / tokens_len return tf_tokens def calc_idf (self): idf_tokens = {} for token in self.tokens: if token in self.word2idf: idf_tokens[token] = self.word2idf[token] return idf_tokens def extract_phrase (self): phrase = {} tmp_phrase = [] for token in self.t.tokenize( " " .join(self.subed.split( " \n " ))): tok = token.surface hinsi, sub, _ = token.part_of_speech.split( "," , 2 ) # ストップワードなどあれば、この辺りで処理しておく self.tokens.append(tok) if tok in self.tfidf_score and hinsi == "名詞" : tmp_phrase.append(tok) elif len (tmp_phrase) > 0 : phrase[ "" .join(tmp_phrase)] = sum ( [self.tfidf_score[tp] for tp in tmp_phrase] ) tmp_phrase = [] phrase_score_list = sorted (phrase.items(), key= lambda x: x[ 1 ], reverse= True ) self.phrase_list = [phrase[ 0 ] for phrase in phrase_score_list] # for link with slack event subscriptions class ChallangeJson ( object ): def data (self, key): return { 'isBase64Encoded' : 'false' , 'statusCode' : 200 , 'headers' : {}, 'body' : key } def search_es (phrase_list): res = es.search( scroll= '5m' , index=INDEX_NAME, body={ "query" : { "match" : { "text" : " " .join(phrase_list[: 20 ]), } }, "_source" : [ "text" , "ts" , "link" , ], "size" : 3 , }, ) return res def search_kibela (phrase_list): # 互いに含有しないtfidf値の高い最大2単語をクエリとして使用 q = phrase_list[ 0 ] for p in phrase_list[ 1 :]: if p in q: continue q = q + " " + p break # 全検索(合計で最大3件になるまで) res_text_list = [] query = """ query { search(query: \ """" + q + """ \ ", first: 10) { edges { node { title, url, folder { fullName } } } } } """ res = get_kibela(query) for edge in res[ "data" ][ "search" ][ "edges" ]: title = edge[ "node" ][ "title" ] url = edge[ "node" ][ "url" ] # 適宜取りたくない記事はルールベースでスキップ if re.search( r'hoge|fuga' , title, re.IGNORECASE): continue res_text_list.append(f "{title} \n {url}" ) if len (res_text_list) > 2 : break return " \n\n " .join(res_text_list) def get_kibela (query): endpoint = f "https://{KIBELA_DOMAIN}/api/v1" headers = { "Authorization" : f "Bearer {KIBELA_KEY}" , "Content-Type" : "application/json" , "Accept" : "application/json" , } payloads = { "query" : query } r = requests.post(endpoint, data=json.dumps(payloads), headers=headers) res = r.json() return res def save_to_es (text, tp, ts, thread_ts, target_link): bulk_dataset = [] bulk_dataset.append({ "index" : { "_index" : INDEX_NAME}}) subed_text = tp.subed phrase_list = tp.phrase_list bulk_dataset.append({ "orig_text" : text, "text" : subed_text, "keyword" : phrase_list, "ts" : ts, "thread_ts" : thread_ts, "link" : target_link, }) bulk(bulk_dataset) def bulk (data, dry= False ): if len (data) == 0 : return buf = io.StringIO() for d in data: buf.write(json.dumps(d, ensure_ascii= False )) buf.write( " \n " ) buf.seek( 0 ) if dry: print (buf.read()) else : body = buf.read() es.bulk(body) def send_slack (channel, text): headers = { 'Content-Type' : 'application/json; charset=UTF-8' , 'Authorization' : 'Bearer {0}' .format(BOT_TOKEN) } post_data = { 'channel' : channel, 'text' : text, } url = 'https://slack.com/api/chat.postMessage' req = urllib.request.Request( url, data=json.dumps(post_data).encode( 'utf-8' ), method= 'POST' , headers=headers ) urllib.request.urlopen(req) time.sleep( 3 ) def lambda_handler (event, context): # verify slack if not is_valid(event): return body = event_to_json(event) # return if it was challange-event if 'challenge' in body: challenge_key = body.get( 'challenge' ) logging.info( 'return challenge key %s:' , challenge_key) return ChallangeJson().data(challenge_key) # skip timeout retry, http_error retry if "x-slack-retry-reason" in event[ "headers" ] and event[ "headers" ][ "x-slack-retry-reason" ] in ( "http_timeout" , "http_error" ): return # SlackMessageに特定のキーワードが入っていたときの処理 if "type" in body.get( "event" ) and body.get( "event" ).get( "type" ) == "message" \ and "user" in body.get( "event" ) and body.get( "event" ).get( "user" ) != BOT_USER_ID: text = body.get( "event" ).get( "text" ) # @cs_dev_teamのみ拾う if "<!subteam^*********|@cs_dev_team>" not in text: return channel = body.get( "event" ).get( "channel" ) ts = body.get( "event" ).get( "ts" ) thread_ts = body.get( "event" ).get( "thread_ts" ) target_link = "https://{}/archives/{}/p{}" .format(SLACK_DOMAIN, channel, ts.replace( '.' , '' )) # tokenizer tp = TfidfProcessor() phrase_list = tp.process(text) # search es es_res = search_es(phrase_list) es_list = [] for hit in es_res[ "hits" ][ "hits" ]: link = hit[ "_source" ][ "link" ] es_list.append(link) if len (es_list) > 2 : break es_list_text = "関連: \n " + " \n " .join(es_list) # search kibela kibela_res = search_kibela(phrase_list) # post message to main send_slack(OUT_CHANNEL, target_link) # post ref data message to main send_slack(OUT_CHANNEL, es_list_text) # post kibela message to main send_slack(OUT_CHANNEL, kibela_res) # save message for future questions save_to_es(text, tp, ts, thread_ts, target_link) return コード中の各種キーやドメイン名などは、動作環境に合わせて適宜修正してください。 大枠の流れは、 API Gatewayから受け取ったイベントを処理し、処理すべきイベントか判断 Janomeを使用して本文を解析し、Elasticsearch、Kibelaを検索するための単語を抽出 それぞれ検索を行い、結果をSlackに投稿 受け取った新規問い合わせは、次回の過去問い合わせとなるのでElasticsearchに過去問い合わせとして登録 という流れで処理を行います。 以下に一部コードを抜粋して説明します。 Slack周り API Gatewayからイベントを受け取るときに、Slackからきたイベントだというのを判別するため is_valid という関数で処理しています。下記リンクが詳細になります。 SLACK_SIGNING_SECRET にはSlack Botの管理画面の「Signing Secret」を代入してください。 api.slack.com また、Botを使った自動投稿システムを作るときに、自分自身の投稿も反応対象として拾ってしまうと無限に投稿をし続けてしまうという問題が発生してしまいます。今回は特定のメンションを含むときを対象に抽出するので問題はないかもしれませんが、レスポンス内容を変更する場合は事前にBotからの投稿は無視するという処理をしておくと良いでしょう。 if "type" in body.get( "event" ) and body.get( "event" ).get( "type" ) == "message" \ and "user" in body.get( "event" ) and body.get( "event" ).get( "user" ) != BOT_USER_ID: text = body.get( "event" ).get( "text" ) ここの部分です。Botのuser_idはどこにあるかわかりにくいですが、こちらでBotのtokenを渡すことで取得できると思います。 api.slack.com その他、投稿する内容については send_slack 内のpost_dataに追記することで色々できるようです。また、投稿についてですがAPI Limitなどの余裕を持って3秒ほどsleepさせています。 検索クエリの抽出ロジック 問い合わせのテキストを解析し、検索に使用する単語を抽出するのは TfidfProcessor クラスで行います。 具体的な処理の内容としては、 絵文字やリンクなどを削除 形態素解析をし、名詞についてのみ形態素群を抽出し、それぞれのTFIDF値を計算 形態素解析時に連続する名詞は名詞句として扱い、TFIDF値は合算値とする 「振込/申請」とあった場合は、「振込申請」として「振込」と「申請」のTFIDF値を合算したものを使用する TDIFD値の高い順に名詞・名詞句を並び替えておく という流れになります。これによりTFIDF値の高い名詞・名詞句が獲得できるようになったので、以下のようにそれぞれ検索を行っています。 Elasticsearchでは上位20件の名詞・名詞句の空白区切りテキストを、過去問い合わせのtextに対して全文検索する (結構適当でもうまくやってくれるイメージです) Kibelaでは上位2件の名詞・名詞句をスペース区切りでクエリとして全文検索する 「振込申請」「振込」「注文」と続く場合は「振込申請」「注文」を選択するような処理を入れています 過去問い合わせとKibelaドキュメントのどちらも最大3件までとしました。 Kibela検索クエリについて Kibela APIの検索クエリはGraphQLで書く必要があります。 github.com 以下のschemaに沿ってクエリを作成します。 github.com 特定のフォルダ直下で検索したいときなどは、 query { search(query: "hoge fuga", first: 10), folderIds: ["{フォルダのID}"]) { edges { node { title, url, folder { fullName } } } } } という風に書くと絞り込みができたりしますので、弊社の場合はその検索も併用していたりします(フォルダIDはAPIを叩いて得られたものを手で入れました)。 デプロイ あとは保存をしてデプロイを行うだけです。うまくいけば、特定チャンネルの対象グループへのメンション投稿に対して、 そのメンションへのリンク(展開されます) Elasticsearchを利用した過去問い合わせ3件へのリンク(展開されます) Kibelaで検索した関連記事3件のタイトルとリンク が指定したチャンネルに順次Botから投稿されるかと思います。イメージは冒頭の画像の通りです。 実際には綺麗な出力にしていくためには、ストップワードを人手で登録したり、Kibelaの検索を細かい設定で分けたりフィルターをかますことで結果の記事を良い感じに調整するような泥臭い作業が重要になってきます。本記事では具体例は割愛しましたが、その辺りはいくつかサンプルで試してみて見つけつつ、運用が始まったら実際の出力などをみつつさらに調整する感じが良いかと思います。 終わりに エンジニアによる調査が必要だったりする技術的なお問い合わせに対して、過去の類似したお問い合わせや関連する社内ドキュメントの推薦を行うシステムを社内で導入した件についてまとめました。まだまだ精度改善の余地などはありますが、これ以前もあったような気がするな・・?というようなお問い合わせについては過去の事例を参照することで、同様の調査や回答の作成を楽に行えるようになったかと思います。実際に「自動で出してもらった回答がそのまま使えました!」というような声もいただいたりしました。 弊社の場合はSlack通知部分をSNSなどを利用して汎用システムとして用意していたり、各サービスの連携や変数などは構成管理ツールで管理させていたりします。また、実際には構築済みのVPC内で実装を行っていましたが、それらの説明については今回は省略しました。BASEでは現在、不正検知エンジニアを中心に機械学習エンジニアを募集しています。詳しいお話などは是非ご面談等でお話お伺いできればと思います! 今後も引き続き改善を行い、より早くお問い合わせの返信を行えるよう努力していければと思います。 明日はService Devチームの炭田さんです!お楽しみに!
アバター
この記事はBASE Advent Calendar 2020の10日目の記事です。 devblog.thebase.in はじめに こんにちは、BASE株式会社 ServiceDevセクション マネージャーの菊地です! サービスの急成長に伴って組織の拡大が急務であり、最近は採用活動に専らコミットメントしています。BASEに興味ある方はお気軽に 私まで ご連絡ください! さて、BASEでは120万を超えるショップオーナー様と多くのユーザー様にご利用いただいており、日々多くのお問い合わせを頂いております。基本的には弊社カスタマーサポートチーム(以下CSチーム)が一次受けして回答しているのですが、CSチーム内で回答できないものについては開発チームに依頼がきて調査/対応しています。 採用活動を行う中で他社のエンジニアと話す機会が多くあるのですが、「CS対応の運用がうまくいかない。一部のメンバーに負担が集中してしまう」といった悩みを持っている会社さんがとても多いようです。一方「BASEではCS対応を当番制にしたらうまく運用できています」とお話しすると興味を持って頂けることが多くあり、弊社のCS対応に関する知見を共有することは一定の需要があるのかなと思い、アドベントカレンダーのネタとして採用しました。 CS当番制導入以前の対応について 開発チームへのお問い合わせ対応依頼は1日あたり10数件程あります(11月23日~12月4日のデータを集計)。すぐに回答できるものもあれば、不具合が発覚し不具合修正に1日かかってしまうケースも少なくありません。 当番制導入以前はSlackにある #CSお問い合わせ対応チャンネル (弊社の全エンジニア約60名がジョインしています)において @here で全エンジニアに対応が呼び掛けられていました。 しかしどのエンジニアもメインのPJの機能開発で忙しいため、こういった日々の突発的なお問い合わせの対応を行うことは各々の主体性に期待しているだけではなかなかうまくいきませんでした。 @here のメンションが飛んできても誰も反応しないということが多々あり、結果的に人一倍当事者意識の強いCTOやテックリードに対応の負担が集中してしまうという状況が起きていました。CTOやテックリードには彼らにしか解決できない難しい課題に取り組んで欲しいので、毎日数時間をCS対応に費やす状況は好ましい状況ではありませんでした。 そこで課題を解決するべく、今年の4月頃から全開発組織を巻き込んでCS当番制を導入してみようということになりました。 CS当番制の仕組みについて 「CS当番に期待されていること」、「お問い合わせの対応フロー」等は社内のドキュメントに明文化してあり、CS当番が選出されるたびにメンバーを集めて読み合わせを行い認識のすり合わせを行っています。 仕組みの大枠の部分はエンジニアリングマネージャ(以下EM)とテックリード(以下TL)陣で議論して決めましたが、運用が始まってからはメンバーからの改善案も多く取り入れています。取り入れた案についての具体的な事例は後ほど紹介します。 下記にドキュメントから一部抜粋して弊社のCS当番制の仕組みについて紹介します。 CS当番のメンバー CS対応に責任を持つメンバーを毎週全開発チーム(バックエンド, フロントエンド, SRE, DS, etc)から1人以上選出して @CS当番 というグループを作成します 当番の人数は大体10人弱くらいになります 担当期間は1週間です 開発チームは各チーム6人程度なので大体1.5ヶ月に1回当番が回ってくるペースになります 全体の指揮(決起会、振り返り会の開催、改善案の採用など)はEMが行います CS当番に期待されていること 当番の週はCS対応を優先に行うこと。本来の業務に専念したい場合等は上長に相談して当番の週を変更してもらいましょう ボールを持った人が必ずしも調査/対応を行う必要はありません。難しかったり、忙しかったりした場合は他のメンバーや上長にヘルプを求めましょう 一部の人に負荷が集中しないように、みんなで協力して対応しましょう お問い合わせ対応を通してBASEのサービス&システムの理解を深めていきましょう 月曜日に決起会、金曜日に振り返り会を行い改善していきましょう お問い合わせの対応フロー @CS当番 のメンションがきたら対応をお願いします。 メンションがきたら5分以内の反応を心がけましょう。 絵文字で反応だと調査に取り掛かっているのか分からないので「確認します」のように一言書いてボールを持っている人が誰だかはっきりと分かるようにしましょう。 1問い合わせにつき1スレッドでやりとりを行いましょう。 問い合わせの回答が得られたら 済 の絵文字をCSメンバーに入れてもらいましょう。 調査/対応したことはドキュメントにまとめて知見を貯めていきましょう。 運用していく中で改善したこと 当番制を導入後、メンバーから上がってきた多くの改善案を取り入れてきました。上記の対応フローの中にもメンバーから上がってきた案が多く含まれています。ここでは案を取り入れるに至った背景なども含めていくつか紹介させていただきます。 決起会・振り返り会を行うことでチームの結束力を高める CSお問い合わせは自分が詳しくない領域のものも多くあるので、自分以外の当番のメンバーが「どういった領域が得意な人たちなのか」を把握していることはコミュニケーションをとって円滑にお問い合わせ対応を行う上でとても重要です。 一方で最近ではフルリモート下で入社してきたメンバーも多く、彼らにとってはもはや「話したこともないし、見たこともない」メンバーとの連携が求められることになります。さすがにこれを新入社員のコミュニケーション能力でカバーしてもらうことを期待するのは酷なのではないかという課題感がありました。 そういった課題感を感じていたときにメンバーから、「当番がスタートする月曜日に決起会を行い、そこで自己紹介や得意な領域等に関する共有を行うようにし、金曜日には振り返り会としてCS対応を行ってみて感じたことや改善案などを話し合う場を設けるようにしたらメンバー間でコミュニケーションが取りやすくなるのではないか」という提案があり、取り入れてみることにしました。 決起会・振り返り会を行うようになってからは新入社員に限らず、古くから在籍しているメンバーからも「コミュニケーションが取りやすくなり、お問い合わせ対応を協力して行いやすくなった」という声があがっています。 決起会の様子です。本記事を書くにあたって久しぶりに参加したら好きな動物について紹介し合っていました。この画像の中だけでもフルリモート下で入社してきたメンバーが5人もいます。 「誰がボールを持っているか」を明確にする CS対応は1つの「コト」に対して多くの「ヒト」で向き合っているので、「誰が何をするのか/しているのか」が明確になっていると、状況の進行に対して安心感を付与させられると思います。1番不安なのは「ボールが宙に浮いて誰も手を付けていない」という状況です。その他にも、「全く同じことを調べていた」「複数人で別個に調査資料をまとめていた」というのも勿体ないです。 そういった課題感を感じていたメンバーから、「調査に取り掛かる際は「確認します!」のようにはっきりと宣言して、ボールを持っている人が誰だか分かるようにしよう」という提案があり、対応フローの一つとしてルール化しました。 ルール化して以降は 「誰がボールを持っているか」が把握しやすくなったため、「あの人がボールを持ってくれてるから自分は他の問い合わせの調査をしよう」「ボールを持っている人のフォローとして私に何か出来ることはあるか」などそれぞれのメンバーが自分が今何をすべきかを理解し、チームとして効率的に動けるようになったと感じています。 1問合せにつき1スレッドで対応する BASEではスレッドの運用について明確にルールはなく、CS対応についても人によってチャンネルとスレッドでやりとりが混在していました。それによって次のような課題がありました。 複数の問い合わせのやりとりが、 #CSお問い合わせ対応チャンネル 上に混在しているのでやりとりを追いづらい 各お問い合わせの対応ステータス(解決済みなのかどうかなど)が追いづらい そういった課題感を感じていたメンバーから、「スレッドでやりとりするようにルール化すれば、問い合わせ元のメッセージに対して「 済 」などと絵文字を入れておくだけで、状況の把握が容易になるし、「どの問題が発生中・進行中なのか」について、チャンネルを開くだけで判然とするようになるのでは」という提案があり、対応フローの一つとしてルール化しました。 ルール化して以降は下の画像のようにお問い合わせが解決したかどうか、一目で把握できるようになり非常に見通しが良くなりました。 過去の類似のお問い合わせや関連する社内ドキュメントを自動的に取得する 過去の類似のお問い合わせや関連する社内ドキュメントを見つけることができれば容易に解決できるパターンがそれなりに多かったため、DS(機械学習)チームのメンバーがそれらを複数件自動取得してくるシステムを自発的に作ってくれました。これにより調査がしやすくなりました。こちらについては明日のアドベントカレンダーで詳しく紹介させていただきます。 その他 その他にも「調査/対応した際はドキュメントを書くこと」をルール化したり、「誰に相談したら分からないときに相談できるチャンネルを作成」したりなどメンバーからの改善案を多く取り入れてきました。今後もさらに改善を積み重ね、より良い運用を行っていきたいです。 CS当番制を導入して良かったこと CS対応を当番制とすることで責任の所在や期待されていることが明確になり、BASEの全エンジニアがお問い合わせ対応に取り組むようになりました。 導入前、一番課題に感じていた一部のメンバーへ負荷が集中するという問題が大きく軽減されました。 コミュニケーションが不足しがちになる昨今のフルリモート環境下において、決起会や振り返り会を行うなどチームを超えて協力し合うことでコミュニケーションの活性化に役立っています。 お問い合わせ対応を通して普段馴染みのない領域の調査を行うことでBASEの幅広いサービス理解・システム理解に役立っています。 新入社員がBASEに馴染むためのオンボーディングとしても役立っています。 まとめ 以上、CS対応を当番制にしたらうまく運用できていますという紹介でした。少しでも参考になれば幸いです。 今後も改善を積み重ね、より早くお問い合わせの返信ができるように体制を整えていきたいと思います。 明日は、DSチームの粟村さんです!お楽しみに!ばーい! ※ 文中で用いている #CSお問い合わせ対応チャンネル や @CS当番 という名前は事実とは異なります 改めて、仲間大募集中です! Webアプリケーションエンジニア open.talentio.com Webアプリケーションエンジニアは主体技術はバックエンド実装ですが、サービスを作る時にフロントエンドも書いています。 フロントエンドエンジニア open.talentio.com 開発プロジェクトにおけるフロントエンド実装と、BASEのフロントエンド実装におけるライブラリや実装技術の守り神を担います。
アバター
はじめに この記事はBASE Advent Calendar 2020 9日目の記事です。 初めまして、BASE株式会社 CSEチームに所属している秋谷です。CSEについては下記の記事に詳しく書かれていますので詳細は省きますが、一言で言うと社内の業務効率良くして働きやすくして行こう!をミッションに、社内業務改善と内部統制の二つの軸で業務を遂行しています。 devblog.thebase.in 私は今年の3月に入社しましたが、その頃には既にコロナが流行し始めており、特にBASEはWork From Home (以下WFH)をいち早く実践していたため、出社した回数はトータルで1ヶ月もありません。 そんな私がこの10ヶ月を振り返り、WFH下でのCSEとしての業務の振り返りをしていきたいと思います。 社内業務改善 社内業務改善では、私は主に経理業務の業務改善に携わっています。「業務改善」とは現存のプロセス全体を最適化することを目的としており、ごくごく一部だけ自動化するプログラムを書いて終わりではありません。関係する各所へのヒアリングや業務全体に関わるフローの確認、不要なプロセスの削減・代替方法の検討など様々あります。 このWFH下では既存の業務の多くを見直し、フローをより良く改善する良い機会になったのではないかと個人的には感じています。 実際に入社後すぐに経理業務の一部を改善する機会をいただいたのですが、その際に既存業務のフローを洗い出し、出社が必要な部分のフローが本当に必要なのかをヒアリングし、その業務に関わる全てのフローを出社無しで従来の半分以下の時間で実施出来るようになりました。 元々業務を経理からCSEに移管する話は出ていましたが、更に業務に関わるフローを短縮できたのはWFHのおかげではないかと感じています。 余談ですが、この時の業務は後に取得できる情報を大幅にアップデートし、売上データダウンロードAppとしてリリースされています。ぜひご活用ください。 apps.thebase.in 内部統制整備 こちらの記事 にもあるように、BASEでは上場企業が守らないといけないJ-SOX法に対して、2021年度末までに未整備な項目の是正・必要書類の作成などが必要になります。一社員として決められた項目に沿って証跡を取得するのではなく、整備していく立場になるのは殆どのメンバーが初めての経験だったため、まずはCSEチーム内での内部統制本の輪読会を実施し、内部統制への理解を深めてから担当にわかれ、それぞれが業務を遂行していきました。 輪読した本はこちら www.amazon.co.jp 私はIT業務処理統制の担当になったのですが、IT業務処理統制ってなんぞ??なところからスタートしています。そのため、最初に社内で対象になりそうな項目を洗い出していただき、その担当者に対してヒアリングを実施していきました。余談ですが、このヒアリングが社内業務の理解にも繋がり、内部統制とは直接は関係ないところでも大いに役立っています。ヒアリングした内容はPlantUMLを用いてワークフロー図に起こし、社員全員が見られるところに公開しました。これにより、新しい取り組みを開始する前に既存のワークフローの確認やいろんな場面で共有し易くなり、フロー図の修正も容易になりました。 また、内部統制上必要なテーブルに対して変更が実施された場合、この変更を内部統制のレポートを作成するシステムに反映する必要がありますが、このWHF化ではこの情報をキャッチするのがなかなか大変でした。 これを解決するために、BASEでは期の初めにその期で実施するプロジェクトの概要をスプレッドシートにまとめて全社員に共有するという取り組みがあるのですが、そのプロジェクト一覧に内部統制上必要なテーブルに対して影響があるかどうかを記載するようにしてもらい、影響がありそうであれば担当者に話を聞きにいくということを実施するようになりました。このシートにはいつ頃リリース予定かが記載されているため、リリース前の少なくとも1ヶ月前には必要な項目を確認していくことができるようになったのでやりやすくなったかなと思います。 終わりに 多くの企業が在宅に踏み切る中、いきなり在宅になって戸惑った方も多くいらっしゃるかと思います。 ここで紹介した内容はちょっとした工夫程度のものですが、意外とそういったちょっとした工夫でフローは良くなっていくことが多々あります。この機会に、手元の業務のフローや伝達方法を見直してみてはいかがでしょうか。 明日はServiceDevセクションの菊地さんです!
アバター
はじめに この記事はBASE Advent Calendar 2020の8日目の記事です。 devblog.thebase.in BASE株式会社 ServiceDevのShopグループ所属、エンジニアの栗田です。 Shopグループではネットショップ作成サービス「BASE」及びショッピングアプリ「BASE」の機能をチームで協力しながら開発しております。 この記事では、私が属するShopグループで勉強会を続けて行くことができたよ。というお話と付随して様々なコミュニケーションのきっかけになったよ。というお話を紹介したいと思います。 チーム勉強会開始前 元々エンジニアを中心に社内で不定期に勉強会が開催されておりました。 具体的には下記のようなテーマ・形式の勉強会でした。 ここ1,2年の間に開催されていたテーマ BASEで使われている特定の技術の勉強会 エンジニア向けの決済勉強会 全従業員向けの BASE BANK勉強会 (※ BASE BANKは金融サービスを扱うグループ会社) デザイナー勉強会 その他多数 開催されていた形式 特定のメンバーで輪講形式 特定のメンバーで事前読み&感想共有形式 特定のチーム内での開催をする形式 個人で読書メモを残す形式 特定のメンバーで輪講形式で行われた「入門 監視」については、SREグループの富塚が書いた記事がありますので もしよければ、こちらも合わせてご覧いただければと思います。 devblog.thebase.in Shopグループでの勉強会 今までは例えば決済代行について突発的な勉強会の開催や、特定の問題について突発的にカジュアルに話す機会はありましたが、1つの本や話題についてチームで腰を据えて学ぶといった事はありませんでした。 個人個人で主に技術的にスキルアップしてその結果をプロダクトに還元できる流れを作っていこう!という事で開催していく事になりました。 題材は 1冊目にクリーンアーキテクチャ、2冊目にオブジェクト指向プログラミングの本を読みました。 1冊目の選定は結構勢いで決まり、2冊目の選定は社内のテックリードエンジニアとも話合って、BASEの環境で直に実践で使えそうで勉強会にオススメの本を何冊か紹介してもらいその中から選びました。 どんな形式で行ったか 自由参加で、各自が好きなタイミングで1週間に1章読んでドキュメントに感想をまとめる形式を取りました。 平日に読む事のできなかったメンバーは休日にもり返したりと各自好きなペースで進めていきました。感想がまとまった後は、ドキュメント上で会話したり 1時間ほど時間を取ってZoomでランチ勉強会したりしました。 ちょうど他のチームも以前読んでいた本なので、様々なメンバーの感想がドキュメント上で見れたり会話のキッカケになる事もあったのも良かったポイントでした。 またランチ勉強会に関しては、オンライン懇親会制度を活用して美味しいランチを食べながら進めるなどメリハリをつけて進めました。 basebook.binc.jp 良かったこと オンラインで集まる事が難しくなった中でのコミュニケーションのきっかけになった。 チームを超えて参加があった。システム基盤の開発を担うチームからの参加・サポートも得て知識を補完する事ができた。 普段の開発の内容に勉強会の内容を盛り込めるようになった。 次の勉強会は何にしようかという声が自然に上がるようになった。 テックリードエンジニアに教えてもらえるのは福利厚生! まとめ 皆様の会社ではどのような方法で勉強会をされていますか? またどのようにチームでの勉強会のコミュニケーションを取られていますか? BASEではShopグループ以外にも同じProductDevのPaymentグループや特定のプロジェクト内などでも勉強会が活発になり、より盛り上がりを見せています。 一緒にプロダクトを作っていく仲間と共に学べて、学びをプロダクトに還元できるサイクルが回っていくととても良いですね。 明日はCSEグループの秋谷さんです!
アバター
この記事はBASE Advent Calendar 2020の7日目の記事です。 devblog.thebase.in こんにちは、BASEのCorporate Engineering CSEグループの小林です。 昨年まではProduct DevのShopグループに所属し、Instagram販売 App、顧客管理 App、メールマガジン App、時にはAndroidアプリの開発まで、幅広く「BASE」の機能開発に携わっておりました。 今までの開発経験をもとに、新設されたグループに異動しましたので、どのようなグループなのかを紹介させていただきます。 CSEとは Corporate Solutions Engineering (略CSE) 「BASE」のショップ開設は120万店舗を突破し、登録される商品数、取引額が増える中、社内業務の効率化と財務の信頼性担保することを専門とするチームとして、1年ほど前にProduct DevのCorporate EngineeringにCSEグループが発足しました。 ミッション コーポレートエンジニアのミッションは、ショップオーナーに安心してサービスを使い続けていただくために、業務の有効性及び効率性・財務報告の信頼性・事業活動に関わる法令などの遵守並びに資産の保全といった内部統制の環境整備と、DX(Developer Experience:開発者体験)の両立を考え続け、改善し続けていくことです。 上記のミッションで、私が好きなのは 「 ショップオーナーに安心してサービスを使い続けていただくため 」ここを大事にしている事です。 社内ではすごくたくさんの業務が日々発生しています。その中には属人化している業務などを、適切に作業分担・自動化などできれば、素早い対応ができるようになり、結果的にショップオーナーのためになります。 ミッション達成のために、大きく2つの業務をする 社内業務改善 サービス成長に伴い、増え続ける社内業務を見直し、サービスを安定的に提供するための業務改善 社内で利用している管理画面の開発、銀行、外部の会計サービスとの連携し、作業の効率化 属人化していた作業を適切に分担できるように、システムの改善 内部統制の整備 ショップ、BASE、取引先企業、株主の資産を守るために、不正がおこらないよう仕組みを整備/ルールづくり 業務を適切な承認・適切な部門で業務が遂行されるよう、業務のプロセスを整理し体系化する 以下は誤った認識。CSEは、こういう目的のチームではない 新しくできたチームだったため社内でもCSEの意味が伝わらず、CSEは何をするチームなのか曖昧で、他のチームの人に正しく理解してもらえずにいました。目的とミッションを正しく知ってもらうために、間違った認識例もあえて定義もしました。以下の内容は、どれも深くCSEの業務に深く係る内容ですが、それが目的のチームではないという事です。 社内用の管理画面の開発をするチーム 業務改善で社内の管理画面の開発が起点となる事はあるが、開発担当ではありません。「BASE」に新しい機能がリリースされ、社内の管理画面にも開発が必要あれば、機能開発をしたプロジェクトメンバーで社内ツールも実装します。 IT全般統制に必要な開発をするチーム IT全般統制で整備が必要な項目は、内部統制に関わるメンバーで議論し、統制が必要になる項目は出していきます。しかし整備項目は開発だけでなく、ネットワーク、DBなど多岐にわたるため、必要な開発などは他のチームなどにお願いしています。もちろん、全く実装しない方針ではありません。 決済・売上に関する開発をするチーム CSEとして経理部門などと連携する事が多く、ショップの売上周りデータを常に意識しています。経理部門とのデータ連携や突き合わせ作業のため開発はしますが、決済周りの実装を担当する事はほぼありません。 内部統制の目的で、不正ができないような開発(整備)はするが、「BASE」の新規機能などの開発の設計などには関わる事は、ほぼありません。 「BASE」の機能開発を しない チーム 実はこれも違います。業務改善の一環で、「BASE」にちょっとした機能を追加すれば、解決する項目も多くあります。今年の11月には、メンバーの1人が売上データダウンロード Appという機能の開発に携わっていました。 開発設計・実装は別のチームにお願いする形となりましたが、売上についての知識やショップオーナーのニーズなどを深く理解できているからこそ、「BASE」の機能開発に関わっていく、良い例でした。 どのような業務をやっているか CSEは、実際にどのような業務を行なっているのか、もう少し詳しく説明したいと思います。 社内業務改善 社内で多くの作業が毎日発生していますが、課題感をもって詳しく見てみると、大体このパターンに分類できます。 作業の属人化で、スケールしてない業務 システム起因で、スケールしてない業務 対応できているが、自動化できる業務 承認プロセスなどで、作業効率が悪くなる業務 作業の属人化で、スケールしてない業務 1つの業務でも、複数のシステムで操作をする必要があるなり、条件によって操作が違うなど場合など、属人化していってしまいます。 また、1日で作業が完了しないタスクなども、どこまで作業したのかをエクセルで管理してしまうなど、よくあるかと思います。 解決方法としては、1つのタスクとして正しく定義をして、やらなくてはならない作業フローを構築していきます。ミスなく作業を完了できるよう、社内の管理画面または外部のシステムとの連携で、誰でも作業できるように設計・構築していきます。 システム起因で、スケールしてない業務 複数人での作業分担を考慮していない管理画面の要因で、分担できない業務などもあります。 管理画面に、タスク一覧などページもあるのですが、1人で作業していた場合は上から順に作業で問題なかったのですが、2人で作業分担にしようとすると難しくなっていきます。上から作業する人と下から作業する人に分かれて作業するなど工夫して運用していました。 同時に同じタスクをするなどの作業ミスが発生しやすいため、システム側で適切に作業を分けていく必要があります。 対応できているが、自動化できる業務 システムでの運用ができておらず、自動化ができてない業務。または、機械学習などを利用することで業務の負担を減らすこともできます。 社内の業務をヒヤリングしていくと自動化できておらず、手作業によって対応している内容が多くあることに気づきます。「BASE」の開発しているエンジニアは、「BASE」のシステムについてはとても詳しいのですが、社内で利用している業務ツール・外部システムの理解が浅いため、業務ツールの連携が弱く、結果的に手作業が発生しています。 毎日開設されるショップ、登録される商品、振り込み申請など多くの情報から、機械学習で自動化に貢献できる業務もあり、Data Strategyチームと協力し自動化をしていきます。 承認プロセスなどで、作業効率が悪くなる業務 一人で完結できない業務などは、他部署・上長へ作業を依頼、または稟議や上長の承認プロセスを得ないと実施できない業務など、人とのコミュニケーションが必要な業務です。下記の項目などが重要になってきます。 承認プロセスや作業依頼のため、ワークフローツールの導入 差し戻し原因となる入力不備おきないよう管理画面の改修 Slack通知などを利用して、コミュニケーションロスを減らす 内部統制の整備 BASE株式会社は、2019年10月25日に東証マザーズに上場しましたが、上場企業は、いわゆるJ-SOX法の遵守が求められます。そのため、2021年度末までにIT統制として不十分な項目の是正・必要書類の作成などが必要となってきます。 CSEメンバーには、IT統制や内部統制について深く理解しているメンバーがいなかったため、プロジェクトを開始する前にメンバー全員でIT統制についての読書会を実施しました。 Amazon: ITエンジニアのための内部統制対応マニュアル 週1回の読書会を通して、IT統制の要点や用語、書類などは理解する事ができるようになりました。しかし、IT統制について書かれている本の多くが、J-SOXが施行された2008年前後に出版された本が多いです。そのため、いわゆるWeb系の企業が想定されている本が少なく、ウォーターフォール開発やオンプレミスの運用の記載されている事が多く、BASEにはそのまま適用できません。 また、IT統制については各社によって事情が異なる事が多く、ネットにも有益な情報があまりありません。 BASEでは、GithubのPullRequestでの開発フロー(承認プロセス)、AWSを前提とした内容に読みかえながら、監査法人と議論を重ねています。 なお、昨年のAdvent CalendarでもGithubを活用した事例をあげています。 devblog.thebase.in IT全般統制 財務に関する不正が起きないように、社内のルールの策定・システムの改修 統制がとれた開発ができるように、システム要件の定義 IT全般統制で整備が必要な項目が出てくると、開発、ネットワーク、DBに関する変更なども発生します。全てをCSEメンバーでは対応できないため、要件をまとめ、各部署と整備を実施して行く事が肝心となってきます。 IT業務処理統制 財務に関わるシステム・業務手順、それにまつわるリスクの把握 新しいApp、新たな取り組みなど、システム・業務手順の整備 いわゆる3点セット(業務フロー図、業務記述書、リスク・コントロールマトリクス)の作成 メンバー全員がエンジニア出身な事もあり、業務フロー図と業務記述書については、PlantUMLを用いて記述しています。 エクセルで図を作成してしまうと、バージョン管理ができず修正が発生した際に、修正箇所の把握が難しくなります。PlantUMLを利用すればテキストで管理できるため、githubでバージョン管理し、PullRequestで変更箇所、承認なども把握しやすくなります。 個人情報の取り扱いの方針定義など J-SOXとは直接関係ありませんが、個人情報の取り扱い方針の定義などもしています。 ショップオーナーが安心してご利用いただくには、個人情報の取り扱いについても重要な要素となってくるため、指針などを策定し、適切に管理できるよう整備を行っていきます。 監査法人へ内部監査状況の報告 監査法人へ内部統制の状況を報告しています。 新しい機能など日々リリースされています。システムの変更内容、システムの構成、時にはテーブル構造を説明し、財務報告の内容やショップの売上が正しい事を確認し報告します。 まとめ BASEの成長を支えてきた行動指針のひとつに「Move Fast」があります。内部統制については、統制内容によっては開発スピードを落としてしまう要因になりかねません。全員が重要視している指針だからこそ、どういった統制内容ならBASEの文化に合い、なおかつ統制として信頼できるかが重要になります。メンバーで統制内容を決める際に、一番気をつけている内容です。 業務改善では、私自身もすべての業務を把握していないので、「一度は業務をやってみる」ことを心がけています。アカウント権限などでできない業務もありますが、それでも一度は一緒に画面を見ながら操作することで内容を把握しているようにしています。一番の理由は、担当者から業務を聞いただけでは業務全体が把握しづらく、業務改善も部分最適になってしまうと感じているからです。 CSEのチームメンバーが社内業務について深く知ることで、他の部署とのミーティングで「○○ような事できますか?」という質問がどんどん出てくるようになりました。小さな問題や改善要望なども気軽に相談してもらえる環境になってきていると感じます。 明日はShopグループの栗田さんです。 お楽しみに。
アバター
この記事はBASE Advent Calendar 2020の6日目の記事です。 devblog.thebase.in こんにちは。BASE BANK 株式会社 Dev Division所属、Software Developer の松雪( @applepine1125 )です。 現在、BASE BANK株式会社(以下BASE BANK)内で事業に対する認識を揃え効率良くプロダクト開発を行うために行っているドメインモデリングについてご紹介します。 BASE, BASE BANKのドメインとは BASE BANKでのドメインモデリングの話をする前に、まずはBASE株式会社(以下BASE)やBASE BANKの事業領域について少しお話しましょう。 BASEでは、誰でも簡単にネットショップを作成できるサービス「 BASE 」 を運営しており、ショップ画面のカスタマイズや商品の管理、決済、発送、売上管理など、オーナーズが「BASE」上でショップ運営を行うための様々な機能を提供しています。 BASE BANKでは、オーナーズのキャッシュフローを加速させるために、資金調達サービス YELL BANK や売上金を最短翌日に振り込むことができる お急ぎ振り込み機能 など、オーナーズがBASEで作成したショップを通して得た売上にまつわる様々なサービスや機能の開発、運用を行っています。 これらのことからわかるように、BASE BANKの事業はBASEから完全に独立しているわけではなく、むしろBASEと事業面でもシステム面でも密接に結びついています。 そのため、事業運営のためのドメイン知識も共通の概念が多く、サービスや機能の設計、開発ではBASE側のメンバーとコミュニケーションを取りながら進めることがほとんどです。 ドメインモデリングを始めるキッカケ BASE BANKが設立されてから最近までごく少数のメンバーで開発運用が行われてきたこともあり、事業やシステムに登場する概念とその関係が何らか目に見える形で継続して整理され続けるということはありませんでした。 しかしBASE BANKとしてショップの売上というかなり重要な領域に携わっているため、BASE BANKのメンバーはドメインに対する理解を常に深めていくべきであり、またチームに人が増えてきたタイミングなのでドメインモデリングを通してドメイン知識のインプットも行えると効率がよいのでは、という声があがりBASE BANKのメンバーでドメインモデリング会が開催されるようになりました。 自分が入社して日が浅い頃に出したPRの中で、そこそこな量の語彙やロジックの認識合わせが発生し、上記のようにドメインモデリングの機運が高まったのがキッカケでした。 そもそもドメインモデリングとは そもそもドメインモデリングとは一体何なのでしょうか、ここで一旦おさらいしておきましょう。 Domain Driven Designの著者であるEric Evansは、まずドメインモデルについて ドメインモデルとは特定の図ではなく、図が伝えようとしている考え方である。 これはドメインエキスパートの頭の中にある単なる知識ではなく、その知識が厳密に構成され、選び抜かれて抽象化されたものなのだ。 と述べています。 そしてドメインモデルの基本的用法、つまりシステムや事業に対しどのように作用するかとして、 1. モデルと設計の核心が相互に形成し合う 2. モデルは、チームメンバ全員が使用する言語の基盤である 3. モデルとは、蒸留された知識である の3つを挙げています。 モデルと設計の核心が相互に形成し合う とは、つまりモデルとそれに基づいた設計、実装が密接に結びつくことでモデルに価値が生まれ、ソフトウェアが正確であると言えることを示しています。 モデルは、チームメンバ全員が使用する言語の基盤である ことで、モデルをチーム内の共通言語とし、エンジニアのみならず同じチームであるデザイナーやビジネスのメンバーとも共通言語を用いてコミュニケーションを取ることで、コミュニケーションとソフトウェアの実装とを深く素早く結び付けられるようになります。 最後の モデルとは、蒸留された知識である が指し示しているのは、先に引用した 知識が厳密に構成され、選び抜かれて抽象化されたもの と同義と考えてよいでしょう。 モデルが "厳密に構成され、選び抜かれ" ていることで、無駄な語彙のブレや認識のズレの発生を抑えることができ、より効率的にメンバーが作業を行えるようになります。 そういったドメインモデルを形成する際、効率的なモデリングの要素として 1. モデルと実装を結びつける 2. モデルに基づいて言語を洗練させる 3. 知識豊富なモデルを開発する 4. モデルを蒸留する 5. ブレインストーミングと実験を行う の5つをEric Evansは自身の経験を基に導き出しています。 1,2,4はドメインモデルの基本的用法に結びつきそうなのですが、3と5はどういった意味なのでしょうか。 モデルの名前や関係だけでなく、その振る舞い、制約も含めてモデリングすることで、業務に必要な様々な知識をモデルを通して捉える事ができる。そういったことを 知識豊富なモデルを開発する と表現しています。 ブレインストーミングと実験を行う ことは、思考実験用に様々なモデルを登場させ、様々なユースケースに適用し使えるかどうかを机上で試すことで、表現や振る舞いが的確かどうかのフィードバックを素早く獲得できるということを指しています。 モデリングにおいていちばん重要なのは、以下の図のようにモデリングの成果物を設計や実装に落とし込んで運用してみて得た新たな知見をモデルに反映させる フィードバックループ(継続的な学習) を構築することです。 ドメインエキスパートも含め、事業やシステムについて始めから"完全に"理解していることは殆ど無いでしょう。事業を運営したりシステムを構築、運用していく上で様々な発見をするはずです。"わからないことがわかる(無知の知や不確実性に気づく。と言えるかもしれないですね)"ということもあるでしょう。 そういった発見を無碍に扱うのではなく、意識的にモデルに取り込み、育て、共有し、次の設計や開発に活かすことでドメインモデルの真価が発揮されます。 俺たちのドメイン”リ”モデリングとは ここまで前提となるBASE, BASE BANKのドメインについてや、ドメインモデリングの考え方について説明してきましたが、ようやくBASE BANKで行っているドメイン"リ"モデリングについてご説明します。 ちなみにタイトルやこの節の名前がドメインモデリングではなく ドメイン"リ"モデリング となっていることにお気づきでしょうか。 ドメインモデリングを始めるキッカケ の節で述べたように、これまで体系立てて継続的にドメインの整理を行ってこなかったので、メンバー各々の頭の中に存在するふわっとしたモデルを一度解体し、再構築する。という意味合いを込め ドメイン"リ"モデリング と呼びました。 具体的に、ドメイン"リ"モデリングでは、 - 現在運営、開発しているサービスの概念をホワイトボードツール miro を使ってまず羅列し、ドメインエキスパート(BASE BANK立ち上げ時から関わっているPMやエンジニアなど)に各概念や関係の説明をしてもらう - その中で他のメンバーも含め質問や議論をしながら、名前(クラス名含む)のブレを修正し、各概念の関係を再検討、再構築していく といったことをひたすら繰り返していきます。 例を挙げると、振込申請という機能についてドメインモデリングを行った際は以下のような成果物となりました。 モザイクだらけで申し訳ありません。概念が多数登場して色々な整理を行っていそうだな?というのが伝わればと・・・ ドメイン"リ"モデリングではモデルの脱構築を目的としているので、すでに構築されているシステムの実装(各モジュールやテーブル定義など)に引っ張られないことが重要です。チーム内の議論でも、油断すると現在のシステムではこうなっているから~という論じ方になってしまうため注意深く進めました。 より深くモデリングを行うのであれば、リレーションシップ駆動要件分析(RDRA)やICONIXなどの分析フレームワーク、プロセスを採用すべきかと思いますが、BASE BANKのドメインモデリングではRDRAで言うところの概念モデルのみを作成している段階です。 理由は、BASE BANKが複数サービス、機能を受け持っているため、まずはそれぞれの概念について広く整理することを目的としたからです。 しかしライトウェイトなドメインモデリングだけで終わらせる気はなく、運用していく上でさらにドメインに関する知見を得たり、フィードバックループのイテレーションをより素早く回せるようになったら、より深いモデリングを行っていくつもりです。 ドメイン"リ"モデリングのこれから そもそもドメインモデリングとは で述べたように、モデルは実装と結びついて価値が生まれます。しかし”リ”モデリングを始めてからまだ日も浅く、すでに構築されたシステムに対して"リ"モデリングしたモデルを適用しようとすると大きく変更が必要となる箇所がいくつかあるため、現状モデルと実装を密接に結びつけられているとは言い難い状況であるというのが課題としてあります。 しかしドメイン"リ"モデリングをきっかけに、追加機能の設計やそれに伴うリファクタリングにおいて、チーム内で作成したモデルを基に活発に議論が交わされたり、カジュアルにドメインモデリング会が開催され設計や実装が行われている様が見受けられます。 また現在新規開発しているサービスにおいても、概念やその関係の整理を丁寧に行いながら開発を進められているので、ドメイン"リ"モデリングはただのムーブメントに終わらずBASE BANKチームに定着していくだろうな、そうなるように今後も継続して既存の実装に対してもアプローチできるようにしていかなければと感じています。 もう一つの課題として、 BASE,BASE BANKのドメインとは の節で BASE側のメンバーとコミュニケーションを取りながら進めることがほとんど と述べましたが、BASE側のメンバーも含めてドメインモデリング会をやろうとすると大所帯になり機動力が落ちてしまうので、BASE BANKのメンバーのみでモデリングを行っているというのが現状です。 つまり業務上の関係者全員とはモデルを共有できていないということになるので、Eric Evansの言う "モデルは、チームメンバ全員が使用する言語の基盤である" というドメインモデルの基本用法と逸脱してしまっています。 これに関しては、今後業務内で関係者とコミュニケーションを取る際に少しづつ浸透させていったり、ドメインモデリングという活動自体を社内に広めていき、よりコミュニケーションを取りやすくできればなと思っています。 おわりに 以上、俺たちのドメイン"リ"モデリングの紹介でした。 余談ですが、ドメインモデリングを行う過程で各事業のドメインが整理されるだけでなく、BASEの中でのBASE BANKという会社の立ち位置がより明確になったのがとても印象的でした。 after baseとは、オーナーズがショップで売上を立ててから先のお金の流れとそれに関連する機能、サービス(YELL BANKやお急ぎ振込など)を指す社内用語です。 ドメイン"リ"モデリングを通じて、各事業への理解が深まるだけでなく、それらを運営していく会社そのもののスタンスも明らかになることは今回新たな発見でした。 明日は BASE 株式会社 CSEチームの小林さんです! ではまた。 参考文献 Eric Evans, エリック・エヴァンスのドメイン駆動設計( https://www.shoeisha.co.jp/book/detail/9784798126708 ) Vaughn Vernon, 実践ドメイン駆動設計( https://www.shoeisha.co.jp/book/detail/9784798131610 )
アバター
この記事はBASE Advent Calendar 2020の5日目の記事です。 SRE Groupのngswです。 Eコマースプラットフォーム「BASE」における障害発生時に、社内関係者に連絡網に基づいて電話発信するシステムを構築しました。 このエントリでは、その導入までの経緯と具体的な当該システムの説明をします。 TL;DR 「BASE」で問題が発生した際に意思決定者に電話発信する周知システムを構築した 「導入前に考えたこと」をまず主題として書いた 参考URL記事のまま手順であるが、それでも導入時に詰まった事柄など落ち穂拾い的に追記した 謝辞 Twilio FunctionsとStudioを使って連続架電を行う - Qiita 大変わかりやすい記事であり、ほぼすべてを参考にさせていただいた。このQiita記事がなければ短期間で実現することは不可能であったと考える 導入に至る経緯 07月某日 : サイト閲覧遅延障害が起きたことで「発生とあわせて社内関係者に機械的に一斉周知する方法が必要なのではないか」という議論があった 09月某日 : 07月と別起因ではあるが同様のサイト閲覧遅延が発生し、当時の議論が再度浮上した 個人的な感想 : 二度議論されたことは対応する価値があるのではないか。少なくとも検証する価値はあるのではないかと考えた 導入前に考えたこと まずはじめに「解決すべき課題がなにか」を考えなくてはならない。 「あわせて社内関係者に機械的に一斉周知する方法が必要なのではないか」という議論から考えられるのは、議論発案側の役割(とそこに課せられた責務)が関係してくる。プロダクトが正常動作していないことを利用者であるショップオーナー様に通知する責任があり、またはその状況を認識して次なる意思決定に備える必要があるという、それぞれの立場からくる「わたしに然るべき通知をできるだけ早くください」という表明にほかならない。 その一方でこれらがなぜ求められる状態になったのか。これは「システム障害対応時に対応エンジニアが周知する時間をうまく取れない場合がある」ということの証左でもある。システムに対する止血対応と、プロダクトに対するインパクト度合いを含めた状況説明。これがアラートに機敏に反応できた対応エンジニアに一手にかかってきてしまっていて、結局その対応エンジニアが障害対応全般を含めたボトルネックになっているということでもある。この点を機械的にスムーズに解決する方法が求められているのだろう。 なるほど、裏を返せばWebエンジニアは意思決定者を含めた社内関係者に、関係者はユーザであるショップオーナー様に対して「説明責任がある」ということである。今思えば仕事というもののほとんどはこの「説明責任を果たすことに終始するのではないか」とまで考えるようになった。 であればこれは単なる障害通知システムの補助機能ではない。わたしは大義を手に入れた。それでははじめよう。 仕様 BASEショップページの応答速度低下の検知は、既存のmackerel監視設定を利用する 発報はmackerel にて Twilio架電通知 を利用すればよさそうである ただし上記機能だけでは架電先が1つの番号に限られてしまうという制約がある そのためmackerel上の設定では dummy call とする(この成否はどうでもよいので ngsw の番号を設定した) mackerelは上記処理の成否をStatus Callback URL(Twilio) にリクエストする 上記のリクエストを契機に後述する Twilio Functions に設定したjsが電話番号のDictを持つのでfor文で一人ずつ電話発信 音声「対応可能なら1、無理なら0」に対して、キー1押下ならそこでループ終了、キー0なら次の人(無視、通話中、即切りも同じ) 配列最後までいったら指定最大回数までループする 構成要素 / 登場人物 全体 Name Role Memo 「BASE」 監視対象 主に閲覧に関する応答速度に注目 Mackerel 監視システム 「BASE」システムを監視してTwilioに通知 Twilio 電話発信システムとして利用 Twilio内の細分化 Name Role Memo Twilio Functions Flowを呼び出すためのスクリプト URLをもつ Twilio Studio Flowを定義する 音声認識やプッシュ番号認識ができ、条件分岐ができる 購入番号 発信番号 購入しないと発信行為に制約がある 参考URL Twilio FunctionsとStudioを使って連続架電を行う - Qiita を参考にした 大事なことなので何度でも Twilio Functions Functionsのコードは一度デプロイして画面を閉じた場合、改めてFunctions画面からスクリプト内容を再取得しようとすると失敗が繰り返され編集できない事象を確認している(運良く取れるときがある / Rate Limit ?) 以下でURLが確定する https://${DOMAIN}/$(パス名) Functions作成時に Environments DOMAIN が払い出される パス名を自分で決める Functions /function-name Assets (無視) Settings - Environment Variables - FLOW_SID https://jp.twilio.com/console/studio/dashboard で閲覧可能な FW から始まるSIDのこと FROM_NUMBER +{購入番号} // https://qiita.com/mobilebiz/items/8757eec854f37ce0cb2d /** * Studioを連携させた連続架電 - serialCallStudio * * @param idx リストインデックス * @param loop ループ回数 */ // 架電先リスト(発信したい電話番号のリストをE.164形式で記述します) const callList = { "携帯1": "+8150yyyyyyy0", "携帯2": "+8180yyyyyyy1", }; // ループ回数(1以上の整数、1ならループしない) const maxLoop = 2; exports.handler = function(context, event, callback) { // カウンター関連 let idx = event.idx || 0; // インデックスパラメータを取得 let loop = event.loop || 1; // ループパラメータを取得 if (idx >= Object.keys(callList).length) { // リストの最後まで到達 idx = 0; // インデックスは0に戻す if (loop >= maxLoop) { // ループ回数が最大値を超えたので終了 callback(null, "Call count was expired."); } else { loop++; // ループ回数をインクリメント } } // 架電先電話番号 let number = callList[Object.keys(callList)[idx]]; idx++; // インデックスをインクリメント // 架電するStudioフローを呼び出す const client = context.getTwilioClient(); client.studio.v1.flows(context.FLOW_SID).executions.create({ to: number, from: context.FROM_NUMBER, parameters: JSON.stringify({ idx: idx, loop: loop, }) }) .then((call) => { callback(null, "OK"); }) .catch((error) => { callback(error); }); }; Twilio Studio 手順的なデッドロック Functions が先にできないと Twilio Studio で Flow が完成しない しかしFunctions完成には FLOW_SID が必要 解決策は以下の手順の中で Run Function だけをとりあえず置いといてPublishなりして Flow SID を確定しておくこと FLOW CONFIGURATION Flow 実行のトリガー Config FLOW NAME SerialCallStudio REST API URL https://studio.twilio.com/v1/Flows/FWxxxxxxxxxxxxxxxxxxx/Executions WEBHOOK URL https://webhooks.twilio.com/v1/Accounts/ACxxxxxxxxxxxxxxxx/Flows/FWxxxxxxxxxxxxxxxxxxx TEST USERS (空) Transitions IF INCOMING MESSAGE (空) IF INCOMING CALL (空) IF REST API call MAKE OUTGOING CALL V2 架電時に受電側のステータスで分岐 Config WIDGET NAME call NUMBER TO CALL {{contact.channel.address}} RECORD CALL OFF DETECT ANSWERING MACHINE OFF SEND DIGITS (空) TIMEOUT 60 SECONDS SIP USER NAME (空) SIP PASSWORD (空) Transitions IF ANSWERD Gather IF BUSY LoopFunction IF NO ANSWER LoopFunction IF CALL FAILED LoopFunction GATHER INPUT ON CALL 通話中のメッセージと、そのメッセージに対する回答の保持 メッセージはテキストだけでなく設定次第で音声ファイルでも可能 受電者の以下のアクションを理解できる どのプッシュボタンを押下したか 音声認識による回答内容(はい or いいえみたいなもの) これはうまく動かなかった Config WIDGET NAME Gather SAY OR PLAY MESSAGE TEXT TO SAY {ここに発音させたい1 or 0で分岐するようなテキスト文を書く} LANGUAGE Japanese MESSAGE VOICE Alice NUMBER OF LOOPS 1 STOP GATHERING AFTER 5 SECONDS STOP GTHERING ON KEY PRESS? YES / # STOP GATHERING AFTER (空)DIGITS SPEECH RECOGNITION LANGUAGE Default SPEECH RECOGNITION HINTS (空) PROFANITY FILTER True ADVANCED SPEECH SETTINGS - SPEECH TIMEOUT (IN SECONDS) auto SPEECH MODEL Numbers & Commands Transitions IF USER PRESSED KEYS YesOrNo IF USER SAID SOMETHING (空) IF NO INPUT LoopFunction Split Based On… 条件分岐とその判定 Config WIDGET NAME YesOrNo VARIABLE TO TEST widgests.Gather.Digits Transitions COMPARING WITH {{Gather.Digits}} IF NO CONDITION MATCHES LoopFunction YES == 1 Equal To / 1 / SayYes NO == 0 Equal To / 0 / LoopFunction Say/Play Push 1 だった際の後処理 Config WIDGET NAME SayYes SAY OR PLAY MESSAGE OR DIGITS Say a Message TEXT TO SAY {ここに発音させたいテキスト文を書く} LANGUAGE Japanese MESSAGE VOICE Alice NUMBER OF LOOPS 1 Transitions IF AUDIO COMPLETE (空) Run Function Push 0 ないしは 1 以外だった際の再実行 または通話中やなんらかの理由で通話不可だった場合 Config WIDGET NAME LoopFunction SERVICE SerialCallStudio ENVIRONMENT ui FUNCTION /function-name 1 FUNCTION URL (自動付与) Function Parameters これらはjs内で引き回して利用される idx {{flow.data.idx}} loop {{flow.data.loop}} Transitions IF SUCCESS (空) IF FAIL (空) 結び 前半では導入の経緯を、後半ではシステムの詳細を書いた。繰り返しになるが概ねの主題は前半部である。そこが語りたいことのすべてであった。後半部の成果物解説はその残滓でしかないが、それでも同様に導入を考えた方がいた場合に、何かしらの参考になればと大元のQiita記事をさらに補完できるような記述を心がけた。 導入後はすこぶる順調で特にクレームもなく、期待通りの稼働をしており役割は果たせたと考えている。このようなシステムやツールを用いて説明責任を果たしていくのがSRE(だけでなくエンジニア)なのだと感じたという点を強く強調し、結びとしたい。 明日は BASE BANK 株式会社の松雪さん (@applepine1125) の記事です。 引き続きよろしくお願いいたします。
アバター
この記事はBASE Advent Calendar 2020の4日目の記事です。 devblog.thebase.in こんにちは、BASEのデータストラテジーチームを担当している鈴木( id:rmarl )です。 普段は、機械学習エンジニアやデータエンジニアメンバーと一緒にデータ活用の推進を行っております。 昨年のアドベントカレンダー でもDSチームの取り組みについて書かせていただきましたが、今年はより開発対象を拡大し、以下のような領域について開発を進めております。 ネットショップ作成サービス「BASE」をご利用のショップオーナー様への自動アドバイス、ショッピングアプリ「BASE」のユーザーへおすすめ商品のレコメンド 時系列分析やそれを活用したモデルの構築(BASE BANK) 異常検知 検索エンジンの精度向上 データ基盤の構築とBIツールの利用促進 このように開発領域が広がっていくにつれて、集中して技術習得する時間がないという課題が見えてくるようになりました。 それに対して年初からHackWeekを導入し実際に目に見える効果も出てきたので、ここで共有したいと思います。 HackWeekとは? 一定期間業務を離れて、一つの研究テーマに集中する時間を設ける事です。 それにより、チーム全体の開発力の底上げを期待しています。 元々は2017年にデータサイエンティスト向けの教育プランの論文として発表された物がベースになっております。 arxiv.org ここにもあるように、HackWeekは集中する事によってもたらす以下の効果を期待しています。 最先端の方法論の習得 ピアラーニング 実務との融合(論文にあるコラボレーションもその一つ) 上記に加え、Google社の20%ルールのような「内製化による技術革新のブースト」、ハッカソンのような「短期間における発想力の強化」も期待できます。 BASEでの活用方法 そもそもがチーム全体の開発力の底上げを期待しているため、実業務を優先しつつ無理のない範囲で、メンバー主体でHackWeekに立候補して頂いています。 個々のメンバーの希望を元に、以下のような条件で定めています: 対象テーマ 先述の開発領域において、将来的に活用しそうな学習モデルの検証 最新の論文内容を実データで検証 過去の開発時に、後で検討するとしたテーマの検証 日程について 期間は原則1週間 期間終了後、速やかに報告会を行う リリース前・繁忙期を避ける サポート問い合わせ対応期間は避ける 環境の準備 検証のためだけの有料サービス利用も可 sandboxとして、ML専用サーバーに空きがあれば占有も可 また、HackWeekに入る事を宣言したメンバーに対しては、その期間の間、以下のような勤務体系としています。 弊社では特に導入で問題はなかったのですが、この辺りの調整を要するケースもあるかと思います。 HackWeekに専念するため、出社は任意 現在はWork From Home実施のため、基本はリモート勤務となっております そのメンバーが担当していた通常業務は、出来る限り他のメンバーが協力して代行する それでも緊急対応が入ったら、その対応の日数分、報告会を延期する どのような内容を実施したか進捗ログをまとめておき、報告会で共有する。 報告会 HackWeek終了後、報告会は以下の要領で実施します。 検証完了・中途問わず、発表する 報告20~30分、質疑応答20~30分と1時間で開催 可能であれば、デモ環境も用意する 報告用文書は必須とする どういったテーマが選ばれるか? 今年に入ってから20件近く報告会が開催されましたが、一部を抜粋すると以下のようなテーマがありました。 ナレッジ収集のためのグラフDB活用法 商材動画における物体検知 学習にobjectの位置情報を利用しないobject detection Grad-CAM カテゴリー・属性抽出の手法 音声フレームワークの検証 どのような成果が出てきているか? 一番の成果としてはピアラーニングの効果が大きく、メンバーの気付きが開発中案件に応用される例も出てきています。 また、メンバーは任意のテーマを選ぶことが出来るようにしてあるのですが、実務と結びついたテーマも多かったです。メンバー曰く「実務中に試してみたかったけど時間や採算性の問題で保留にした課題をこの期間で検証したい」との事で、こちらもHackWeekの成果の方が効果があるという事で実務に組み込まれた例もあります。 まとめ HackWeek実践のメリットを取り上げてきましたが、BASEにおいてはデータ活用を非常に重要視した取り組みをおこなっており、その実現に対して柔軟な体制をしいております。 今後とも、データ活用を通じて皆様の利便性に貢献していければと思います。 明日は、SREチームの長澤さん( id:ngsw )です!お楽しみに!
アバター
この記事はBASE Advent Calendar 2020の3日目の記事です。 devblog.thebase.in BASE株式会社 SRE Groupの相原です。 BASEのインフラはAWS上に構築しておりいくつかのツールを使って構成管理していますが、主にEC2のサーバ設定ツールとして利用しているのが現状で、構成管理できていないAWSリソースもちらほらあります。 そこでまずはSRE Groupで使っている社内ツールや、直接サービス影響のないものを Terraform で構成管理をしてみて、ある程度運用が固まってきたら主サービスの管理もそちらに寄せていこうという方向で進んでいます。 Terraform導入にあたり最も悩んだのがtfstateの分け方とディレクトリ構成だったので、そこをメインに紹介できればと思います。 謝辞 以下の書籍と記事を非常に参考にさせていただきました。ありがとうございます。 野村友規 (2019) 『実践Terraform AWSにおけるシステム設計とベストプラクティス』インプレスR&D Terraformのディレクトリ構成の模索 | ADWAYS ENGINEERS BLOG Terraformなにもわからないけどディレクトリ構成の実例を晒して人類に貢献したい | M3 Tech Blog Sansan Labs 開発での Terraform ディレクトリ構成 | Sansan Builders Blog 前提として 最初から全てのAWSリソースをTerraformで構成管理することは諦める AWS上ではTerraform以外ですでに構成管理されているリソースや、そもそも管理されていないものも存在しています。それらを一挙にまとめてTerraformへ移行するのは難しいと感じたので まずは社内ツールなどサービスに直接影響のないものから小さくはじめる 運用の形が定まってきたら徐々にTerraformへ移行していく という前提で進めています。 tfstateの分け方とディレクトリ構成 ディレクトリ構成は以下となります。 . ├── prd │ ├── projectA │ │ ├── terraform.tf │ │ ├── main.tf │ │ └── variables.tf │ └── projectB │ ├── terraform.tf │ ├── main.tf │ └── variables.tf ├── stg │ ├── projectA │ │ ├── terraform.tf │ │ ├── main.tf │ │ └── variables.tf │ └── projectB │ ├── terraform.tf │ ├── main.tf │ └── variables.tf ├── dev ... └── modules ├── moduleA │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── moduleB │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ... 方針としては以下になります。 1. 環境ごとにディレクトリを分ける BASEでは多少なりとも環境間での差分があります。workspaceの利用も検討しましたが、その場合差分を吸収するためのロジックを書く必要があり、かえって分かりにくくなりそうでした。そのためディレクトリで環境を分けるようにしました。 2. tfstateは環境ごとではなくprojectごとにもつ projectという単位は「なんらかの意味を持ったリソース群」といった意味合いで便宜上使っています。 前提として社内ツールなどサービス影響の少ないものから小さく始めていきたいというのがあります。環境単位でtfstateを持ってしまうと、影響範囲が大きくなることもありこの前提から外れてしまいます。そのためtfstateはprojectごとに持つようにしました。 3. moduleを利用する resource はmoduleに書いて、projectのtfファイルから呼び出す形をとっています。これにより環境の複製はmoduleに渡す変数の値を変更するだけで良くなります。 ただこの場合、どこまでmoduleに汎用性を持たせるか難しいところではあります。また複数のprojectから同一moduleを呼び出している場合、module変更による影響範囲が大きくなってしまいます。この辺りは特に運用しながら改善していくポイントかなと考えています。 その他取り入れたこと 1. Terragruntの導入 Terragrunt というTerraformのラッパーツールを導入しました。これはbackend定義周りのコードをDRYに保つためです。 というのもtfstateを環境単位ではなくproject単位で持たせるので、backendの定義もproject単位で書く必要があります。その場合にproject毎でそれらを書くのが面倒なのと、設定ミスが起きることを懸念したためです。 Terragruntの導入にあたっては、以下記事を大変参考にさせていただきました。 TerragruntでTerraformのbackend周りのコードをDRYにする | Developers.IO 以下は、ほぼ記事の内容通りに実践したものになりますが、具体的なディレクトリ構成とファイルの内容です。 . ├── prd │ ├── projectA │ │ ├── terragrunt.hcl │ │ ├── terraform.tf │ │ ├── main.tf │ │ └── variables.tf │ ├── projectB │ │ ├── terragrunt.hcl │ │ ├── terraform.tf │ │ ├── main.tf │ │ └── variables.tf │ └── terragrunt.hcl ├── stg │ ├── projectA │ │ ├── terragrunt.hcl │ │ ├── terraform.tf │ │ ├── main.tf │ │ └── variables.tf │ ├── projectB │ │ ├── terragrunt.hcl │ │ ├── terraform.tf │ │ ├── main.tf │ │ └── variables.tf │ └── terragrunt.hcl ├── dev ... 各環境、projectごとにterragrunt.hclが追加されています。 このファイルにTerragruntの設定を書いていきます。 環境ごとのterragrunt.hcl {env}/terragrunt.hcl remote_state { backend = "s3" config = { bucket = "test-aihara-terraform-tfstate" key = "${path_relative_to_include()}.tfstate" region = "ap-northeast-1" dynamodb_table = "test-aihara-terraform-tfstate-lock" } } path_relative_to_include()は親ディレクトリから子ディレクトリへの相対パスを返します。 https://terragrunt.gruntwork.io/docs/reference/built-in-functions/#path_relative_to_include projectごとのterragrunt.hcl {env}/{project}/terragrunt.hcl include { path = find_in_parent_folders() } find_in_parent_folders()は親ディレクトリからterragrunt.hclを探してきて読み込んでくれます。 https://terragrunt.gruntwork.io/docs/reference/built-in-functions/#find_in_parent_folders 例えばこの場合だと{env}ディレクトリにあるterragrunt.hclを読み込むことになります。こうすることでprojectごとにおけるbackendの定義は、同じ内容のterragrunt.hclを用意するだけで済みます。 2. terraformで作られたことがわかるようにtagをつける AWS上ではTerraformで構成管理されたもの/そうでないものが混在していくことになります。その時、どれがTerraformで作られたのかわかるように特定のtagをつけるようにしています。 例えば以下のように terraform: true のtagをつけるようにするなどしています。 resource "aws_security_group" "security_group" { name = var.name description = var.description vpc_id = var.vpc_id tags = { "terraform" = "true" } } おわりに 前述したとおりTerraformの導入はまだ社内ツールやサービス影響のないものに留まっております。 一旦方針決めはしたものの、tfstateの分け方やディレクトリ構成に関してまだ試行錯誤しているというのが現状です。ここに関して決まった正解はないと思いますが、運用を続けていく中で継続的に改善しより組織やサービスにフィットするようにしていければと思います。 明日はProduct Dev DS Groupの鈴木(@rmarl)さんです!お楽しみに〜
アバター
この記事はBASE Advent Calendar 2020の2日目の記事です。 devblog.thebase.in こんにちは、BASEのフロントエンドチーム エンジニアの加藤です。 先日、弊社松原の こちらのブログ にて、「既存のVue.jsによる資産は積極的にメンテナンスしつつ、その時その時で総合的に判断して最適な技術を選定する」スタンスで我々は考えているということをお話しました。直後に Vue.js 3.0の正式リリース があり、アップデートに関して実際に取り組みを始めています。 現在イメージしている流れ 現在、BASEのサービスで使われているバージョンは2.6系で、そこからのアップデートということになります。 現在利用しているVue.jsに依存したUIライブラリやバリデーションライブラリも3系対応で大きくAPIが変わることが予想され、一気に全ての使用箇所でバージョンをアップデートすることは困難だと感じています。 そこで、次のような流れで進めていくことを現在計画しています。 Deprecatedな機能の置き換え作業 社内UIライブラリのVue.js3系対応版の開発 monorepo化とそれに際する依存関係の再整理 packageごとにバージョンを使い分け、徐々に移行 Vue.jsを使用しているBASEのショップ向け管理画面では、 こちらのブログ にあるように、機能単位でエントリポイントが存在するMPAであるという前提があります。この単位でpackageを分割し、それぞれでVue.jsのバージョンを使い分けることで段階的なアップデートの実現を考えています。 今回は、現在進行中の「Deprecatedな機能の置き換え作業」についてお話します。 対象範囲 Vue.js 3のマイグレーションに関するドキュメント から参照します。 前述の通りpackageごとにアップデートするという予定ですが、事前に対応できるものがあればやっておきたいところです。今回対応するものは、この中で「置き換える機能がすでに2.6系に存在しているか、3にアップデートせずとも別の修正方針が存在する」もののみをターゲットにしました。 filterの廃止 Functional Componentの廃止 slot属性,slot-scope属性の廃止 (2.6にてDeprecatedとなっていますが3で正式に廃止されます) EventEmitter系のAPI($on, $off, $once)の廃止に伴う対応 以上が一旦対応すべきものとして洗い出されました。 事前準備 弊社右京の こちらのブログ で設定してもらったように、ESLintのeslint-plugin-vueプラグインで非推奨の機能を機械的に判定します。また、fixオプションで修正できるものは修正してしまい、それ以外の以下は手動で修正して回ります。 実際の修正 filterの廃止 どのように修正するか メソッドに置き換える 対応するeslint-plugin-vueのルール vue/no-deprecated-filter 注意点 ここでは単なるメソッドに置き換えます。場合によっては、算出プロパティでその処理を記述しておくほうが良さそうな実装も見かけましたが、対応を統一したいため一旦方針として今回はメソッドで実装しています。 金額表示を変換するfilterがよく使われていましたが、これらはMixin経由でVueのprototypeメソッドとしても登録されているのでそちらに置き換えました。 Functional Componentの廃止 どのように修正するか 通常のコンポーネントに書き換える 対応するeslint-plugin-vueのルール vue/no-deprecated-functional-template 注意点 関数型コンポーネントはインスタンスを持たない(thisのコンテキストが無い) ため、render関数内にcontextというオブジェクトが渡され、そこからpropsなどを参照するといったコードになっています。注意しながらこれらを普通のSFCに書き換えました。 実はこの対応で1つバグを出してしまったので、詳しくお話します。 我々はVeeVelidateというバリデーションライブラリをサービス全体で利用しています。このライブラリでは各コンポーネントに独自のバリデータスコープがあり、親子コンポーネントでのバリデーション結果を共有するために、$validatorという変数を親からinjectオプションを利用して注入することでバリデーションスコープを共有する、ということをおこなっています。 たとえばこういったparentコンポーネントがあった場合、 <template> <child> <grandchild /> <grandchild /> <grandchild /> </child> </template> childコンポーネント内でinjectオプションを利用することで、grandchildコンポーネントにて$validatorを参照することが出来るようになり、親から子のバリデーションを実行することもできるようになります。 今回のバグはこういったケースでFunctionalコンポーネントかそうでないかで挙動が変わってしまった、というものです。 たとえば、上記のgrandchildコンポーネントがFunctionalコンポーネントであった場合を考えてみます。Functionalコンポーネントにはインスタンスがないため、親のスコープですぐに評価されます。このときの親のスコープというのが、ややこしいのですが childではなくparentコンポーネントになります 。生成されたvnodeがスロットコンテンツとしてparentに渡されるわけです。 これは、childがレンダリングされる前に、grandchildコンポーネントがすでに実行されている、ということです。ですから、childコンポーネントでinjectしようにもできない、ということが発生します。 ということで、grandchildがFunctionalコンポーネントだった当時は実装者がparentコンポーネントで$validatorをinjectして事なきを得ました。 これを知らずにそのままFunctionalコンポーネントをそのまま通常のコンポーネントに直してしまうと、当然意図しない動作になります。通常のコンポーネントは、親コンテキストですぐにはレンダリングされず、childのレンダリングサイクル中にレンダリングされます。つまりchildコンポーネントに必要なものをinjectする必要があるということです。 こちらのissueコメント がこの現象を詳しく説明しています。 slot属性,slot-scope属性の廃止 どのように修正するか それぞれ、slot属性ではなくv-slotディレクティブに、slot-scope属性ではなくプロパティを受け取るv-slotディレクティブに書き換える 対応するeslint-plugin-vueのルール vue/no-deprecated-slot-attribute vue/no-deprecated-slot-scope-attribute 注意点 eslint --fix にて一部自動で修正できますが、v-slotディレクティブはtemplateタグにしか利用できないので、templateタグ以外にslot属性が使われているものは自動修正できません。これが修正としては一番数が多く面倒でした。 1つハマったポイントなのですが、slot属性で渡す要素内でthisを参照したときに、slot属性であれば動くものが、v-slotディレクティブでは動かない、という事象がありました。事前にこれらをすべて修正してからslot属性を修正する必要があります。こちらは vue/this-in-template のルールにてチェックできますので慌てて追加しました。元々thisの使用は禁止されていたはずなので、いい機会でした。 まとめ Deprecatedな機能の置き換えに関しては、以下を残り対応する予定です。 Event Emitter系のAPI($on, $off, $once)の廃止に伴う対応 実は今回お話したものもまだすべては直しきれていないので、引き続き対応を続けていきます。 この作業が終了次第、前述の流れに沿って移行していく予定です。 また、2021 1Qに作業される予定の2.7は、3系移行のための支援となる機能が追加されることが予定されています。まず2.7に移行することでよりソフトランディングなアップデートになるのではないでしょうか。タイミングによっては既存の2.6系を2.7にアップデートしてから3系に進めることも考えてもよいと思っています。 明日はProduct Dev SREチームの相原さんです。お楽しみに〜
アバター
この記事は2020年 BASEグループのアドベントカレンダー1日目になります。 devblog.thebase.in BASE株式会社取締役EVP of Developmentの藤川です。同じく子会社であるPAY株式会社の取締役、BASE BANK株式会社にも関わっており、グループ横断でスムーズな組織運営とサービス開発を実現し、グループシナジーを通じたバリューアップを意識して仕事をしています。 まだ12月の頭で少し早いタイミングではありますが、2020年を振り返っていきたいと思います。 2020年ってどんな年だった これはずばり我々の価値の出し方の潮目が変わったタイミングだと考えています。グループの真ん中にあるBASEというサービスは、サービスを作ってから今年の頭まで、誤解を恐れずに言えば、代表の鶴岡が作ったサービスコンセプト、そしてそれを体現するユーザ体験、平たく言えばデザイン性に支えられてきたサービスだったように今を考えると思えます。今どきで言うBizDevとサービスデザインが優れていたと表現するとわかりやすいでしょうか。 これまでのエンジニア陣の仕事は、あえて大げさな表現をすると、その環境を維持運営し、ビジネスの自然成長を支えたことで会社が上場するところまで来た、そんな印象すら思うこともあります。 ところがコロナ禍において増えゆくトラフィックを支えていくシーンを目の当たりにし、このままだとこの先の5倍10倍それ以上の成長において壁が出てきそうだと考えたのが2020年に起きたことでした。 その辺を技術的な言葉で冷静に書いたのがCTO川口の記事ではあります。 devblog.thebase.in この経験を通じて、考え方が変わったは大げさまでも、これまで徐々に自分たちが作ってきた採用基準や既存メンバーの成長について、再度言語化することになり、CTOが責任を担うサービス技術と、マネージャメンバーが責任を担うチームマネジメントを一体化して取り組むことを始めたのが2020年の最大の変化だったと思います。 それまでは、どこかサービスを作るという取り組みと、技術を良くしていくという取り組みを、それぞれが得意な人が分業してやればできるんじゃないか?と考えていたところがありましたが、それでは成長していくサービスを支えきれない。このサービスに携わるメンバー全員が、しっかり技術のことを意識し、優れた技術力を礎に良いサービスを作っていくという形にしないといけないということを強く認識させられることになりました。 新型コロナによる社会情勢の変化はとても望ましいものではありませんが、コロナ禍において発生した急速な社会のデジタルシフトの波の中で、我々にとっての甘えが許されなくなったタイミングだとも言えます。 devblog.thebase.in エンジニアリング力の底上げについては、技術投資として仕込んでいるものもあり、引き続きCTOの川口がリードする形でマネージャ陣やチームメンバーと連携しながら進めていくことになります。この成果についてはまた来年の技術ブログで継続的にご報告して参ります。 basebook.binc.jp 2021年はどうなる 開発チームにおいては、非連続的な成長を求められる1年になるかもしれません。 それは今後起こりうる組織の成長や人の増加によって、起こりうるであろう軋轢をどうやってマネジメントしていくか?という視点であったり、人が増えることで責任が明確化されていくであろうCTOを始めとする技術責任者級の人たちの行動、技術、人間における成長を求めていくことになる気がしています。 2020年までの僕自身は、あえて自負をさせていただくと、さまざまな問題について、どうにかうまく立ち回って解決するという行動をしてきたように思えます。新しい問題解決のために体制を作ると言った、インキュベーションのプロセスにおいては、ひとまず自分で手を動かしてプロトタイプを作って知見をまとめてからプロジェクトや組織として人に移譲することもあるし、または、いろんな人達に仕事を任せ動いてもらうことで、成果を出せた人もいれば、そうでない人もいて不確実なイシューを解決することに対するチャレンジをしてきました。 チャレンジそのものは引き続き続いていきますが、そういう混沌の中で成長してきてくれた人に、明確な責任を渡して、責任者としてチームとしてまとめていくことを求めていく年になりそうです。 僕自身は、その中で起きるさまざまなことに目を向けて、開発メンバー全員がBe Hopefulに働き続け、かつSpeal Openlyに自由に発言でき、開発者界隈で叫ばれる心理的安全性的なるものを維持しながら、メンバーの成長を実現し、結果としてサービス開発を成功に導くというのが仕事になるだろうなと思っています。 それにより新たに課題を増やし、共有し、それを解決するために新たに人を迎えてチームが大きくなっていくイテレーションを描いていくというのが、組織の正常進化と思っていますので、このことを躓かないようにしっかりやっていくというのが、引き続きの課題になります。 devblog.thebase.in PAYとBASE BANKについて 技術ドリブンでサービス開発者向けの決済APIを提供するPAYチームは、引き続き少数精鋭の技術者を中心にサービス提供を続けていくことになります。2020年のコロナ禍においては、BASEの決済安定性のためにPAYのメンバーにも多々ご尽力いただきました。BASEというサービスが想定以上に成長することで、PAY.JPサービスにおけるBASEの関与度が大きくなりすぎてしまい、メンバーに対して大きな負担をかけてしまったというのが一つあります。しかし、この経験を礎にPAY.JPのより一層の安定運用に寄与するべく、SREの採用なども進めています。 BASE BANKは2020年で仲間が順調に増えてきたのがなによりもの特徴です。BANKチームは、プロダクトマネージャの柳川と、テックリードの東口が元々BASE開発チームのコアメンバーだったことから、BASEと強く連携するサービスを作ることに慣れていますが、後から入ってきたメンバーもGo言語というキーワードでの入社が無視できなかったにも関わらず、PHPで書かれたBASE側のサービスにもがっつり携わってもらうなど、意識の高い技術者集団として活躍してもらっています。BANK自体のビジネスの促進もさることながら、BASEサービスのショップで売上が発生した後の取り回しについて、しっかりソリューションを強化していき、ショップの成長を支えていくという今後の活躍が楽しみですし、しっかりバリューを実現していきたいと思っています。 僕自身はどうなるんだろう笑 自分が2021にどうなるとかどうしたいとかはあんまりよくわからないですね 笑 実現すべき計画そのものはしっかり存在していますから、それをしっかり実現していくのは当たり前ですが、それ以外に起きうるいろんなことをしっかり問題解決していける余力を持ちながら、日常を生きていく、そんなイメージを持っています。PMIでひーひー言ってるような近しい未来もまたあるのではないでしょうか?ぐらいは覚悟しています。その際には業界での経験者(大体CTO/VPoE経験者かな)には採用オファー、業務委託等々でご相談することもあると思いますので、その際にはよろしくお願いいたします。 2020年もまさかこんなことになるなんて思ってもみませんでしたが、来年もまた自分たちの成長を促される難しい問題が出てきて、それをしっかり解いていける機会があったら、それはそれで楽しいんじゃないかと思っています。我々はまだまだスタートアップであり、上場もゴールではなくサービスの社会的信用を得るためのプロセスでしかないと思います。ショップオーナーさんはもちろんのこと社会からの期待にしっかり応えていく開発組織を作っていくことが責務だと思っていますので、まぁいろいろ起こることが楽しいなと。 何も起きないのが一番退屈でモチベーションが下がる混沌loverなタイプなのですが、変化によってメンバーが躓いたりするのは見たくないですし、慎重かつ大胆にしっかり状況をモニタリングしていきながら、何か問題が起きても小さなうちに発見し、解決していくイテレーションを回すというのは変わらないと思っています。
アバター
こんにちは。BASE BANK 株式会社 Dev Division にて、 Software Developer をしている東口( @hgsgtk )です。 TL;DR AWS のマネージド脅威検出サービスである Amazon GuardDuty を有効化する場合、全リージョンに対して設定することが推奨される Amazon GuardDuty を全リージョンで有効化し、検出した内容を Slack に通知するまでの構成を説明・それを実現する具体的な Terraform コードを解説する 記事公開時点で terraform-provider-aws が AWS Chatbot に対応していないため、一部 Console 画面で作成する 当記事のサンプルコードは こちら にて公開している Amazon GuardDuty / AWS Chatbotとは Amazon GuardDuty(略:GuardDuty)は悪意のある操作・不正動作をモニタリングするツールです。AWS 環境を実運用する場合、セキュリティ上の脅威が含まれていないか継続して確認する事が重要でしょう。GuardDuty はセキュリティベストプラクティスとして導入できる 1 つのサービスです。 aws.amazon.com Amazon GuardDuty は、AWS のアカウント、ワークロード、および Amazon S3 に保存されたデータを保護するために、悪意のあるアクティビティや不正な動作を継続的にモニタリングする脅威検出サービスです。 GuardDuty は発見的統制のサービスとなります。発見的統制とは望ましくない事象が発生したことを発見するIT統制活動の一種です。セキュリティ保護に関して GuardDuty がどのような役割を担うかについては次の YouTube 動画にて詳しく説明されています。 www.youtube.com そして、AWS Chatbot を用いると Slack チャンネルへの通知を手間少なくかんたんに実現できます。 aws.amazon.com AWS Chatbot は、Slack チャンネルや Amazon Chime チャットルームで AWS のリソースを簡単にモニタリングおよび操作できるようにしてくれるインタラクティブエージェントです。 今回は、この2つのサービスを組み合わせて検出された脅威を Slack に通知するワークフローを紹介します。CloudFormation での構築例はいくつかあったのですが、Terraform で構築する例について解説している内容がインターネット上に少なかったので、Terraform で運用している開発現場の方にとって参考になる手順となれば幸いです。 当記事で出来ることと構成図 当記事では、GuardDuty からの検出結果を AWS Chatbot を用いて Slack 通知する構成を紹介しています。そのため、当記事の内容・サンプルコードを用いることで次のような通知を Slack で受けることが出来るようになります。 Slack通知のイメージ なお、画像内にある検出内容は GuardDuty の機能の 1 つである「Generate sample findings(結果のサンプルの生成)」によって生成したサンプル内容です。 そして、これを実現するための構成図が以下です。 本記事で実現する構成図 各リージョンごとに必要なリソースと、AWS Chatbot や IAM Role といった Global なものが存在します。 また、執筆当時 AWS Chatbot は terraform-provider-aws が対応していないため直接 Terraform 管理にすることはできません。 github.com そのため、当記事ではこの構成の作成のため次の手順を踏みます。 Terraform で全リージョン分の GuardDuty の有効化・通知ワークフローを構築する AWS Console 画面から AWS Chatbot を設定する なお、 @gainings さんにご教示いただきましたが、CloudFormation を Terraform 管理することで間接的に AWS Chatbot を Terraform 管理下に置くという方法があるそうです。Booth で販売されている『 クラウド破産を回避するAWS実践ガイド 』という書籍にその方法について詳しく説明があります。どのような方針でコード化していくかによりますが、AWS Chatbot を構築するひとつの選択肢になりますね。 Terraformで構築する 今回のサンプルコードは次のリポジトリに公開しています。 github.com 当リポジトリの構成は以下です。 . ├── hgsgtk-dev <- 構築する環境、moduleを利用して全リージョン分設定する └── modules ├── chatbot <- AWS Chatbot module └── guardduty <- GuardDuty module guardduty module GuardDuty とその通知ワークフローのための構成要素は以下です。 GuardDuty の有効化 EventBridge(CloudWatch Event)の設定 SNS Topic の暗号化 SNS Topic の作成 GuardDutyの有効化 GuardDuty の有効化は次の記述のみで完了です。 resource " aws_guardduty_detector " " guardduty " { enable = true } その他、S3 へのエクスポートなど様々な設定が可能です。当記事では通知ワークフローの紹介が趣旨なので詳しく紹介しませんが、気になる方は AWSの開発ガイド や Terraformのドキュメント を確認してください。 EventBridge(CloudWatch Event)の設定 EventBridge では GuardDuty からの検出をイベントとして受け取って後続の SNS Topic をターゲットにハンドリングします。 まず Event Rule を作成します。 resource " aws_cloudwatch_event_rule " " guardduty " { name = " capture-guardduty " description = " Capture Guard Duty finding events " event_pattern = << EOF { " source " : [ " aws.guardduty " ] , " detail-type " : [ " GuardDuty Finding " ] } EOF } event_pattern で設定した内容は、「イベントを検出した」ことを条件にしています。これらの設定以外にも検出内容の severity(緊急度)を条件に追加できます。 docs.aws.amazon.com { " source ": [ " aws.guardduty " ] , " detail-type ": [ " GuardDuty Finding " ] , " detail ": { " severity ": [ 4 , 4.0 , 4.1 , 4.2 , (省略) ] } } 筆者の現場では最小限の設定で始めて必要に応じて通知対象の severity(緊急度)を調整する方針としました。 次に、作成したルールで受け取ったイベントの送信ターゲットに次に作成する SNS Topic を設定します。 resource " aws_cloudwatch_event_target " " guardduty-sns " { rule = aws_cloudwatch_event_rule.guardduty.id target_id = " guardduty-sns " arn = aws_sns_topic.event_bridge_to_chatbot.arn } SNS Topicの暗号化 以前、当開発ブログで Terraform のセキュリティ静的解析 tfsec を紹介しました。 devblog.thebase.in tfsec の指摘項目には SNS Topic の暗号化 が含まれています。セキュリティ上のベタープラクティスのひとつとして SNS Topic は Amazon KMS(Key Management Store)によって暗号化します。 resource " aws_kms_key " " for_encrypt_sns_topic " { description = " guarddutyからのeventを受けるsns topic暗号化用 " enable_key_rotation = true policy = data.aws_iam_policy_document.policy_for_encrypt_sns_topic.json } resource " aws_kms_alias " " for_encrypt_sns_topic_alias " { name = " alias/guardduty/for_encrypt_sns_topic " target_key_id = aws_kms_key.for_encrypt_sns_topic.key_id } data " aws_iam_policy_document " " policy_for_encrypt_sns_topic " { version = " 2012-10-17 " # defaultでついてくるルートアカウントに対する権限設定 statement { sid = " Enable Root User Permissions " effect = " Allow " principals { type = " AWS " identifiers = [ " arn:aws:iam::${var.aws_account_id}:root " ] } actions = [ " kms:* " ] resources = [ " * ", ] } # events.amazonaws.com に対する権限が暗号化対象のサービス操作に必要 statement { sid = " AWSEvents " effect = " Allow " principals { type = " Service " identifiers = [ " events.amazonaws.com " ] } actions = [ " kms:GenerateDataKey ", " kms:Decrypt " ] resources = [ " * ", ] } } 注意点として、今回の構成で CloudWatch Event Rule のターゲットとして設定する場合、 events.amazonaws.com に対して復号化( Decrypt )・データキーの生成( GenerateDataKey )のアクションを許可する必要があります。 aws.amazon.com SNS Topicの作成 SNS Topic 暗号化用の KMS Key が作成できたので SNS Topic を作成します。作成した Key は aws_sns_topic.kms_master_key_id で設定します。 resource " aws_sns_topic " " event_bridge_to_chatbot " { name = " event-bridge-to-chatbot " kms_master_key_id = aws_kms_key.for_encrypt_sns_topic.key_id } resource " aws_sns_topic_policy " " event_bridge_to_chatbot_policy " { arn = aws_sns_topic.event_bridge_to_chatbot.arn policy = data.aws_iam_policy_document.sns_topic_policy.json } data " aws_iam_policy_document " " sns_topic_policy " { version = " 2012-10-17 " # defaultでついてくるルートアカウントに対する権限設定 statement { sid = " __default_statement_ID " effect = " Allow " principals { type = " AWS " identifiers = [ " * " ] } actions = [ " SNS:GetTopicAttributes ", " SNS:SetTopicAttributes ", " SNS:AddPermission ", " SNS:RemovePermission ", " SNS:DeleteTopic ", " SNS:Subscribe ", " SNS:ListSubscriptionsByTopic ", " SNS:Publish ", " SNS:Receive " ] resources = [ aws_sns_topic.event_bridge_to_chatbot.arn, ] condition { test = " StringEquals " variable = " AWS:SourceOwner " values = [ var.aws_account_id ] } } # events.amazonaws.com がイベント発行するために必要 statement { sid = " allow_AWSEvents_publish " effect = " Allow " principals { type = " Service " identifiers = [ " events.amazonaws.com " ] } actions = [ " sns:Publish ", ] resources = [ aws_sns_topic.event_bridge_to_chatbot.arn, ] } } ここでは、 events.amazonaws.com に対して sns:Publish アクションを許可する必要があります。 chatbot module AWS Chatbot 用の module を用意します。 AWS Chatbot 自体は前述したとおり Terraform でのコード管理対象外になりますが、AWS Chatbot が利用する IAM Role は既存の IAM を利用できるので Console で作成する前に Terraform で作成します。 AWS Chatbot では 4 パターンの許可設定がテンプレートで用意されています。 通知のアクセス許可 読み取り専用コマンドのアクセス許可 Lambda 呼び出しコマンドのアクセス許可 AWS サポートコマンドのアクセス許可 今回のユースケースでは、「通知のアクセス許可」を権限としてもつ IAM Role を作成することになります。 resource " aws_iam_role " " chatbot-notification-only " { name = " chatbot-notification-only " assume_role_policy = jsonencode ( { Version : " 2012-10-17 ", Statement : [ { Sid : "", Effect : " Allow ", Principal : { Service : " chatbot.amazonaws.com " } , Action : " sts:AssumeRole " } ] } ) description = " AWS Chatbot Execution Role for Only Notification " } resource " aws_iam_role_policy_attachment " " chatbot-notification-only-attach " { policy_arn = aws_iam_policy.chatbot - notification - only.arn role = aws_iam_role.chatbot - notification - only.name } resource " aws_iam_policy " " chatbot-notification-only " { name = " chatbot-notification-only " policy = jsonencode ( { Version = " 2012-10-17 " Statement : [ { Sid : "", Effect : " Allow ", Action : [ " cloudwatch:Describe* ", " cloudwatch:Get* ", " cloudwatch:List* " ] , Resource : " * " } ] } ) } 作成した IAM Role に紐づく IAM Policy は AWS 公式ドキュメント内の解説や実際に Console 上で試しに作成したものを参考にしています。 docs.aws.amazon.com moduleを利用して全リージョン分作成する module の用意が終わったので全リージョン作成していきます。 全リージョン作成するために、今回の作成方法では複数の Provider を用意する方針とします。Terraform では module を利用する際、明示的に Provider を渡せます。 www.terraform.io その仕様を用いてリージョンごとに module を用意します。 全リージョン分のProviderを作成 guardduty moduleを利用し、GuardDutyと通知ワークフローを作成 chatbot moduleを利用し、AWS Chatbot用のIAMロールを作成 全リージョン分のProviderを作成 全リージョン分のProviderを作成します。 # デフォルトのProvider provider " aws " { version = " ~> 3.18.0 " region = var.region } provider " aws " { region = " us-east-1 " alias = " us-east-1 " } provider " aws " { region = " us-east-2 " alias = " us-east-2 " } # (省略) 注意点としては、有効化していないリージョンがある場合は全リージョン設定する必要がない点です。以下のリージョンは有効化しない限り管理対象にする必要はない可能性があります。 af-south-1 アフリカ (ケープタウン) ap-east-1 アジアパシフィック (香港) eu-south-1 欧州 (ミラノ) me-south-1 中東 (バーレーン) docs.aws.amazon.com guardduty moduleを利用し、GuardDutyと通知ワークフローを作成 作成した Provider を利用して全リージョン分の module 利用コードを用意します。 module " guardduty-us-east-1 " { source = " ../modules/guardduty " aws_account_id = var.aws_account_id providers = { aws = aws.us - east - 1 } } module " guardduty-us-east-2 " { source = " ../modules/guardduty " aws_account_id = var.aws_account_id providers = { aws = aws.us - east - 2 } } # (省略) chatbot moduleを利用し、AWS Chatbot用のIAMロールを作成 AWS Chatbot はリージョン設定がない global なサービスなため 1 つだけ作成します。 module " chatbot " { source = " ../modules/chatbot " } Console 画面で残りの AWS Chatbotを作成する 最後 AWS Chatbot を Console 画面から作成していきます。 AWS Chatbot は当記事の公開時点では Amazon Chime と Slack の2つのクライアントをサポートしています。今回は Slack 通知が要件なので Slack を選択します。 Configure client を選択し Slack の Authorization を完了すると、対象の workspace が作成されます。 実際に通知するチャネルを当該画面の Configure Slack channel から設定していきます。 Logging では「エラーのみ」か「全てのイベント」が選択できますが、このロギング先の CloudWatch Logs は US East (N. Virginia) となります。その理由は、 公式ドキュメント にて次のように説明されているとおりです。 You can view your logs in the Amazon CloudWatch console. Note that you must specify US East (N. Virginia) for the Region. docs.aws.amazon.com AWS Chatbot 用に必要な IAM Role は事前に作成したものを利用できるため、Terraform で作成した IAM Role を指定します。 最後に AWS Chatbot で通知する SNS Topic を指定します。ここでは、Terraform で事前に全リージョン分作成した SNS Topic を設定してきます。 この設定をすると内部的には AWS Chatbot からの protocol: HTTPS の SNS Subscription を作成されます。 AWS Chatbotの通知設定をすることで作成されるSNS Subscription なお、筆者は Terraform で SNS Subscription を作成すること検証しましたが不可でした。理由は、terraform上の記法である sns_topic_subscriptionのprotocol の仕様です。 https -- delivery of JSON-encoded messages via HTTPS. Supported only for the end points that auto confirms the subscription . そして、AWS Chatbot の通知設定から SNS Topic を指定するフローでなければ「確認済み」にならないようです。そのため、SNS Topic に対する Subscription も Console 画面から作成する必要があります。Terraform コードで管理する場合は、 terraform-provider-aws の AWS Chatbot のサポート を待つ必要があります。 以上で設定は完了です。 ここまでやると冒頭で紹介したとおり当該 Slack チャンネルに通知が来るようになります。 動作確認方法 GuardDuty では「Generate sample findings(結果のサンプルの生成)」という機能があります。 この機能ではサンプルの検出結果を生成してくれます。この機能で生成された結果は CloudWatch Event にも発行されるため、本記事で紹介しているような通知ワークフローを組む際のデバックに有用です。 おわりに GuardDuty を有効化し Slack 通知を行なう構成を Terraform で作成する事例について情報が少なかったので紹介させていただきました。実際に作成する際に参考になれば幸いです。
アバター
こんにちは、BASE BANK 株式会社 Dev Division でエンジニアとしてインターンをしている前川です。 今回、Amazon Elasticsearch Service(以下、Amazon ES)による、ECS/Fargate で稼働するアプリケーションのログデータの解析基盤を新規で構築することになったので、構築するにあたって調査した内容や関連する内容、実際におこなった構築方法についていくつか紹介します。 今回の構築の簡単な全体構成図は次のようになります。 今回は、 ECS/Fargate のログを S3 にルーティングする Amazon ES にログをルーティングする VPC アクセスの Amazon ES を構築し、Kibana を外部からアクセスできるようにする の3つの手順にわけて、構築方法や関連する内容について紹介していきたいと思います。 なお、この記事で取り扱っている各ツール・サービスのバージョンは次のとおりです。 Terraform: v0.12.5 Terraform provider.aws v3.6.0 SAM CLI: version 1.7.0 AWS CLI: aws-cli/2.0.61 Python/3.9.0 Darwin/19.3.0 source/x86_64 Fargate platform version: 1.4.0 FireLens で ECS/Fargate のログを S3 にルーティングする FireLens について Kinesis Data Firehose を用いて ECS/Fargate のログを S3 へのルーティングする ECS/Fargate のログを S3 に直接ルーティングする方法について Amazon Elasticsearch Service にログをルーティングする Amazon Elasticsearch Service の構築 Amazon Elasticsearch Service の Terraform による構築 ECS のリバースプロキシサーバーを使用した Kibana によるデータ解析環境構築 ECS デプロイツールについて おわりに FireLens で ECS/Fargate のログを S3 にルーティングする FireLens について ECS のコンテナの標準出力/標準エラー出力に出力されたログを S3 にルーティングするために、FireLens をログドライバーとして使用しました。 FireLens を使用することで、ECS で出力されたログを、サイドカーとして実行された Fluentd や Fluent Bit のログルーターコンテナにルーティングすることができます。 別のアプリケーションでは、S3 へのログのルーティングは、CloudWatch Logs 経由で行っていましたが、FilreLens を用いた方法によって、CloudWatch Logs のコストを抑えることや、カスタム設定を用いた柔軟なログの取り扱いができるようになりました。 Kinesis Data Firehose を用いて ECS/Fargate のログを S3 へのルーティングする 今回の構築では Kinesis Data Firehose 経由で S3 にログをルーティングしました。 まずはじめに、Kinesis Data Firehose と S3 の構築をします。 Terraform での定義例は次のようになります。 resource "aws_kinesis_firehose_delivery_stream" "sample" { destination = "s3" name = "sample" s3_configuration { bucket_arn = aws_s3_bucket.sample.arn role_arn = aws_iam_role.sample-kinesis-firehose-iam-role.arn prefix = "sample/" } } resource "aws_iam_role" "sample-kinesis-firehose-iam-role" { name = "sample-kinesis-firehose" assume_role_policy = jsonencode( { Version = "2012-10-17", Statement = [ { Sid = "", Effect = "Allow", Principal = { Service = "firehose.amazonaws.com" }, Action = "sts:AssumeRole" } ] } ) } resource "aws_iam_policy" "sample-kinesis-firehose-iam-policy" { name = "sample-kinesis-firehose-iam-policy" policy = jsonencode( { Version = "2012-10-17" Statement = [ { Sid = "" Effect = "Allow" Action = [ "s3:AbortMultipartUpload", "s3:GetBucketLocation", "s3:GetObject", "s3:ListBucket", "s3:ListBucketMultiPartUploads", "s3:PutObject", ] Resource = [ aws_s3_bucket.sample.arn, "${aws_s3_bucket.sample.arn}/*", ] } ] } ) } resource "aws_iam_role_policy_attachment" "sample-kinesis-firehose-iam-role-attach" { role = aws_iam_role.sample-kinesis-firehose-iam-role.name policy_arn = aws_iam_policy.sample-kinesis-firehose-iam-policy.arn } resource "aws_s3_bucket" "sample" { bucket = "sample" acl = "private" } Kinesis Data Firehose に対しては、データを送信する S3 の設定と、その S3 を操作するために割り当てる IAM ロールを作成しています。 次に、ECS のタスク内へ FireLens コンテナを追加し、サイドカー構成とするために、ECS のタスク定義を修正する必要があります。 ログを出力するアプリケーションコンテナにログドライバーを次のように設定します。 "logConfiguration": { "logDriver": "awsfirelens", "options": { "Name": "firehose", "region": "ap-northeast-1", "delivery_stream": aws_kinesis_firehose_delivery_stream.sample.name } } delivery_stream のところには、先程構築した Kinesis Data Firehose の name で設定したものを使用します。 次に FireLens 設定を含むログルーターコンテナを追加します。 定義例は次のようになります。 { "name": "log-router", "image": "906394416424.dkr.ecr.ap-northeast-1.amazonaws.com/aws-for-fluent-bit:latest", "essential": true, "firelensConfiguration": { "type": "fluentbit" } } 最後に、タスクロールを修正し、FireLens コンテナが Kinesis Data Firehose へとストリームを送信できるようにします。 ポリシーの定義例は次の通りです。 { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Allow ", " Action ": " firehose:PutRecordBatch ", " Resource ": " * " } ] } 以上の設定で、ECS のログを S3 に出力することができます。 ECS/Fargate のログを S3 に直接ルーティングする方法について また今回の構築では、S3 へのログの転送に Kinesis Data Firehose を使用しましたが、Fluent Bit が v1.6 より、コンテナログをルーティングする送信先として S3 をサポートし、Kinesis Data Firehose を経由せずに直接 S3 にログを転送することができるようになったようです。 aws.amazon.com ただし、デフォルトの設定である S3 のマルチパートアップロード API を使用し、この方法を使う場合は、Fluent Bit のインスタンスが、データのバッファリングのために永続的なディスクを必要とするので注意が必要です。 このディスクは、マルチパートアップロード API で送信するデータのチャンクをバッファリングするために使用され、もし Fluent Bit が不意に停止した場合に、同じディスクで再起動し、未完了のアップロード処理を完了するために必要だということです。 今回のような ECS/Fargate の環境で使用する場合は、Amazon EFS ファイルシステムを ECS タスクで使用することが推奨されているようでした。 また、永続的なディスクを使用しない場合に、S3 の PutObject API を使用し、データをバッファリングせずに頻繁に送信する方法によって、データの損失を抑える設定もあるようでした。 信頼性を重視する場合には、Kinesis Data Firehose を Fluent Bit と S3 間のバッファとして使用する方法が推奨されているようでしたので、S3 にログを転送する場合には、特別な理由がない限り Kinesis Data Firehose を経由する方法でおこなうのが良いのかなと思いました。 github.com Amazon Elasticsearch Service にログをルーティングする Kibana を用いたログの調査環境を構築するために、S3 にルーティングされたログを Amazon ES に転送する Lambda を作成しました。 Lambda は S3 の PUT をトリガーにして起動し、受け取った event の情報を用いて取得した S3 からの対象のログデータを加工し、Amazon ES に転送します。 BASE BANK チームでは、AWS SAM CLI を使用した Lambda の運用をおこなっており、今回の Lambda の環境構築にあたっても AWS SAM CLI を使用しました。 AWS SAM CLI を用いた Lambda の環境構築方法に関しては、同僚の永野が書いた下記エントリをご参照ください。 devblog.thebase.in AWS SAM CLI で Lambda を管理する場合は、AWS SAM のテンプレートファイルでトリガーとなるイベントの設定やロール、環境変数、VPC 周りの設定をおこないます。 BASE BANK チームでは、AWS リソースの管理を Terraform で行っているので、Lambda リソースの管理のみを SAM を用いて Cloud Formation でおこない、arn などを通してトリガーの対象となるリソースを参照し、テンプレートファイルを記述していました。 今回 Lambda のトリガーの対象となる ECS からのログが保存されている S3 についても、当初は Terraform で管理していたのですが、AWS SAM CLI で Lambda を構築し、トリガーの対象を S3 とする場合は、S3 を Lambda と同じテンプレートファイルで定義しなければならないという制約がありました。 github.com よって、Terraform で管理されていた S3 を、AWS SAM のテンプレートファイルで定義することで CloudFormation で管理するようにし、Terraform 側では、Data Resource として S3 を参照するように修正しました。 SAM テンプレートファイルのリソースセクションの作成例は次のようになります。 Resources : LogToEsFunction : Type : AWS::Serverless::Function Properties : CodeUri : log_to_es/ Handler : app.lambda_handler Runtime : python3.8 Events : BucketEvent : Type : S3 Properties : Bucket : !Ref LogBucket Events : s3:ObjectCreated:Put Filter : S3Key : Rules : - Name : prefix Value : log_prefix/ Role : !Ref ExecutionRole Environment : Variables : ES_DOMAIN_URL : !Ref EsDomainUrl VpcConfig : SecurityGroupIds : !Ref SecurityGroupIds SubnetIds : !Ref SubnetIds LogBucket : Type : AWS::S3::Bucket Properties : BucketName : !Ref S3Bucket S3 に保存された ECS のログを、Lambda を使用して Amazon ES に転送する方法を今回は使いましたが、ECS のログを Amazon ES にルーティングする方法として、FireLens を使用することで Fluent Bit が直接ログデータを Amazon ES にルーティングすることもできるようになったようです。 aws.amazon.com こちらの方法を使う場合は、Amazon ES へ送信するデータの加工についてや、ログデータが欠損した場合の考慮などを考える必要がありそうでしたが、手軽に Amazon ES にログデータをルーティングできるのは良さそうだと思いました。 Amazon Elasticsearch Service の構築 Amazon ES の VPC アクセスでの構築と、外部から Kibana にアクセスするための環境構築をおこないました。 Amazon ES の使い分けとして、 Lambda による、Elasticsearch API を用いたログデータの投入 Kibana によるログデータの解析のためのアクセス があります。 今回は、VPC アクセスによる構築により、Amazon ES に対するセキュリティの強化、Amazon ES と Lambda 間の安全な通信を実現することができましたが、一方で、Kibana に対しては適切なアクセス制限をした上で、データ解析のための外部からのアクセスをする必要があったので、ALB と ECS のリバースプロキシサーバーを使用した環境構築をおこないました。 Amazon Elasticsearch Service の Terraform による構築 今回 Amazon ES は、Terraform で構築しました。 Amazon ES を Terraform で構築する場合の、基本的な設定をした定義例は次のようになります。 resource "aws_elasticsearch_domain" "es" { domain_name = "sample" elasticsearch_version = "6.3" cluster_config { instance_type = "t2.small.elasticsearch" instance_count = 1 } vpc_options { subnet_ids = var.es_subnet_ids security_group_ids = var.es_security_group_ids } ebs_options { ebs_enabled = true volume_size = 10 } } この例では、Elastic Search の version や、インスタンスタイプの設定、サブネットやセキュリティグループの VPC 周りの設定をしています。 Amazon ES のセキュリティに関しては、 VPC アクセス設定によるネットワークに関するセキュリティレイヤー ドメインアクセスポリシーによる、リソースベースのセキュリティレイヤー Fine Grained Access Control による、ロールベースの細かいアクセスコントロールによるセキュリティレイヤー の 3 つの主要なセキュリティレイヤーがあり、このうち Terraform によるドメインアクセスポリシーの定義例は次のようになります。 resource "aws_elasticsearch_domain_policy" "es" { domain_name = aws_elasticsearch_domain.es.domain_name access_policies = jsonencode( { Version : "2012-10-17", Statement : [ { Effect : "Allow", Principal : { AWS : "*" }, Action : "es:*", Resource : "${aws_elasticsearch_domain.es.arn}/*" } ] } ) } ドメインアクセスポリシーの設定については、aws_elasticsearch_domain 内の access_policies でも設定することができますが、Terraform では、リソースの定義内で自らを参照することができないので、今回の場合は上記のように別リソースで定義しました。 BASE BANK チームでは、Terraform のセキュリティ静的解析ツールである tfsec を導入していて、今回の構築にあたって、tfsec の指摘により設定の検討をした項目がいくつかありました。 tfsec についてや、検討した項目に関しては、同僚の東口が書いた下記エントリをご参照ください。 devblog.thebase.in Amazon ES の構築にあたって注意すべき点として、既存のドメインには設定できず、新規で構築する際にしか設定できない項目や、インスタンスタイプや Elasticsearch のバージョンによっては設定できない項目が存在するというのがありました。 例えば、Audit Logs の有効化があります。 Amazon ES では、Audit Logs を使用することで、Elasticsearch へのすべてのリクエストのロギング、インデックスの変更、受信検索クエリの記録などのあらゆるユーザーアクティビティのログが記録できるようになりました。 aws.amazon.com しかし、この項目を設定するには、既存、または新規の Amazon ES で、Elasticsearch のバージョンが 6.7 以降であり、Fine Grained Access Control が有効になっている必要があります。 Fine Grained Access Control とは、ロールベースのアクセスコントロールによる、クラスターレベル、インデックスレベル、ドキュメントレベル、フィールドレベルの細かいアクセス許可や、Kibana マルチテナンシーの使用などができるようになるものです。 この設定を有効化するには、 保管時のデータの暗号化 と ノード間の暗号化 が有効になっており、ドメインへのすべてのトラフィックに HTTPS を要求する設定が有効になっていなければなりません。 このうち、保管時のデータの暗号化と、ノード間の暗号化、Elasticsearch のバージョンに関しては、既存のドメインに対して変更することができないので、変更したい場合はドメインを新規に作成する必要があります。 また、保管時のデータの暗号化に関しては、インスタンスタイプによっては設定できないので注意が必要です。 docs.aws.amazon.com ECS のリバースプロキシサーバーを使用した Kibana によるデータ解析環境構築 VPC アクセスによって構築された Amazon ES において、Kibana への外部からのアクセスをする方法はいくつかありますが、今回は ALB と ECS のリバースプロキシサーバーを使用した方法により環境構築しました。 ECS のリバースプロキシサーバーを経由せずに、ALB から直接アクセスすることもできますが、Amazon ES の Private IP の変更を定期的にメンテナンスする必要があり不便です。 ECS のリバースプロキシサーバーを経由することで、Amazon ES のホスト名を使って設定ができるので、定期的なメンテナンスが不要になり、より柔軟な設定をすることもできるかと思います。 今回の構築では、ECS のリバースプロキシサーバーは、nginx のイメージを使用し、設定ファイルを置き換えるだけの簡素な方法でおこないました。 ECS デプロイツールについて ECS のデプロイ方法についてはいくつか検討した上で、ECS CLI によるデプロイ環境を構築しました。 docs.aws.amazon.com ECS CLI は、ECS クラスターおよびタスクの作成、更新を、Docker Compose ファイルを利用しておこなえるツールです。 ECS CLI では、ALB やセキュリティグループなどのリソースの作成、管理ができませんが、今回は Terraform で ECS サービス、タスク以外の必要な AWS リソースが管理されていたので容易に導入することができました。 また、Docker Compose と Amazon ECS の統合により、Docker コマンドラインを使用し ECS へのアプリケーションのデプロイ ができるようになりました。 aws.amazon.com Docker Compose で構築された既存のアプリケーションの拡張や、Docker Compose を利用する ECS 開発者の開発体験の向上が図れるようになるようなので、機会があれば ECS CLI との違いについても含めて調査してみたいと思いました。 デプロイ方法を検討する中で、AWS Copilot CLI についても調査しました。 aws.github.io AWS Copilot CLI は、最低限 Dockerfile さえあれば、ECS クラスターやサービス、タスクに加えて、VPC やサブネット、ALB などのその他必要な AWS リソースを作成と、複数の AWS アカウントや複数の環境に対するデプロイのサポートまでおこなってくれる、かなり抽象度の高いツールです。 一方で、v0.3 から既存の VPC やサブネットの設定ができるようになりましたが、細かい設定についてはまだまだできない状況です。 aws.amazon.com 既存の ALB を設定するなど、その他の細かい設定ができない状況から今回は導入を見送りましたが、ECS でプロトタイプを動かしたり、技術検証をしたりすることが簡単にできる強力なツールだと思いますので、今後の進化に期待しつつ、使っていきたいと思いました。 おわりに Amazon ES を用いた、ECS/Fargate アプリケーションのログ解析基盤の構築例と、それに関連する内容について紹介させていただきました。 これから ECS/Fargate アプリケーションのログ解析基盤の環境構築をされる方々の助けになれば幸いです。 また今回の構築にあたっては、AWS のアップデートのサイクルの早さをとても感じました。 次々と新しい機能がリリースされるので、定期的にキャッチアップしたり、環境構築するたびに新しい技術の検証をしていかないとなと思いました。
アバター
BASE BANK 株式会社 Dev Division でSoftware Developer をしている清水( @budougumi0617 )です。 みなさんの開発現場でも社内ライブラリ・モジュールとして開発しているコード・GitHubリポジトリがあると思います。 そのようなリポジトリはパッケージ管理システムを経由して利用することがほとんどですが、そのためにはリリース作業を行う必要があるかと思います。 私のチームでは先日GitHubリポジトリのリリース作業をGitHub Actionsで自動化したので、本記事ではその内容を共有したいと思います。 TL;DR 今回はGitHub Actionsとrelease-it npmを使っています。 github.com www.npmjs.com 上記の技術を組み合わせることで次のような自動リリースのワークフローを構築しました。 (Pull Requestがマージされるなどで)mainブランチにコミットがプッシュされたらタグを打ち、GitHubリリースを作成する。 前回リリースとの差分で コミットメッセージのリリースノートを作成する 特定のファイルまたはディレクトリが更新されていたときだけ リリースする コミットメッセージに応じてセマンティックバージョンのパッチ/マイナー/メジャーアップデートを切り替える Actions上でしか使わないnpmパッケージなので、 リポジトリにpackage.jsonを置かない GitHub Actionsを使って自動化を行なうと、 コミット内容に応じた操作が簡単に実現 できます。 ただし、次の制約もありました。 プロテクトブランチを利用している場合はGitHub Actions上からコミットをプッシュすることはできない リリース用のPRをつくるといった迂回策が必要 そのため、今回の自動リリースでは「リポジトリ内の version 変数の値を更新してコミットしておく」のような操作は含んでいません。 なお、今すぐ試してみたい方は以下の2つのファイルを用意するだけで実現できます。 .release-it.json .github/workflows/release.yml .release-it.json の内容は次のとおりです。GitHubリポジトリのルートディレクトリに配置します。 https://github.com/budougumi0617/autorelease-by-release-it-on-actions/blob/main/.release-it.json { " requireUpstream ": false , " requireCleanWorkingDir ": false , " github ": { " release ": true } , " git ": { " commit ": false , " push ": false , " requireUpstream ": false , " requireCleanWorkingDir ": false } , " npm ": { " publish ": false , " ignoreVersion ": true } } .github/workflows/release.yml の内容は次のとおりです。 https://github.com/budougumi0617/autorelease-by-release-it-on-actions/blob/main/.github/workflows/release.yml name : auto release demo on : push : # mainブランチにコミットがpushされたときに限定 branches : - main # 上記条件に加えてgenディレクトリ配下が変更されたときのみという条件を追加 paths : - gen/** jobs : auto-release : runs-on : ubuntu-latest env : GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }} RELEASE_IT_VERSION : 14.2.1 steps : - name : Check out codes uses : actions/checkout@v2 with : fetch-depth : 0 - name : Setup Node uses : actions/setup-node@v1 with : node-version : '12' - name : Set releaser settings run : | git config --global user.name release-machine git config --global user.email email@example.com - name : Major release id : major if : contains(toJSON(github.event.commits.*.message), 'bump up version major' ) run : npx release-it@${RELEASE_IT_VERSION} -- major --ci - name : Minor release id : minor # メジャーバージョンアップをしていないときマイナーバージョンアップを行なうか if : steps.major.conclusion == 'skipped' && contains(toJSON(github.event.commits.*.message), 'bump up version minor' ) run : npx release-it@${RELEASE_IT_VERSION} -- minor --ci - name : Patch release # コミットメッセージに特に指定がない場合はマイナーバージョンを更新する if : "!(steps.major.conclusion == 'success' || steps.minor.conclusion == 'success')" run : npx release-it@${RELEASE_IT_VERSION} -- patch --ci 今回のサンプルYAMLの場合はmain ブランチに gen ディレクトリ内への変更を含んだPRをマージすると自動でリリースが行なわれます。 また、 bump up version major といったメッセージが含まれていた場合はメジャーバージョンアップが行なわれます。 https://github.com/budougumi0617/autorelease-by-release-it-on-actions/releases サンプルリリースページ なお、本記事で利用している各ツールのバージョンは以下のとおりです。 ツール名 バージョン GitHub Actions v2 release-it npm 14.2.1 Node.js 12.X系 以下のURLは実際にGitHub Actionsで何回か自動リリースをしてみたサンプルリポジトリです。 https://github.com/budougumi0617/autorelease-by-release-it-on-actions リリース作業を自動化したい どんな言語を使っていても、業務で開発を行なっていると社内ライブラリを作成することがあると思います。 作成したライブラリはnpmやComposer、Go Modulesなどのパッケージ管理システムを経由して使うことになるのが大半だと思います。 そうなると一定の更新ごとにタグを設定し、バージョン管理する必要が出てきます。 とはいえ 「PRをマージしたら git tag コマンドを打って…」と各開発者が行なうのは億劫 です。 そのため、 mainブランチにPRがマージされたら(コミットがプッシュされたら)自動でタグ打ち、リリースする という自動化を試みました。 もちろんタグはリリースのたびにセマンティックバージョンがインクリメントされるようにします。 GitHub Actions上でrelease-it npmを実行してリリースをする https://www.npmjs.com/package/release-it release-it npmはよしなにセマンティックバージョンをインクリメントしながらリリースノートも作ってGitHubリリースを作成してくれるコマンドです。 たとえば、現時点のバージョンが 0.1.2 だったとき、次のように実行するとマイナーバージョンをインクリメントした 0.2.0 バージョンのリリースを作成してくれます。 $ npm run release -- minor --ci 次のリンクはrelease-it npmで作成されたリリースノートです。 https://github.com/budougumi0617/autorelease-by-release-it-on-actions/releases/tag/0.0.1 リリースノートのサンプル GitHub Actions上でNode.js環境を用意して実行するだけで終わりかと思いきや、いろいろ設定する必要があったので、ポイントを解説していきます。 GitHub Actions実行時にタグも取得しておく https://github.com/actions/checkout#checkout-v2 Set fetch-depth: 0 to fetch all history for all branches and tags. 今回構築する自動リリースのワークフローでは既存のタグからリリースするセマンティックバージョンを決定します。 そのため、GitHub Actions実行時に タグも一緒にチェックアウトしておく必要があります 。タグはGitHub Actionsを利用時にほぼ100% use されているであろう actions/checkout に対して fetch-depth: 0 オプションを渡すだけで取得可能です。 - name : Checkout codes uses : actions/checkout@v2 with : fetch-depth : 0 特定のパス配下が更新されたときのみリリースする 今回自動リリースしたいリポジトリはコードの自動生成を行なっていました。そのため、次のような事情がありました。 PRがマージされるたびのリリースは (どんどんバージョンが上がってしまうので)してほしくない 自動生成したコードが配置されている gen/ ディレクトリの内容が変更されたときだけ リリースしたい OpenAPIやgRPCなどを利用して同リポジトリ内でクライアントコードを自動生成したりしていると、同様のニーズが生まれると思います。 最初はCircleCIを利用して自動リリースを実現しようと思ったのですが、 パスを使ってCIを制御するのはGitHub Actionsのほうが簡単だったので 、GitHub Actionsで自動リリース作業を行なうことにしました。 GitHub Actionsのワークフローでは次のような制御をすることができます。 コミット内容を確認して特定ディレクトリに更新があったか確認する https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestpaths GitHub Actionsでは、 on.<push|pull_request>.paths を使うことで、特定ディレクトリに更新があったときだけにジョブの実行を制限できます 1 。 次のサンプルコードは以下の2つの条件を満たしたときのみ実行される設定です。 mainブランチにコミットがプッシュされた プッシュされたコミットの中に gen/ ディレクトリ内の更新が含まれていた on : push : branches : - main paths : - gen/** ワークフローの制御にコミットメッセージを利用する https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions このデータ構造は おそらく公式ドキュメントに明示的に載っていない のですが、GitHub Actionsでブランチにpushされた 一連のコミットの情報をジョブ実行中に利用可能 です。 ワークフロー実行時の情報は github context として参照できるのですが、この中の github.event でpushされたコミットの情報を持っています 2 。 この情報をパースするとコミットの内容をワークフロー中に使うことができます。 次のコードは「コミットのメッセージに 'bump up version major' があったら true になる」式です。 contains(toJSON(github.event.commits.*.message), 'bump up version major' ) https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsif これと、 jobs.<job_id>.steps.if 、を使うことで、「コミットメッセージによって実行されるstep」をワークフローに用意することができます。 jobs : sample : runs-on : ubuntu-latest steps : - name : teststep if : contains(toJSON(github.event.commits.*.message), 'コミットメッセージを確認' ) run : echo 'executed!!' https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions#steps-context また、contextの steps.<step id>.conclusion を用いることで前ステップの実行結果を利用して else if のような制御を行なうことも可能です。 jobs : ifelse-pattern : steps : - name : foo id : foo if : contains(toJSON(github.event.commits.*.message), 'foo' ) run : echo 'if step!' - name : bar id : bar # fooステップがスキップされた && コミットメッセージにbarを含む場合に実行する if : steps.foo.conclusion == 'skipped' && contains(toJSON(github.event.commits.*.message), 'bar' ) run : echo 'elseif step!' 次のリンクは実際にステップをいくつかスキップしているActionsの実行結果です。 https://github.com/budougumi0617/autorelease-by-release-it-on-actions/runs/1454817991 Actions実行画面 ここまではGitHub Actionsの設定方法でしたが、次はrelease-it npmをGitHub Actions上で使うコツです。 タグの設定とリリースはするが、コミットはしない release-it npmはGitHub Actions上で npx コマンドで実行しているので package.json は不要です。 が、release-it npm用の設定を用意する必要があります。 今回利用している設定は次のとおりです。 { " requireUpstream ": false , " requireCleanWorkingDir ": false , " github ": { " release ": true } , " git ": { " commit ": false , " push ": false , " requireUpstream ": false , " requireCleanWorkingDir ": false } , " npm ": { " publish ": false , " ignoreVersion ": true } } ざっくり説明すると、次のような設定です。 GitHubリリースを作成する gitのコミットは作成しない gitのpushはしない npmの公開はしない バージョンを決定するために package.json 内のバージョンを参照しない 鋭い方は「”pushしない”ってことはタグも公開されないんじゃないの?」と思うかもしれませんが、いいのか悪いのか、リリースを行なうときにタグはプッシュされるようです。 Actionsからプロテクトブランチにはコミットをpushできない https://github.community/t/how-to-push-to-protected-branches-in-a-github-action/16101/5 ここまで便利なGitHub Actionsでしたが、ひとつ制約があります。それは プロテクトブランチにコミットをプッシュすることができない 点です。抜け道がないか探していたのですがなさそうなので諦めました。 なので、先ほどのrelease-it npm用の設定ファイルは「タグの設定とリリースはするけどコミットはプッシュしない」という内容になります。 「自動リリースするときは package.json の中にある version も更新したい(コミットプッシュしたい)んだけど!」というようなニーズももちろんあると思います。 しかし、今回のユースケースではリリースタグでバージョンが管理されていればファイルとしてバージョンが参照できる必要はなかったので、こちらも妥協しました。 あとは開発するだけ! 以上の設定を行うと、特定条件のコミットを作るだけで自動でリリースされるようになります。 それぞれの設定は独立しているので、お好みでカスタマイズしていただけばと思います。 特定のディレクトリに限定する必要はないので on.<push|pull_request>.paths の設定は削除する マッチするコミットメッセージを変更する etc... name : auto release demo on : push : # mainブランチにコミットがpushされたときに限定 branches : - main # 上記条件に加えてgenディレクトリ配下が変更されたときのみという条件を追加 paths : - gen/** jobs : auto-release : runs-on : ubuntu-latest env : GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }} RELEASE_IT_VERSION : 14.2.1 steps : - name : Check out codes uses : actions/checkout@v2 with : fetch-depth : 0 - name : Setup Node uses : actions/setup-node@v1 with : node-version : '12' - name : Set releaser settings run : | git config --global user.name release-machine git config --global user.email email@example.com - name : Major release id : major if : contains(toJSON(github.event.commits.*.message), 'bump up version major' ) run : npx release-it@${RELEASE_IT_VERSION} -- major --ci - name : Minor release id : minor # メジャーバージョンアップをしていないときマイナーバージョンアップを行なうか if : steps.major.conclusion == 'skipped' && contains(toJSON(github.event.commits.*.message), 'bump up version minor' ) run : npx release-it@${RELEASE_IT_VERSION} -- minor --ci - name : Patch release # コミットメッセージに特に指定がない場合はマイナーバージョンを更新する if : "!(steps.major.conclusion == 'success' || steps.minor.conclusion == 'success')" run : npx release-it@${RELEASE_IT_VERSION} -- patch --ci 終わりに 「PRマージしたぞー!」と思っても、そのあとにポチポチリリース作業をするのは億劫でした。 これで少しでも生産性があがるといいなと思っています。 なお、GitHub Actionsからプロテクトブランチへの直接コミットプッシュはできないのですが、renovateはPRを作成、Appで自動承認、自動マージという迂回をしてプロテクトブランチへのプッシュを実現しているようです。 GitHub Apps - renovate-approve · GitHub もっと突き詰めたくなったら同様の操作を実装してファイル更新も含めた自動リリースを実現したいなと思います。 最後に、BASE BANKでは新しくデザイナーとカスタマーサクセスの募集を開始したので、ぜひご応募お待ちしています。 www.wantedly.com www.wantedly.com 参考リンク https://github.com/budougumi0617/autorelease-by-release-it-on-actions https://www.npmjs.com/package/release-it https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions https://github.community/t/how-to-push-to-protected-branches-in-a-github-action/16101/5 「特定のディレクトリに更新があったときは無視する」という逆制御もできます。 ↩ contextをechoして無理やり確認しました。 ↩
アバター
BASE株式会社取締役 EVP of Developmentの藤川です。 世界中が新型コロナの影響で雇用の先行きが不透明な中、当社は引き続き成長を模索している状況で、マネージャ陣を中心に採用活動にも注力する毎日を送っています。 当社は正社員採用はもちろんのことですが、業務委託契約の方々にもお手伝いいただいておりますが、今回は業務委託契約にフォーカスした記事を書いてみたいと思います。 内製にこだわるチームを維持するための採用活動 私達はサービスを維持、成長させるために毎日ソースコードのメンテナンスをしています。我々はAWSやGCPなどのクラウドの環境とオープンソースのソフトウエアに恩恵を受けながら、独自のロジックは内製で開発しています。 他社の開発体制の事例として、スタートアップとして最初は内製で立ち上げたとしても、組織が大きくなりビジネスの成長が問われる中で、SIerさんに社内にがっつり入り込んでもらってSES契約などで開発力を補填したり、オフショアで海外に開発をお願いする事例をよく聞きます。 我々は創業以来、内製でソースコードを書いてきたチームですので、やはり内製での開発にはこだわりがあります。内製の重要性は、正社員がソースコードを書くか否かということではなく、ソースコードを書く行為とサービス運用が密接に結びついており、デプロイ後に何かあったら、ソースコードを書いた当人に即座にフィードバックして改善を求められる体制が維持されていることを示します。 これによりWebサービスを作る楽しさと技術者としての成長をリアルタイムに実現し、プロダクトクオリティに結びつけるということを大切にしています。スピーディな開発と改善を実現することが、持続的に成長するWebサービスの改善サイクルを支えています。開発メンバーにおいても、この開発サイクルを回し続けることが、複利的にエンジニアとしてのスキルを向上することに繋がります。 言い方を変えると「プロダクトを成長させることを前提とし、内製による開発体制を維持し続ける」ということもあります。プロダクトを連続的に成長させないのであれば、内製の開発体制にこだわる必要はなく、開発をアウトソースしフェーズごとに、僕らの預かり知らないソースコードを積み上げていって機能を付け足していくという選択もまたあるのでしょう。積極的にオフショアなどをやられている会社さんからすると、これはチームの覚悟の話と思われるのかもしれません。 現時点で思うこととして、もし受託契約やオフショアで開発し、納品されたソースコードによって深夜に不具合やパフォーマンス問題等が起きれば、いの一番に対応するのはSREチームやCTOである可能性が高く、誰が書いたのかの顔が見えないソースコードで不具合に対応し続けるのは酷な話だと僕は考えています。 サービスを運営し続けるのであれば、言い方は変ですが、不具合を出した仲間の顔が思い浮かぶ形、すなわち、自分たちチームの責任として納得行く形で不具合対応をしたりソースコードの改善をしていくことは仕事に対するモチベーション維持としても重要視しています。Webサービスにおいて人が携わる時間は、サーバを維持をするだけの活動ではなく、ショップオーナーさんの流通総額の実現を支え、サービスの改善のヒントを得る大切な活動なので、無駄な時間には使いたくないです。 つまり、内製組織を維持するための人件費や採用費に投資することは、それに携わる人の成長とプロダクトの成長を実現するためであり、結果として企業のポテンシャルを蓄積的に向上させていく手段ということになります。 ただし、これも成長圧力に対して、ちゃんと開発が追いついていればの話。内製にこだわり続けて、採用が滞ってしまって想定する成長を実現できなかったとしたら、もしかしたら内製にこだわる僕が経営責任を取る形で会社の様相が変わってしまったら、こういうこだわりを続けることはできなくなってしまうかもしれません。 そうならないためにもしっかり仲間を増やしていくことは開発チーム全員で携わっていく重要な仕事であると考えています。 内製にこだわるチームを維持するための業務委託 内製チームを実現するために雇用形態や国籍はこだわりません。同じチームで動いて改善サイクルを回せる状態を維持できていれば良いのだと思います。そのため携わるメンバーの契約形態は、現状は正社員としてジョインいただくか、業務委託契約でお願いしている状況です。 これまでBASEやPAYの開発においては、比較的少人数ではあるものの業務委託契約の方にもご活躍いただいてきました。我々が業務委託契約でお願いする方は「社員として登用できないハイスキルな方」と定義してきました。 つまり既に独立してフリーランスや自分の会社を持っていて独立心が高かったり、所属している会社の愛着があるからこそ、社員として来ていただくことが難しい方との契約形態として活用しています。 その代表例として、沖中さんのインタビューを動画で収録しました。もう2年半、BASEをお手伝いいただいていて、BASEの発展には欠かせない仲間です。 業務委託契約の方と社員とは多少の役割の差はあれど、労働環境や開発に必要な情報も含め、できる限り分け隔てなく、社員と同じように活躍を期待しています。技術ブログの執筆も普通にお願いしていたり社内勉強会で活躍いただくなど、情報のインもアウトも特に分け隔てなく業務としてこなしていただいております。 2021開発計画をお手伝いいただく業務委託契約の方を募集します! 現在、2021年の開発計画を整えていますが、とてもとても人が足りません。BASEというサービスをもっとよくして、ショップオーナーさんの成長を支えるための開発を一緒にやっていただける業務委託の人を増やしたいと思っています。もちろん、社員登用も積極的に行っております。 なおエンジニア採用向けの会社紹介資料を作ったので、よろしかったら是非見てみてください。 speakerdeck.com 役割別の募集要項はこちら! あらゆる役割で人材募集中! フロントエンドエンジニア open.talentio.com 開発プロジェクトにおけるフロントエンド実装と、BASEのフロントエンド実装におけるライブラリや実装技術の守り神を担います。 Webアプリケーションエンジニア open.talentio.com Webアプリケーションエンジニアは主体技術はバックエンド実装ですが、サービスを作る時にフロントエンドも書いています。 バックエンドエンジニア(アプリAPI開発) open.talentio.com BASEアプリのAPIの部分を開発するエンジニアになります。
アバター