エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

エムスリーのエンジニア採用について

こんにちは、人事の友永です。主にエンジニアの採用(中途/新卒)を担当しています。先日技術顧問就任をお知らせしましたが、エムスリーでは「エンジニアが生き生きと働ける環境を作ること」それにより「医療業界のイノベーションを更に加速していくこと」を目指しています。今回は組織作りの一環として、エムスリーのエンジニア採用の特徴についてお伝えしたいと思います。

私たちの採用におけるポイントは以下3つです。

  • 主体的にエンジニアが採用に関わる
  • 普段のエムスリーを知ってもらう
  • 全員一致でオファーをする

f:id:nsb248:20170602112904j:plain

主体的にエンジニアが採用に関わる

私は2014年5月にエムスリーに入社しました。その時既にエンジニアグループの中に採用チームがあり、面接や面談対応だけでなく、採用プロセスを検討したり、エンジニアを探すために適切な媒体を選定するなど主体的に採用に関わっていました。それを見て「良い採用ができるのでは!」と直感的に思ったことをよく覚えています。 実務が進んで行くと、私が想像している以上にエンジニアが採用に協力をしてくれるので少し驚くくらいでした。そんななか「何でこんなに協力してくれるのですか?」とあるエンジニアに聞いてみたことがありました(人事担当が聞くのも不思議ですが笑)回答はシンプルで「一緒に働く仲間を自分たちで集めたいから」ということでした。チームでより大きなアウトプットを出すためには、誰と働くかが大事だよねと。このコメントは今でも強く記憶に残っています。ちなみに、当時そのエンジニアは新規事業を担当しており、結果的にチームに優秀なエンジニアが集まり、今ではエムスリーを支える事業の一つに成長していることは個人的にも嬉しいです。

現在もエンジニア3名が採用チームとしてテックブログを盛り上げてくれたり、技術イベントスポンサーの推進やスカウトなど採用活動をリードしてくれています。採用チーム以外でも多くのエンジニアが面接やカジュアル面談の対応でプロセスに関わっており、自分たちで仲間を集める姿勢は今でも変わりません。人事担当としてはお会いする方の情報から興味の方向をイメージし、どのエンジニアと会ってもらうと有意義な時間になりそうか、いつも考えながら面談や面接のアレンジをするよう心がけています。

普段のエムスリーを知ってもらう

人それぞれフィットする会社は違いますし、転職する際の目的や就職先でどのような成長をしたいかも異なります。そのため、候補者の方に会社を正しく理解してもらうことはとても重要です。エムスリーの場合エンジニア界隈の認知にまだ課題があるので、まずはエムスリーについて知ってもらう・興味を持ってもらう機会を設けるようにしています。

具体的には

* Tech Talkなどの勉強会をオープンに実施する

Tech TalkはLT形式のライトな勉強会ですので、まずエンジニアの雰囲気を知りたいなという方にはお勧めです。気軽に参加できますし、Tech Talk後に懇親会を実施することもあるのでお酒を飲みながら一緒に楽しんでいただくこともできます。

* カジュアル面談でお話しする

エムスリーの事業内容や各チームの取り組みなど具体的に聞いてみたい場合はしっかりとコミュニケーションできる環境でお話しをさせていただきます。面談からそのまま会食に行ってざっくばらんに盛り上がるパターンもあります。

勉強会や面談にお越しいただいた方からは「実際の雰囲気が感じられて良かった」「イメージが変わりました!」とFBをいただくこともあるので、直接お話ししたり普段の姿を見ていただくことは重要だなと感じています。また、AIチームが主催している機械学習コンペに参加したことがきっかけでエムスリーに興味を持ち入社に至ったケースもあります。少しでも気になる企業があるようでしたら、積極的に勉強会などに参加してみることをお勧めします。

全員一致でオファーをする

エムスリーの採用プロセスの最後には、候補者にお会いした社員全員と人事で集まる機会があります。このプロセスはエムスリーらしく個人的にも好きなポイントです。具体的には、対象の候補者について期待する点や入社いただいた際にチャレンジいただくミッションなどの共有やすり合わせを行います。その上で全員が是非一緒に働きたい!と結論が出た場合、オファーをさせていただきます。 会社として取り組みたいことがたくさんあるがゆえ、人を採用すること自体が優先されてしまい採用基準が下がったり、懸念があるもののオファーをしてしまうことが時に起きる可能性がありますが、エムスリーではそうならないよう最後に全員で集まり、全員一致したらオファーするようにしています。また、この時間は会社の少し先の未来をイメージしたり、候補者の方が活躍する姿を想像するので、個人的にも楽しい時間です。

最後に

エムスリーの採用についてポイントを書きましたが、まだまだできていないことや改善点も(本当に・・・)たくさんありますのでエンジニアグループ全体で引き続き頑張っていきたいと思います。また採用活動に積極的に関わりたいエンジニアの方も大歓迎です!一緒に組織を前進させていきましょう!

Tech Talkの参加やカジュアル面談希望者も随時募集しておりますので、少しでも気になる方は以下サイトよりお気軽にお問い合わせください。お会いできることを楽しみにしております! jobs.m3.com

胸部X線画像のAI診断エンジンを作ってみる

はじめまして、AIラボ所長兼エンジニアリンググループAI・機械学習チーム所属の高木です。 キャリアのスタートはエンジニアだったのですが、今では9割くらいの時間はビジネス開発に使っています。 今回は合間の時間でやっているエンジニア的な側面でAIラボで何をやっているのかを書いてみることにしました。

AIラボの紹介

AIラボは、臨床現場へのAIの社会実装を実現することを目的にした組織です。 社会実装が目的なので、ビジネスの研究開発とプロダクトの研究開発の両方をやっています。 今回はAIラボの取り組みの一つとして、胸部X線画像のAI診断エンジンを作ってみたので紹介します。

概要

2017年9月にアメリ国立衛生研究所(以下NIH)から約11万枚の胸部X線画像データセットが公開されました。 データセットこちらに公開されています。 このデータセットには14の疾患のラベルがついています。 今回はこのデータセットを用いてAI診断エンジンを作ってみました。 また今回のデータセットではデータ作成をしたNIHが使った訓練とテストのデータ分割方法が公開されていますので同じ条件で精度比較することができます。 ただし次に紹介する論文では必ずしも公式の分割方法を使った性能評価を行なっていない状況です。

先行研究の紹介

(1) ChestX-ray8: Hospital-scale Chest X-ray Database and Benchmarks on Weakly-Supervised Classification and Localization of Common Thorax Diseases

今回のデータセットの作成と開発したエンジンの性能を評価したNIHの論文です。 Resnet50の転移学習をベースに実装されています。 プーリング層と損失関数で一部独自の工夫がなされています。

(2) CheXNet: Radiologist-Level Pneumonia Detection on Chest X-Rays with Deep Learning

Stanford大学のAndrew NGのグループの論文です。 DenseNet121のファインチューニングをベースに実装がされています。 損失関数に関してはNIHの論文と同様に各クラスのデータ数の偏りを補正する工夫が使われています。 また独自に肺炎のデータを収集し、肺炎の検出精度の評価も行なっています。 本論文では公式データ分割ではなく、訓練とテストで患者間の重なりがないpatient-wise分割を採用しています。

(3) Thoracic Disease Identification and Localization with Limited Supervision

GoogleのFei Fei Liのグループの論文です。 Resnet50の転移学習をベースにして実装がされています。 今回のデータセットには11万枚の一部に病変のアノテーションデータがついているので アノテーションデータも活用できるようにResnetの後段に追加のネットワークを入れているのが特徴です。 本論文では公式データ分割ではなく、訓練とテストで患者間の重なりがないpatient-wise分割を採用しています。

(4) Learning to diagnose from scratch by exploiting dependencies among labels

