メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

AWS + ngx_mruby で SSL 証明書の動的読み込みシステム構築

CTO室SREの @kenzo0107 です。

2021年6月24日に「 kakari for Clinic ホームページ制作 」がリリースされました。

f:id:kenzo0107:20210720154151p:plain
kakari for Clinic ホームページ制作

今回は上記サービスで採用した、
AWS + ngx_mruby で構築した SSL 証明書の動的読み込みシステムについてです。

SSL 証明書を動的に読み込みする理由

kakari for Clinic ホームページ制作の1機能で、制作したホームページに独自ドメインを設定する機能がある為です。*1

f:id:kenzo0107:20210728135315p:plain

複数ドメインでアクセスできる =複数ドメインの SSL 証明書を読み込む
を実現する必要があります。

動的に SSL 証明書を読み込むには?

以下いずれかのモジュールを組み込むことで SSL 証明書の動的読み込みが可能になります。

以下理由から ngx_mruby を採用しました。

  • 弊社は Ruby エンジニアの割合が高い!
  • 技術顧問 Matz さんに相談できる!*2

ngx_mruby での SSL 証明書動的読み込み 実装 参考資料

論文「高集積マルチテナントWebサーバの大規模証明書管理」を参考にさせていただきました。

p4 の「図3 動的なサーバ証明書読み込みの設定例 (KVS ベース)」を見ると実装概要がわかりやすいです。

server {
    listen 443 ssl;
    server_name _;
    ssl_certificate /path/to/dummy.crt;
    ssl_certificate_key /path/to/dummy.key;

    mruby_ssl_handshake_handler_code ’
        ssl = Nginx::SSL.new
        host = ssl.servername
        redis = Redis.new "127.0.0.1", 6379
        crt, key = redis.hmget host, "crt", "key"
        ssl.certificate_data = redis["#{host}.crt"]
        ssl.certificate_key_data = redis["#{host}.key"]
    ’;
}

通常、 Nginx の ssl_certificate, ssl_certificate_key に変数を利用できません。 ngx_mruby を利用すると Redis or その他から証明書情報 crt, key を取得し、 設定することができます。

システム構成

右側のシステム管理者・運営者が管理画面から静的コンテンツを S3 に生成しています。
今回は ngx_mruby での証明書の動的配信についてフォーカスして紹介します。*3

f:id:kenzo0107:20210720220614p:plain

ユーザアクセスからのサイトのコンテンツ配信する大まかな流れは以下の通りです。

  1. 患者様 がクリニックサイトにアクセス
  2. ngx_mruby で SSL/TLS ハンドシェイク時にドメインを元に Redis から証明書(crt), 秘密鍵(key) を取得
    • Redis に存在しない場合は DynamoDB から取得し、 Redis にキャッシュ登録
  3. 取得した crt, key を元に SSL/TLS ハンドシェイク
  4. 静的ウェブサイトとしてホスティングされた S3 へ proxy し HTML を表示
    • HTML 内の各種 css, js, img は CDN で配信

システムの詳細・工夫点を以下に記載して参ります。

Nginx を Fargate で起動させる

ngx_mruby を組み込んだ Nginx は Fargate 上で起動させました。

サーバ管理・デプロイやスケーリングの容易さのメリットが大きい為、Fargate を採用しました。

Fargate では net.core.somaxconn が変更できません が、 リクエスト詰まりしない様、タスク数には余裕を持たせています。

Docker イメージは https://github.com/matsumotory/ngx_mruby/blob/master/Dockerfile を参考に alpine でマルチステージビルドし軽量化 (850 MB → 26 MB) しました。

イメージビルドや ECS へのデプロイは GitHub Actions で実施しています。

SSL 終端を Nginx で実施すべく NLB を採用

ALB, CLB では HTTPS (443) 通信する場合は、証明書の設定が必須です。
NLB は TCP (443) を指定し SSL 終端を Target で実施でき、Fargate との親和性も高い為、採用しました。

f:id:kenzo0107:20210720223917p:plain
NLB Listeners TCP:443 で設定すると証明書の設定が不要

ALB は ロードバランサーあたりの証明書 (デフォルト証明書は含まない): 25 であること等、クォータ制限 がある為、AWS LB シリーズでの SSL 終端はサービスがスケールすることを考慮すると採用できませんでした。

証明書発行は ACM でなく Let's Encrypt を採用

ACM 証明書数 クォータ 制限がある為、サービスがスケールすることを考慮して証明書の発行は Let's Encrypt で実施することとしました。*4

過去に業務で利用経験があり、また本件で参考にさせていただいたはてなブログさんでも採用していること、また、プロジェクトが開始される頃に Software Design 2021年4月号 で特集されており、発行の手軽さと信頼性から採用しました。

NLB 利用時の注意点

NLB は ALB と異なり、以下を注意する必要がありました。*5

  • セキュリティグループがアタッチできない
  • WAFがアタッチできない
  • 4xx, 5xx 等のメトリクスがない

対策: セキュリティグループがアタッチできない

セキュリティグループで実施していた IP 制限は ngx_mruby で実装しました。

  • allow_request.rb
# frozen_string_literal: true

# リクエスト許可処理クラス
class AllowRequest
  def initialize(request, connection)
    @r = request
    @c = connection
  end

  def allowed_ip_addresses
    ENV['ALLOW_IPS'].split(',')
  end

  def allowed?
    return true unless (allowed_ip_addresses & [
      @c.remote_ip,
      @r.headers_in['X-Real-IP'],
      @r.headers_in['X-Forwarded-For']
    ].compact).empty?

    false
  end

  AllowRequest.new(Nginx::Request.new, Nginx::Connection.new).allowed?
end

nginx.conf

env ALLOW_IPS;

...

