ハウテレビジョン開発者ブログ

『外資就活ドットコム』を日夜開発している技術陣がプログラミングネタ・業務改善ネタ・よしなしごとについて記していきます。

業務未経験からエンジニアになって半年を振り返る

こんにちは、ハウテレビジョンエンジニアのFです。

突然ですが、ご存知の通りここ最近のプログラミング学習ブームを機に、

  • プログラミングを勉強する非エンジニアの方
  • スクール等に通いエンジニアに転職する方

が増えてきているかと思います。

私自身もその一人。 私がハウテレビジョンにジョインしたのは2017年10月で、それまでエンジニアとしての業務は完全未経験でした。 Webエンジニアを養成するスクール「TECH::EXPERT

tech-camp.in

を経て、 エンジニアとして採用されました。

そんな私が、

  • Web系エンジニア養成スクールに通われている方
  • 未経験からエンジニアを目指している方

などに向け、 エンジニアになって半年ちょっとの間でやってきた業務、感じたことなどを書き綴ります。

エンジニアとして採用されるまで

大学時代は商学部で主にマーケティングを学ぶ、根っからの文系人間でした。 前職は商社にて法人営業をやっており、 これまで「プログラミング」とは無縁の生活を送っておりました。

そんなあるとき、プログラミングに関する雑誌記事に出会いました。 そこに書かれていたチュートリアル通りに小さなアプリを作るべくコードを書いてみると、 これが本当に楽しい。就業後、家に帰ってはPCを開き、時間をかけながらも一つのアプリを作り上げました。

これをきっかけに「ものつくり」の楽しさに目覚め、独学でプログラミングを学びはじめました。 営業の仕事を終え、毎日2時間ほど週末も一日使って勉強をしていましたが、 正直「もっとやりたい」という気持ちがありました。

次第に「フルコミットでプログラミングを学びたい」「ゆくゆくはエンジニアとして働きたい」という気持ちが強くなり、 たまたまTwitter上で見つけたTECH::EXPERTの相談会に足を運んでみました。

その半年後には商社を退職し、TECH::EXPERTでの勉強漬けの毎日(1日10時間×週6日×3ヶ月間)が始まったのです。 約3ヶ月のカリキュラムを終え就職活動をする中で、サービス志向を持つエンジニアを目指していきたいと考え「自社サービスを開発する企業」に興味を持ちました。 そうして、面接を受ける中で自分に合うと感じたハウテレビジョンに入社しました。

半年間の主な業務

自分にとってはここからが本当のスタートです。 入社してからの半年とちょっとの間、「行動すればいろいろなことにチャレンジできる環境」の中、 様々な業務を行ってきました。 具体的には下記のようなことを主に行いました。

PHP(フレームワークはCakePHP)を使った既存サービスの保守・運用、新機能の開発

 - ビジネスサイドから上がってくる要望に基づく実装。小さな機能や少し大きな開発(長期インターンサービスのリリースなど)。

  - 日々上がってくる不具合修正

SQL・Python、Google Analitics・BigQueryを使ったデータ分析・集計

 - こちらもビジネスサイドから上がる要望を基にデータ集計

 - 開発〜効果検証まで

エンジニアインターン生(大学生)のフォロー

 - 開発の手助けやレビュー

半年間で担当した開発

上記のような業務の中で、実際に自分が関わりリリースされた機能を紹介します。

長期インターンページ

gaishishukatsu.com

入社して2ヶ月経った頃に関わった、はじめての大型開発でした。 それまでは小さな運用改善の開発ばかりだったので0からの開発には手こずりましたが、 リリースされたときの達成感は大きかったです。 こちらは現在でも、改善に向けたプロジェクトの開発に携わっています。

企業配下(企業の年収/社風などを紹介する)ページ

gaishishukatsu.com

ここ最近だとこちらの開発に関わりました。 見積もりから進め、ビジネスサイドと密に話をしながら開発していった案件でした。

ベテランエンジニアの方からみれば小さな開発ではありますが、 完全未経験だった頃から考えたらたった半年ほどで様々な開発に携われていることに驚きがあります。

スクール時代の勉強と実際の業務とのGap

HTML、CSSやRuby、PHP、その他GIthubの使い方など、スクールで最低限の基礎は身につけられていたので、 そのあたりは入社後もある程度キャッチアップしやすかったです。 一方で、スクールのカリキュラムだけでは十分に身につけられなかった部分、「入社前にもっと勉強しておけばよかったなあ」 と感じた部分ももちろんありました。 例えば、

ネットワークとかインフラの基礎知識

情報系学部を卒業していれば「ネットワークの仕組み」みたいなところから学べるだろうがそうでもなく。 スクールのカリキュラムに説明はあったが、さっと触れる程度。 業務で実際に必要になった場面はないが、経験あるエンジニア達の会話についていくときに必要と感じた。

SQL

こちらもカリキュラムではほんの触りのみ程度だった。 エンジニアになる前に思っていた以上に分析系タスクが多かった。

「もっと勉強しておけば...」なんて部分は上げたらキリがないですが、特にこの2点はGapがあったなと感じました。 上記の課題を克服する上で、まずは下記のような本を読み漁り、勉強を進めました。

ネットワークの基礎知識に関して

その他、身近な駆け出しエンジニアの中には基本情報技術者試験を受験していた人もおりました。

SQLに関して

この一冊をやれば基礎部分は十分身につくかと思います。

ちなみにこれらの本は、弊社の本棚に置いてある、 もしなければ会社負担で購入もしてもらえる制度があります。

こうしてスクールで学べることと実際の業務のGapを書きましたが、 決してTECH::EXPERTを否定するつもりではありません。 エンジニアとしての基礎技術力・基礎体力を身につけられたことや、 就職支援には非常に感謝しておりますm( )m

エンジニアになってからの学習や意識について

エンジニアになれたとはいえ、もちろん学習が終わりではありません。 より技術力を高める、会社・チームに貢献するために何をすべきか自分なりによく考えました。 ここではその後の勉強法や意識してきたことについて書いていきます。

プライベート開発

自社サービスの運用では、どうしても「サービスを一から開発する」という機会はなかなかないと思います。 また、どうしてもコードを書く時間が限られる場合もあります。(弊社の場合、基本的には書けますが。) 技術力を高めていくためには、エンジニアを目指していた頃同様に業務時間外でも開発・学習が必要になるかと思います。 個人的に考えているのは、写経でもなんでもとにかく手を動かしてアウトプットすることが大事だということ。 もちろん自身で作りたいサービスを考えて作ることができたら素晴らしいですが、 とにかく「量をこなす」「手を動かす」ことこそが大事だと思うので、 あまり内容にはこだわり過ぎずに気楽にやっていくのが良いかと思ってます。 ここで私が入社してからの間、週末などの時間を使ってやってきたことを書きます。

やってきた勉強法

定番

言うまでもない定番どころの学習サービスを使った勉強法です。 ド初心者の言語などはこのあたりで学ぶのが効率的かなと思います。 Udemyは環境構築〜ちょっとしたサービスを作り〜リリースまで学べるコースがあるので、開発の流れを掴むという点ではおすすめです。

経験あるエンジニアの方には参考にならない面もあるかもですが、 駆け出しエンジニアや非エンジニアだけどプログラミングをかじってみたい方などにおすすめです。

その他

  • 既存サービスを真似て作ってみる
  • 知り合いの依頼で開発のお手伝い
  • LINEbot作成
  • MENTAココナラでメンターを雇って質問
  • IoT機器を使って自宅をスマートハウス化(人感で自動で点灯するライトを自作)
  • Twitterエンジニアアカウントをフォローして情報収集

定番ではないですが、その他やってきたことです。 「既存サービスを真似て作ってみる」に関しては、「DB構造はどうなっているのか」「ここをこうしたほうが良いサービスになるな」と考えるだけでも勉強になります。

これらプライベート開発での成果はもちろん業務の中でも役立っております。

技術面以外での意識

自社サービスの理解

とても基本的なことですが、自社のサービスに「どのような機能があるか」「どのようなロジックでできているか」を できるだけ多く自社のサービスに触ることで把握するようにしました。 おかげでビジネスサイドからの相談をスムーズにこなせるようになりました。 (まだまだ先輩エンジニアの方々には遠く及びませんが。)

自身のバックグラウンドを活かす

「情報系学部出身ではない」「前職が他業種」であることを引け目と感じるのではなく、 「個性」「強み」のひとつとして考えました。 私の場合、前職が営業職だったこともあり、「ビジネスサイドとのコミュニケーション」を特に意識しました。 おかげで仕事もやりやすく、信頼されているかどうかはまだしも ビジネスサイドからよく開発相談や開発依頼をいただけるようになりました。

技術力だけで勝負をすれば、経験豊富な先輩エンジニアには及びません。 それでも自分なりに会社やチームに貢献できるところはあるはず。 これからも個性を生かして貢献していけたらと考えています。

終わりに

偉そうに書いてきましたが、まだ業務経験も1年に満たない駆け出しのエンジニアです。 未だにわからないことやつまづくことばかり(その際は社内の先輩エンジニアが優しくご指導くれます笑)なので、 これからも日々精進していきたいと考えています。

弊社では引き続き、私のように「業務は未経験」だけど「エンジニアになるために必死に勉強をしている」方も 積極採用中(実際にTECH::EXPERT経由で新たに1名採用決定!!)です。 ご興味ある方はぜひ弊社に遊びにきてください。

ご連絡はこちらから。

Docker初心者がRails+MySQLの環境構築をDockerでやってみた

はじめに

先日リリースした弊社のリニューアルされたiOSアプリのサーバーサイドはDockerで環境構築されております。
Dockerはとても便利で、設定されていば簡単なコマンドを叩くだけであっという間に開発環境が整ってしまいます。
しかし、その便利さ故にDockerを特に意識することなく時だけが過ぎてしまいました、、、
先日何気なく弊社のDocker周りの設定をふと見てみたら、何が書いてあるか全く理解できず(TдT)
これはまずいと思いDockerの勉強をはじめることにしました。
今回はDocker初心者が勉強がてらRails+MySQLの環境構築ができるか試してみました。

f:id:hy616:20180716143345p:plain

環境

今回構築するのは以下の環境です。

  • Ruby 2.5.1
  • Rails 5.2.0
  • MySQL 5.7

参考(事前勉強)

Dockerfile リファレンス — Docker-docs-ja 17.06.Beta ドキュメント
クイックスタート・ガイド:Docker Compose と Rails — Docker-docs-ja 17.06.Beta ドキュメント

Dockerfile作成

Dockerfileとは、Dockerコンテナの構成内容をまとめて記述しておくファイルです。
ここに書かれた内容をもとに、Dockerイメージが構築されます。
以下を記述します。

FROM ruby:2.5.1
ENV LANG C.UTF-8
RUN apt-get update -qq && apt-get install -y build-essential mysql-client nodejs
RUN gem install bundler
WORKDIR /tmp
ADD Gemfile Gemfile
ADD Gemfile.lock Gemfile.lock
RUN bundle install
ENV APP_HOME /myapp
RUN mkdir -p $APP_HOME
WORKDIR $APP_HOME
ADD . $APP_HOME

Dokerfileで使用している命令について軽く説明します。

FROM(必須)

コンテナのベースとなるイメージを指定します。
DockerHubで公開されているイメージや、自作のイメージも指定できます。

ENV

環境変数を設定します。

RUN

FROMで指定したイメージ上で、コマンドを実行します。
パッケージのインストールなどに用います。

WORKDIR

作業するディレクトリを変更します。

ADD

指定したファイルやディレクトリをコンテナ内にコピーします。

※上記以外にも命令はいくつかあるのでご興味があれば公式ドキュメントを参照ください。

Gemfile作成

今回はversion:5.2.0を指定しています。

source 'http://rubygems.org'
gem 'rails', '~> 5.2.0'

Gemfile.lockを作成

Gemfile.lockが必要なので、空のファイルを作成しておきます。

$ touch Gemfile.lock

docker-compose.yml作成

複数のコンテナを一括管理するための構成管理ファイル。
docker-compose up コマンドで記述したサービスが全て立ち上がります。
今回はRubyとMySQLの2つのコンテナの設定をします。

version: '3.4'
services:
  db:
    image: mysql:5.7.17
    ports:
      - "3306:3306"
    volumes:
      - ./docker/mysql/volumes:/var/lib/mysql
    env_file: .env.dev
  web:
    build:
      context: .
      dockerfile: ./docker/rails/Dockerfile
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    ports:
      - "3000:3000"
    volumes:
      - .:/myapp
    environment:
      RAILS_ENV: development
    env_file: .env.dev
    depends_on:
      - db

上の記述について軽く解説します。

version

Compose ファイルのバージョンを指定できます。

image

ベースイメージを指定します。

ports

ホスト側とコンテナ側の両方のポートを指定できます。(ホスト:コンテナ)
コンテナポートのみを指定した場合は、ホスト側のポートはランダムで指定されます。

volumes

ボリュームをマウントするときに指定します。

env_file

指定したファイルから環境変数を追加します。

depends_on

コンテナの作成順序と依存関係を決められます。
※開始の順序を制御するだけで、コンテナ上のアプリケーションが利用可能になるまで待つという制御は行いません。

※上記以外にも命令はいくつかあるのでご興味があればこちらについても公式ドキュメントを参照ください。