アメリカの医療画像AIスタートアップのenliticの論文です。 こちらは転移学習を行わず、スクラッチでdensenetを学習しているのが特徴です。 転移学習を行わないので512 x 512という大きな画像サイズで学習をしています。 これは縮小によって小さな病変が潰れてしまうことを防いでいます。 本論文では公式データ分割ではなく、完全ランダム分割を使用しています。また3月に出た論文で別の方法で公式分割を行なった論文も出ています。

(5) Diagnose like a Radiologist: Attention Guided Convolutional Neural Network for Thorax Disease Classification

北京交通大学の研究グループの論文です。 こちらはDenseNet121の転移学習をベースに、画像全体での分類と画像全体での分類から得られたclass activation mappingで反応した部分を拡大した画像をもう一度転移学習を行い、全体と部分の両方で分類する手法を取っています。 単純に言うと細部に関するアテンションを見ていることになります。 こちらのデータ分割方法は明示的に書いてはいませんが、おそらく患者間の重なりを考慮せず完全ランダムに訓練とテストを分割しているものと思われます。

(6) Learning to recognize Abnormalities in Chest X-Rays with Location-Aware Dense Networks

医療機器メーカーのシーメンスの研究グループの論文です。 本論文もDenseNet121の転移学習をベースに実装をしています。 画像の縮小を高解像度で行うために転移学習の前に2段のCNNを入れて高解像度の縮小を実現するネットワークを入れているのが特徴です。 本論文ではNIHの公式のデータ分割とpatient-wise分割の両方を実行しています。

(7) Comparison of Deep Learning Approaches for Multi-Label Chest X-Ray Classification

医療機器メーカーのフィリップスの研究員が共著で入っている論文です。 Resnet50をフルスクラッチで実装し、画像サイズを418 x 418にしているのが特徴です。 また画像以外の患者情報も使って手法の比較をしています。 本論文では公式データ分割ではなく、訓練とテストで患者間の重なりがないpatient-wise分割を採用しています。

実装方針

今回の問題を着手し始めた時にtensorflowにまだdensenet121がなかったなどの開発環境の諸条件を考慮して、転移学習を用いないフルスクラッチで構築することにしました。 試行錯誤の末、最終的なモデルは次のような要素を含むチューニングを行ないました。

  1. DenseNetをベースにInceptionを入れたネットワーク
  2. 512 x 512の大きな画像サイズを使用
  3. 画像サイズが大きく、開発環境のメモリが少ないため、Batch Renormalizationを導入
  4. 活性化関数はSWISHを使用
  5. 学習係数は一定期間で減衰するように設定
  6. dilated CNNを使ってメモリを削減

などいくつかのチューニングを行いました。

実験方法

今回は公式のデータ分割、patient-wise分割、ランダム分割の3種類を行いました。 マシン環境はAWS p3.xlargeを使用して学習を行った。

結果

4,5の論文の精度には残念ながら勝てませんでした。 6の論文の精度には勝ったり負けたりですが、若干向こうの方が上です。 f:id:yuzo-takagi:20180726175258p:plain

Class activation mappingで確認してみる

日本放射線技術学会画像部会のデータセットを使って肺腫瘤(nodule)に関してclass activation mappingを見ました。

オリジナル画像

f:id:yuzo-takagi:20180726190656p:plain:w300

病変の場所

f:id:yuzo-takagi:20180726190730p:plain:w300

Class activation mapping

f:id:yuzo-takagi:20180726190803p:plain:w300

なんとなくそれっぽくなっているようです

今後の展開

m3 AIラボでは医療画像の診断補助AIプラットフォームを作ることを発表しました。 今後、各疾患のAIエンジンやプラットフォーム開発などを進めていく予定です。 また希少疾患の発見につながるAIシステムを開発する完全内製プロジェクトもいくつか進んでいます。 臨床現場で使われるAIを開発したい方はぜひm3エンジニアリンググループをご検討ください。 興味のある方は以下のフォームで募集しています

jobs.m3.com

医師への情報伝達を最適化したい

機械学習エンジニアの西場です。

私が「今していること」から「将来しようとしていること」や「仕事のモチベーション」が連想するのが難しいのかなっと思うことが何度かあったので、実現したいことやモチベーションを紹介します。

仕事のモチベーション

私の仕事のモチベーションは、医師への情報伝達を最適化し、医療の質の向上に貢献すること

そのために、様々なプロジェクトを進めており

  • Archimedesでは医師の短期トレンド、長期トレンドのモデル化
  • Bourbakiではコンテンツのバナーのデザインの最適化
  • Cantorではコンテンツのテキストから特徴量の抽出
  • GaussやIsaacsでは医師が興味がある医療系のキーワードの取得

を行っています。(※ プロジェクト名に数学者・物理学者を使っています。個人的にはBourbakiは不満ですが)

それぞれのプロジェクトは独立して既存サービスに貢献しています。将来的にそれらを組み合わせて、さらに情報伝達を最適化することを考えています。

(※ 各プロジェクトの詳細は別のところで紹介したいと思います)

医師に情報を届けることの大切さ

医師に最適な情報を届けることによって医療に貢献できると考えています。

根拠の一つとして、カリフォルニア大学ロサンゼルス校助教の津川友介の論文があります。

この論文は内科医を対象にアメリカで調査が行われています。担当医師が若いほうが入院30日以内に死亡する確率が低いことが示されています。

若い医師のほうが致死率が低い要因として、若い医師のほうが最新の医学知識を持っており、ガイドラインも守る傾向が高いという調査結果が紹介されています。

つまり、最新の医学知識やガイドラインを知っている医師のほうが患者の致死率が低くなることが示唆されます。

情報伝達を効率化する

医学は日々進歩しています。新しい治療法や薬が開発されています。また症例共有も注目されるようになりました。

インターネットのおかげで様々な情報にアクセスできるようになり、昔に比べ情報収集が容易になったと思います。弊社でも様々な情報を医師に発信しています。

そこで次のステップは医師に届ける情報を正しく選別することだと考えています。

多くの医師は非常に忙しく、ニュースで医師不足や激務等が取り上げられているのを度々見ます。

医師の限られた時間で必要な医学情報を効率的にアップデートするためのサポートをしたいと考えています。

理想的には、

「ジャーヴィス、この患者と似た症例とそれらに関する最新の医学情報をまとめてくれ」

の一言で全てが集まることですが。

まだまだ理想には程遠いですが、少しでも効率化できるように精進します。

達成するために必要なこと

これらの理想を実現するためには周辺のことも進めていく必要があります。

たとえば、

  • 継続的に医師にとって価値のあるコンテンツを作る・集める
  • 継続的に医師にアクセスしてもらう

というようなことも非常に重要です。理想的なアルゴリズムができたときに「医師会員がほとんどいなかった」というのは残念すぎます。

これらのことは多くの人が協力し様々な施策を実施して改善しようとしています。

私達も協力できることは積極的に行おうと思っています。

広告やメルマガ等のクリック数の増加等の施策も技術的にもサービス的にも実現したいことに繋がっており、医療の向上にも繋がっていると考えています。

一緒に医療に貢献する仲間がほしい

理想を実現するためには、一緒に働く仲間が必要です。

色んなスキルを持ったエンジニアと協力し社会に大きな価値を提供したいです。 興味のある方はカジュアル面談等の申込ください。

AWS FargateでElixirのコンテンツ配信システムを動かしてみた (実装編)

こんにちは、エムスリーエンジニアの園田です。

この記事は先日のAWS FargateでElixirのコンテンツ配信システムを本番運用してみた - エムスリーテックブログの続きです。

エムスリーでは医療・ヘルスケアサイト向けのコンテンツ配信システムであるChuoiというサービスを運用しています。先日のポストで、ElasticBeanstalkからFargateに運用を切り替えたことについて書きました。

www.m3tech.blog

今回はその実装編で、実際のコードを見ながら説明します。

まずは構成のおさらいです。

f:id:ryoheisonoda:20180713112117p:plain

Fargate化のためにやったこと