# 許可 IP でない場合、 404 を返す
mruby_set $allow_request /etc/nginx/hook/allow_request.rb cache;
if ($allow_request = 'false') {
    return 404;
}

環境変数 ALLOW_IPS に許可したい IP を渡すと ngx_mruby で許可 IP 以外は 404 を返します。

NLB + Nginx on Fargate でクライアント IP を渡す方法

NLB は Target Group のプロトコルが TCP or TLS の場合、 クライアント IP 保持はデフォルトで無効化されています。*6
その為、明示的にクライアント IP の保持を有効化する必要があります。

f:id:kenzo0107:20210721004931p:plain
NLB > Target Group > Attributes 設定

Proxy protocol v2 も有効化し、Nginx で proxy_protocol を設定することで、Nginx でクライアント IP を解釈できる様になります。

server {
    listen 443 ssl proxy_protocol;
    server_name _;

対策: WAF がアタッチできない

NLB には WAF がアタッチできません。
XSS, SQLi 等の WAF は Nginx に NAXSI *7 を導入することで対応しました。*8

location / {
    # NAXSI による SQLi, XSS 等検知しブロックした場合、403 を返す
    SecRulesEnabled;
    DeniedUrl /request_denied;
    CheckRule "$SQL >= 8" BLOCK;
    CheckRule "$XSS >= 8" BLOCK;
    CheckRule "$RFI >= 8" BLOCK;
    CheckRule "$TRAVERSAL >= 4" BLOCK;
    CheckRule "$EVADE >= 4" BLOCK;

    # whitelist: XSS double encoding が誤検知された為、許容する
    BasicRule wl:1315;

    ...
}

# WAF でブロックした際に 403 を返す
location = /request_denied {
    return 403;
}

誤検知した際には特定ルールをホワイトリストとして登録し許容することが可能です。*9

ブロック時には Nginx エラーログに出力されます。*10

2021/06/11 17:53:32 [error] 7#0: *53 NAXSI_FMT: ip=172.21.0.1&server=example.com&uri=/%25U&vers=1.3&total_processed=13&total_blocked=11&config=block&cscore0=$EVADE&score0=4&zone0=URL&id0=1401&var_name0=

対策: 4xx, 5xx メトリクスがない

NLB は ALB とは異なり 4xx, 5xx メトリクスがなく、エラー検知ができません。

以下の様に対応しました。

f:id:kenzo0107:20210722222748p:plain

  1. fluentbit で Nginx のログを CloudWatch Logs へ配信
  2. CloudWatch Metric Filter で 4xx, 5xx エラーをフィルタリング*11
  3. CloudWatch Alarm で 4xx, 5xx の数が閾値を超えると SNS 経由で Chatbot へ通知*12
  4. Chatbot と連携した Slack へ通知

CloudWatch Logs は通知用に利用し
Kinesis Firehose + S3 は Athena でログ捜査時に利用します。

RDS でなく DynamoDB でデータ永続化

ngx_mruby のサンプルコードでは、証明書情報を Redis でキャッシュし、 RDS で永続化するパターンがよく見られました。

ですが、今回は DynamoDB を採用しています。

理由は、ドメイン名をキーに証明書情報を取得する今回のケースでは複雑なクエリを実行する必要がなく、リレーショナル DB と比較して NoSQL の特徴である以下メリットを享受できる為です。

  • 柔軟でスキーマレスなデータモデル
  • 水平スケーラビリティ
  • 分散アーキテクチャ
  • 高速な処理

参考: 何が違う?DynamoDBとRDS - サーバーワークスエンジニアブログ

DynamoDB へのアクセスは API Gateway + Lambda

ngx_mruby は https://rubygems.org/ の gem を利用できません。 *13
低レベル APImattn/mruby-curl で実現できないこともなさそうですが、難易度が高く検証工数を確保できそうにない点から見送りました。

その代わりに
Lambda で aws-sdk を利用し DynamoDB へアクセスする様にしました。
API Gateway で Lambda のエンドポイントを設定し ngx_mruby から mattn/mruby-curl でエンドポイントを叩き Lambda を実行する様にしました。

f:id:kenzo0107:20210722223219p:plain

上記構成で数十ミリ秒程度でレスポンスが返り商用環境の利用は問題ありませんでした。

ちなみに、 永続化データを担保する DynamoDB へのアクセスは以下の場合となり、基本的に頻度は低いです。

  • ElastiCache Redis にアクセスできない
  • ElastiCache Redis のデータが揮発した*14

証明書の自動更新 システム構成

f:id:kenzo0107:20210722231120p:plain

概要は以下の通りです。

  1. EventBridge (cron) で Lambda cert-lifecycle-store を定期実行
  2. cert-lifecycle-store で証明書の有効日数が 30日以下の証明書のドメインリストを取得*15
  3. cert-lifecycle-store から cert-updater にドメイン名を渡し証明書の更新を実行
  4. cert-updatergo-acme/lego を利用し Let's Encrypt で証明書を発行
  5. SSL 証明書 (crt) と 秘密鍵 (key) を DynamoDB, ElastiCache Redis に保存、バージョン管理として S3 に証明書発行時のレスポンスを JSON ファイルに保存

証明書の新規発行は管理画面から cert-updater を実施できる様にしており、運用者が証明書を発行できる様にしています。

参考

おまけ

mruby 仲間を増やしたい気持ちから今回の ngx_mruby を用いた証明書の動的読み込みを簡易的に体験できるリポジトリを用意しました。

github.com

ngx_mruby 初めましての方もそうでない方も遊んでいただけると幸いです。

以上です。

採用のリンク


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


*1:弊社テックブログでも利用しております、はてなブログの「独自ドメイン」の設定と同様の機能です。

*2:弊社では定期的に Matz さんへ聞きたいこと!会を開催頂いております。

*3:Ruby on Rails で構成される管理画面で静的コンテンツをS3にアップロードする仕組みについては別途本ブログで紹介予定です。お楽しみに✨

*4:プロジェクト開始前に弊社担当の AWS ソリューションアーキテトに相談したところ、サービスがスケールすることを考慮すると ACM でなく別途証明書発行システムを採用することを推奨されました。

*5:弊社では NLB は本プロジェクトが初採用でした。

*6: NLB Client IP preservation にて「If the target group protocol is TCP or TLS, client IP preservation is disabled by default. 」と記載がある通りです。

*7:NAXSI は Nginx Anti XSS & SQL Injection の略で Nginx 特化の WAF モジュールです。

*8:Nemesida WAF Freeは alpineベースだと導入方法がわからなかった(できなかった)。Nginx Plus ModSecurityは年間40万円以上の有償サービスで検証工数が確保できず、断念しました。

*9:w:1315 の 1315 は ルールに採番されているIDで https://github.com/nbs-system/naxsi/blob/master/naxsi_config/naxsi_core.rules に記載されています。

*10:LOG を設定するとブロックせずログに出力するモードがある様ですが、LearningMode (学習モード)を設定しないと「Assertion failed: strlen(fmt_config) != 0 (/usr/local/src/naxsi/naxsi_src//naxsi_runtime.c: ngx_http_nx_log: 1076)」というエラーが発生することを確認しています。AWS WAF の count の様な機能を期待していましたが違いました。

*11:CloudWatch Metric Filter のアイコンが見つからなかった

*12:SNS 連携先を Lambda でなく Chatbot にした場合、通知内容を

*13:その代わり https://github.com/mruby/mgem-list にある gem を利用できます

*14:よくある質問 - Amazon ElastiCache | AWS にて「エンジンのアップグレードプロセスは、既存のデータをベストエフォートで保持するように設計されており、Redis レプリケーションに成功する必要があります。」とあり、データは揮発する可能性があることを前提に設計しています。

*15:Let's Encrypt の証明書の有効期間は 90 日間で 60日毎の更新を推奨している為です

【令和最新(当時)】メドピア開発からVueの プログレッシブを俯瞰する

CTO室の小宮山です。肉体系Youtuber巡りが最近の趣味です。

2020年6月5日に開催された令和最新(当時)なこちらのイベントにて、登壇者として発表させていただきました。

techplay.jp

そして発表資料は体裁を整えてこのブログにて大々的にドカンと投稿しようと考えていたのですが、そのまま忘れ去って気づけば早1年が経過しておりました。時の流れとは残酷なものです。

内容自体は色あせにくいものであり、このまま埋もれさせてしまうのももったいないと感じたため、そのまま投稿してお披露目とさせていただきたいと思います。

埋め込みスライドだと左右が見切れてしまうようなので、スライドのリンクを貼っておきます。

https://speakerdeck.com/tomoyakomiyama123/medopiakai-fa-karavuefalse-puroguretusibuwofu-kan-suru

一応埋め込みもここに。

speakerdeck.com

以上です、どうぞよろしくお願いいたします。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


入門 GitHub Actions

CTO室SREの @sinsoku です。

社内のGitHub ActionsのYAMLが複雑になってきたので、私が参考にしてる情報や注意点、イディオムなどをまとめておきます。

頻繁に参照するページ

新しい機能の説明が日本語ページに反映されていないため、基本的に英語ページを読むことを推奨。

よく使うaction

actions/checkout

イベントによってはデフォルトブランチをチェックアウトするため、 ワークフローをトリガーするイベント のページで GITHUB_SHA を確認する必要がある。

例えば pull_request イベントの GITHUB_SHA はデフォルトブランチとのマージコミットになるため、ブランチのHEADを使う場合は以下のような指定が必要です。

- uses: actions/checkout@v2
  with:
    ref: ${{ github.event.pull_request.head.sha }}

actions/github-script

簡単なAPIの実行であれば、これで事足りる。

例えば、Issueコメントにリアクションをつけるコードは下記の通り。

name: reaction

on:
  issue_comment:
    types: [created]

jobs:
  - name: Create a reaction
    uses: actions/github-script@v3
    with:
      script: |
        await github.reactions.createForIssueComment({
          owner: context.repo.owner,
          repo: context.repo.repo,
          comment_id: context.payload.comment.id,
          content: "+1",
        });

ワークフローを書く時に注意すること

文字列は一重引用符

RubyやJavaScriptを書いていると間違いやすいので注意。

"foo" はエラーになるので 'foo' にします。

タイムアウトの指定

Actionsは実行時間で課金されるため、意図しない長時間の実行を防ぐために基本的に設定しておく方が良い。

timeout-minutes: 5

並列実行数の制御

ワークフローを無駄に実行しないように、基本的に設定しておく方が良いです。

ただ、 github.ref だけ指定すると他ワークフローを意図せず止めてしまうことがあるため、 github.workflow を接頭辞につけておいた方が安全です。

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

GITHUB_TOKEN を使うと新しいActionは起動しない

意図せず再帰的にActionが起動するのを防ぐためですが、知らないとハマります。1

  1. Approveされたプルリクを自動マージ
  2. マージされた後に自動デプロイ

例えば上記のように2つのActionを作っても、2つ目のActionは起動しないです。

これを解決するには GITHUB_TOKEN の代わりに personal access token を使う必要があります。

envは steps の中でしか使えない

以下のコードは Unrecognized named-value: 'env' でエラーになります。

env:
  FOO: foo

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 5

    env:
      BAR: ${{ env.FOO }}-bar

    steps:
    - run: echo ${{ env.BAR }}

同様に matrix の中でも env は使えないです。

success() はifでしか使えない

以下のコードは Unrecognized function: 'success' でエラーになります。

- name: Notify finish deploy to Rollbar
  uses: rollbar/github-deploy-action@2.1.1
  with:
    environment: 'production'
    version: ${{ github.sha }}
    local_username: ${{ github.actor }}
    status: (success() && 'succeeded') || 'failed'

if条件は式構文 ${{ }} を省略できるケースがある

ドキュメントに記載されてはいるが、Web上の事例ではあまり書いてないので紹介する。

式に演算子が含まれていない場合は ${{ }} を省略できます。

if: always()

ただ、 ${{ }} をつけても特に問題はないため、常に ${{ }} で囲んでおいた方が良いかも。

outputs のデフォルト値

ドキュメントに記載されていないですが 空文字列 になります。

例えば、デプロイ処理の準備中にワークフローをキャンセルされることもあるため、以下のように if: でoutputsをチェックしておく必要があります。

deploy:
  outputs:
    deployment-id: ${{ steps.deploy.outputs.deployment-id }}
  steps:
  - name: Prepare for deployment
    run: echo "do something"
 
  - name: Deploy
    id: deploy
    run: echo "::set-output name=deployment-id::1"

rollback:
  needs: [deploy]
  if: cancelled() && needs.deploy.outputs.deployment-id

イディオム

三項演算子

三項演算子と同等のことは以下の書き方で実現できます。

env:
  RAILS_ENV: ${{ (github.ref == 'refs/heads/main' && 'production') || 'staging' }}

ArrayとObjectの生成

リテラルの記法が存在しないため、 fromJSON を使う必要があります。

env:
  is_target: ${{ contains(fromJSON('["success","failure","error"]'), github.event.deployment_status.state) }}
  rollbar_status: ${{ fromJSON('{"success":"succeeded","failure":"failed","error":"failed"}')[github.event.deployment_status.state] }}

その他

Dependabotを設定する2

DependabotはGitHub Actionsに対応しているので、設定しておくと便利です。

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "daily"

採用のリンク


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html



  1. GITHUB_TOKEN の詳細な権限は Authentication in a workflowを参照してください。

  2. Keeping your actions up to date with Dependabot

1枚岩なMPAプロダクトでWebpackのマルチエントリーをさらにグルーピングしてビルドする

Noita世界の理不尽をこの身をもって体験した末にバウンドルミナスで全てを切り刻んでクリアしました、フロントエンドグループの小宮山です。

以前からこれできたらいいのになぁと思いながら無理そうと諦めていた掲題の事柄を実現できた嬉しさの勢いのままに書き始めています。

状況

1枚岩なMPAプロダクトがどういうものかというと、

  1. ルーティングをRails側で管理するMPA(複数エントリーポイント)
  2. 異なる種別のユーザー向けシステムが複数内包されている

という構成です。

ルーティングについては要するにSPAではなく、ページ毎のhtmlファイルとmain.jsがあるということです。

異なる種別というのは、要するにユーザー向け画面と管理者向け画面が分かれているような状況です。場合によっては3種類、4種類以上の異なるシステムが内包されたりもします。BtoBtoCなサービスだったりする場合ですね。

課題感

このような状況で普通にWebpack設定を組み上げると、もちろん全てのフロントエンドアセットをひっくるめて同じ設定でビルドすることになります。幸いWebpackはマルチエントリーなビルドにも対応しているのでビルド自体に難しさはありません。

一方で、異なる種別のユーザー向けのフロントエンドアセットを一緒くたにビルドするとやや困ったことが起きてきます。

特に気になっていたのが、splitChunksによる複数画面利用モジュールの切り出しに関することです。
SPAと異なりMPAの場合は画面遷移毎にscriptファイルのロードが行われるため、例えばVue.jsなど、ほぼ全画面で利用するようなモジュールは個別のエントリーファイルに入れてしまうとパフォーマンスの低下が懸念されます。
そこでsplitChunksをいい感じに設定していい感じに切り出すわけですが(いい感じの切り出し方は無限に議論があるので今回は触れません)、困ったことにこの切り出しが全ユーザー種別を跨って行われてしまいます。

f:id:robokomy:20210528183805g:plain
従来のビルドイメージ

シンプルな例を挙げると、管理画面でしか使わない重厚なリッチエディタ用モジュールが、splitChunksの対象となることでそれを全く必要としないユーザー向け画面でも取得対象に含まれてしまったような状況です。
node_modules配下を丸ごとvendor.jsに切り出すような設定をしているとあるある状況だと思われます。

妥協案

Webpackビルドをユーザー種別ごと別々に行えば当然ですが上記のような問題は起きません。しかしこれはこれで様々な面倒事が付きまといます。

ルーティングをバックエンドで制御している場合、フロントエンドアセットはmanifest.jsonで管理することが多いと思われます。
Webpackビルドを分けた場合、当然このmanifest.jsonも複数種類出力されることになります。つまりバックエンドも複数のmanifest.jsonを読み分けるような処理をしないといけません。既にやりたくありません。

複数のWebpackビルドを実行しなければいけない点も見逃せません。複数のWebpackビルドを--watchモードで動かしながら開発することに喜びを見出す会の皆様には申し訳ないですが、複数のWebpackビルドを--watchモードで動かしながら開発することに喜びを見出すことは私のような一般フロントエンドエンジニアには不可能でした。

このような様々な不便が付きまとうことから、多少のパフォーマンス悪化には目をつぶってまるごと単一のWebpackビルドで片付けてしまっていたのが現状でした。

光明

1シーズンに1回くらいの頻度でこの課題感と無理感の再発見を繰り返してきていて、今シーズンも再発見に勤しもうと思ったら実はなんとかなりそうなピースが揃っていることに気がつきました。

ピースその1

実はWebpackは複数の設定を配列で持つことができます。

webpack.js.org

リンク先にもある通り、こういう書き方ができます。

module.exports = [
  {
    output: {
      filename: './dist-amd.js',
      libraryTarget: 'amd',
    },
    name: 'amd',
    entry: './app.js',
    mode: 'production',
  },
  {
    output: {
      filename: './dist-commonjs.js',
      libraryTarget: 'commonjs',
    },
    name: 'commonjs',
    entry: './app.js',
    mode: 'production',
  },
];

このビルドを実行すると、2つの設定に基づいたビルドを同時(内部的には順次かもしれません)に走らせてくれます。

サンプルコードのように用途別に生成物を分けたり、ユニバーサルJSなプロダクトでサーバーとクライアント環境それぞれをビルドしたいような場面で活躍しそうです。

ユーザー種別ごとに異なる設定を用意したいという場面も状況は同じなので、きっとそのまま適用できるでしょう。
「複数のWebpackビルドを実行しなければいけない」という問題はこれでなんとかなりそうです。

If you pass a name to --config-name flag, webpack will only build that specific configuration.

なんと名前を付けておくと特定の設定でだけビルドすることもできるようです。admin系画面が不要な開発中はビルド対象から外して高速化するといった使い方もできそうです。

ピースその2

Webpackプラグインとしてmanifest.jsonをいい感じに生成してくれるのがwebpack-assets-manifestで、このプラグインにはmergeというオプションがあります。

github.com

このmergeオプションを有効にするとその名の通り、同名のmanifest.jsonファイルが既に存在している場合、そこに追記する形で新たなmanifest.jsonを生成してくれるようになります。

前回生成したmanifest.jsonを一部引き継ぐなんて絶対面倒な何かを引き起こす厄介な機能じゃないと勘ぐりたくなりますが、実はこのオプションが欲しい状況が存在します。

今回なんとかしたかった状況が正しくそれでした。ユーザー種別ごとに異なる設定でWebpackビルドを行いつつ、最終的なmanifest.jsonは1つに統合することが可能となります。

マルチエントリーグルーピングビルド設定

以上のピースを当てはめるとこのようなWebpack設定を組み上げることが可能です。

app/javascript/packs配下にエントリーファイルが設置されるとして、さらに顧客用画面はcustomers、管理用画面はadminでネームスペースを切っているような例です。

- app
  - javascript
    - packs
      - entry.js
      - customers
        - entry.js
      - admin
        - entry.js

gist.github.com

そして生成されるmanifest.jsonはこのようになります。個別のエントリーファイルは以前同様に生成されつつ、splitChunksしたvendor系ファイルはネームスペース毎の個別で生成してくれています。

{
  /**/
  "entry": "/packs/entry.abc-hash.js",
  "customers/entry": "/packs/customers/entry1.abc-hash.js",
  "admin/entry": "/packs/admin/entry1.abc-hash.js",
  "vendor-root.js": "/packs/vendor-root.abc-hash.js",
  "vendor-customer.js": "/packs/vendor-customers.abc-hash.js",
  "vendor-admin.js": "/packs/vendor-admin.abc-hash.js",
}

f:id:robokomy:20210528183900g:plain
改善後のビルドイメージ

注意点として、splitChunksしたファイルを実際に読み込むhtmlファイルはネームスペース毎に別で用意する必要があります。
とはいえ全ユーザー種別で単一のlayoutファイルを使い回すことは稀で、それぞれ専用のlayoutファイルを用意するケースがほとんどだと思われます。manifest.jsonをhelperで読み分けるような面倒さに比べたらきっと些細なことです。

おわり

異なるユーザー種別向けの設定を別で用意しつつ、ビルド自体は統合されているようにふるまわせたいという贅沢な願いはこうして無事に叶えることができました。 Webpackを素で触れる環境はやはりよいものです。皆様も素敵なWebpackライフを。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


Amazon ECSで動かすRailsアプリのDockerfileとGitHub Actionsのビルド設定

CTO室SREの@sinsokuです。

Dockerイメージのビルドを高速化するため、試行錯誤して分かった知見などをまとめて紹介します。

AWSのインフラ構成

assetsもECSから配信し、CloudFrontで /assets/packs をキャッシュする構成になっています。

f:id:sinsoku:20210527182858p:plain
Rails on ECS

デプロイ時にassetsが404になる問題

以前の記事に詳細が書かれているため、ここでは問題の紹介だけしておきます。

Rails等のassetsファイルをハッシュ付きで生成し配信するWebアプリケーションの場合、ローリングアップデートを行うと、アップデート時に404エラーが確立で発生してしまいます。

引用: メドピアのECSデプロイ方法の変遷

Dockerfile

実際のDockerfileには業務上のコード、歴史的な残骸などが含まれていたので、綺麗なDockerfileを用意しました。

rails new した直後の最小限のRailsアプリで動作確認していますが、業務のRailsアプリではapkでインストールするパッケージなどは少し変える必要があるかもしれません。

# == base
FROM ruby:3.0.1-alpine3.13 AS base

WORKDIR /app
ENV RAILS_ENV production
ENV BUNDLE_DEPLOYMENT true
ENV BUNDLE_PATH vendor/bundle
ENV BUNDLE_WITHOUT development:test

RUN gem install bundler --no-document --version 2.2.16

# == builder
FROM base AS builder

# Add packages
RUN apk update && apk add --no-cache --update \
      build-base \
      postgresql-dev \
      tzdata \
      git \
      yarn \
      shared-mime-info

# == bundle
FROM builder AS bundle

# Install gems
COPY Gemfile Gemfile.lock .
RUN bundle install \
      && rm -rf $BUNDLE_PATH/ruby/$RUBY_VERSION/cache/*

# == npm
FROM builder AS npm

# Install npm packages
COPY package.json yarn.lock .
RUN yarn install --production --frozen-lockfile \
      && yarn cache clean

# == assets
FROM builder AS assets

COPY . .

COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=npm /app/node_modules node_modules

# Set a dummy value to avoid errors when building docker image.
# refs: https://github.com/rails/rails/issues/32947
RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile \
      && rm -rf tmp/cache/*

# == main
FROM base AS main

# Add packages
RUN apk update && apk add --no-cache --update \
      postgresql-dev \
      tzdata \
      nodejs \
      shared-mime-info

COPY . .

# Copy files from each stages
COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=assets /app/public/assets public/assets
COPY --from=assets /app/public/packs public/packs

ARG SHA
ENV SHA ${SHA}
ENV PORT 3000
EXPOSE 3000

CMD bin/rails server --port $PORT

rails new をMacで実行した場合

MacでBundler 2.2.3以上を使っている場合、Gemfile.lockにPLATFORMSが入ってしまいDockerイメージのビルドに失敗してしまいます。

 > [bundle 2/2] RUN bundle install       && rm -rf vendor/bundle/ruby/3.0.1/cache/*:
#12 0.953 Your bundle only supports platforms ["x86_64-darwin-20"] but your local platform
#12 0.953 is x86_64-linux-musl. Add the current platform to the lockfile with `bundle lock
#12 0.953 --add-platform x86_64-linux-musl` and try again.

ビルドできるようにするため x86_64-linux を追加する必要があります。1

$ bundle lock --add-platform x86_64-linux

マルチステージビルド

このDockerfileには以下のステージが含まれています。

  • base: 全てのステージの親ステージ
  • builder: bundle,npm,assetsの親ステージ
  • bundle: bundle install を実行する
  • npm: yarn install を実行する
  • assets: assets:precompile を実行する
  • main: 本番環境で使うステージ

ステージの依存関係は以下の通りです。

bundle ─┬- assets ─ main
        │
   npm ─┘

bundle stage

bundle install の引数を使うと、mainステージでも bundle config をする必要がありDRYでは無いため、baseステージで環境変数を指定しています。2

ENV BUNDLE_DEPLOYMENT true
ENV BUNDLE_PATH vendor/bundle
ENV BUNDLE_WITHOUT development:test

bundle installを実行します。

RUN bundle install \
      && rm -rf $BUNDLE_PATH/ruby/$RUBY_VERSION/cache/*

npm stage

特筆すべき点は特にないです。 yarn install を実行します。

RUN yarn install --production --frozen-lockfile \
      && yarn cache clean

assets stage

RAILS_ENV=production を設定しているので、 assets:precompile を実行するときに SECRET_KEY_BASE が存在しないというエラーが起きてしまいます。
Railsのissueを参考にSECRET_KEY_BASE=dummy を指定すれば回避できます。3

RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile \
      && rm -rf tmp/cache/*

main stage

rails server の起動に必要なパッケージをインストールしたり、ソースコードや各ステージの成果物をコピーします。

assets:precompile でassetsファイルは結合されているため、 node_modules はサーバ起動時に不要です。

RUN apk update && apk add --no-cache --update \
      postgresql-dev \
      tzdata \
      nodejs \
      shared-mime-info

COPY . .

# Copy files from each stages
COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=assets /app/public/assets public/assets
COPY --from=assets /app/public/packs public/packs

環境変数の定義位置には注意が必要です。 GitのSHAのように毎回変わる値をイメージに埋め込む場合、Dockerfileの下部に記載しておかないとレイヤーキャッシュが効かなくなってしまいます。

ARG SHA
ENV SHA ${SHA}

最後にポート番号とサーバの起動コマンドを設定します。

ENV PORT 3000
EXPOSE 3000

CMD bin/rails server --port $PORT

GitHub Actions

DockerイメージをビルドするGitHub ActionsのYAMLを紹介します。

name: build

on:
  pull_request:
    branches:
      - master
  push:
    paths:
      - '.github/workflows/build.yml'
      - 'Dockerfile'

env:
  ECR_REPOSITORY: example
  HEAD_SHA: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || github.sha }}

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
    - uses: actions/checkout@v2
      with:
        ref: ${{ env.HEAD_SHA }}

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v1

    - name: Cache Docker layers
      uses: actions/cache@v2
      with:
        path: /tmp/.buildx-cache
        key: ${{ runner.os }}-buildx-${{ env.HEAD_SHA }}
        restore-keys: |
          ${{ runner.os }}-buildx-

    - name: Build bundle stage
      uses: docker/build-push-action@v2
      with:
        context: .
        target: bundle
        cache-from: type=local,src=/tmp/.buildx-cache/bundle
        cache-to: type=local,dest=/tmp/.buildx-cache-new/bundle

    - name: Build npm stage
      uses: docker/build-push-action@v2
      with:
        context: .
        target: npm
        cache-from: type=local,src=/tmp/.buildx-cache/npm
        cache-to: type=local,dest=/tmp/.buildx-cache-new/npm

    - name: Build and push
      uses: docker/build-push-action@v2
      with:
        context: .
        build-args: |
          SHA=${{ env.HEAD_SHA }}
        tags: |
          ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, env.HEAD_SHA) }}
          ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, 'latest') }}
        push: ${{ github.event_name == 'pull_request' }}
        cache-from: |
          type=local,src=/tmp/.buildx-cache-new/bundle
          type=local,src=/tmp/.buildx-cache-new/npm
        cache-to: type=inline

    # Temp fix
    # https://github.com/docker/build-push-action/issues/252
    # https://github.com/moby/buildkit/issues/1896
    - run: |
        rm -rf /tmp/.buildx-cache
        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

キャッシュ

docker/build-push-actionのCacheのページを参考に、GitHub cacheを使ってbundle,npmのステージをキャッシュします。4

エクスポートするのはbundle, npmのステージになるため、各ステージでもキャッシュを消してイメージサイズを小さく保つようにします。

マージした時のみpush

build-push-action ではレジストリへのプッシュを制御できるので、「git pushではレジストリにプッシュしないで、Actionsでビルド結果を確認してからプルリクを作る」という運用が簡単に実現できます。

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: .
    build-args: |
      SHA=${{ env.HEAD_SHA }}
    tags: |
      ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, env.HEAD_SHA) }}
      ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, 'latest') }}
    push: ${{ github.event_name == 'pull_request' }}
    cache-from: |
      type=local,src=/tmp/.buildx-cache-new/bundle
      type=local,src=/tmp/.buildx-cache-new/npm
    cache-to: type=inline

改善の効果

MedPeerのリポジトリで改善したときは他に CodeBuild -> GitHub Actions化なども実施しているため、厳密な計測結果ではありませんが10%程度の高速化を実現できました。

  • 改善前: 🐢10〜11分程度(CodeBuild)
  • 改善後: 🚀8〜9分(Dockerfileの改善 + GitHub Actions化)

まとめ

Dockerイメージのビルドを高速化する知見をブログにまとめてみました。 これらの内容が何か参考になれば幸いです。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html



  1. Bundler 2.2.3+ and deployment of Ruby apps の記事を参考にしました。

  2. bundle configで使える環境変数を調べられる。

  3. https://github.com/rails/rails/issues/32947

  4. GitHubのキャッシュは5GBまでしか使えないため、Registry cacheを使う方が良いケースもあります。

SREチームのセキュリティインシデントゲームデー

CTO室SREの侘美です。最近はM5Stackを嗜んでおります。

ここ半年ほど、MedPeerグループ全体のAWSのセキュリティ改善に力を入れてきました。 その中で、AWS Well-Architectedのセキュリティのベストプラクティスにも記載があるゲームデーを実施したところ、とても学びが多かったので本記事にまとめました。

ゲームデーとは?

Well-Architectedには以下のように記載されています。

ゲームデーを実施する

ゲームデーを実施する: さまざまな脅威について、インシデント対応イベントのシミュレーション (ゲームデー) を実施します。このゲームデーには、主要なスタッフや管理者を参加させてください。
教訓から学ぶ: ゲームデーの実行から得られた教訓は、プロセスを改善するためのフィードバックに含まれている必要があります。

出典: https://wa.aws.amazon.com/wat.question.SEC_10.ja.html

実施したゲームデーの概要

今回行ったゲームデーの概要は以下の通りです。

  • 擬似的に社内のいずれかのサービスでAWS上のインフラに関するセキュリティインシデントを発生させる。
  • どのサービスで発生するかは発生するまで不明とする。
  • セキュリティインシデント対応計画に則りSREチームで対応する。
  • ステージング環境を本番環境とみなしてゲームデーを行う。

今回は初回ということもあり、ゲームデーへの参加はSREのみとし、 私がインシデントを擬似的に発生させ、他のSREメンバー5名で対応にあたるシナリオとしました。

ゲームデーのシナリオ

より実際のセキュリティインシデントを再現するため、次のようなシナリオを事前に作成しました。 SREチームのメンバーへはゲームデー終了後に公開しました。


シナリオ全体の図

f:id:satoshitakumi:20210308135840p:plain
ゲームデーのシナリオ

インシデント

IAMアクセスキーの流出。
ある開発者がバグ調査のためにIAMユーザーを作成し、IAM認証情報を作成した。
Slack上の社外のゲストユーザーが参加しているチャンネルでAWSのアクセスキーIDとシークレットアクセスキーを投稿してしまった。*1
認証情報には、S3やECRやSystem Managerパラメータストアの権限が含まれていた。

インシデントの検知

CTOのTwitterのDMに以下のメッセージがあった体とする。*2

XXX社のYYYです。
御社の<サービス名>というサイトのユーザーのリストと思われる情報がWebに公開されていました。
現在は公開されていないようです。
名前とメールアドレスが載っているので、個人情報流出的に大丈夫ですか??

サイト: https://example.com/path/to/user-list

掲載されている情報(抜粋)

1,テスト太郎,taro.test@example.com
2,テスト次郎,jiro.test@example.com

掲載されている情報は、流出したS3上のCSVファイルの一部となっている。

攻撃者が取得した情報

攻撃者はSlackに投稿されたIAM認証情報を利用して、以下の操作を行った。 (実際に事前に認証情報を利用してこの操作を行う)

  • S3バケットからユーザー情報が記載されたCSVファイルをダウンロード
  • ECRへログインし、サービスで利用しているECS用のRuby on Railsアプリケーションのイメージを取得

本シナリオを利用したゲームデーで確認したい点

  • 初動でIAM認証情報を無効化できるか
  • 以下を調査し適切に報告へ含めることができるか
    • 流出したIAM認証情報
    • 流出した情報
    • ECR上のイメージ流出による影響
    • 流出した経路
  • セキュリティインシデント対応計画の内容は適切か

ゲームデー当日

当日は2時間と時間を区切ってインシデント対応にあたることにしました。

私はシナリオ作成者なので調査は見ているだけで参加しなかったのですが、インシデント検知→作業を分担して各種ログを調査→IAM認証情報特定→他の操作調査とスムーズに対応が進んでいました。

具体的な調査としては、CloudTrail、ALB、ECS、S3アクセスログなどを調査し、流出したIAM認証情報の特定やその認証情報で実行された操作等を特定していきました。
必要なログが取得されて無いといったことは発生しませんでした。

特に初動で流出したデータ内容から、RDS、S3バケット、管理画面などが流出経路である可能性があると判断し、分担して対応にあたった場面は想定以上にスムーズに実行できていたと思います。

最終的な報告では、流出経路を含めほとんど特定でき、その上で流出した情報からサービスの緊急停止を検討すべきか等の議論も行われており、十分対応できた結果となりました。

学び

ゲームデー終了後にSREチームで振り返りを行ったところ、多くの学びや改善点が見つかりました。
すべては記載しきれないので、一部を掲載させていただきます。

  • AWSやGoogle等が説いているインシデント対応のゲームデーの重要さについて、真の意味で理解できた。
  • S3アクセスログ、CloudTrail、Athenaなどを利用して調査を行ったが、習熟度が人それぞれだったため、得意不得意が出た。
    • ログを1箇所に集約し、Athenaを事前に設定しておくことで解消できそう。
  • CloudTrailでのS3オブジェクトレベルのアクセスログ記録が有効になっていないため、S3側で設定しているアクセスログも調査する必要があった。
    • コストとの兼ね合いもあるが、一箇所で検索できると調査がスムーズになる。
  • IAMユーザーの総数が少ないと流出したIAM認証情報の特定は速い。
    • IAMユーザーの棚卸しはやはり大事。
  • (一部のエンジニアにIAMのフル権限を与えているので)IAMユーザー作成検知の仕組みが欲しい。
    • ゲームデー後にCloudTrailのログを利用して通知する仕組みを実装した。
  • 初動の分担等、陣頭指揮は重要。
  • ECRログイン後のアクセスキーが変化するという仕様に気づかず、ECRからイメージをダウンロードしたログを特定できなかった。
  • Ruby on Railsで扱う秘匿情報の管理方法について見直す必要がある。
  • 報告がSlackで散発的に行われたので、最後に報告をまとめる際に苦労した。
    • テンプレートが用意されており、共同編集可能な環境にまとめていくのが良さそう。
  • 次は各サービスのRailsエンジニア等と協力して調査する部分もシミュレートすべき。

AWSのソリューションアーキテクトの方からのアドバイス

AWSのソリューションアーキテクトの方に今回実施したゲームデーの内容を共有したところ、以下のようなコメントをいただきました。
(SAさんに気軽に相談できる環境は本当にありがたい!)

  • 目的・手段が噛み合っているシナリオで良い
  • CloudTrailから影響を絞っていく対応も良かった
  • セキュリティ系インシデントであれば、エンジニア系職種以外の他職種・他チームも巻き込んでシミュレートすると、より実際の対応に近づく
  • セキュリティ系以外には可用性の障害を発生させるシナリオもよくある

次回

上記を踏まえて次回は以下の点を事前に準備・検討した上で再度ゲームデーを夏頃に開催する予定です。

  • 報告書のテンプレートの準備
  • ログ検索の整備
  • SRE以外のエンジニア、非エンジニア職も巻き込んむ

まとめ

セキュリティインシデントのゲームデーを実施することで、実際にやってみないと見えてこない課題などがいくつも見つかりました。
サービスを運用している会社では是非実施することをおすすめします!


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html


*1:GitHubで公開してしまったというよくあるパターンも検討したが、すぐに通知が飛んできて準備中に攻撃がバレそうなので今回は却下しました。

*2:ブログ用に一部内容を修正しています。

メドピアで開催している社内読書会と社内読書分科会について

こんにちは。メドピアのお手伝いをしています @willnetです。花粉が厳しい季節みなさんいかがお過ごしでしょうか。僕は毎年レーザー治療したいな、と思っているのですが気づくともう春になっています。次回こそは…。

さて、メドピアでは毎週水曜日の11時から12時の間に社内読書会を開催しています。これは社内でなるべく多くの人が仕事上で使える知見を獲得することを目的としています。進め方は

  • 予習不要
  • 音読する
  • キリのいいところで止めて感想を話す

という形です。詳しい内容は以前個人のブログで書いたので興味のある人は読んでみてください。

しかし読書会を定期開催するようになってから4年がすぎ、また社内のエンジニアがどんどん増えてくると「読書会をもっと改善したいな」と思うことが増えてきました。

すでに読んでしまった本は題材として扱いづらい

例えばメタプログラミングRuby 第2版は良い本なので、当然この本を題材にした読書会は開催済みです。最近入社したエンジニアにも読んで欲しいのですが、読書会はすでに開催済みのため社内にすでに読了済みの人がたくさんいて、どうしても次の題材選定の際に優先順位が下がってしまいます。

音読しづらい書籍は題材として扱いづらい

日本語訳されていない洋書は、仮にそれが良書でも音読形式で進めづらいので対象外になってしまいます。

ニッチな内容の書籍は題材として扱いづらい

読書会の効果を最大限に高めるために、どうしても最大公約数的な書籍を選びがちです。そのため、RubyやRails全般に関わる書籍、かつジュニア向けの書籍以外を選定するのが難しくなっています。

そこで読書分科会ですよ

現行の読書会は維持しつつ、ここまで出てきた問題を解決するために読書会の分科会(以降分科会)を開催しています。これは通常の読書会とは形式を変えています。

  • 予習前提。予め決めておいた範囲を読んでおく
  • 30分で読んだ感想を話す

これまで取り扱ったお題は次の通りです*1

最大公約数的な題材は通常の読書会、それ以外の題材は分科会とすることで広い範囲をカバーできているように思えます。それぞれ書いたコードを比較し合う、といった読書に限らないことを実施できるのも良い点です。

難点としては、予習前提であることから敷居が高く、脱落率も通常の読書会よりも高いことがあげられます。業務が忙しく予習する時間が取れないことがあるので、なるべく一回の範囲を狭くして、一度サボっても復帰しやすいように工夫しています。

まとめ

メドピア社内で開催している読書会と、そこから派生した分科会について紹介しました。

「どうやってエンジニアのスキルを底上げしていくと良いのか」は難しい問題です。読書会についてももっと良いやり方を模索しています。「うちはこうやっているよ!」という事例があったら教えてもらえると嬉しいです。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:Working with Ruby ThreadsとWorking with TCP Socketsは現時点で購入する場所がなくなってしまっている模様です。悲しい