.env.dev作成

パスワードは別で管理したいため、.env.devを作成します。
ここに任意のユーザーとパスワードを設定します。

MYSQL_USER=****
MYSQL_PASSWORD=****
MYSQL_ROOT_PASSWORD=****

Railsアプリケーション作成

docker-compose runコマンドでwebコンテナの中でrails newコマンドを実行します。
ここでdatabaseにMySQLを指定します。

$ docker-compose run --rm web rails new . --force --database=mysql --skip-bundle

イメージをビルド

$ docker-compose build

config/database.ymlを編集

default: &default
  adapter: mysql2
  encoding: utf8
  pool: 5
  username: root
  password: <%= ENV['MYSQL_ROOT_PASSWORD'] %>
  host: db

development:
  <<: *default
  database: development

コンテナを起動

下のコマンドでRailsサーバーとMySQLサーバーを起動してくれます。

$ docker-compose up

データベースを作成

最初はデータベースがまだ作られていないので、以下のコマンドでデータベースを作成します。

$ docker-compose run --rm web rake db:create

http://localhost:3000 にアクセス

なんとかRailsが動いていることが確認できました。

f:id:hy616:20180702015927p:plain

まとめ

今回はDockerを用いて、Rails+MySQLの環境構築を試してみました。
Dockerに関しての知識があまりなくても、最低限ですがRails+Mysqlの環境構築はできました。

個人的にはDockerの学習は、コンテナを立ち上げるまでは学習コスト低めだと思いますが、実際の運用にはもっと多くのノウハウが必要なので、総合して学習コストは高いのかなと感じました。
しかしその反面、他の作業者と同じ環境が簡単に用意できるのはとても魅力的だと感じました。
まだDockerを触ったことがない方はぜひ触ってみてはいかがでしょうか。

ハウテレビジョンでは絶賛エンジニア採用中です!

ここまで読んで頂きありがとうございます。
Docker、Rails、ReactNative、Goなどを業務で使ってみたいエンジニアの方がいらっしゃいましたら、ぜひとも一度オフィスに遊びに来てお話しましょう!

ご連絡はこちらからお願いします!

半年でエンジニア4人→12人に急増したチームで、iOSアプリ(ReactNative)とAPI(Golang)を作り直して無事リリースした話(前編)

はじめに

どうも。 先日行われた開発合宿に自転車(ロード)で行こうと思ったら台風の中120kmも走るハメになった O里 です。(開発合宿については別記事にて書きます)

さて、去る2018年5月中旬に、外資就活ドットコムiOSアプリを全面リニューアルしました🎉

prtimes.jp

このプロジェクトでの僕の役割は、バックエンド側の設計・実装・進捗管理とメンバーの採用で、それに加えてアプリリニューアル以外の開発案件のとりまとめなども並行してやってました。

今回のリリースはiOSアプリのデザインを根本的に見直すことでもっともっと使いやすいアプリにすることが最大の目的でしたが、ReactNativeGoDockerを使うという、技術的に大きな挑戦も含んでいました。

外資就活はWebアプリケーションとスマホアプリ向けのAPI、iOSアプリとandroidアプリを提供していますが、WebとAPIはCakePHP(2.x) で、iOSアプリはSwift、androidアプリはJavaでそれぞれ書かれており、開発環境の構築には VirtualBOX+Vagrant+Chefを使っていました。(一部今も使ってます)

f:id:bumcru0310:20180713110538p:plain

今回の記事では、この大きな技術的転換点をどのように乗り切ったのか、チームビルディングの観点から振り返りたいと思います。超大作になりそうなので2〜3記事に分けて書きます。

序章 ~ プロジェクト発足の背景 ~

スマホアプリからのアクセスが急増

https://img.esa.io/uploads/production/attachments/5008/2017/07/26/5972/3fada706-08f0-449e-baaa-5f1dec3d7280.png

スマホアプリをリリースしたばかりの2015年頃はスマホよりPCからのアクセスが大勢を占めていた外資就活も、いつしかスマホからの利用が上回るようになり、特にアクティブ率の高いユーザーが多いスマホアプリ改善への要望も高まっていました。

リニューアル前の外資就活アプリはログインしないとほとんどの機能が使えない仕様で、「すでに会員登録済みのユーザーにより便利に使ってもらうアプリ」という代物。デザイン的にも最新のAppleUIガイドラインに準拠していませんでした。

これを、「とりあえずアプリをDL→めっちゃ便利→面倒だけど会員登録してみよう」という順番にUXを入れ替え、さらにデザインも直感的に使えてより便利なUIに作り変えようという声が高まり、iOSアプリのリニューアルが外資就活の最重要ミッションになりました。2017年8月の話です。(ちなみにandroidよりiOSユーザーの方が圧倒的に多いため、まずiOSから作り直すことになりました)

iOS専門のエンジニアがいない → ReactNativeだ!!!!

しかし当時の開発チームにはWeb出身のエンジニアしかおらず、スマホアプリネイティブの改修となると腰が重たい状況が続いていました。小さな開発チームでは各社課題になっていることですね。 当時は開発リソースが少なかったこともあり「ネイティブアプリだけど中身はWebViewの箱のようなアプリにしてしまおう!」という企みも検討されましたが、結局は「ユーザーにぬるぬるサクサク快適に使ってもらうアプリを作る上でネイティブでの開発が必須」という結論に至りました。

そうは言ってもWebとiOSとandroid全部修得してその後もメンテし続けるのはやはり現実的ではありません。 そこで当時にわかに人気が高まっていたReactNativeの導入を検討するようになりました。この背景には、スマホアプリだけでなく何ならWeb側もバックとフロントのコードを完全に分離し、フロントはReactで固めたら最高だぜ!という企みがありました。(まだ企んでます)

このブログを執筆時点でもReactNativeの最新バージョンは 0.56 で、「メジャーバージョンがまだ 0 のもの使って本当に大丈夫か!?」という懸念はありましたが、メルカリさんがReactNativeの開発案件で求人を出し始めたのを見て背中を押され「よし、RNで行こう!」と決意が固まったのを覚えています。

ちなみに僕はJS大好きっ子なので、この選択を心から歓迎していました。

アプリ作り直すならバックも作り直さないとしんどい… → Goだ!!??

外資就活のバックエンドは複数に分かれたWebアプリ(本体のWebアプリ、スマホ向けのAPI、社内向けの管理システムなど)がDBにつながっており、そのWebアプリ同士が一部無秩序に相互参照していたり、それぞれがほとんど同じコードを重複して持ってしまっていたり(コピペコード問題)、CakePHP2系のサポートが切れそうだったり、そもそもPHPを使っていたり、と、いくつもの課題を抱えていたため、かねてから技術的負債解消への糸口を探していました。

最大の課題はDB設計の不味さ(外部キーが無い、良くないポリモーフィック、今は使われていないテーブルやカラム達、命名規則バラバラ…など)なのですが、前述のような状況では手の出しようがありません。

また、スマホアプリを大幅に作り直すにあたりユーザー認証周りの仕様を大幅に変える必要があったのですが、既存のバックエンドのコードに新仕様でのコードを追加していくのは影響範囲が広すぎて設計が難しく、何よりお世辞にも楽しい作業とは言えませんでした。

こうして既存APIを拡張するのではなく、新APIを構築して徐々に既存処理を統合していく方針が決まり、技術選定へ。Java、Go、Kotlin、PHP7など、いくつかの言語やフレームワークでAPIのサンプルを作成し、どれにするか検討しました。

負債解消は先が長いプロジェクトで、しかも非エンジニアのメンバーからはその必要性が理解されづらいものです。そこで、今回の技術選定ではエンジニア目線での「楽しさ」を重視することにしました。 楽しければそのプロジェクトは勝手に進むし、やりたい人も自ずと集まってくるものだと確信したからです。(※ 後で書きますが、素敵なエンジニアが勝手に集まってくることはありません。相応のアクションは必要です。)

そんな中でGoを選んだのは以下のような理由からです。

  • 漠然とした「イケてる」感
  • シンタックスがシンプルで取っつきやすい親近感
  • 静的型付けがある安心感(PHPのふわふわ感にいい加減 辟易していた)
  • コンパイル言語で動作が高速
  • コードフォーマッタが予め組み込まれているのでコードの質を担保しやすい
  • ginでAPI作るだけならnginxとか無くても1ファイルに数行書くだけでOK
  • エンジニア採用においても優位性を出せるかも
  • Java家系じゃない

ちなみに、Goの中でさらにどのFWを使うかを決める際は gin にするか echo にするか悩みましたが、botmanの鶴の一声でサクッと決まりました。

Hello, Docker. Goodbye Chef.

また、開発環境および本番環境のインフラ構築にはDockerを使いました。「今どきコンテナ環境使わないのやばい」という世の中の見えない圧力を感じ、当たり前のようにDockerで環境を作り始めました。

実際Chefと比べても、各コンテナごとに何をやっているのかシンプルに記述されているため、インフラが苦手な自分でも理解しやすいという印象でした。

第2章 ~ メンバー集め ~

こうしてプロジェクトが始動したのが2017年8月下旬。当時の開発チームにはエンジニアが4人しかおらず、ReactNativeやGo、iOSアプリ開発の経験がある人はいませんでした。Dockerはかろうじて開発環境で使ったことがありましたが、本番運用の経験はありません。ここまで聞くとあまりに無謀過ぎて「アホなの(^ν^)?」と自分でも思います。

10月: iOSネイティブの超ベテラン降臨

何はともあれエンジニアを採用しないと翌年5月のリリースに間に合わない!そんな逼迫感からまず募集をかけたのはiOSアプリのネイティブエンジニアです。

前述の通り、リニューアル前のiOSアプリはSwiftで書かれているのですが、新アプリの開発に加え、既存のアプリのメンテや緊急度の高い機能追加もやる必要がありました。 また、RNで開発するとは言え、Webとは違うアプリ開発の常識を心得ていて、一部のネイティブとのブリッジコードを書いてくれるネイティブエンジニアの存在は必要不可欠でした。

このときジョインしてくれたNさんは、iOSアプリの開発案件が日本で出始めたばかりの頃(10年前くらい)からアプリ開発を専門でやっている超ベテランでした。 10月から8ヶ月間ともに働くことが出来ましたが、他のメンバーからも「Nさんみたいなエンジニアになりたい」と憧れられるような偉大な存在でした。

Nさんを紹介してくれたのはギークスさんという会社で、数人紹介してくれた中でNさんと出会い、約1ヶ月後から一緒に働き始めることになりました。業務委託という形式にも関わらず開発合宿にも参加してくれるなど柔軟に対応頂き、本当にありがたい限りでした。

geechs.com

10月: Webエンジニア2名ジョイン!!

iOSアプリのリニューアルが最重要案件だとしても、明らかな不具合や会員数を伸ばすためにどうしても試したい施策、売上を達成するための新商品開発など、サービスを運営し続けている以上目先の開発タスクは山のように降って湧いてきます。本当に優先度の高いものだけに絞ったとしても、いくつかの案件には対応せざる終えません。「アプリはリニューアルして良くなったけど、その間にユーザーが離れてました」では本末転倒です。

しかし、腰を据えて新しい技術を習得する上では、これらの細々とした案件が障害となり、リニューアルプロジェクトを停滞させてしまいます。 明らかにエンジニアの手が足りなくなり、「バリバリ手を動かしてタスクを片付けてくれる人が欲しい!」と切望した時、奇跡が起きました。

何と、若手のWebエンジニアの入社が1ヶ月たらずで決まったのです。しかも2名同日入社!

この時、採用に力を貸してくれたのはTECH::EXPERT(テックエキスパート)さん。迅速すぎる対応で、募集開始〜入社までが1ヶ月を切るという異例のスピード採用でした。

tech-camp.in

テックエキスパートについてざっくり説明すると「エンジニアに転職したい未経験者をみっちり育ててIT企業とマッチングする」というものです。 なので、業務経験のないエンジニアをある程度教育する必要はありますが、何より「エンジニアとして働きたい」というモチベーションの高いメンバーを採用することができます。

ここで入社した2人はその後、1人はリニューアルプロジェクトのバックエンドチームに参加しGoでAPIをバンバン実装し、もう1人はリニューアルプロジェクト以外の開発案件をほぼ1人で受け切るなど、それぞれ活躍してくれました。 本当にこのタイミングで入ってくれた2人には感謝しています。

次回予告

さて、リリースを半年後に控えた時点でエンジニアはまだ7名。GoとReactNativeの経験者はまだいません。

そろそろ長くなってきたので、続きは次のブログにて。

次回は「怒涛のフリーランス入社ラッシュ」「沖縄からの襲来者(Goエンジニア)」「錯綜する設計、終わらないモブプロ、狂う見積り」などの内容でお送りする予定です。

次号を待て!!

ハウテレビジョンでは絶賛エンジニア採用中です!

ここまで読んで頂きありがとうございます。ReactNativeやGo、Dockerを業務で使ってみたいエンジニアの方がいらっしゃいましたら、ぜひとも一度オフィスに遊びに来てお話しましょう!

ご連絡はこちらから。

リモートワークをやってみてわかったメリット・デメリットと制度化に必要な工夫

リモートワーク導入2ヶ月のまとめ

リモートワーク。