AWS Fargateで運用するために実際にやった作業は大まかに以下の通りです。

  • Elixir/PhoenixアプリのDocker化
  • Docker化したアプリのFargate動作確認
  • 社内GitlabからのCI/CDパイプライン構築
  • Terraformテンプレート作成

今回はこのうち Fargate での動作確認までを、弊社事例をサンプルに説明したいと思います。 なお、 Fargate より Elixir の内容が多めです。 Elixir なんて使ってないぜ、という方は次回からご覧いただいて大丈夫です。

デプロイパイプラインの構築とTerraformテンプレートについてはおって投稿します。乞うご期待。

2018/08/01 追記 デプロイパイプラインの構築記事を書きました。 www.m3tech.blog

Elixir/PhoenixアプリのDocker化

Fargateで動かすためには当然のことながら、Dockerイメージを作成する必要があります。

元々の構成では、Elixirソースを直接EC2に配置し、ElasticBeanstalkのデプロイフックでコンパイルを行い、Phoenixの起動コマンドにmix phx.serverというコマンドで起動していました。*1
しかし、これだとDocker化するにあたりイメージサイズが肥大化してしまうため、リリースビルドによって生成された配布可能なバイナリを含むDockerイメージを作成しました。

なお、このサービスではクリックカウントなど業務メトリクスをログファイルに出力してfluentdでデータレイクに送信しているため、Dockerボリューム上にログを吐き出し、同一タスク内のfluentdコンテナでログを配信可能にする必要がありました。
また、静的コンテンツをnginxのgzip_staticで配信したかったので、nginxのコンテナも作成しました。

最終的に作成したDockerfileはアプリ(app)、ウェブ(web)、ログ配信(log)の3つとなりました。

Dockerイメージの作成に関連するディレクトリ構成は以下のようになっています。

アプリケーションディレクトリ構成
.
├── config/
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── qa.exs
│   └── test.exs
├── dockerfiles/
│   ├── app/
│   │   ├── Dockerfile
│   │   └── entrypoint.sh
│   ├── log/
│   │   ├── Dockerfile
│   │   ├── entrypoint.sh
│   │   └── fluent.conf
│   ├── web/
│   │   ├── Dockerfile
│   │   ├── entrypoint.sh
│   │   └── nginx.conf
│   ├── archive-and-upload-src.sh
│   ├── build-docker-images.sh
│   └── push-to-ecr.sh
├── lib/
├── priv/static/
├── rel/config.exs
├── ts/
├── buildspec.yml
├── mix.exs
├── mix.lock
├── package.json
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

dockerfilesディレクトリに作成するイメージごとのディレクトリを作成し、Dockerfileやentrypoint、設定ファイル等を格納しています。
dockerfilesディレクトリ直下にあるシェルはAWSCodeBuildで実行されるビルドスクリプトで、Dockerイメージには含まれません。*2

コンテンツの取得・描画にTypeScriptを利用しているため、tsディレクトリやwebpack.config.jsがありますが、これらはDockerイメージビルド時にトランスパイルされ、Phoenixによってダイジェストが付与されます。

Dockerfile、エントリポイント、設定ファイル

appコンテナのDockerfile

app コンテナイメージの作成は大きく分けて以下のステップとなります。

  1. 依存ライブラリのインストール
  2. TypeScript のトランスパイルと minify, gzip 圧縮
  3. 静的コンテンツに対してダイジェスト付与
  4. 配布可能なリリースビルドの生成
  5. リリースビルドを含むイメージの作成

以下がElixir/PhoenixアプリのDockerfile(dockerfiles/app/Dockerfile)です。

# -------------------------------------- #
# Multi Stage Build - Build Stage
# -------------------------------------- #
FROM elixir:1.6.0-alpine AS builder

ARG MIX_ENV=dev        # ・・・(1)
ARG SECRET_KEY_BASE="" # ・・・(2)
ARG LOG_DIR=/var/log/chuoi

# mix.exs と mix.lock を環境にコピーして依存パッケージを取得/コンパイル
COPY mix.* /work/
WORKDIR /work
RUN mix do local.hex --force, \
           local.rebar --force, \
           deps.get --only=${MIX_ENV}, \
           compile

# Chuoi では brunch を使わず yarn を使っている
COPY package.json yarn.lock /work/
RUN apk add --no-cache yarn \
    && yanr install --production --non-interactive

# TypeScript のコンパイルと、ダイジェスト付与
COPY ts /work/ts
COPY priv/static /work/priv/static
COPY config /work/config
COPY webpack.config.js tsconfig.json /work/
RUN yarn run webpack \
    && MIX_ENV=${MIX_ENV} mix phx.digest # ・・・ (3)

# 残りのソースをコピーしてリリースアーカイブ(tar.gz)をビルド ・・・ (4)
COPY . /work
RUN SECRET_KEY_BASE=${SECRET_KEY_BASE} LOG_DIR=${LOG_DIR} \
    mix release --env=${MIX_ENV} \
    && mv _build/${MIX_ENV}/rel/chuoi/releases/*/chuoi.tar.gz /

# -------------------------------------- #
# Multi Stage Build - Runtime Stage
# -------------------------------------- #
FROM alpine:3.7 # ・・・ (5)

ENV APP_ROOT=/var/app
EXPOSE 4000

# tzdata, bash, openssl をインストール ・・・ (6)
RUN apk add --no-cache tzdata bash openssl \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# アプリ起動スクリプト(内容は後述) ・・・ (7)
COPY dockerfiles/chuoi/entrypoint.sh /entrypoint.sh
CMD [ "/bin/bash", "/entrypoint.sh" ]

# 前段で作成したイメージからビルド済みのパッケージを取得
COPY --from=builder /chuoi.tar.gz /chuoi.tar.gz

# パッケージを展開
# 静的ファイルディレクトリにシンボリックリンクで別名をつける ・・・ (7)
RUN mkdir -p $APP_ROOT \
    && tar -zxf /chuoi.tar.gz -C $APP_ROOT \
    && rm -f /chuoi.tar.gz \
    && ln -s $APP_ROOT/lib/chuoi-*/priv/static /web-static

WORKDIR $APP_ROOT

最終的に生成されるDockerイメージを小さくするため、マルチステージビルドによりビルドイメージとランタイムイメージを分けています。*3

各ステップのポイントは以下の通りです。

  • (1) リリースビルドの生成には Phoenix 公式にもあるdistilleryを利用しています。distilleryを利用した場合、config.exsの設定情報は 静的にコンパイルされる ため、設定値に環境変数を利用している場合、たとえば MIX_ENV*4などはビルド時に与える必要があります。実行時には MIX_ENV を必要としません。実際にはこの環境変数CodeBuildのプロジェクト環境変数として設定されていて、docker build --build-argsコマンドでビルドプロセスに注入されます。

  • (2) SECRET_KEY_BASE も MIX_ENV 同様、CodeBuildからパラメータとして受け取りますが、こちらはCodeBuildに直接設定されているわけではなく、Parameter Storeに暗号化されて格納されています。CodeBuildでは Parameter Storeのパラメータを環境変数として利用可能な機能が備わっている ため、シークレット情報を安全にDockerイメージに埋め込むことが可能です。

  • (3) TypeScriptをトランスパイルして生成されたjsファイルにPhoenixの機能でダイジェストを付与しています。ここでは、WebpackによるMinifyとGzip圧縮も行っており、圧縮された静的ファイルをappコンテナを介さずNginxのgzip_staticで直接配信できる ようにしています。リリースビルド時に生成されるアーカイブファイルに含めるため、生成コンテンツはpriv/staticディレクトリに出力しています。

  • (4) distilleryを使ったリリースバイナリのビルドです。実際にはバイナリや静的ファイル、ライブラリおよび起動スクリプトが同梱されたtarボール(.tar.gz)が生成されます。distilleryErlangのランタイムを含めてビルドできるため、このリリースバイナリの実行イメージにはErlangをインストールする必要がなくなります 。これのおかげで、ランタイムイメージは非常にコンパクトなサイズにすることが可能です。*指定しているパスは実際にはバージョン番号が入りますが、Docker上でビルドする場合は常にシングルバージョンとなるため*で問題ありません。後でコピーしやすいようにルートパスに移動しています。

  • (5) ランタイムイメージのベースイメージは、ビルドイメージのベースイメージと同じイメージを利用します。ここでは、elixir:1.6.0-alpineのベースイメージerlang:21-alpineのさらにベースとなるalpine:3.7を指定しています。

  • (6) distilleryで生成される起動スクリプトbashで実装されているため、ランタイムイメージにbashをインストールする必要があります。opensslはないとエラーになったので入れました。

  • (7) アプリ起動時にGzip圧縮済みの静的ファイルをNginxのドキュメントルートにコピーします。そのため、静的ファイルのディレクトリにはシンボリックリンクでわかりやすい別名を付けています。Nginxのドキュメントルートは、読み取り専用のDockerボリュームとしてWebコンテナ(Nginxコンテナ)にマウントされます。*5