自宅、カフェ、コワーキングスペースなどオフィス以外の場所で勤務する働き方。朝夕の通勤ラッシュに揉まれることなく、オフィスの空調戦争に巻き込まれることもない。例えば自分の好きな場所で、好きな音楽を聞きながら、リラックスして仕事に集中できる。

そんなリモートワークがこの5月より弊社にも導入されました。
「事前にSlackで申請をすることにより、週2回/月4回までリモートワークが可能」という制度です。 対象となるのはエンジニア職のみで、まだ制度として試行段階ではありますが 実際に2ヶ月体験して感じたメリット・デメリット、そして今後の課題などをまとめました。

体験してわかったリモートワークのメリット3つ

f:id:yumasukey:20180703141939j:plain

「サラリーマンの夢=出社しない」を実現

なんといってもこれが一番大きなメリットだと思います。 毎朝通勤ラッシュに揉まれて、オフィスにつく頃にはヘトヘトでモチベーションもだだ下がり。 そんな状況から開放されるのは、サラリーマンの夢だといっても過言ではないと思います。 リモートだと当然ですが通勤の必要がありません。 朝はちょっと遅めに起きて、ご飯を食べたらシャワーでも浴びて、スッキリとした気分で自室のデスクに向かう。 通勤ラッシュという苦行をこなす必要がないので、仕事を始めるモチベーションも上がりますよね。

「ちょっといいですか?」に邪魔されない

メールチェックやミーティングなども終わり、ようやく仕事に集中しだした頃にやってくる一言。

「あの、ちょっといいですか?」

経験ある方もいると思いますが、これをやられるとせっかくの集中力が途切れてしまいます。 一度切れた集中力を戻すのは大変。「質問は後にしてくれよ」と思ってる方も多いはず。 リモートワークだと、コミュニケーションは基本的にメールかチャットベース。 なので自分の好きなタイミングでチェックでき、集中したいときに邪魔されることはありません。

リラックス感が半端ないって

誰にも邪魔されることなく、好きな格好で好きな音楽を聞きながら仕事をする。 この暑い季節、オフィスで繰り広げられる不毛な空調戦争とももちろん無縁です。 そりゃ当然オフィスで仕事するよりリラックスできます。 ちなみに私の場合、自宅だったので甚兵衛を着て、スピーカーでBGMを流しながら作業してました。

メリットまとめ

リモートワークで感じたメリットをまとめると まず通勤にかかる時間とラッシュによるストレスがなくなります。 次に誰にも邪魔されずリラックスできるので、高い集中力を持続させやすくなります。 ゆえに作業効率がアップし、仕事も捗ります。

逆にデメリットも3つある

f:id:yumasukey:20180703141747j:plain

もちろんいいことばかりではなく、デメリットもあります。 次にあげるようなことが、実際感じた・思ったリモートワークのデメリットです。

ちゃんと伝わってる?!コミュニケーションが難しく

リモートだと、基本的にメールかチャットなどテキストベースでやりとりすることになります。 テキストベースだと、どうしても微妙なニュアンスが伝わりづらく、文章に工夫が必要になります。 言葉では簡単に伝えられるのに、文字だと言いたいことが正確に伝わらないというストレスを感じることがありました。 対面に比べて伝わる/伝える情報量が少ないので、会議や打ち合わせといったケースは特に対応が難しいかもしれません。

現場の温度感・緊急性がわかりづらい

テキストベースでのコミュニケーションに起因するものとして、現場の温度感を感じ取りにくいというデメリットもあります。 たとえば顧客から緊急の問い合わせがあり、すぐに回答がほしいのに、その「緊急感」が文字からは読み取れなかったり チャットやメールの確認が遅れたりして、結果としてトラブルになってしまうケースも考えられます。

煩悩との戦い!高い自律が求められる

オフィスとちがって誰も見ていないので、サボろうと思えばいくらでもサボれてしまいます。 ゲーム、テレビ、ネットなどオフィスに比べて誘惑はいっぱいあります。 「ちょっとくらいいか〜」と気を許したらもうダメです。ズルズルと時間を溶かしてしまいます。 誰にも見られていないとはいえ、業務中であるという認識と、誘惑に負けない自律性が必要です。

デメリットまとめ

文字から伝わる情報は対面と比較して乏しいため、相手に正確に伝えるためには文章表現に工夫が必要になります。 そして口頭でのコミュニケーションとは異なり、リアルタイムにやり取りできないので現場との空気感・緊張感の共有が難しく、後で問題になることも。 上記に加え、誘惑に負けないように自分を律する必要があります。ですので誰にでも合う働き方ではないと思います。 特に自室で一人勉強するよりも、友達と勉強したり、自習室にいったほうが捗るといったタイプの人には向かないかもしれません。

「ハイブリッド型」のリモート制度がいいのでは?

2ヶ月ほど週1回のペースでリモートワークを体験してみて感じたことをまとめてみました。 まだまだ制度としては試行錯誤の段階ですが、私の場合はプラスの面のほうが多かったように思います。 確かにどうしてもリモートでは対応できない状況もありますので、フルリモートではなく、週にN回までといったハイブリッド型の制度にするのが良いと感じています。

一方でデメリットであげたように、リモートワークというスタイルは誰にでも合う働き方ではないと思います。 人や業務によって合う合わないがあると思います。 それ以外のデメリットについては、チャットやメールだけでなく、Webカメラやマイクを使用して音声でもやりとりしたり こまめに進捗を報告したりと、やり方を工夫をするなど改善を重ねていくことにより魅力的な制度になっていくはずです。

iOSアプリのバックエンドをAWS ECSとGoで作りました

はじめまして、Goのpackage import pathのためにgithubアカウントを短くしたymgytです。

 ハウテレビジョンでは2018年5月15日にiOSアプリ外資就活ドットコムのリニューアル版をリリースいたしました。 このアプリのバックエンドをAWS Elastic Container Service(ESC)とGoを中心に作りました。本blogではその際にハマったところや意識したところを述べていきたいと思います。

構成

f:id:yamaguchi7073xtt:20180709175852p:plain

主に利用しているAWS関連のリソースは

  • ALB(Application Load Balancer)
  • Autoscaling Group(EC2)
  • ECS
  • Lambda
  • CloudWatch(Logs, Alarm, Events)
  • RDS
  • Elasticache(redis)

Terraform

 AWSリソースの作成はterraformを利用しました。  terraformはvagrant,packer等を提供しているHashiCorpが作成したGo製のCLIツールです。

Cloudformationとの比較

 私はAWSのリソース管理にはCloudformationを利用しておりましたが、今Projectではterraformを用いて行うことになっており、terraform初挑戦でした. Cloudformationを利用していた時は、AWS CLIやAWS SDKを用いてCloudformation APIを叩くことになりますが、この際の適用scriptは自分でつくる必要があります。丁寧にやろうとするといきなりCloudformationのstackを更新せずに、Change SetというAWS リソースへの変更を表現したデータを一度作成して、内容を確認した後にこれを適用する必要があります。また、各リソースのパラメータ毎に更新された場合にリソースが破棄=>再作成されるのか、動的に更新されるのかが決まっているのですが、このあたりを意識せずにterraform plan を実行すればよいだけなのがすごく良かったです。  どんな設定fileでもそうですが、再利用性を考えた場合や後の変更箇所は変数化されていていき、結果変数の数が増えていきます。Cloudformationでは1 templateで利用できるパラメータ数が60という制限があり、適宜Mappingやroot templateを設けてtemplateを細分化して対応したりして対応していました。terraformでは variable.tf fileを作成して変数を宣言し, terraform.tfvarsfileを作成して変数を明示的に管理できる仕組みが用意されており、templateと変数の分離/管理が行いやすかったです。ただし、terraformはAWSに特化したツールではないので、Cloudformationで利用できる組み込み関数(GetAZs)やConditionといったものは利用できません。また各リソースのパラメータについての説明はterraform上のdocumentは説明が簡素なので、場合によってはCloudformationのdocumentを参照する必要がでてきます。

Go製CLIツール

 最後は個人的な好みになりますが、terraformがGoで書かれているので+5億点でした。terraformの中では、HashiCorp創業者であるMitchell Hashimoto氏が作成された github.com/mitchellh/cligithub.com/mitchellh/panicwrap 等が利用されており、読んでいてとても勉強になります。

ECS

 先日のAWS Summit Tokyo 2018でap-northeast-1 regionでのAWS Fargateの対応予定がアナウンスされましたが構築中の時期ではFargateはap-northeast-1では未対応でしたので、通常のEC2で作成しました。

Cluster

 ECSではEC2 Instanceの集合をClusterという概念で捉え、CPU使用率やメモリ使用率といったリソースの使用状況もCluster単位で把握できる仕組みを提供してくれています。ただし、すべてをCluster単位で考えて、それぞれのEC2 Instanceのことは考えなくてよいかというと後述するポートの関係等でEC2 Instanceを意識せざるをえない場面もあります。最初にわかりづらかった点として、EC2 Instanceを作成するに際して、当該InstanceとECS上のなにかを対応づける情報が一切ない点です.

Cluster作成のtf fileのsample
   resource "aws_launch_configuration" "ecs" {
     name_prefix                 = "ecs-${var.app_name}-"
     image_id                    = "${var.image_id}"
     instance_type               = "${var.instance_type}"
     iam_instance_profile        = "${aws_iam_instance_profile.ecs.id}"
     key_name                    = "${var.key_name}"
     security_groups             = ["${aws_security_group.ecs_node_sg.id}"]
     user_data                   = "${data.template_file.ecs_config.rendered}"
     associate_public_ip_address = false
   
     lifecycle {
       create_before_destroy = true 
     }
   }
   
   resource "aws_autoscaling_group" "ecs" {
     vpc_zone_identifier       = ["${data.aws_subnet.public_L.id}", "${data.aws_subnet.public_R.id}"]
     name                      = "${var.app_name}-group-${var.env_name}"
     min_size                  = "${var.asg_min_size}"
     max_size                  = "${var.asg_max_sieze}"
     health_check_grace_period = 300
     health_check_type         = "ELB"
     desired_capacity          = "${var.ec2_desired_capacity}"
     launch_configuration      = "${aws_launch_configuration.ecs.name}"
   
     lifecycle {
       create_before_destroy = true
     }
   
     tag {
       key                 = "Name"
       value               = "${var.app_name}-node-${var.env_name}"
       propagate_at_launch = true
     }
   
     tag {
       key                 = "ClusterName"
       value               = "${aws_ecs_cluster.api.name}"
       propagate_at_launch = true
     }
   }
   
   resource "aws_iam_instance_profile" "ecs" {
     name = "ecs-instance-profile-${var.app_name}-${var.env_name}"
     path = "/"
     role = "${aws_iam_role.ecs_instance_role.name}"
   }
   
   resource "aws_iam_role" "ecs_instance_role" {
     name = "ecs_instance_role-${var.app_name}-${var.env_name}"
   
     assume_role_policy = <<EOF
   {
     "Version": "2008-10-17",
     "Statement": [
       {
         "Action": "sts:AssumeRole",
         "Principal": {
           "Service": "ec2.amazonaws.com"
         },
         "Effect": "Allow",
         "Sid": ""
       }
     ]
   }
   EOF
   }
   
   resource "aws_iam_policy" "ecs_instance_policy" {
     name = "ecs_instance_policy-${var.app_name}-${var.env_name}"
   
     policy = <<EOF
   {
     "Version": "2012-10-17",
     "Statement": [
       {
         "Action": [
           "ec2:Describe*"
         ],
         "Effect": "Allow",
         "Resource": "*"
       }
     ]
   }
   EOF
   }
   
   resource "aws_iam_role_policy_attachment" "ecs_instance_role_attach_manage" {
     role       = "${aws_iam_role.ecs_instance_role.name}"
     policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
   }
   
   resource "aws_iam_role_policy_attachment" "ecs_instance_role_attach_custom" {
     role       = "${aws_iam_role.ecs_instance_role.name}"
     policy_arn = "${aws_iam_policy.ecs_instance_policy.arn}"
   }

tagやIAM Roleにecsを意識した設定はでてくるものの、Clusterの作成は通常のautoscale groupを利用したEC2 Instanceの作成とかわりません。 autoscale groupをclusterにしているのが、user_dataとして渡している起動scriptでおこなわれるecs agent設定です。

user_data.sh
#!/bin/bash

# Install JQ JSON parser
yum install -y jq aws-cli

# Get the current region and instance_id from the instance metadata
region=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)
instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)

# Fetch ECS cluster name from tag
cluster_name=$(aws ec2 describe-instances --region $region --instance-ids $instance_id | jq '.Reservations[0].Instances[0].Tags | from_entries | .ClusterName' -r)

echo ECS_CLUSTER=$cluster_name >> /etc/ecs/ecs.config
echo ECS_AVAILABLE_LOGGING_DRIVERS=[\"json-file\",\"syslog\",\"fluentd\",\"awslogs\"] >> /etc/ecs/ecs.config
echo ECS_UPDATES_ENABLED=true >> /etc/ecs/ecs.config
echo ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION=1h >> /etc/ecs/ecs.config
echo ECS_CONTAINER_STOP_TIMEOUT=5m >> /etc/ecs/ecs.config

AMIはECS-Optimized AMIを利用しているので、ecs-agentはpreinstallされています.ecs agentは /etc/ecs/ecs.configに従って機能するので、起動時に必要なパラメータを設定します。この処理のおかげてECS上のCluterという単位でEC2 Instance群を捉えられるようになります。 FargateとEC2 Instanceを抽象化するためか、ECS上ではEC2 InstanceはECS Container InstanceとしてAPIのパラメータやdocument上で扱われます。

Service

 Clusterを定義した後は、Serviceを作成します・ Serviceは最初はわかりづらい概念ですが、私はコンテナ群とロードバランサーを紐付ける概念上のモデルと捉えています。ServiceのパラメータでlaunchType(FARGATE,EC2)を設定したり、taskDefinition(docker-componse.yml)やコンテナ数(task数)を設定します。

Deploy

ECSにおけるDeployは、Serviceのtask definitionの更新なのでdeploy自体は下記のコマンドで実行しています

elb_target_group_arn=$(aws elbv2 describe-target-groups --names ${ELB_TARGET_NAME} | jq -r '.TargetGroups[].TargetGroupArn')
ecs-cli compose --project-name ${PROJECT_NAME} --task-role-arn ecs_task_role-${PROJECT_NAME} service up --container-name api --container-port 80 --target-group-arn ${elb_target_group_arn}

ecs-cliがdocker-compose.yml fileをtaskDefinitionとして扱ってくれるのでdocker imageを更新したうえでecs-cli service up を叩くとdeployが走ります。 ports: 0:80は動的ポートを設定しており、コンテナをスタートするとホスト上で動的に確保されたポートをALBのtarget groupに登録してくれます。この設定によって、1台のEC2 Instanceに複数のtaskを配置できEC2 Instanceのリソースを有効に使い切れます。 Client -> ALB(443) -> EC2 Instance(32777) -> Docker Container(80)というイメージです。 ecs-cliはaws-cliとは別にawsによって管理されており、こちらもGo製です。

version: '2'
services:
  api:
    image: ${IMAGE_TAG_API}
    mem_limit: ${MEM_LIMIT}
    ports:
      - "0:80"
    environment:
       - APP_ENV=${APP_ENV}
    command: /usr/local/bin/api-runner
    logging:
      driver: fluentd
      options:
        fluentd-address: ${FLUENTD_ADDRESS}
        tag: apiv2.{{.Name}}.{{.ID}}

Autoscaling

 ECSのAutoscaling、特にlaunch typeをEC2に設定している場合,autoscalingさせるには2つの設定が必要です。それがServiceのscalingとClusterのscalingです. アプリケーションの負荷状況に応じて配備しているtask数を動的に変更していくのがServiceのautoscaling, Serviceが利用できるCPU/メモリはClusterとして登録されているEC2 InstanceのCPU/メモリの合計なので、これを超えてtaskを配置するためのClusterのautoscalingがそれぞれ必要になります。

Service
resource "aws_iam_role" "ecs_autoscale_role" {
  name = "${var.app_name}-ecs-autoscale-role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "application-autoscaling.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "ecs_autoscale_role_attach" {
  role       = "${aws_iam_role.ecs_autoscale_role.name}"
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceAutoscaleRole"
}

resource "aws_appautoscaling_target" "main" {
  max_capacity       = 20
  min_capacity       = 2
  resource_id        = "service/${aws_ecs_cluster.api.name}/${var.app_name}-${var.env_name}"
  role_arn           = "${aws_iam_role.ecs_autoscale_role.arn}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

resource "aws_appautoscaling_policy" "scale_out" {
  name               = "${aws_ecs_cluster.api.name}-scale-out"
  resource_id        = "service/${aws_ecs_cluster.api.name}/${var.app_name}-${var.env_name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 60
    metric_aggregation_type = "Average"

    step_adjustment {
      metric_interval_lower_bound = 0
      scaling_adjustment          = 1
    }
  }

  depends_on = ["aws_appautoscaling_target.main"]
}

resource "aws_appautoscaling_policy" "scale_in" {
  name               = "${aws_ecs_cluster.api.name}-scale-in"
  resource_id        = "service/${aws_ecs_cluster.api.name}/${var.app_name}-${var.env_name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 60
    metric_aggregation_type = "Average"

    step_adjustment {
      metric_interval_upper_bound = 0
      scaling_adjustment          = -1
    }
  }

  depends_on = ["aws_appautoscaling_target.main"]
}

resource "aws_cloudwatch_metric_alarm" "ecs_service_cpu_utilization_high" {
  alarm_name          = "ecs-service-${var.app_name}-cpu-high-alarm"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 1
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = "180"
  statistic           = "Average"
  threshold           = "70"
  actions_enabled     = true
  treat_missing_data  = "missing"
  alarm_description   = "ecs service"

  dimensions {
    ClusterName = "${aws_ecs_cluster.api.name}"
    ServiceName = "${var.app_name}-${var.env_name}"
  }

  alarm_actions             = ["${aws_appautoscaling_policy.scale_out.arn}"]
  insufficient_data_actions = []
  ok_actions                = []

  depends_on = ["aws_appautoscaling_policy.scale_out"]
}

resource "aws_cloudwatch_metric_alarm" "ecs_service_cpu_utilization_low" {
  alarm_name          = "ecs-service-${var.app_name}-cpu-low-alarm"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 1
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = "3600"
  statistic           = "Average"
  threshold           = "5"
  actions_enabled     = true
  treat_missing_data  = "missing"
  alarm_description   = "ecs service"

  dimensions {
    ClusterName = "${aws_ecs_cluster.api.name}"
    ServiceName = "${var.app_name}-${var.env_name}"
  }

  alarm_actions             = ["${aws_appautoscaling_policy.scale_in.arn}"]
  insufficient_data_actions = []
  ok_actions                = []

  depends_on = ["aws_appautoscaling_policy.scale_in"]
}

 このように、appautoscaling policyを作成、cloudwatch alarmを作成して、metricの閾値に応じてactionを実行する形でautoscalingさせるよう設定しました。ただしこの方法ですと、scale downさせるためにもcloudwatch alarmを設定しなくてはならず、問題がないのもかかわらずalarmが発火してしまうので、他のalarm運用と衝突してしまいかねないので、なにかいい方法がないか探しているところでもあります。

Cluster
resource "aws_autoscaling_policy" "scale_out" {
  name                   = "autoscaling-${aws_ecs_cluster.api.name}-scale-out"
  policy_type            = "SimpleScaling"
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = 1
  cooldown               = 300
  autoscaling_group_name = "${aws_autoscaling_group.ecs.name}"
}

resource "aws_autoscaling_policy" "scale_in" {
  name                   = "autoscaling-${aws_ecs_cluster.api.name}-scale-in"
  policy_type            = "SimpleScaling"
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = -1
  cooldown               = 300
  autoscaling_group_name = "${aws_autoscaling_group.ecs.name}"
}

resource "aws_cloudwatch_metric_alarm" "ecs_cluster_memory_reservation_high" {
  alarm_name          = "ecs-cluster-${aws_ecs_cluster.api.name}-memory-reservation-high-alarm"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 1
  metric_name         = "MemoryReservation"
  namespace           = "AWS/ECS"
  period              = "60"
  statistic           = "Average"
  threshold           = "70"
  actions_enabled     = true
  treat_missing_data  = "missing"
  alarm_description   = "ecs cluster memory reservation high"

  dimensions {
    ClusterName = "${aws_ecs_cluster.api.name}"
  }

  alarm_actions             = ["${aws_autoscaling_policy.scale_out.arn}"]
  insufficient_data_actions = []
  ok_actions                = []
}

resource "aws_cloudwatch_metric_alarm" "ecs_cluster_memory_reservation_low" {
  alarm_name          = "ecs-cluster-${aws_ecs_cluster.api.name}-memory-reservation-low-alarm"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 1
  metric_name         = "MemoryReservation"
  namespace           = "AWS/ECS"
  period              = "3600"
  statistic           = "Average"
  threshold           = "30"
  actions_enabled     = true
  treat_missing_data  = "missing"
  alarm_description   = "ecs cluster memory reservation low"

  dimensions {
    ClusterName = "${aws_ecs_cluster.api.name}"
  }

  alarm_actions             = ["${aws_autoscaling_policy.scale_in.arn}"]
  insufficient_data_actions = []
  ok_actions                = []
}

 Cluster側も同様にautoscalingのscaling policyを作成し、監視するMetricに応じたcloudwatch alarmを作成して、scalingさせていきます。 こちらは通常のEC2 InstanceのAutoscaleと変わらないかと思います。ただし、ここでCluster側が一切ECSを意識しなくてよいことによる問題が生じます。それはclusterに属するEC2 Instanceがscale inする際に当該Instaceで起動しているContainerに関知しないことです。そこで、scale in時にhookを設定し、gracefulな終了処理を行います。

Terminate Hook Lambda

f:id:yamaguchi7073xtt:20180707161543p:plain

処理の概要としては、Autoscaleのscalein時のHookにSNS通知を設定し、Lambdaを起動します。Lambdaの中で通知情報から対象EC2 Instanceを取得し、当該EC2 Instance(ECSからはContainer Instace)の状態をECS APIを利用してDrainingに変更します。その後running状態のtask数を取得し、まだrunning状態のtaskがある場合には再度snsに自身を起動した通知を再度pushし、running状態のtaskが0の場合は、autoscale APIのcompleteLifeCycleAction APIを呼び出します。 LambdaはGoで実装しました。

terminating hook lambda
resource "aws_iam_role" "asg_lifecycle_hook_role" {
  name = "asg-lifecycle-hook-${aws_ecs_cluster.api.name}"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Effect": "Allow",
      "Principal": {
        "Service": "autoscaling.amazonaws.com"
      }
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "asg_lifecycle_hook_role_attach" {
  role       = "${aws_iam_role.asg_lifecycle_hook_role.name}"
  policy_arn = "arn:aws:iam::aws:policy/service-role/AutoScalingNotificationAccessRole"
}

resource "aws_autoscaling_lifecycle_hook" "terminating" {
  name                    = "${aws_ecs_cluster.api.name}-terminating-hook"
  lifecycle_transition    = "autoscaling:EC2_INSTANCE_TERMINATING"
  autoscaling_group_name  = "${aws_autoscaling_group.ecs.name}"
  default_result          = "ABANDON"
  heartbeat_timeout       = 900
  notification_target_arn = "${aws_sns_topic.asg_lifecycle_hook_terminating.arn}"
  role_arn                = "${aws_iam_role.asg_lifecycle_hook_role.arn}"
}

resource "aws_iam_role" "terminate_hook_lambda_role" {
  name = "${var.app_name}-asg-terminate-hook-lambda-role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow"
    }
  ]
}
EOF
}