出来上がったイメージは40MBで、だいぶコンパクトにできました。

なお、webpackビルドもステージを分けようかと思いましたが、pullするイメージサイズが増えてかえって遅くなったのでやめました。

SECRET_KEY_BASE など環境ごとに異なるパラメータをイメージに埋め込むことはベストプラクティスではないです。 実行時の環境変数として渡せるのであればそれに越したことはないですが、 distillery を利用するため仕方なくそうしているのと、 セッションに機密性がないシステムであるため、このシステムに限ってはイメージに埋め込んでいます。
機密度の高いシークレット情報はイメージには埋め込まず、実行時にParameter StoreSecret Managerから取得できるようにした方が良いでしょう。 Railsであれば以前紹介した拙作のgemが利用できます。 また、ECRの利用者が限られていて、イメージ利用に関する監査などが行われているのであれば、イメージに埋め込んでも問題ないはずです。

appコンテナのentrypoint
# このディレクトリを app コンテナに書き込み可能でマウントしておく
# また、このディレクトリは web コンテナにも読み取り専用でマウントされる
WEB_DOC_DIR=/var/app/public

cp -r /web-static/* $WEB_DOC_DIR/

# distillery によって生成された起動スクリプト実行
exec bin/chuoi foreground
web コンテナのDockerfile

web コンテナのDockerファイルはオフィシャルのnginxを利用するため、かなりシンプルです。

FROM nginx:alpine

# ヘルスチェックコマンド用に curl をインストール
RUN apk add --no-cache tzdata curl \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /entrypoint.sh

# CMD は entrypoint.sh で上書き
CMD [ "/bin/sh", "/entrypoint.sh" ]
web コンテナの nginx.conf と entrypoint.sh

nginx.conf はポイントだけ記載します。

upstream appserver {
    server %APP_SERVER%:4000; # nginx起動時に環境変数の値で置換する
}

Fargate を利用する場合、コンテナ間通信の宛先は必ず localhost になります。 ただしそのままだとローカル環境や通常のDockerホストでの開発やテストができないため、APP_SERVERという環境変数に app コンテナのホスト名を入れることで 開発時やテスト時にも通信可能なようにしました。*6

そのため、entrypoint.sh では以下のように nginx.conf を書き換えてから nginx を起動しています。

# デフォルトは localhost
# 実際には 127.0.0.1 を指定すること、詳しくは後述
[ -z "$APP_SERVER" ] && APP_SERVER=localhost

# nginx.conf の該当設定箇所を文字列置換
sed "s|%APP_SERVER%|$APP_SERVER|g" -i /etc/nginx/nginx.conf

/usr/sbin/nginx -g "daemon off;"
log コンテナのDockerfile

log コンテナもほぼ公式のイメージそのままでシンプルです。

FROM fluent/fluentd:v1.2

RUN apk add --no-cache tzdata \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime \
    && gem install fluent-plugin-s3 -v 1.0.0 --no-document

COPY fluent.conf /fluentd/etc/fluent.conf

fluentd の設定ファイルで注意しなければいけないのは、コンテナなので終了時にバッファをフラッシュする必要があることです。 ECS ではコンテナの停止時にSIGTERMが送出されますが、fluentd の file バッファではSIGTERMで flush されないので、 file バッファを利用している場合はflush_at_shutdown trueを設定します。

ここまででFargate上で動かすためのファイルが揃いました。
CodePipelineを構築する前に、実際にFargate上で動作確認を行います。

Docker化したアプリをFargateで動かす

Fargateを構築するまでの手順はかなり多く、大まかに以下の通りです。

  1. ECRリポジトリを作成
  2. ECRリポジトリにイメージをpush
  3. ECSサービスとタスク用のIAMロールを作成
  4. タスク定義を作成
  5. ECSサービス用のセキュリティグループを作成
  6. ECSサービス用のALBとターゲットグループなどを作成
  7. ECSクラスタとFargateサービスを作成

以降の説明において、マネジメントコンソールのスクリーンショットは、面倒なので どうせすぐに変わるので、あえて貼っていません。

ECR リポジトリの作成とファーストイメージの push

事前にAWSの開発環境に ECR のリポジトリを 3 つ (app/web/log) 作成しておきます。
ここではまだ Terraform は使わず、マネジメントコンソールで作成しました。
ここはCLIで作成したほうが楽なので、コマンドも載せておきます。

for repo in app web log; do
  aws ecr create-repository --repository-name $repo
done
Dockerfileをローカルでビルド
docker build -t app:latest \
  --build-args SECRET_KEY_BASE="xxxx..." \
  --build-args MIX_ENV=qa \
  -f dockerfiles/app/Dockerfile ./

docker build -t web:latest ./dockerfiles/web
docker build -t log:latest ./dockerfiles/log
Dockerイメージを ECR にプッシュ
AWS_ACCOUNT_ID="xxxxxx"
AWS_REGION=ap-northeast-1
ECR_REPO_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com

# ECR にログイン
$(aws ecr get-login --region ${AWS_REGION} --no-include-email)

# 各イメージを各リポジトリに push
for repo in app web log; do
  docker tag "${repo}:latest" "${ECR_REPO_URI}/${repo}:latest"
  docker push "${ECR_REPO_URI}/${repo}:latest"
done

これでECRにファーストイメージが latest というタグで登録されました。(ややこしいな)

なお、予めイメージを登録しておかないと、ECSサービスを作成した際にタスクが起動せずにエラーになります。 エラーが出てもあとから登録すれば勝手に起動してくれるので実害はないですが、エラーが出るのが気持ち悪いので先にイメージを登録しておく手順としました。

Fargate(ECSサービスとタスク)用のIAMロールを作成

Fargateを実行するために以下の2つのIAMロールを作成します。

  • ECSタスク実行ロール (ecsTaskExecutionRole)
  • ECSタスクロール

ECSタスク実行ロールは、ECSサービスに割り当てられるIAMロールで、ECSタスクを実行するための認可を与えます。こちらはマネージドポリシー(AmazonECSTaskExecutionRolePolicy)が用意されているので、IAMロールを作成してポリシーをアタッチすればOKです。IAMロールのAssumeRolePolicyにはecs-tasks.amazonaws.comの信頼関係を設定しておきます。*7

ECSタスクロールは、実行されたタスクのコンテナ内部で利用するIAMロールです。log コンテナで利用する S3 への書き込み権限などを付与しておきます。こちらもAssumeRolePolicyはecs-tasks.amazonaws.comとなります。

タスク定義 (TaskDefinition) の作成

AWSのマネジメントコンソールで3つのコンテナを起動するタスク定義を作成します。
ここでのポイントはボリュームの定義です。以下の3つのボリュームを定義しました。

  • アプリのログを格納するボリューム app-log
  • gzip圧縮された静的ファイルのボリューム app-static
  • fluentdのbuffer/posファイルのボリューム log-tmp

静的ファイルはアプリのイメージに含まれているので、アプリの起動コマンドの中でボリュームにコピーするのは前述の通りです。web コンテナではそれを読み取り専用でマウントします。 log コンテナが再起動した際にログを重複送信しないように、bufferファイルとpositionファイルはDockerボリュームに格納します。*8

マネジメントコンソールでタスク定義を作成しますが、最終的に出来上がった TaskDefinition は以下のようになります。(コメントが書けるようにyamlにしていますが、実際にはjsonです)

# Fargate の場合は以下2行は固定
requiresCompatibilities: [ FARGATE ]
networkMode: awsvpc

# ECSタスク実行ロール
executionRoleArn: arn:aws:iam::999999999999:role/ecs-task-execution-role
# 実行されたタスクのコンテナ内部で利用するIAMロール
taskRoleArn: arn:aws:iam::999999999999:role/task-role

# CPUとメモリ とりあえず最低限を確保
# Fargate では CPU とメモリの組み合わせに制限があるので注意
cpu: '256'
memory: '512'

# Dockerボリュームの定義
# Fargate では sourcePath の指定をしていはいけない
volumes:
  - { name: app-log, host: { sourcePath: } }
  - { name: app-static, host: { sourcePath: } }
  - { name: log-tmp, host: { sourcePath: } }

containerDefinitions:

  # app コンテナの定義
  -
    # コンテナ名
    # CodePipelineでのデプロイ時にこのコンテナ名を参照するため、わかりやすい名前にする
    name: app

    # ECRに登録されているイメージのURIを指定
    # CodePipeline によるデプロイでは、ここだけが書き換わる
    image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest

    # essential を true にすると、このコンテナがエラーとなった場合にタスク全体が失敗する
    essential: true

    # ポート指定 `docker run -p`のパラメータ
    # コンテナ間通信が必要な場合は必ず定義する
    # Fargateの場合、hostPort と containerPort は必ず同じでなければならない
    portMappings: [ { hostPort: 4000, containerPort: 4000, protocol: tcp } ]

    # コンテナに直接渡される環境変数 `docker run -e`のパラメータ。
    # DB_PASSWORD などのシークレット値はコンテナ内のプロセスから取得するようにし、ここには埋め込まない。
    environment:
      - { name: REDIS_HOST, value: redis.xxxxxx.ng.0001.apne1.cache.amazonaws.com }
      - { name: REDIS_PORT, value: '6379' }

    # Dockerボリュームのマウント指定 `docker run -v`のパラメータ。
    mountPoints:
      # 静的コンテンツ格納ボリューム: web コンテナでは読み取り専用マウントする
      - { containerPath: /var/app/public, sourceVolume: app-static, readOnly: }
      # アプリログ格納ボリューム: log コンテナでは読み取り専用マウントする
      - { containerPath: /var/log/chuoi, sourceVolume: app-log, readOnly: }

    # Fargateではawslogsのみをサポート
    # CloudWatch Logs にコンテナの標準出力と標準エラー出力を配信する
    logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /chuoi/ecs/tasks/app
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: qa

    # ulimit
    ulimits:
      - { name: nofile, softLimit: 65536, hardLimit: 65536 }

  # web コンテナの定義
  -
    name: web
    image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/web:latest
    essential: true
    portMappings: [ { hostPort: 80, containerPort: 80, protocol: tcp } ]

    mountPoints:
      # 静的コンテンツ格納ボリューム: 読み取り専用マウントする
      # app コンテナが起動時にファイルを書き込む
      - { containerPath: /var/app/public, sourceVolume: app-static, readOnly: true }

    logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /chuoi/ecs/tasks/web
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: qa
    ulimits:
      - { name: nofile, softLimit: 65536, hardLimit: 65536 }

  # log コンテナの定義
  -
    name: log
    image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/log:latest
    essential: true

    mountPoints:
      # アプリログ格納ボリューム: 読み取り専用でマウントする
      - { containerPath: /var/log/chuoi, sourceVolume: app-log, readOnly: true }
      # fluentd一時ファイル格納ボリューム: 書き込み可能でマウントする
      - { containerPath: /fluentd/log, sourceVolume: log-tmp, readOnly: }

    environment:
      - { name: S3_REGION, value: ap-northeast-1 }
      - { name: S3_BUCKET, value: hogehoge-log-bucket }

    logConfiguration:
      logDriver: awslogs
      options:
        awslogs-group: /chuoi/ecs/tasks/log
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: qa
    ulimits:
      - { name: nofile, softLimit: 65536, hardLimit: 65536 }

セキュリティグループの作成

FargateをWeb利用する上で必要なセキュリティグループは以下の2つです。

  • ECSサービスのセキュリティグループ
  • ALBのセキュリティグループ

実現したいことは以下の通りです。

  • app コンテナから ElastiCache Redis の 6379/tcp に対するアクセス許可
  • ALB から web コンテナの 80/tcp に対するアクセス許可
  • インターネットから ALB の 80/tcp, 443/tcp に対するアクセス許可

上記を実現するためのセキュリティグループを実装します。 なお、ECSではサービスに対してセキュリティグループを割り当てるため、コンテナごとにセキュリティグループを分けることはできません。

今回作成したセキュリティグループ

今回作成したセキュリティグループを例に説明しますが、実現方法は1つではないのであくまでも一例としてとらえてください。

ElastiCache Redis のセキュリティグループはすでに作成済みで、以下のルールとなっています。

  • Redisのセキュリティグループ (sg-redis)
    • 自身のセキュリティグループから 6379/tcp へのアクセス許可

それとは別に、以下のセキュリティグループを作成しました。

  • ECSサービスのセキュリティグループ (sg-ecs-service)

    • 自身のセキュリティグループから 80/tcp へのアクセス許可
  • ALBのセキュリティグループ (sg-ecs-alb)

    • 外部から 80/tcp, 443/tcp へのアクセス許可

上記3つのセキュリティグループに対し、ECSサービスとALBを以下のように所属させます。

  • ECSサービス => sg-ecs-service, sg-redis
  • ALB => sg-ecs-alb, sg-ecs-service

余談ですが、私はセキュリティグループを作成するときは自身からのアクセス許可のみで作成し、そこにリソースを追加する形式で実装します。
こうすることで、セキュリティグループが他のセキュリティグループやリソースに依存しなくなり、Terraformで作成する際にモジュール化しやすくなります。
ネットではよくセキュリティグループ間に対して通信許可を与えるパターンを目にしますが、これはTerraform利用においてはアンチパターンです。

ECSサービス用のALBとターゲットグループを作成

こちらの手順は通常のALB作成手順のため割愛しますが、ターゲットグループはipをターゲットとした空のターゲットグループを作成します。 セキュリティグループは前述のとおりに割り当てます。

ALBを利用せずにECSサービスをPublicサブネットに配置する場合はこの手順は不要です。 動作確認するだけであればALBを使わずにIP直アクセスでも事足りることが多いでしょう。

ECSクラスタとFargateサービスを作成

必要なリソースができたら ECS クラスタを作成し、そのクラスタに Fargate サービスを作成します。 まだ動作確認だけなので、タスク数は1で、スケーリングポリシーは設定しません。
ポイントは以下です。

  1. 起動タイプでFARGATEを選択、タスク定義を選択してサービス名を入力、タスク数には1を入力
  2. 作成済みの VPC と Private サブネット、作成したセキュリティグループを選択し、パブリックIP割り当ては Private サブネットなのでDISABLEDを選択
  3. Application Load Balancerを選択し、作成した ALB とターゲットグループを選択、ターゲットに web コンテナを選択

ECSサービスを作成すると、自動的にタスクが開始されます。 開始されたタスク内の各コンテナログは CloudWatch Logs に配信されているのでそちらで動作確認を行います。

Fargate上で起動した際に発生したエラー

Redisにつながらない

これはdistilleryのドキュメントをよく読んでいなかったために発生したエラーです。
config.exsの設定がリリースビルド時に静的にコンパイルされますが、それを知らなかったため発生しました。

もともとのconfig.exsでは以下のようにRedisのホスト名やポートを環境変数から取得していたのですが、 これらはDockerイメージのビルド時に評価されるため、localhostに対して接続しようとしてエラーになっていました。

redis_host = System.get_env("REDIS_HOST") || "localhost"
redis_port = (System.get_env("REDIS_PORT") || "6379")
             |> String.to_integer()
redis_connection_size = (System.get_env("REDIS_CONNECTION_SIZE") || "1000")
                        |> String.to_integer()
config :redix,
  args: [
    host: redis_host,
    port: redis_port,
    database: 0
  ],
  max_connection: redis_connection_size

こちらは実行時(コネクション接続時)に環境変数から取得するように関数化しました。 以下はRedisコネクションプール作成時の実装(簡易版)です。

defp get_env_of_integer(name, default_value) do
  (System.get_env(name) || default_value) |> String.to_integer()
end

def init([]) do
  redix_args = [
    host: System.get_env("REDIS_HOST"),
    port: get_env_of_integer("REDIS_PORT", "6379"),
    database: 0
  ]
  pool_option = [
    name: {:local, :redis_pool},
    worker_module: Redix,
    size: get_env_of_integer("REDIS_CONNECTION_SIZE", "1000")
  ]
  children = [
    :poolboy.child_spec(:redis_pool, pool_option, redix_args)
  ]
  supervise(children, strategy: :one_for_one)
end

log コンテナがDockerボリュームにファイルを書き込めない

log (fluentd) コンテナが buffer ファイルの書き込みに失敗していました。
fluentd の実行ユーザをrootに変更したら書き込めるようになりました。

公式fluentイメージの entrypoint.sh を以下のように修正して、DockerイメージにCOPYします。

- exec su-exec fluent "$@"
+ exec su-exec root "$@"
COPY entrypoint.sh /bin/entrypoint.sh

ここらへんはFargateというよりDockerの制約ですね。

nginx がまれに 502 エラーになる

これは運用開始後に気づいたのですが、およそ1時間に1回くらいの頻度で 502 エラーが発生していました。
ALB -> web (nginx) -> app (phoenix) というフローで、 app に到達せずに web で 502 を ALB に返していました。

詳しくは調査していませんが、NginxでのProxy先ホストをlocalhostとしていたため、127.0.0.1だけでなく[::1]にProxyしようとしてエラーになっているようでした。

2018/07/13 15:05:51 [crit] 7#7: *3 connect() to [::1]:4000 failed (99: Address not available) while connecting to upstream, client: 10.0.0.116, server: _, request: "GET /system/healthcheck HTTP/1.1", upstream: "http://[::1]:4000/system/healthcheck", host: "10.0.7.111"

参考: qiita.com

なので、明示的に127.0.0.1にProxyするように web コンテナの entrypoint.sh を以下のように書き換えました。

- [ -z "$APP_SERVER" ] && APP_SERVER=localhost
+ [ -z "$APP_SERVER" ] && APP_SERVER=127.0.0.1

これで 502 のエラーが発生しなくなり、スループットも向上しました。*9

Redisコネクション数が急増してRedisが詰まる

これは単純なオペミスですが、ElasticBeanstalkの旧環境が起動したままFargateを起動したため、一時的にRedisのコネクション数が倍増してRedisが詰まりました。
なので、正しい手順として以下のようにデプロイする必要があります。Redisだけでなく、DB(RDS等)でも同様です。

  1. ElasticBeanstalkのRedisコネクション数を半分程度にする (縮退運転)
  2. FargateタスクのRedisコネクション数を本来の半分程度にする
  3. Fargateサービスを作成する
  4. Route53のALIASレコードをElasticBeanstalkからFargateのALBに変更する
  5. ElasticBeanstalkのRedisコネクション数をさらに少なくする
  6. Fargateのコネクション数を増やす

なお、当然のことながらRedisコネクション数を環境変数等で制御できるようになっている前提です。
一時的に縮退運転となるため、トラフィックが安定している時間帯に行います。もともとRedisのコネクション数に余裕があればこの対応は不要です。
Fargate云々ではなく、一般的なシステム移行では当たり前のことですね。凡ミスでした。

まとめ

  • Dockerfile のマルチステージビルドで Elixir の実行コンテナイメージを軽量化できる。
  • Elixir のリリースビルドを生成する場合、設定値の環境変数をビルド時に渡すか、実行時評価のための関数に変更する。
  • 同一タスク内のコンテナ間通信では 127.0.0.1 を利用する。
  • コンテナ停止時のシグナルは SIGTERM なので、必要に応じてトラップする。
  • Fargate ではDockerボリュームもタスク内だけの揮発性。ファイルの永続化を行う場合は S3 などを利用する。
  • セキュリティグループを作成するときは自身からのアクセス許可のみで作成し、そこにリソースを追加する。
  • 自動生成されるリソースも、自前で作成したほうがTerraformフレンドリー。

今回はここまでです。次回は AWS Fargate へのデプロイパイプラインの構築について書きたいと思います。

2018/08/01 追記 デプロイパイプラインの構築記事を書きました。 www.m3tech.blog

エンジニア積極募集!!

エムスリーではAWSの新サービスを試したいエンジニアを募集しています!!エムスリーならわずか1週間で試せちゃいます!! AWS に限らず、Firebase や GCP なども積極的に利活用しています。興味が湧いた方はぜひ Tech Talk やカジュアル面談にお越しください。 お問い合わせは以下フォームより、お待ちしております!!

jobs.m3.com

*1:サーバ上でコンパイルを行うElasticBeanstalkのお作法に則ったのと、起動時のトラブルや環境のトラブルを少なくするため

*2:実際には buildspec.yml でそれらのファイルを実行しています

*3:最終的なイメージサイズが小さくなればいいため、ビルドイメージはあえてステップを細かく刻んでいます

*4:MIX_ENVはElixirプロジェクトの環境を表す変数で、RAILS_ENVのElixir版みたいなものです。

*5:こちらもDockerイメージに含めてしまえばよかったのですが、ちょっとイメージの管理が煩雑になりそうだったのでやめました

*6:同じやりかたで、worker数なども環境ごとに変更できるようになります。

*7:マネジメントコンソールでECSサービスを作成した場合、ecsTaskExecutionRole というロールが自動生成されますが、Terraformフレンドリーではないので自前で作成します

*8:どれか1つでもコンテナが落ちたらタスク全体が失敗するように設定するためこの設定は不要かもしれませんが、このシステムではログが重要なため、念のためこうしています。

*9:ただ、 [::1] で接続できなければ 127.0.0.1 にフォールバックするはずなので、なぜ1時間に1回だけ 502 になっていたかはわからずじまいでした

なぜfoldRightが美しいのか

エンジニアリンググループの冨岡です。

私は最近関数型プログラミングにハマっていて、社内でFP in Scala (訳書)の輪読会を主催するなどして関数型やScalaが好きな人を増やす活動をしています。

この輪読会ですが、本自体の素晴らしさもあって未だに参加者7人が誰一人脱落することなく読み進めています。現在8章と折返し地点なのですが、これまでの章で十分に訓練された私たち参加者は、(説明の都合上)副作用を起こす処理が教科書に出てこようものなら大ブーイング。教科書の解法を確認しては「美しい!」「エレガントだ!」と盛り上がりながら読み進めるようになりました。

中でもfoldRightは大人気で、登場のたびに場を盛り上げてくれます。この記事では、このfoldRightなぜ美しいのかを解説します。

foldLeft / foldRight

まずは、似た処理であるfoldLeft / foldRightとはなにか、簡単に説明します。

trait List[+A]について、foldLeftは以下のように初期値としてのBから始まり、Listの左の要素から順に op: (B, A) => B再帰的に適用し、最終的なBを返却します。

f:id:jooohn:20180718223944p:plain

一方foldRightは、Listの右の要素から順に、op: (A, B) => B再帰的に適用し、最終的なBを返却します。

f:id:jooohn:20180718224141p:plain

opの引数A, Bの順番が、foldLeftfoldRightで逆になっていますが、このような図を考えると直感的でわかりやすいですね。

実装の違い

一見この2つのメソッドは、それぞれ単に処理する順番が違うだけ、それ以上でもそれ以下でもないように思えます。本当にそうでしょうか。

それぞれの実装を考えるために、まずは自分でListを定義してみましょう。

sealed trait List[+A]
case class Cons[A](head: A, tail: List[A]) extends List[A]
case object Nil extends List[Nothing]

foldLeftはこんな感じの実装になると想像できます。

sealed trait List[+A] {

  def foldLeft[B](z: B)(op: (B, A) => B): B =
    this match {
      case Nil => z
      case Cons(head, tail) => tail.foldLeft(op(z, head))(op)
    }

}

foldRightはこんな感じでしょうか。

sealed trait List[+A] {

  def foldRight[B](z: B)(op: (A, B) => B): B =
    this match {
      case Nil => z
      case Cons(head, tail) => op(head, tail.foldRight(z)(op))
    }

}
val list = Cons(1, Cons(2, Cons(3, Nil)))
list.foldLeft(0)(_ + _) // 6
list.foldRight(0)(_ + _) // 6

良さそうに見えます。

しかし、この素朴なfoldRightの実装には落とし穴があります。foldLeftは末尾再帰ですが、foldRightはそうでないため、長いListの場合にjava.lang.StackOverflowErrorの原因になります。

f:id:jooohn:20180720151319p:plain
@tailrec アノテーションにより foldRight が末尾再帰ではないことが警告されるの図

なお最近のScalaであれば、標準ライブラリのList.foldRightはスタックセーフな実装になっています。

では、このように素朴な実装でスタックオーバーフローを引き起こすfoldRightが、なぜ美しいのでしょうか。

foldLeft を使って他のメソッドを実装する

純粋関数の魅力の一つは、composabilityです。副作用を起こさない関数は他の関数と組み合わせることによって、別の関数を安全・簡潔に定義することができます。(注: 関数とメソッドは厳密には別の概念ですが、ここでは純粋であり合成可能なことが重要なのでこの差を無視します)

では、foldLeftを使って、mapを実装してみましょう。

sealed trait List[+A] {

  def map[B](f: A => B): List[B] =
    foldLeft(Nil: List[B])((acc, a) => ???)

}

この???を埋められるでしょうか。acc :+ f(a)というような、リストの末尾に項目を追加する処理が必要そうです。しかし、immutableなListの末尾に項目を追加するにはリストの長さnに対してO(n)の計算量が必要になります。これがfoldLeftの中で再帰的に行われるとなるとO(n^2)の計算量となり非常に効率の悪い処理となってしまいます。(f(a)の計算はnに無関係な定数時間で計算できるとします)

foldLeftで現実的な実装をするとなると以下のような感じでしょうか。

sealed trait List[+A] {

  def map[B](f: A => B): List[B] =
    foldLeft(Nil: List[B])((acc, a) => Cons.apply(f(a), acc)).reverse

  def reverse: List[A] =
    foldLeft(Nil: List[A])((acc, a) => Cons.apply(a, acc))

}

あまり直感的ではありませんね。

foldRightを使って他のメソッドを実装する

今度はfoldRightを使ってmapを実装してみましょう。

sealed trait List[+A] {

  def map[B](f: A => B): List[B] =
    foldRight(Nil: List[B])((a, acc) => Cons(f(a), acc))

}

右の要素から順に、fを適用した結果をheadに追加していく、非常に素直な実装になっています。 Cons(f(a), acc)はO(1)の計算量のため、全体でもリストを走査する分のO(n)の処理となります。美しい上に、効率的です。

同様に、filterや、2つのリストを連結するappendといったメソッドも、foldRightを使ってエレガントに実装することが可能です。

sealed trait List[+A] {

  def filter(f: A => Boolean): List[A] =
    foldRight(Nil: List[A])((a, acc) => if (f(a)) Cons(a, acc) else acc)

  def append[B >: A](that: List[B]): List[B] =
    foldRight(that)(Cons.apply)

}

美しいですね!

これが何を意味するのか

この2つのメソッドの違いはいったい何を意味するのでしょうか。

これを確認するために、データ構造の形に注目してみましょう。 immutableなListは以下のように、長いListが、末尾により短いListを保持する形になっています。

f:id:jooohn:20180720022505p:plain

Listを新しく作成するときは、immutableであるために以下のように最も短いリスト(Nil)から順に、長いListを作る必要があります。

f:id:jooohn:20180720022610p:plain

これが、foldRightの畳み込み処理の順番なのです。あるListから新しいListを作るmapfilterといった操作をfoldRightを使うことで素直に書けるのは、foldRightがListの作成時と同じ順番で値を処理するためです。逆に、foldLeftは、最後に追加された要素から順番に処理していきます。QueueとStackの関係に似ているかもしれませんね。

言い換えると、immutableなデータ構造に関して、foldRightの処理順は"右から左"よりも寧ろ、"内側から外側"に処理すると考えられます。これがデータ構造にとてもよくマッチして美しく再利用しやすい処理になるのです。

まとめ

foldLeft / foldRightの対比から、foldRightの美しさについて解説しました。

  • foldLeftfoldRightは似ているようで性質の異なる関数である
  • foldRightはimmutableな再帰的データ構造によく馴染む美しい関数である

この記事を見てfoldRightが好きになったという方がいたら是非一緒に畳み込まれましょう!

エムスリーインターン体験記(プロダクトマネージャー編)

こんにちは、エムスリーで約1ヶ月間インターンをしていた(している)山田です。

今回私はソフトウェアエンジニアのインターンに参加したのですが、この記事ではプロダクトマネージャー(以下PM)のインターンについて書きます。

 

PMインターンのきっかけ

採用担当の方とお話した際に「他のチームも覗いてみたいですね〜」「PMにもちょっと興味があるんですよ〜」と軽く(本当に軽く!)言ってみたら、翌日あたりには他のチームでのPMインターンにも参加できることになっていました。PMに関する知識はほぼ無かったので本当に大丈夫なのかと不安にもなりましたが、元々PMインターンのプログラムがあったわけではなく期間も限られていたことが幸いして半日×3日間でPMとしての基礎を身につける! という私に合わせたインターン計画を組んでいただくことが出来ました。

ソフトウェアエンジニアの仕事もあるため事前学習の時間はあまり取らず、PMの企画会議にちょっと参加したり、関わるプロダクトについての説明を軽く受けたりした程度の状態で当日に臨みました。

f:id:x3yuki:20180719210058j:plain

左から境さん、私、山崎さん

 

1日目 - 発想

まずPMについての知識がなかったため、メンターの山崎さん・境さんからざっくりと以下のような説明を受けました。

  • 発想 → 検証 → IA(Information Architecture、画面設計書のようなもの) の流れで企画を進める
  • PMとしていちばん大切なのは仮説の検証を通じてより良いアイディアを生み出せること

今回のインターンでは「発想の段階で出したアイディアについて、そのアイディアが成り立つための前提仮説を丁寧に検証し、確実にいけると確信できたらIAに落とし込む」というPMの工程を3日間で全て体験します。

説明の後に【電子カルテを自動学習で圧倒的によくする!】というお題を頂き、初日はアイディアを出すところから始めました。アイディアを出す際には 誰のどんな困りごと(課題)を解決するのか解決できたらどの程度のインパクトがあるのか を明確にする必要があるそうです。な〜んだそんなの簡単じゃん! と思うかもしれませんが、いざやってみると案外難しく、追加・変更する機能の内容ばかりに気を取られて課題から離れてしまっているアイディアを出してしまうことが何度かありました。特にエンジニアだとよくあるらしいのでやってみてください。最終的に5つほどのアイディアをまとめて簡単なプレゼンをしました。

 

2日目 - アイディアの検証

前日に出したアイディアを元に、アイディア・困りごと・インパクトを表にまとめてそれを検証しつつ、より良いアイディアは無いか? と考えていきます。一つの困りごとに対して様々なアプローチのアイディアを出し、そこから新しい困りごとが見えたらまたそれに対するアイディアを考える……という作業を繰り返して、メンターの境さんにアドバイスを頂きながら前日は5つだったアイディアを30個ほどにまで増やしました。

ある程度アイディアを出し切ったら、その中から筋の良さそうなものを選んで、今回は社員の有識者にインタビューをします。今回は困りごとを2つ選び、それぞれに対してアイディアを3つずつ見ていただきました。この際、「どのアイディアが一番良いか」に着目してしまいがちなのですが、大切なのは各アイディアについて「それぞれのアイディアをより良くするためにはどうするべきか」を考えることだそうです。私も前者に寄ってしまっていたのですが、メンターさんやインタビュイーの社員さんから指摘を受けつつ各アイディアに対する意見を頂くことができました。

 

3日目 - 仮説の検証

最終日です! この日は前日のインタビューで頂いた意見を元にもう一度検証を行いました。これまでにまとめていた内容に加えて、各アイディアに対する前提仮説も書き出していきます。例えば私は問診票を使ったアイディアを出したのですが、それに対しては以下のような前提仮説をリストアップしました。

  • 問診票は電子データとして入力されているのか
  • 問診票では本当に必要な情報が確認されているのか
  • 問診票に記入された内容は信用できるのか
  • 問診票の項目は診療科ごとに異なるのではないか

書き出しが終わったらもう一度インタビューを行います。アイディアについてはとにかく自由にたくさん出したのですが 、検証は批判的に行わなければなりません。重要な前提仮説から順番に確認し、アイディアに対する意見をいただきました。実際の企画ではインタビューをしてアイディアを再度ブラッシュアップする、という検証プロセスを何度も繰り返すのですが今回は時間が限られていたので、ブラッシュアップしたアイディアの中から一番良さそうなものを選び、それについてのIAを書いて終了です。

 

まとめ

3日間を終えて、PMとしての考え方を知ることが出来ました。エンジニアとして働いていると課題は決まっている場合が多く、それに対する(主に技術的な)アプローチを考えることがメインになるのですが、PMの場合はそれだけではなく本質的な課題を探すところから始める必要があります。「抽象の階段を上り下りする」のだと何度か言われましたが本当にその通りで、抽象的な困りごとと具体的な仮説やアイディアとの間を行ったり来たりしつつ企画を進めることはとても難しく、同時に楽しいことでした。今回得た考え方は訓練を繰り返すことで身につくものだと思うので、今後も意識していこうと思っています。

また、良いプロダクトをつくるためにはそのための環境が必要なのだということも実感しました。アイディアに詰まったときに相談できるメンターさんや、インタビュイーとして的確な意見を出してくださる社員さんが揃っていたからこそ、初心者の私でも充実したインターンを体験することが出来たのだと思います。たくさん頂いたフィードバックも今後活かしていこうと思います。本当にありがとうございました!

f:id:x3yuki:20180719210101j:plain

最後に受付前で写真を取りました(顔が似ているような気がしなくもないですね)

 

エムスリーでのPMインターンに興味を持った方は、以下のリンクから「PMインターン希望」と書いて応募してみてくださいね!

jobs.m3.com

エムスリーインターン体験記(ソフトウェアエンジニア編)

こんにちは、エムスリーで約1ヶ月間インターンをしていた(している)山田です。

私と、エムスリーと、ときどきインターン や 学生から見たエムスリー に続きインターンブログを書くのは3人目なのですが、今回私はソフトウェアエンジニアとプロダクトマネージャー、両方(!)のインターンを体験したのでそれぞれについて記事を書きます。こちらはソフトウェアエンジニア についてです。

 

自己紹介・エムスリーとの出会い

大学入学後にプログラミングを始めて、今はお茶の水女子大学大学院で勉強と研究をしているM1です。Web系の企業に就職したいかな〜とふんわり考えています。

友人に誘われた逆求人イベントで初めてエムスリーのことを知り、面白そうだったので選考を受けました。面接時の印象が良かったこと、プロダクトに興味が持てたこと、時期や期間を自由に決められてスケジュール的にも都合が良かったこと、web関係の開発経験に乏しい私でも様々なことにチャレンジ出来そうだったこと、などが最終的にインターンへの参加を決めた理由です。

 

インターン内容

選考を通過したので、6月の半ばから学校の授業に行きつつインターンにも参加するようになりました。私が担当したのは AskDoctors というサービスの生理日管理機能を改善することです。生理開始と排卵の予定日のみの表示だったところをカレンダー形式での表示にしたり、生理日以外の項目も記録・表示できるようにしたりしました。リリースはもう少し先なのですが是非使ってみてください!

開発は主に Ruby on Rails と Vue.js を使って進めました。どちらも始めて触ったのですが、分からない部分についてはリファレンスや他のコードも参照しながら丁寧に教えていただき、その度に理解が深まるのがとても楽しかったです。メンターのNickさんと席が隣だったこともあり、毎日何度も質問していました。

f:id:x3yuki:20180720160143j:plain

左がメンターのNickさんです

技術を学ぶことも面白かったのですが、機能改善のプロセスに初めから関われたことも良い経験になりました。実装する機能やデザインを企画する段階から関わり、プロダクトマネージャーさんやデザイナーさんとたくさん相談しながら実装を進めることは簡単ではありませんでしたが、こういったプロセスを経ることで完成度の高いプロダクトが出来上がるのだと学ぶことができました。インターンの私が発言したことでもどんどん採用されるのはもちろんのこと社員さんから意見を求められることも多々あり、立場は関係なく全員でより良いものを作ろうとする社風はとても働きやすかったです。

 

エムスリーのいいところ

インターンとして働く中で実感した「エムスリーのいいところ」から、私にとって特に魅力的だったものを3つ選んでご紹介します。

会社の規模は大きいけれど開発チームの規模は小さい

社内には小さめのチームがたくさんあり、スピード感をもって開発を進めています。時価総額やサービスの規模からは自由に働けなさそうな印象を受けますが、実際は新しい技術を柔軟に取り入れていたり、個人からの要望(チーム異動や開発環境など何でも)も可能な限り叶えようとしていたり、現行の仕組みではやりたいことが出来ないようであれば仕組みの方を変えてしまったりなど、会社の規模が大きいからこそ安心して何でも出来るとても働きやすい会社でした。

何でもやらせてくれる

興味とやる気があれば何でも挑戦することが出来ます。インターンの私でも「プロダクトマネージャーにも興味がある」「他のチームがどんなことをしているのか見てみたい」とランチのときにちょっと話しただけで、その日の午後には話が進み、他のチームでプロダクトマネジメントインターンに参加できることになりました。驚きましたが、急なことでも楽しみながら対応してくださる優秀な社員さんが多く、本当にありがたかったです。

素敵な人しかいない

私は自分のチームの方だけではなく他のチームの方と関わる機会も多かったのですが、一緒に働いたら楽しそうだと思える社員さんばかりでした。また、一度だけ18卒で入社した方々の勉強会にお邪魔したときには、ただ仕事に慣れようとするのではなく業務改善についてなど様々なことをフランクにお話されていて驚きました。実力のある社員さんが多いだけでなく、その力を存分に発揮出来る環境が整っているからこそこのように感じられるのだと思います。

 

終わりに

f:id:x3yuki:20180720160048j:plain

最終日にチームの皆さんと写真を撮りました

1ヶ月という短い間でしたが、予想以上にたくさんのことを経験できた楽しいインターンでした。2ヶ月前にはエムスリーという会社の名前すら知りませんでしたが、なんとなく面白そうだという直感で選考を受けてみて本当に良かったです。

主に医師向けのサービスを展開していることもあり就活生からの知名度はあまり高くないのですが、 エムスリーに興味をもった方は是非以下のサイトからインターンに応募してみてください!

jobs.m3.com