resource "aws_iam_policy" "terminate_hook_lambda_policy" {
  name        = "${var.app_name}-asg-terminate-hook-policy"
  path        = "/"
  description = "asg,cw,ec2,sns operations"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "autoscaling:CompleteLifecycleAction",
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "ec2:DescribeInstances",
        "ec2:DescribeInstanceAttribute",
        "ec2:DescribeInstanceStatus",
        "ec2:DescribeHosts",
        "ecs:ListContainerInstances",
        "ecs:SubmitContainerStateChange",
        "ecs:SubmitTaskStateChange",
        "ecs:DescribeContainerInstances",
        "ecs:UpdateContainerInstancesState",
        "ecs:ListTasks",
        "ecs:DescribeTasks",
        "sns:Publish",
        "sns:ListSubscriptions"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "terminate_hook_lambda_basic_execution" {
  role       = "${aws_iam_role.terminate_hook_lambda_role.name}"
  policy_arn = "${aws_iam_policy.terminate_hook_lambda_policy.arn}"
}

resource "aws_lambda_function" "terminating_hook" {
  s3_bucket         = "${var.lambda_s3_bucket}"
  s3_key            = "${lookup(var.lambda_s3_key, "asg-terminate-hook")}"
  s3_object_version = "${lookup(var.lambda_s3_object_version, "asg-terminate-hook")}"
  description       = "make sure no task running when terminating cluster instance."
  function_name     = "${var.app_name}-asg-terminate-hook"
  role              = "${aws_iam_role.terminate_hook_lambda_role.arn}"
  handler           = "handler"
  runtime           = "go1.x"
  memory_size       = 128
  timeout           = 10

  environment {
    variables = {
      LAMBDA_LOG_ENCODE               = "console"
      LAMBDA_AWS_REGION               = "ap-northeast-1"
      LAMBDA_LOG_LEVEL                = -1
      LAMBDA_EC2_TAG_KEY_CLUSTER_NAME = "ClusterName"
    }
  }
}

resource "aws_lambda_permission" "sns_invoke" {
  statement_id  = "${var.app_name}-allow-execute-from-sns"
  action        = "lambda:InvokeFunction"
  function_name = "${aws_lambda_function.terminating_hook.function_name}"
  principal     = "sns.amazonaws.com"
  source_arn    = "${aws_sns_topic.asg_lifecycle_hook_terminating.arn}"
}
lambda

前述したとおり、AutoscalingはClusterの情報をもたないので、terminateされようとしているEC2 Instanceから所属しているClusterを取得する必要があります。約束としてTagにClusterNameを設定する運用をおこなっていますが、lambdaの中では、tagとuser dataのparseという2つのアプローチでClusterNameを取得しようとしています。

package main

import (
 "bufio"
 "bytes"
 "context"
 "encoding/base64"
 "encoding/json"
 "fmt"
 "os"
 "regexp"
 "strconv"
 "strings"
 "time"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/aws/aws-sdk-go/aws"
 "github.com/aws/aws-sdk-go/aws/session"
 "github.com/aws/aws-sdk-go/service/autoscaling"
 "github.com/aws/aws-sdk-go/service/ec2"
 "github.com/aws/aws-sdk-go/service/ecs"
 "github.com/aws/aws-sdk-go/service/sns"
 "github.com/davecgh/go-spew/spew"
 "github.com/juju/errors"
 "go.uber.org/zap"
)

const (
 // ECS Container Instanceの状態
 ConInsStatusActive   = "ACTIVE"
 ConInsStatusInactive = "INACTIVE"
 ConInsStatusDraining = "DRAINING"

 // ECS Taskの状態
 TaskStatusRunning = "RUNNING"
 TaskStatusStopped = "STOPPED"
 TaskStatusPending = "PENDING"
)

type App struct {
 Service *Service
 Options AppOptions
 Log     *zap.Logger
}

type AppOptions struct {
 LogLevel                     int
 LogEncode                    string
 Region                       string
 EC2InstanceTagKeyClusterName string
}

// 設定を環境変数から取得
func newAppOptionsFromEnv() AppOptions {
 logLevel, err := strconv.ParseInt(os.Getenv("LAMBDA_LOG_LEVEL"), 10, 0)
 if err != nil {
     logLevel = 0
 }

 logEncode := os.Getenv("LAMBDA_LOG_ENCODE")
 if logEncode == "" {
     logEncode = "console"
 }

 region := os.Getenv("LAMBDA_AWS_REGION")
 if region == "" {
     region = "ap-northeast-1"
 }

 clusterTag := os.Getenv("LAMBDA_EC2_TAG_KEY_CLUSTER_NAME")
 if clusterTag == "" {
     clusterTag = "ClusterName"
 }
 return AppOptions{
     LogLevel:  int(logLevel),
     LogEncode: logEncode,
     Region:    region,
     EC2InstanceTagKeyClusterName: clusterTag,
 }
}

func NewApp() *App {
 app, err := newApp(newAppOptionsFromEnv())
 if err != nil {
     fmt.Println("failed to init app", err.Error())
     os.Exit(1)
 }
 return app
}

func newApp(o AppOptions) (*App, error) {
 logger, err := GetLogger(
     WithLoggingLevel(o.LogLevel),
     WithEncoded(o.LogEncode),
 )
 if err != nil {
     return nil, err
 }

 service, err := NewService(o.Region)
 if err != nil {
     return nil, err
 }

 return &App{
     Service: service,
     Log:     logger,
     Options: o,
 }, nil
}

type Service struct {
 EC2 *ec2.EC2
 ECS *ecs.ECS
 ASG *autoscaling.AutoScaling
 SNS *sns.SNS
}

func NewService(region string) (*Service, error) {
 sess, err := session.NewSession(&aws.Config{
     Region: aws.String(region),
 })
 if err != nil {
     return nil, err
 }

 return &Service{
     EC2: ec2.New(sess),
     ECS: ecs.New(sess),
     ASG: autoscaling.New(sess),
     SNS: sns.New(sess),
 }, nil
}

// 実行時間が500ms程度なので即sns publishすると1secに複数回実行されてしまう
// contextから残り実行時間を取得し可能な限りwaitする
func (a *App) Wait(ctx context.Context) {
 deadline, ok := ctx.Deadline()
 a.Log.Debug("wait", zap.Time("deadline", deadline))
 if ok {
     wakeup := deadline.Add(time.Second * -2)
     if time.Now().Before(wakeup) {
         a.Log.Debug("wait", zap.Time("before_wait", time.Now()))
         select {
         case <-time.After(time.Until(wakeup)):
         }
         a.Log.Debug("wait", zap.Time("after_wait", time.Now()))
     }
 }
}

// このLambdaをもう一度実行するために、同じMessageをSNSにPublishする
func (a *App) PublishSNS(ctx context.Context, topicARN, message string) error {
 const subject = "publish sns to invoke me again"
 input := &sns.PublishInput{
     Message:  aws.String(message),
     Subject:  aws.String(subject),
     TopicArn: aws.String(topicARN),
 }

 a.Wait(ctx)
 output, err := a.Service.SNS.Publish(input)
 if err != nil {
     return errors.Trace(err)
 }
 a.Log.Info("publish_sns",
     zap.String("msg", "successfully publish sns"),
     zap.String("message_id", aws.StringValue(output.MessageId)))
 return nil
}

// Autoscaling GroupにLifecycle Hookの完了を通知する
func (a *App) CompleteASGLifecycleAction(e LifecycleEvent) error {
 input := &autoscaling.CompleteLifecycleActionInput{
     AutoScalingGroupName:  aws.String(e.AutoScalingGroupName),
     InstanceId:            aws.String(e.EC2InstanceID),
     LifecycleActionResult: aws.String("CONTINUE"),
     LifecycleActionToken:  aws.String(e.LifecycleActionToken),
     LifecycleHookName:     aws.String(e.LifecycleHookName),
 }
 _, err := a.Service.ASG.CompleteLifecycleAction(input)
 if err != nil {
     return errors.Trace(err)
 }
 a.Log.Info("comple_asg_lifecycle_hook",
     zap.String("msg", "successfully complete lifecycle hook"))
 return nil
}

// 指定されたcontainer instanceに配置されている指定された状態のTaskを返す
func (a *App) getTasks(clusterName, containerInstanceARN, status string) ([]*ecs.Task, error) {
 if clusterName == "" || containerInstanceARN == "" {
     return nil, errors.Errorf("clusterName %s and/or containerInstanceARN %s is empty", clusterName, containerInstanceARN)
 }
 inputList := &ecs.ListTasksInput{
     Cluster:           aws.String(clusterName),
     ContainerInstance: aws.String(containerInstanceARN),
     DesiredStatus:     aws.String(status),
 }

 var taskARNs []*string
 for {
     outputList, err := a.Service.ECS.ListTasks(inputList)
     if err != nil {
         return nil, errors.Annotatef(err, "clusterName %q, containerInstanceARN %q", clusterName, containerInstanceARN)
     }
     taskARNs = append(taskARNs, outputList.TaskArns...)
     if outputList.NextToken == nil {
         break
     }
     inputList = inputList.SetNextToken(*outputList.NextToken)
 }
 if len(taskARNs) == 0 {
     return nil, errors.NotFoundf("clusterName %q, containerInstanceARN %q", clusterName, containerInstanceARN)
 }

 // 一度に問い合わせできるTaskの上限は100
 var tasks []*ecs.Task
 var n, m int
 for {
     n = m
     m = m + 100
     if m > len(taskARNs) {
         m = len(taskARNs)
     }
     inputDesc := &ecs.DescribeTasksInput{
         Cluster: aws.String(clusterName),
         Tasks:   taskARNs[n:m],
     }
     outputDesc, err := a.Service.ECS.DescribeTasks(inputDesc)
     if err != nil {
         return nil, errors.Annotatef(err, "min %d, max %d", n, m)
     }
     tasks = append(tasks, outputDesc.Tasks...)
     if m >= len(taskARNs) {
         break
     }
 }
 return tasks, nil
}

func (a *App) GetRunningTasks(clusterName, ec2InstanceID string) ([]*ecs.Task, error) {
 if clusterName == "" || ec2InstanceID == "" {
     return nil, errors.Errorf("cluster_name %s and/or ec2_instance_id %s is empty", clusterName, ec2InstanceID)
 }
 containerInstance, err := a.getContainerInstance(clusterName, ec2InstanceID)
 if err != nil {
     return nil, errors.Trace(err)
 }

 containerInstanceARN := aws.StringValue(containerInstance.ContainerInstanceArn)
 tasks, err := a.getTasks(clusterName, containerInstanceARN, TaskStatusRunning)
 if err != nil {
     if errors.IsNotFound(errors.Cause(err)) {
         return nil, nil
     }
     return nil, errors.Trace(err)
 }
 return tasks, nil
}

// ECS Container Instanceと EC2 Instanceは区別されている
func (a *App) getContainerInstance(clusterName, ec2InstanceID string) (*ecs.ContainerInstance, error) {
 inputList := &ecs.ListContainerInstancesInput{
     Cluster:    aws.String(clusterName),
     MaxResults: aws.Int64(100),
 }

 var containerARNs []*string
 for {
     outputList, err := a.Service.ECS.ListContainerInstances(inputList)
     if err != nil {
         return nil, errors.Annotatef(err, "clusterName %q, ec2InstanceID %q", clusterName, ec2InstanceID)
     }
     containerARNs = append(containerARNs, outputList.ContainerInstanceArns...)
     if outputList.NextToken == nil {
         break
     }
     inputList = inputList.SetNextToken(*outputList.NextToken)
 }

 log := a.Log.With(zap.String("cluster_name", clusterName), zap.String("ec2_instance_id", ec2InstanceID))
 log.Debug("list_container_instance", zap.Strings("container_instance_arns", aws.StringValueSlice(containerARNs)))
 if len(containerARNs) == 0 {
     return nil, errors.NotFoundf("clusterName %q, ec2InstanceID %q", clusterName, ec2InstanceID)
 }

 inputDisc := &ecs.DescribeContainerInstancesInput{
     Cluster:            aws.String(clusterName),
     ContainerInstances: containerARNs,
 }
 outputDisc, err := a.Service.ECS.DescribeContainerInstances(inputDisc)
 if err != nil {
     return nil, errors.Annotatef(err, "clusterName %q, ec2InstanceID %q", clusterName, ec2InstanceID)
 }

 target := func(outputDisc *ecs.DescribeContainerInstancesOutput) *ecs.ContainerInstance {
     for _, conIns := range outputDisc.ContainerInstances {
         if strings.Compare(ec2InstanceID, aws.StringValue(conIns.Ec2InstanceId)) == 0 {
             return conIns
         }
     }
     return nil
 }(outputDisc)
 if target == nil {
     return nil, errors.Errorf("failed to fetch container instances from cluster %q, ec2_instance_id %q", clusterName, ec2InstanceID)
 }
 return target, nil
}

// ECS Taskの配置を停止し、既存のTaskをStopするためにContainer Instanceの状態を変更する
func (a *App) ChangeInstanceStatus(clusterName, containerInstanceARN, status string) error {
 if clusterName == "" || containerInstanceARN == "" {
     return errors.Errorf("clusterName %s and/or containerInstanceARN %s is empty", clusterName, containerInstanceARN)
 }
 input := &ecs.UpdateContainerInstancesStateInput{
     Cluster:            aws.String(clusterName),
     ContainerInstances: aws.StringSlice([]string{containerInstanceARN}),
     Status:             aws.String(status),
 }
 output, err := a.Service.ECS.UpdateContainerInstancesState(input)
 if err != nil {
     return errors.Annotatef(err, "clusterName %q, containerInstanceARN %q, status %s", clusterName, containerInstanceARN, status)
 }
 failures := output.Failures
 if len(failures) > 0 {
     buf := new(bytes.Buffer)
     for _, f := range output.Failures {
         buf.WriteString(fmt.Sprintf("%s %s\n", aws.StringValue(f.Arn), aws.StringValue(f.Reason)))
     }
     return errors.Errorf("clusterName %q, containerInstanceARN %q, status %s\nreported failures %s",
         clusterName, containerInstanceARN, status, strings.TrimRight(buf.String(), "\n"))
 }
 return nil
}

// ECS Container Instanceの状態をDraining状態にする
// 既にDrainingであればなにもしない
func (a *App) EnsureInstanceStatusDraining(clusterName, ec2InstanceID string) error {
 if clusterName == "" || ec2InstanceID == "" {
     return errors.Errorf("cluster_name %s and/or ec2_instance_id %s is empty", clusterName, ec2InstanceID)
 }
 containerInstance, err := a.getContainerInstance(clusterName, ec2InstanceID)
 if err != nil {
     return errors.Trace(err)
 }

 containerInstanceARN := aws.StringValue(containerInstance.ContainerInstanceArn)
 status := aws.StringValue(containerInstance.Status)
 log := a.Log.With(
     zap.String("cluster_name", clusterName),
     zap.String("ec2_instance_id", ec2InstanceID),
     zap.String("container_instance_arn", containerInstanceARN),
     zap.String("container_status", status))
 switch status {
 case ConInsStatusDraining:
     log.Info("ensure_container_status", zap.String("msg", "status already draining"))
 case ConInsStatusActive, ConInsStatusInactive:
     log.Info("ensure_container_status", zap.String("msg", "change container status to draining"))
     err := a.ChangeInstanceStatus(clusterName, containerInstanceARN, ConInsStatusDraining)
     if err != nil {
         return errors.Trace(err)
     }
     return a.EnsureInstanceStatusDraining(clusterName, ec2InstanceID)
 default:
     return errors.Errorf("unexpected container instance status %s", status)
 }
 return nil
}

var _clusterNameRegexp = regexp.MustCompile(`^[^#]*ECS_CLUSTER=(?P<name>[${}\w]+) ?.*$`)

func matchClusterName(line string) (clusterName string, ok bool) {
 m := _clusterNameRegexp.FindStringSubmatch(line)
 if len(m) == 2 {
     clusterName, ok = m[1], true
 }
 return
}

func clusterNameFromECSConfig(ecsConfig []byte) (clusterName string, err error) {
 s := bufio.NewScanner(bytes.NewReader(ecsConfig))
 for s.Scan() {
     clusterName, found := matchClusterName(s.Text())
     if found {
         return clusterName, nil
     }
 }
 return "", s.Err()
}

func clusterNameFromUserData(encoded string) (clusterName string, err error) {
 if encoded == "" {
     return "", errors.New("encoded userdata is empty")
 }
 decoded, err := base64.StdEncoding.DecodeString(encoded)
 if err != nil {
     return "", errors.Annotatef(err, "encoded userdata: %s", encoded)
 }
 return clusterNameFromECSConfig(decoded)
}

func (a *App) fetchClusterNameFromUserData(ec2InstanceID string) (clusterName string, err error) {
 // 1. UserData取得
 // 2. base64 decode
 // 3. ClusterName 抽出
 // 4. チェック($ついていない)
 input := &ec2.DescribeInstanceAttributeInput{
     Attribute:  aws.String("userData"),
     InstanceId: aws.String(ec2InstanceID),
 }
 output, err := a.Service.EC2.DescribeInstanceAttribute(input)
 if err != nil {
     return "", errors.Annotatef(err, "ec2 instance id %s", ec2InstanceID)
 }
 if output.UserData == nil {
     return "", errors.Errorf("DescribeInstanceAttributeOuteput.UserData is nil")
 }
 encodedUserData := aws.StringValue(output.UserData.Value)

 clusterName, err = clusterNameFromUserData(encodedUserData)
 if err != nil {
     return "", errors.Trace(err)
 }

 log := a.Log.With(zap.String("ec2_instance_id", ec2InstanceID))
 log.Info("fetch_cluster_name", zap.String("value", clusterName))
 // echo ECS_CLUSTER=$cluster >> /etc/ecs.configのような変数参照をcare
 if strings.Contains(clusterName, "$") {
     log.Info("fetch_cluster_name",
         zap.String("msg", "discard invalid cluster name"),
         zap.String("cluster_name", clusterName))
     clusterName = ""
 }
 return
}

// ec2 instanceに設定されているtagの値を返す
func tagValue(tags []*ec2.Tag, key string) string {
 for _, tag := range tags {
     if strings.Compare(key, aws.StringValue(tag.Key)) == 0 {
         return aws.StringValue(tag.Value)
     }
 }
 return ""
}

func (a *App) fetchClusterNameFromTag(ec2InstanceID string) (clusterName string, err error) {
 input := &ec2.DescribeInstancesInput{
     InstanceIds: aws.StringSlice([]string{ec2InstanceID}),
 }
 output, err := a.Service.EC2.DescribeInstances(input)
 if err != nil {
     return "", errors.Trace(err)
 }

 target := func(output *ec2.DescribeInstancesOutput) *ec2.Instance {
     for _, rsv := range output.Reservations {
         for _, ins := range rsv.Instances {
             if strings.Compare(ec2InstanceID, aws.StringValue(ins.InstanceId)) == 0 {
                 return ins
             }
         }
     }
     return nil
 }(output)
 if target == nil {
     return "", errors.Annotatef(err, "failed to fine ec2 instance %s", ec2InstanceID)
 }

 return tagValue(target.Tags, a.Options.EC2InstanceTagKeyClusterName), nil
}

// EC2 Instance IDからClusterの名前を取得する
// UserData, Tagから取得を試みる
func (a *App) FetchClusterName(ec2InstanceID string) (clusterName string, err error) {
 ec2InstanceID = strings.TrimSpace(ec2InstanceID)
 if ec2InstanceID == "" {
     return "", errors.New("FetchClusterName: ec2InstanceID is empty")
 }

 clusterName, err = a.fetchClusterNameFromUserData(ec2InstanceID)
 if err != nil {
     return "", errors.Trace(err)
 }
 if clusterName != "" {
     return clusterName, nil
 }

 clusterName, err = a.fetchClusterNameFromTag(ec2InstanceID)

 return clusterName, errors.Trace(err)
}

// SNS Messageに格納されているLifecycle Hookの情報
// Document https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/lifecycle-hooks.html#sns-notifications
// 値のsample
// (main.LifecycleEvent) {
// LifecycleHookName: (string) (len=37) "xxx-cluster-terminationg-hook",
// AccountId: (string) (len=12) "XXXXXXXXXXXXXXXXXXXXX",
// RequestId: (string) (len=36) "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
// LifecycleTransition: (string) (len=36) "autoscaling:EC2_INSTANCE_TERMINATING",
// AutoScalingGroupName: (string) (len=17) "xxx-xxx-xxx-xxx",
// Service: (string) (len=16) "AWS Auto Scaling",
// TimeRaw: (string) (len=24) "2080-03-03T11:22:33.855Z",
// EC2InstanceId: (string) (len=19) "i-YYYYYYYYYYYYYY",
// NotificationMetadataRaw: (string) (len=41) "{\n \"cluster\": \"\"\"xxx-xxx-xxx-xxx\"\n}\n",
type LifecycleEvent struct {
 LifecycleHookName       string `json:"LifecycleHookName"`
 AccountID               string `json:"AccountId"`
 RequestID               string `json:"RequestId"`
 LifecycleTransition     string `json:"LifecycleTransition"`
 AutoScalingGroupName    string `json:"AutoScalingGroupName"`
 Service                 string `json:"Service"`
 TimeRaw                 string `json:"Time"`
 EC2InstanceID           string `json:"EC2InstanceId"`
 NotificationMetadataRaw string `json:"NotificationMetadata"`
 LifecycleActionToken    string `json:"LifecycleActionToken"`

 // 任意のjsonをSNS Topic作成時に指定できる.
 // 現時点では利用していない
 NotificationMetadata map[string]interface{}
}

func NewLifecycleEventFromJSON(raw string) (LifecycleEvent, error) {
 var event LifecycleEvent
 if raw == "" {
     return event, errors.New("NewLifecycleEventFromJSON: empty json")
 }

 err := json.Unmarshal([]byte(raw), &event)
 if err != nil {
     return event, errors.Errorf("NewLifecycleEventFromJSON: json unmarshal error: %s", err)
 }

 meta := event.NotificationMetadataRaw
 if meta != "" {
     event.NotificationMetadata = make(map[string]interface{})
     if err := json.Unmarshal([]byte(meta), &event.NotificationMetadata); err != nil {
         // 現時点では値を利用していないのでerrorを無視する
     }
 }
 return event, nil
}

// SNS通知のValidation
type EventValidator func(LifecycleEvent) error

func serviceIsAutoScaling(e LifecycleEvent) error {
 const want = "AWS Auto Scaling"
 if e.Service != want {
     return errors.Errorf("LifecycleEvent.Service should be %q, but %q", want, e.Service)
 }
 return nil
}

func transitionIsTerminating(e LifecycleEvent) error {
 const want = "autoscaling:EC2_INSTANCE_TERMINATING"
 if e.LifecycleTransition != want {
     return errors.Errorf("LifecycleEvent.LifecycleTransition should be %q, but %q", want, e.LifecycleTransition)
 }
 return nil
}

func ValidateEvent(event LifecycleEvent, fns ...EventValidator) error {
 for _, f := range fns {
     if err := f(event); err != nil {
         return errors.Trace(err)
     }
 }
 return nil
}

// 1. SNSからTerminate対象EC2 Instanceを取得
// 2. EC2 InstanceからCluster Nameを取得(userdata, tag)
// 3. EC2 Instanceの状態をDrainingに変更
// 4. EC2 Instanceのrunning task数を確認
//   1. task数 == 0 => OK
//   2. task数 >= 1 => SNSに再度通知して終了
// 5. AutoscalingにLifecycle Action完了を通知
func (a *App) HandleRecord(ctx context.Context, record events.SNSEventRecord) error {
 lcEvent, err := NewLifecycleEventFromJSON(record.SNS.Message)
 if err != nil {
     return errors.Trace(err)
 }
 a.Log.Debug("parse_lifecycle_event", zap.String("dump", spew.Sdump(lcEvent)))

 err = ValidateEvent(lcEvent,
     serviceIsAutoScaling,
     transitionIsTerminating)
 if err != nil {
     return errors.Annotatef(err, "failed to validate lifecycle event")
 }

 clusterName, err := a.FetchClusterName(lcEvent.EC2InstanceID)
 if err != nil {
     return errors.Annotatef(err, "failed to fetch cluster name from ec2 instance %s", lcEvent.EC2InstanceID)
 }
 if clusterName == "" {
     return errors.Errorf("failed to fetch cluster name from ec2 instance %s", lcEvent.EC2InstanceID)
 }
 log := a.Log.With(
     zap.String("cluster_name", clusterName),
     zap.String("ec2_instance_id", lcEvent.EC2InstanceID),
 )
 log.Info("fetch_cluster_name")

 err = a.EnsureInstanceStatusDraining(clusterName, lcEvent.EC2InstanceID)
 if err != nil {
     errors.Annotatef(err, "ec2 instance: %s", lcEvent.EC2InstanceID)
 }

 runningTasks, err := a.GetRunningTasks(clusterName, lcEvent.EC2InstanceID)
 if err != nil {
     return errors.Trace(err)
 }
 log.Info("fetch_running_tasks", zap.Int("running_tasks_num", len(runningTasks)))

 if len(runningTasks) == 0 {
     log.Info("complete_asg_lifecycle_action",
         zap.String("lifecycle_hook_name", lcEvent.LifecycleHookName),
         zap.String("autoscaling_group_name", lcEvent.AutoScalingGroupName))
     return a.CompleteASGLifecycleAction(lcEvent)
 }
 log.Info("publish_sns",
     zap.String("topic_arn", record.SNS.TopicArn),
     zap.String("message", record.SNS.Message))
 return a.PublishSNS(ctx, record.SNS.TopicArn, record.SNS.Message)
}

func (a *App) HandleEvent(ctx context.Context, event events.SNSEvent) error {
 a.Log.Info("start")

 for _, record := range event.Records {
     select {
     case <-ctx.Done():
         return ctx.Err()
     default:
         if err := a.HandleRecord(ctx, record); err != nil {
             a.Log.Error("failed", zap.String("details", errors.Details(err)))
             return err
         }
     }
 }

 a.Log.Info("success!")
 return nil
}

func main() {
 app := NewApp()
 lambda.Start(app.HandleEvent)
}

Goのlambdaはbuildしたbinaryをzip化してS3に設置するだけなのでシンプルです。terraform側ではbucket/keyとversionIDを指定しています。

GOOS=linux GOARCH=amd64 go build -o _build/handler .
(cd _build && zip handler.zip handler)
aws s3 mv _build/handler.zip s3://backet/prefix/asg-terminate-hook/handler.zip
aws s3api head-object --bucket bucket --key prefix/asg-terminate-hook/handler.zip --query 'VersionId' --output=text

コンテナは動的に増減していくので、API作成時は後述する12factor app 9の 廃棄容易性を意識してすばやくshutdownできる必要があります。

GoでのAPI Server実装

利用したpackage

主に以下のpackageを利用しました

gin

http関連のhandlingはginを利用しました、requestをparseしてjsonを返すsimpleなAPIなので特に問題なく利用できまし。URLのroutingにはhttprouter(https://github.com/julienschmidt/httprouter)が利用されているので,URLが特殊な場合は事前に対応できるか確認が必要です。validate処理にはvalidator(https://github.com/go-playground/validator)を利用してvalidate処理を行いました

gorm

DB処理にはgorm(https://github.com/jinzhu/gorm)を利用しました。ORMというよりはSQL Builder的に利用しました。document(http://gorm.io/docs/)が充実しており、とても利用しやすかったです。 subqueryが必要な場合は明示的にsubqueryを生成する必要があるので、裏側でとんでもないSQLが生成されるといったこともなく、過度な抽象化は行わない方針で進めました。

zap

個人的にはGoのloggingについて、標準 packageの log 以外に決定版が存在していないと思っており,皆様がloggingをどのように行っているか気になっているところです。 今回は構造化されたloggingを行いたかったのでuber社が公開しているzap(https://github.com/uber-go/zap)を利用しました。

package layout

 今回の一番の悩みどころがpackageのlayout(directory構成)でした。 Goの場合Webframeworkといった形で構成から命名規則まで統一的に管理するより各component単位でpackageを組み合わせ、できるだけ小さく保っていくアプローチがマッチしていると感じています。そこで、Clean Architectureの考えにのっとり,domain層(業務logic),httpに関連するcontroller層,データのCRUDを担当するrepository層の分離を意識したpackage構成にしました。自分自身、clean architectureの概念を勉強中で Clean Architecture: A Craftmans\'s Guide to Software Structure and Design(https://www.amazon.co.jp/dp/B075LRM681)や各種blog等を読ませていただき悪戦苦闘している状態です。  個人的には以下の点を満たせていればよいのではと思っております

  • 業務logicを表現するlayerは他のlayerに依存せず,必要なサービスや機能はinterfaceで表現
  • interfaceを実装するうえで共通のリソース(DB,検索engin,cache...)に依存する処理をpackageに落とし込む
  • http関連の概念は担当layerでまとめて、layer間はできるだけprimitiveなデータとinterfaceだけの依存関係にする

12 factor app

Dockerを用いて開発したこともあり、12 factor app(https://12factor.net/)に適合するよう意識しました. 12 factor appとGoについては12 Factor Applications with Docker and Go(https://www.amazon.co.jp/12-Factor-Applications-Docker-English-ebook/dp/B075HWVLMC)が参考になりました。

コードベース

githubを利用しているので、ここは特に意識しませんでした。

依存関係

Goのpackage管理にはdepを利用しました。

設定

設定は設定fileを用意することはせず、すべて環境変数から渡すようにしました。そのため設定値を管理するためにviper(https://github.com/spf13/viper)を利用したりはせず、直接環境変数を読んで構造体にbindするシンプルなものにしました。認証情報はAWS SecretsManager上に保持するようにしました。

バックエンドサービス

前述しました、clean architectureにそっていれば自然と外部サービスへの依存はinterfaceとして切り出されているはずなので、特に意識することなく環境変数を通じた切り替えができました。

ビルド, リリース, 実行

この箇所は自分の理解が曖昧なのですが、リリースを一意に識別し、ロールバックする点については、ECRを通じたimage管理で実現できていると思っています。

プロセス

単純なAPI Serverだったので特に意識せずにstatelessかつshared-nothingが実現できたのではないでしょうか。

ポートバインディング

Goを利用していれば net/http packageのおかげて自然とport bindingまで完結されるで特に意識しませんでした。

並行性

理解が曖昧ですが、share-nothingが満足されていれば、自然と満たせると考えております。 この考えを推し進めるとあまり多くのgoroutineを生成すべきでないという結論にいきつくのでしょうか。

廃棄容易性

廃棄容易性と仰々しいですが、signalをhandlingしてhttp serverをshutdownする処理を加えました。 ginのsampleを参考にしました(https://github.com/gin-gonic/gin/blob/master/examples/graceful-shutdown/graceful-shutdown/server.go) autoscalingの中でappの内容をできるだけ意識したくないので、起動/終了処理が数秒で完了することは運用面からとても重要な性質だと実感しています。 非同期で処理するジョブを抱え込んだりして、すぐに終了できなくなった場合は黄色信号で、Queue等を通じて処理を分散させる必要があります。

ログ

12factor appを読む前はログといえばログファイルに書き込んでローテションさせるものと思っており、app側が出力ストリームより向こう側を意識してはいけないという主張は極端だなと感じていました。ところが実際にapp側はSTDOUTに書き込むだけにすることで、logging関連処理をappから切り離し、ローテション処理やストレージ管理といった依存性もなくすことができました。 ログ管理については後述します。

管理プロセス

この考えは徹底できておらず、今後の改善が望まれる箇所です。 定期実行が必要な処理はlambda等に切り出し、DBや外部サービスの管理は別途オーケストレーションツール(chef,ansible)で行っています。

Log

logging処理の流れは, App -> fluentd -> bigquery というシンプルな構成をとりました。

app

loggingに関しては12 factor ppの箇所でも述べた通り、app側ではSTDOUTへの書き込み以降は意識しないようになっており、出力先はtask definition(docker-componse.yml)で制御しています。 以下のようにlogging.dirverとしてfluentdを指定しています。logging driverにfluentdがサポートされているので、appの中ではfluentdに書き出していることも意識しないことができました.

version: '2'
services:
  api:
    image: ${IMAGE_TAG_API}
    mem_limit: ${mem_limit}
    ports:
      - "0:80"
    environment:
       - APP_ENV=${APP_ENV}
    command: /usr/local/bin/api-runner
    logging:
      driver: fluentd
      options:
        fluentd-address: ${FLUENTD_ADDRESS}
        tag: apiv2.{{.Name}}.{{.ID}}

fluentd

fluentdについてもAPI Server同様、ECSで管理しています。 logがどのように利用されるかはlogging処理作成時点ではわかっていない/運用次第で変化しうる箇所なので、いったんaggreageする層が必要だと考えています。 fluentd imageについては公式を参考に以下のようなDockerfileになりました

FROM fluent/fluentd:v1.1.1-debian-onbuild

# install plugins
RUN buildDeps="sudo make gcc g++ libc-dev ruby-dev" \
 && apt-get update \
 && apt-get install -y --no-install-recommends $buildDeps \
 && sudo gem install \
        fluent-plugin-bigquery:1.2.0 \
 && sudo gem sources --clear-all \
 && SUDO_FORCE_REMOVE=yes \
    apt-get purge -y --auto-remove \
                  -o APT::AutoRemove::RecommendsImportant=false \
                  $buildDeps \
 && rm -rf /var/lib/apt/lists/* \
           /home/fluent/.gem/ruby/2.3.0/cache/*.gem

# ONBUILDでcurrentのfluent.confは/fluentd/etc/fluent.confにCOPYされる
RUN mkdir -p /fluentd/etc/files
ADD files /fluentd/etc/files/

# -v  -> debug
# -vv -> trace
ENV FLUENTD_OPT ""

CMD exec fluentd -c /fluentd/etc/fluent.conf -p /fluentd/plugins $FLUENTD_OPT

fluentdがAPI Serverの構成と異なる点は, ALB ListenerのprotocolをTCPにする関係で動的ポートの設定ができず、1Host-1Taskなってしまっている点です。この点は今後の改善点ですが現状ではfluend側の機能で吸収しきれています。また、fluentd側のlogDriverにはawslogsを設定し、そのままforwardすることで、Cloudwatchからlogを確認できるようにしています.

[
    {
        "name": "fluentd",
        "image": "${image_url}",
        "cpu": 0,
        "memory": ${memory},
        "portMappings": [
            {
                "hostPort": 24224,
                "protocol": "tcp",
                "containerPort": 24224
            }
        ],
        "logCOnfiguration": {
          "logDriver": "awslogs",
          "options": {
            "awslogs-region": "${region}",
            "awslogs-group": "{cw_logs_group}",
            "awslogs-stream-prefix": "${cw_stream_prefix}"
          }
        },
        "essential": true
    }
]

bigquery

GCP Serviceのひとつであるbigqueryがlogの最終的な格納先です。 bigquery自体は既存システムが利用していた関係で採用されていますが、logを構造化して出力したい理由のひとつでした。 というのも、bigqueryは事前に定義されたschemaをもつので、loggingもアドホックにせず決められたkeyを出力する必要があったからです。 bigqueryへの入力はfluentdのfilter pluginを用いて、jsonをparseし、bigquery plugin(https://github.com/kaizenplatform/fluent-plugin-bigquery)を利用してinsertしました。logの分析がbigqueryを通じて行えるので, SQLが使えるのがとてもありがたいです。

-- 問題のあるendpointの抽出
SELECT *
FROM <log_table>
WHERE
  status_code != 200
  AND
  path = "/version/endpoint"
  AND
  timestamp BETWEEN <FROM> AND <TO>

-- endpointごとのlatency
SELECT path, AVG(latency) AS latency_avg
FROM <log_table>
GROUP BY path

運用

監視

外形監視

外形監視についてはMackerel(https://mackerel.io/) を利用しております。 Mackerelはsimpleで使いやすくUIも洗練されていて,とてもすばらしいサービスだと思います。(もちろん(CLI)https://mackerel.io/ja/docs/entry/advanced/cli)ただし、リクエスト間隔は1分で固定なので注意が必要です。 EC2 InstanceのMetricはECSを通じて収集しているので、mackerel-agent(https://github.com/mackerelio/mackerel-agent)は利用していないのですが、Go製でServerのMetricとはどうやって収集するのか興味があるので、ソースを読んで勉強させてもらいたいと思っています。

Metric監視

CloudWatch AlarmをECSの各種Metricに設定し,SNS経由でpagerdutyに集約するようにしています。

ecs-agent監視

ecs-agentについてはECS API DescribeContainerInstancesからAgentConnectedを取得し、接続を確認できない場合はSNSに通知するLambdaをCloudWatch Eventで定期実行しています。

fluentd監視

fluentdの監視についてはCloudWatch LogGroupを作成し、log metric filterを作成し、シンプルにlog中のerrorを拾ってMetricに変換しています。bigqueryのinsertに関してはfluentdのlogGroupに対してlog subscriptionを設定し、lambdaを呼び出しています。lambdaの中でfluentdのlogをparseし、CloudWatchのPutMetrics APIを呼び出しています。

resource "aws_cloudwatch_log_subscription_filter" "watch_fluentd" {
  name            = "fluentd_status_logfilter"
  log_group_name  = "${aws_cloudwatch_log_group.log.name}"
  filter_pattern  = "${var.fluentd_subscription_filter_pattern}"
  destination_arn = "${aws_lambda_function.watch_fluentd.arn}"
  distribution    = "ByLogStream"

  depends_on = ["aws_lambda_permission.allow_cw_invoke_lambda"]
}
package main

import (
 "context"
 "encoding/json"
 "fmt"
 "log"
 "os"
 "strconv"
 "strings"

 ac "github.com/howtv/go-awsctr"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/juju/errors"
 "github.com/koron/go-dproxy"
 "go.uber.org/zap"
)

// App -
type App struct {
 Service *Service
 Options AppOptions
 Log     *zap.Logger
}

// AppOptions -
type AppOptions struct {
 LogLevel  int
 LogEncode string
}

// 設定を環境変数から取得
func newAppOptionsFromEnv() AppOptions {
 logLevel, err := strconv.ParseInt(os.Getenv("LAMBDA_LOG_LEVEL"), 10, 0)
 if err != nil {
     logLevel = 0
 }

 logEncode := os.Getenv("LAMBDA_LOG_ENCODE")
 if logEncode == "" {
     logEncode = "console"
 }

 return AppOptions{
     LogLevel:  int(logLevel),
     LogEncode: logEncode,
 }
}

// NewApp -
func NewApp() *App {
 app, err := newApp(newAppOptionsFromEnv())
 if err != nil {
     fmt.Println("failed to init app", err.Error())
     os.Exit(1)
 }
 return app
}

func newApp(o AppOptions) (*App, error) {
 logger, err := GetLogger(
     WithLoggingLevel(o.LogLevel),
     WithEncoded(o.LogEncode),
 )
 if err != nil {
     return nil, err
 }

 service, err := NewService(o)
 if err != nil {
     return nil, err
 }

 return &App{
     Service: service,
     Log:     logger,
     Options: o,
 }, nil
}

// Service -
type Service struct {
 Aws *awsResource
}

// NewService -
func NewService(o AppOptions) (*Service, error) {
 awsRsc := newAWSResource()
 return &Service{
     Aws: awsRsc,
 }, nil
}

type awsResource struct {
 cw ac.CloudWatch
}

func newAWSResource() *awsResource {
 sess := ac.NewSession("ap-northeast-1")
 return &awsResource{
     cw: ac.NewCloudWatch(sess),
 }
}

// FluentStatus -
type FluentStatus struct {
 BufferQueueLength     string
 BufferTotalQueuedSize string
 RetryCount            string
}

// ParseMessage -
// input sample:
// 2018-05-02 05:26:46.346961637 +0000 fluent.status: { "plugin_id": "bq_nginx", "plugin_category": "output", "type": "bigquery", "output_plugin": true, "buffer_queue_length": 0, "buffer_total_queued_size": 0, "retry_count": 0 }
func ParseMessage(msg string) ([]ac.CountMetricsInfo, error) {
 msgs := strings.SplitN(msg, " ", 5)
 m := strings.Join(msgs[4:], "")

 var fluentStatus interface{}
 err := json.Unmarshal([]byte(m), &fluentStatus)
 if err != nil {
     return nil, err
 }
 f := dproxy.New(fluentStatus)
 pluginIDValue, _ := f.M("plugin_id").String()
 bufferQueueLength, _ := f.M("buffer_queue_length").Float64()
 bufferTotalQueueSize, _ := f.M("buffer_total_queued_size").Float64()
 retryCount, _ := f.M("retry_count").Float64()

 var info []ac.CountMetricsInfo
 info = []ac.CountMetricsInfo{
     ac.CountMetricsInfo{
         NameSpace:      "fluentd",
         DimensionName:  "PluginId",
         DimensionValue: pluginIDValue,
         MetricName:     "buffer_queue_length",
         Value:          bufferQueueLength,
     },
     ac.CountMetricsInfo{
         NameSpace:      "fluentd",
         DimensionName:  "PluginId",
         DimensionValue: pluginIDValue,
         MetricName:     "buffer_total_queued_size",
         Value:          bufferTotalQueueSize,
     },
     ac.CountMetricsInfo{
         NameSpace:      "fluentd",
         DimensionName:  "PluginId",
         DimensionValue: pluginIDValue,
         MetricName:     "retry_count",
         Value:          retryCount,
     },
 }

 return info, nil
}

// HandleLogEvent -
func (a *App) HandleLogEvent(e events.CloudwatchLogsLogEvent) error {
 a.Log.Debug("handle_log_event",
     zap.String("id", e.ID),
     zap.Int64("timestamp", e.Timestamp),
     zap.String("message", e.Message),
 )

 metrics, err := ParseMessage(e.Message)
 if err != nil {
     return errors.Annotatef(err, "failed to parse event message %s", e.Message)
 }

 for _, m := range metrics {
     err = a.Service.Aws.cw.PutCountMetrics(m)
     if err != nil {
         return errors.Annotatef(err, "failed to put metrics %#v", m)
     }
 }

 a.Log.Info("put_metrics", zap.String("msg", "put log message successfully"))
 return nil
}

// parseRawData -
func (a *App) parseRawData(raw events.CloudwatchLogsRawData) (events.CloudwatchLogsData, error) {
 return raw.Parse()
}

// HandleEvent -
func (a *App) HandleEvent(ctx context.Context, event events.CloudwatchLogsEvent) error {
 log.Println("start")

 data, err := a.parseRawData(event.AWSLogs)
 if err != nil {
     return errors.Annotatef(err, "failed to parse event raw data")
 }

 a.Log.Info("handle_event",
     zap.String("log_group", data.LogGroup),
     zap.String("log_stream", data.LogStream),
     zap.Strings("subscription_filters", data.SubscriptionFilters),
     zap.String("message_type", data.MessageType),
     zap.Int("log_events_num", len(data.LogEvents)))

 for _, logEvent := range data.LogEvents {
     select {
     case <-ctx.Done():
         return ctx.Err()
     default:
         if err := a.HandleLogEvent(logEvent); err != nil {
             a.Log.Error("failed", zap.String("details", errors.Details(err)))
             return err
         }
     }
 }

 log.Println("success")
 return nil
}

func main() {
 app := NewApp()
 lambda.Start(app.HandleEvent)
}

まとめ

  • terraformはcloudformationより使いやすかった
  • ECSはlaunch typeをEC2にする限り、ClusterとService2つのautoscalingの設定が必要
  • Clusterをscale inする際は自前でgracefulな処理を作らないといけない
  • 12 factor appに従うようにしておくと、アプリをコンテナ化して運用しやすい
  • Goは楽しい

お知らせ

ハウテレビジョンでは、エンジニアを募集しています。AWS,Go,Dockerで一緒にシステムを作っていきませんか。ご連絡はこちら

React Nativeにおける多タブかつ件数の多いリストをつつがなく表示させるには

TL; DR

  • はじめに
  • 主要なライブラリ/ディレクトリ構成
  • このようなリストのおはなしです
  • 実装上で気をつけたポイント
  • 起こりがちな問題点 NINJA
  • NINJAを防ぐにはどうするか
  • おわりに

はじめに

こんにちは!世界で挑戦したいと思う学生に向けた就活支援プラットフォーム、外資就活ドットコムの開発チームです。 先日iPhoneアプリをReact Nativeでリニューアルの上リリースしました!(パチパチ)

さて、このアプリは募集情報や体験記、コミュニティなど、比較的件数が多いリストを表示させる箇所がいくつかあります。

そしてそういったリストも業界などのカテゴリやタグでのタブ切り替え表示やソート順などでの切り替え表示をさせています。

つまり、

- データの管理
- 大量のコンポーネント(->ここではリストのアイテム1個1個のこと)の表示管理
- 遅延の少ない切り替え

といったことが必要になってきます。

それを実現するまでにどのような構成で作っていったか、また想定外のハードルも乗り越える必要があったのでそこで得た知見をシェアできればと思います。

主要なライブラリ/ディレクトリ構成

本題に入る前に主要なライブラリ構成を書いておきます。(一部抜粋)

redux:状態を一元管理
react-redux:reactとreduxをつなげる
redux-saga:非同期処理
redux-persist:データ永続化
react-native-router-flux:画面遷移 (使い方はreact-routerに似ている)
styled-components:セマンティックにCSS設計ができる
NativeBase:UIコンポーネント補助

またこのようなディレクトリ構成になっています。

src:
  actions:reduxのaction
  api:sagaで呼び出すapi
  components:各画面で呼び出すコンポーネント (部品)
  constants:定数定義(reduxのaction名他)
  containers:画面(reduxとconnect)
  reducers:reduxのreducer
  router:react-native-router-fluxのrouter
  saga:redux-sagaのsaga

*本エントリでは各項目の詳しい内容についてはレクチャーしないので、参考リンクを置いておきます

このようなリストのおはなしです

下記画像のように業界タブごとに表示させることも可能で、 企業名や体験記の種別で絞り込んだり、人気順などでソートすることも出来ます。

f:id:tacker-howte:20180629171626p:plain

実装上で気をつけているポイント

さて、つらつらと書いていくと長くなってしまうので、気をつけたポイントを列挙していこうと思います。

データの管理

リストの内容とフォロー状態などのstateは分ける

リスト取得時にフォロー状態も合わせて取得できるようなAPIにしていますが、それをreduxのstateに保管するときは内容とフォロー状態(KeyValue)、またページング(KeyValue)に分けて保管しています。

そうすることでフォローのコンポーネントがそのリストアイテム以外にある場合でも瞬時に状態を共有でき、変わった場合それぞれのコンポーネントで再レンダリングを走らせることが可能になります。

また下記のように、内容とページングはカテゴリ(タブ)ごと、またソート順ごとに保管するためそこまではObject(連想配列)で保管するのに対し、フォロー状態はアイテムのKeyValueなので単純な配列で保管しています。

reducers/reportListReducer.js

  const initialState = {
    ...
    actionPage: {},
    reportListByIndustryCategory: {},
    reportClips: [],
    ...
  }

ソート順を保管し、各ソート順のページ状態も保管する

先ほどソート順に保管すると書きましたが、 これはそうすることで単純なソート切り替え時に(すでに取得済みの場合)通信が走らずにパッと切り替えられるようにすることを目指しました。

またページングも合わせて保管することでエッジケースの齟齬がないようにしています。

(ここはSagaを利用してバックグラウンドで表示しているタブ以外も逐次取得したいと思っているのですが、フィルタ切り替えを頻繁に行うなどの際に今の所うまくいっていないのでちょっと放置しています)

reducers/reportListReducer.js

  case REPORT_LIST_GET_LIST_SUCCESS: {
      const lists = deepCloneObj(state.reportListByIndustryCategory);
      const actionPage = deepCloneObj(state.actionPage);
      ...
      actionPage[`${action.industryCategoryId}`][`${action.order}`] = action.page;
      ...
      lists[`${action.industryCategoryId}`][`${action.order}`] = lists[`${action.industryCategoryId}`][`${action.order}`].concat(action.reports);

      const reportClips = state.reportClips.slice();
      action.reports.forEach((val) => {
        reportClips[val.id] = { clipped: val.clipped, count: val.clipsCount };
      });

      return {
        ...
        reportListByIndustryCategory: lists,
        ...
        actionPage,
        reportClips,
      };
    }

大量のコンポーネントの表示管理

React Nativeではいかに再レンダリングを走らせないようにし、端末に負担をかけさせないようにするかが重要になります。

そのためコンポーネントは(ユーザーがフォローを行うなど)頻繁に表示が変わりうるものとそうでないものは分けています。 表示が変わらないものは shouldComponentUpdate=false or PureComponent 使用をしています。

また、リストのコンポーネントはVirtualizedList を使用しているのですが、Props removeClippedSubviewstrue にすることでメモリ使用量の増大や突然アプリが落ちる事態を防いでいます。

遅延の少ない切り替え

  • react-native-scrollable-tab-viewprerenderingSiblingsNumber1にする

    こちら、NativeBaseのScrollableTabを使用すると実質的にはこのライブラリを使うことになるのですが、 この prerenderingSiblingsNumber に1を設定しないと タブ切り替え時の挙動がモタっとしてしまいます。

React NativeではちょっとしたPropsの設定で挙動のタイミングが大幅に変わり、 体験も大きく変わってしまうことがよくあるので気をつけましょう。

  • react-native-scrollable-tab-view の onChangeTab で変更したカテゴリ情報を componentWillReceiveProps で検知する

    まだリストを取得していないタブに移動する場合、当初は onChangeTab でリスト取得処理も合わせて行なっていたのですが、そうすると描画までもたついてしまうんですね。

    そこでここでは単純なタブ移動(ここではつまりカテゴリの変更)のみreduxに通知、保管し、それを reactのlifeCycleである componentWillReceiveProps で 状態の変更を検知し、リスト取得処理を走らせるようにしました。 これによりレンダリングはそこまで遅延せずに済むようになっています。

    • ちなみにUpdate on Async Rendering にあるように、 componentWillReceiveProps は React 17以降削除されてしまうという移り変わりの早さが良くも悪くもすごいですね。

※なおデータの管理で書いた

- ソート順を保管し、各ソート順のページ状態も保管する

も遅延の少ない切り替えに貢献しています。

起こりがちな問題点 NINJA

上で書いた実装上の気をつけるポイントをしなくても、社内でNINJAと呼ばれているレンダリングの不正が起こります。

開発スタートした当初はリストで11番目のアイテムを表示させようとしただけでリスト全体がぐしゃっと重なった表示(隠れ身の術)になるなど、常にリスト周りは暗雲が立ち込めていました。。

また、タブもうまくデータ管理しておかないとタブが左右に高速回転しだす(分身の術)など、憎めないヤツです(笑) f:id:tacker-howte:20180629175313g:plain

一番悩まされたのは下部メニューを切り替えるとたまーに真っ白になるケースでした。これはとあるウルトラCを使っているのですが、気になる方はぜひオフィスに遊びに来てください♪

NINJAを防ぐにはどうするか

一番疑った方が良いのは、意図せず複数のAPI取得が走っていないか、またニアリーイコールですが意図せぬ挙動が走っていないかです。

なので action がいつ走っているか常にデバックしないとですね。

そしてそれでもダメな場合はreactやredux、redux-saga公式のdocsとにらめっこしましょう。ほとんどの場合はなんとかなります。(たぶん)

終わりに

最近 React Native at Airbnb: The Technology が話題ですが、 現在の弊社アプリの規模感だと中期的にはReact Nativeをこれからも使用していく予定です。(もっと大規模になるとAirbnbのように別の形をトライするかもしれませんが。)

ただ、React Nativeにおいても既存のiPhone/Android開発の知見は必要な部分も多いので、そういう方で新しい技術にもトライしてみたい方など絶賛募集中です。

またReactとかVueとかAngularとか触ってはいるけどまだアプリに手を出していない方も絶賛募集中です!

ぜひぜひオフィスに遊びに来てくださいー!

ご連絡はこちらよりお待ちしております。

AWS Summit、Google Cloud Nextに参加しました

弊社サービスも日頃からお世話になっている、AWSとGCP。
この両者が大規模なカンファレンスを東京で行うということで、参加してきました。
AWS Summitに3日間、Google Cloud Nextに2日間参加しましたので、ざっくりとしたレポートを掲載します。

なお、個々の発表には触れず、全体的な感想のみ記載します。

f:id:itamisky:20170616171550p:plain

会議の概要

基調講演があり、導入事例紹介や各サービスの紹介が行われるセッションがパラレルで開催される、という点は同じです。

AWS Summit

AWS Summitでは豊富なセッションがあり、実に様々な機能・事例の紹介がありました。
参加者も多く、広い会場が埋まっており勢いを感じました。
また、SummitとDev Dayに大別されて会場が分かれており、専門や興味に応じた参加が可能でした。
なお、Dev Dayの最終日は「Serverless Evolution Day」として全てサーバーレスに関する発表がなされるという思い切った構成でした。

Google Cloud Next

f:id:itamisky:20170616171602p:plain

Google Cloud Nextでも、基本的にはAWS Summitと同じような構成が取られ、発表内容も親しいものでした。
が、規模は比較すると小さく、セッション数も少ないため、興味に合うものが見当たらないことも発生しがちだったかと思います。
GCPが生まれた経緯を踏まえてか、一部セッションでGoogle社内システムの説明があり興味深かったです。
また、KubernetesやOpen API互換などに代表されるように、オープンであることを押し出しており、ロックインを自ら放棄する姿勢が素敵です。

発表の傾向

参加したセッションによりどうしても偏りがでますが、キーワードとして「マイクロサービス」が挙げられるかと思います。

単純なサーバーはパブリッククラウドへの移行が順調に進んでおり、更に各サービスを利用させ、新サービスをクラウド上で構築させる段階に入っているのかな、と感じました。

発表内容の大別

上記を踏まえ、発表内容は以下の2つに大別できるかと思います。

  1. オンプレからパブリッククラウドへの移行促進
  2. 各種サービスの利用促進

1は、引き続きオンプレで運用しているサービスをクラウド上に移行してもらうためのものです。
2は、AI系サービスの新規利用やマイクロサービス化など、今まで各自が実装していた機能を提供することで、利用量を増やすためのものです。
これはロックインにも繋がります。

この区分で言うと、1より2の発表が多く見え、何となく時代の流れを感じました。

その他

AWS Summitの企業ブースは、配っているグッズを貰うと手持ちの二次元コードをスキャンされ、後ほどメールが届くという効率的なシステムでした。

アンケートに答えると公式グッズがもらえます。
何がもらえるかは行ってのお楽しみ。

まとめ

AWS、GCP共に次々と公式サービス・関連サービスが追加され、開発者にとっては便利な世の中になっています。
低コストで高度な機能が実現できるため、これを使わない手はないでしょう。

どのような機能が実現できるかのイメージを掴むため、このような公式のイベントに参加してみてはいかがでしょうか。

おまけ

ハウテレビジョンでは、AWSやGCPの好きな仲間を募集しています。
Webエンジニア
エンジニアインターンシップ