AWS Lambda でコンテナに入れた Sinatra を動かす

何番煎じか分からないけど、最近やったので。

前提知識

つまりコンテナ化した Sinatra アプリを Lambda 上にデプロイして HTTP リクエストを受け付けることができる。

動かす準備はもう全部整っていて、お手軽そうですね。

Ruby アプリを Lambda で動かすコンテナイメージを作る

Sinatra 以前に、そもそも Ruby はどうやって Lambda Container Image 上で動くのか。公式にチュートリアルがあるのでこの通りで良い。

Deploy Ruby Lambda functions with container images - AWS Lambda

https://gallery.ecr.aws/lambda/ruby の Usage をなぞる。

  1. https://gallery.ecr.aws/lambda/ruby から base image を選んで、Dockerfile を作って
  2. docker build して
  3. docker run で Image を立ち上げると HTTP で待ち受けるので
  4. curl で発火させる
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"payload":"hello world!"}'

謎 URL だけど「こういうもの」として覚えておけば良い。

特筆すべき点

ENV["GEM_PATH"]
#=> "/var/task/vendor/bundle/ruby/2.7.0:/opt/ruby/gems/2.7.0"

なので、ここに gem を入れておくと bundle exec しなくても gem を使える。いやまぁ bundler 使えば良いと思いますが。。

Rack アプリを Lambda で動かす

前述した公式の https://github.com/aws-samples/serverless-sinatra-sample の他に、 https://github.com/logandk/serverless-rack というものもある。

仕組み

どちらも肝は Rack::Builder.parse_file です。 config.ru を eval することで実行したい Rack App を取り出す処理。

App を取得できたので、env を組み立てて

app.call(env) して

Rack の status, headers, body の組から、Lambda の response になるように JSON を組み立て直す

Lambda の response というのはコレ。https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format

{
    "isBase64Encoded": true|false,
    "statusCode": httpStatusCode,
    "headers": { "headerName": "headerValue", ... },
    "multiValueHeaders": { "headerName": ["headerValue", "headerValue2", ...], ... },
    "body": "..."
}

実装

これを自分の Rack アプリにどう組み込むかというと

app/config.ru と lambda.rb を置いて Dockerfile の CMD では lambda.handler を指定すると良い。

PROJECT_ROOT
├── app/
│  ├── config.ru
│  ├── Gemfile
│  └── Gemfile.lock
├── Dockerfile
└── lambda.rb

lambda.rb は https://github.com/aws-samples/serverless-sinatra-sample/ からコピーします。*1

他のファイルはこんな感じ。

# app/Gemfile
source "https://rubygems.org"
gem "rack"
# app/config.ru
run ->(env) { ["200", { "Content-Type" => "text/plain" }, ["OK"]] }
FROM public.ecr.aws/lambda/ruby:2.7

COPY lambda.rb ${LAMBDA_TASK_ROOT}

# rack を ${LAMBDA_TASK_ROOT}/vendor/bundle に入れたい
WORKDIR ${LAMBDA_TASK_ROOT}/app
COPY app/Gemfile app/Gemfile.lock .
RUN bundle config set path ${LAMBDA_TASK_ROOT}/vendor/bundle
RUN bundle install

COPY app/config.ru .

WORKDIR /var/task
CMD [ "lambda.handler" ]

で、実行するときは

curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"httpMethod":"GET","requestContext":{}}'

すると、Lambda の JSON response が取得できる。

{"statusCode":"200","headers":{"Content-Type":"text/plain"},"body":"OK"}

渡す JSON のパラメータは https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format を参照。

{
    "resource": "Resource path",
    "path": "Path parameter",
    "httpMethod": "Incoming request's method name"
    "headers": {String containing incoming request headers}
    "multiValueHeaders": {List of strings containing incoming request headers}
    "queryStringParameters": {query string parameters }
    "multiValueQueryStringParameters": {List of query string parameters}
    "pathParameters":  {path parameters}
    "stageVariables": {Applicable stage variables}
    "requestContext": {Request context, including authorizer-returned key-value pairs}
    "body": "A JSON string of the request payload."
    "isBase64Encoded": "A boolean flag to indicate if the applicable request payload is Base64-encoded"
}

rackup した HTTP Server として Docker Image を実行したい

Lambda の Docker Image として実行するときは Lambda function for proxy integration の形式で JSON をやりとりしないといけない、というのは今まで書いてきた通り。

開発中は bundle exec rackup しておけばいいんだけど、正しく焼けてるか不安なときがあるので、Docker Image 上の Rack アプリをブラウザから実行したい。

つまり

  • HTTP サーバが立って
  • リクエストをいいかんじに JSON に変換して Lambda に投げて
  • Lambda からのレスポンスをいいかんじに HTTP に変換して返す

してくれる proxy (つまり API Gateway や ALB 相当のもの) があると便利だよね。というわけで雑にこんなのを用意しました。

コンテナを立ててた上でこの proxy を立てておくと、http://localhost/ でコンテナの中の Lambda の中の Rack アプリとやりとりできる。

絶対どこかにもっと良いやつあると思うので全然作り込んでない。(例えば multiValueHeadersisBase64Encoded は対応していない)

#!/usr/bin/env ruby
require "json"
require "net/http"
require "webrick"

LAMBDA_ENDPOINT = "http://localhost:9000/2015-03-31/functions/function/invocations"

s = WEBrick::HTTPServer.new
s.mount_proc("/") do |req, res|
  uri = URI(LAMBDA_ENDPOINT)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme === "https"

  headers = {}
  req.each do |k, v|
    headers[k] = v
  end
  json_data = {
    httpMethod: req.request_method,
    path: req.path,
    queryStringParameters: req.query,
    body: req.body,
    headers: headers,
    requestContext: {},
  }
  lambda_res = http.post(
    uri.path,
    JSON.generate(json_data),
    { "Content-Type" => "application/json" },
  )

  lambda_innser_res = JSON.parse(lambda_res.body)

  res.status = lambda_innser_res["statusCode"]
  lambda_innser_res["headers"].each do |k, v|
    res[k] = v
  end
  res.body = lambda_innser_res["body"]
end
Signal.trap("INT") { s.shutdown }
s.start

まとめ

  • Lambda でコンテナに入れた Sinatra アプリを動かすことができる
  • HTTP Request を Rack に変換して実行する仕組みを説明した
  • Lambda function for proxy integration の形式で JSON をやりとりすることになるので、proxy 立てると便利

*1:実際はディレクトリ構造変えたり apigatewayv2 対応したりでもはや別物になっているけど説明面倒なので省略

株式会社はてなに入社しました

株式会社はてなに入社しました

株式会社はてなに入社しました - hitode909の日記

4年目ですね。 さすがにそろそろ知らないことがあると恥ずかしい時期なので、だいたい手の中にはあると思います。 前提整える必要が無い=加速できるので、出力にご期待ください。

今年もよろしくお願いします。

Slack キーワード通知に設定しているワード 100 連発

エゴサが趣味なので、社内の Slack のキーワード通知をめちゃくちゃ便利に使っている。

slack.com

何か見ておくと良いことがありそうなものを片っ端から通知するようにしているんだけど、100 個が上限なの知ってた?

f:id:onk:20210331222523p:plain
100 個以上設定しようとするとこうなる

僕はこんなキーワードを入れています。適当に分類しながら見てみよう。

自分

onk, o/nk, on/k, おんく, 大仲, onaka, 眼科

自分の名前やハンドルネームを入れておくと、どこかで自分が呼ばれたときに気づけるようになる。

定時外だと親切でキーワード通知避けのために / を入れて発言する人もいるので、それも通知させるためにこういうキーワードになっています。戸籍ネームはほっっとんど呼ばれることは無いんだけど、念のため。

「眼科」は中心性漿液性網脈絡膜症になってるっぽいけど放置してたら T シャツが作られたので、戒めのためにずっと通知しています。

suzuri.jp

チーム

自分が所属しているチーム名だったり、プロダクトのコードネームだったり、サブ会 名だったり。

他のチームから言及されたときにすぐに気づける用。

サービス

小説, ノベル, カクヨム, kakuyomu, 魔法, maho, らんど, ランド

カクヨム魔法のiらんどを運営しているチームに居るので、サービスに関連しそうな話題が出たときに気づけるように。

障害検知

CRITICAL, 障害, 不具合, 負荷, 脆弱性, 重い, 重く, Incident, 申し送り

障害っぽかったら通知が来るようにしてある。

「申し送り」は障害対応後とかに使われることが多いワードですね。「重い」は月初に勤怠管理システムに対しての発言をよく見る。

インフラ系

infra, インフラ, SRE, SLA, SLO, SLI, 監視, PWG

インフラっぽい話題が出たら通知が来るようにしてある。

「PWG」は Performance Working Group の略で、パフォーマンス等が悪くなっていないかを定期的に (主に月次開催) チェックする会です。サービスのレスポンス速度やエラー率、発報されたアラートや障害などを3ヶ月ぐらいの目線で確認し、キャパシティプランニングを行ったり、監視設定や根本対策に思いを馳せたりします。

はてなにおける日々の仕事の中にあらわれるMackerelの活用 - Mackerel ブログ #mackerelio でも紹介されていますね。当時とは組織体制はかなり変わっていますが、目的はあまり変わっていません。

mackerel.io

コミュニケーション

(グループウェアのドメイン), 議事録

bot の発言ではなく、人間が URL を貼ったなら、何かしら人に伝えたい意図が含まれた面白い記事なんだろうってところから。実際、キャッチアップしておくと便利な記事がよく流れてきます。

「議事録」は割と興味本意かな。社内のミーティングでは「議事録」ってワードはあまり出ないので、他社さんとの会話が見えることが多い。

マネージャ仕草

cost, コスト, 予実, 予算, 実績, 費用, 数字, 稟議, 申請, release, リリース, キックオフ

はい。

他部署のものでも数字に敏感になっておくと色んな場面で役に立つし、キックオフやリリース時点で話題をキャッチできていると、これも会話の種になる。「デプロイ」はさすがに流速がヤバかったのでキーワードから外した。

マネージャ仕草 人事編

全社, ミッション, ビジョン, バリュー, MVV, 目標, 評価, グレード, 昇格, 昇給, 1on1, 面接, 面談, 異動, 採用, 新卒, インターン, CTO, チーフ, シニア, リーダー, メンター, メンティー, マネージャ, 方針, オンボーディング

1on1 した感想とか、目標設定や評価に対しての感想とかを拾っておきたい。

ふりかえり

ふり返り, 振り返り, 振りかえり, ふりかえり, KPT, YWT

ふりかえりだけでも見ておくとチームの現状が分かるので、覗きに行ってます。

技術

MySQL, RDS, Aurora, redis, Elasticache, Elasticsearch, ruby, rb, rails, レイルズ, レールズ, アジャイル, scrum, スクラム

この辺のワードが出たらいっちょかみしたい技術ワード。

こんなに登録していて仕事できるの

かろうじてなんとかなってるんじゃないかな……。

集中したいときはガン無視しているので、呼ばれても返事してないし、何ならときどきミーティングすっぽかしてる orz

Gemfile の Dual Boot 方法

Gemfile 内で切り替える

こういう ENV で切り替えるやつとか、eval_gemfile を使うやつとか。

https://github.com/discourse/discourse/blob/v2.6.2/Gemfile#L9-L16

def rails_master?
  ENV["RAILS_MASTER"] == '1'
end

if rails_master?
  gem 'arel', git: 'https://github.com/rails/arel.git'
  gem 'rails', git: 'https://github.com/rails/rails.git'
else
  # NOTE: Until rubygems gives us optional dependencies we are stuck with this needing to be explicit
  # this allows us to include the bits of rails we use without pieces we do not.
  #
  # To issue a rails update bump the version number here
  gem 'actionmailer', '6.0.3.3'
  gem 'actionpack', '6.0.3.3'
  gem 'actionview', '6.0.3.3'
  gem 'activemodel', '6.0.3.3'
  gem 'activerecord', '6.0.3.3'
  gem 'activesupport', '6.0.3.3'
  gem 'railties', '6.0.3.3'
  gem 'sprockets-rails'
end

Gemfile.lock に差分が出るので、アプリケーション (Gemfile.lock をリポジトリに含める) の場合はイマイチだと思う。

ライブラリの場合は Gemfile.lock は含めないことの方が多いので、大いにやると良さそう。マトリックステストも ENV を置くだけなので簡単。

eval_gemfile と言えば dry-rb や thredded 等で、開発用の gem を別ファイルに追い出している文化も面白いよね。(余談です)

これも余談だけど、一時期、Gemfile.local を読み込ませるために

eval_gemfile(__FILE__ + ".local") if File.exist?(__FILE__ + ".local")

と書くのが流行っていた時期もあって、これも Gemfile.lock に差分が出るので僕は苦手だったなって記憶が蘇ってきた。

各環境の Gemfile を用意する

各環境用の Gemfile を用意して、 BUNDLE_GEMFILE 環境変数を設定する。これも gem でよく見る。

# gemfiles/rails_5_2.gemfile
source "https://rubygems.org"

gem "rails", "~> 5.2.0"
gemspec path: '../'
# .github/workflows/main.yml
strategy:
  matrix:
    gemfile:
      - rails_5_2
      - rails_6_0
      - rails_6_1
env:
  BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile

gem は基本的に gemspec に依存が書かれていて、一部の指定が厳しい部分だけ *.gemfile で上書きすれば良いので、この形が自然に取れる。

アプリケーションの場合は eval_gemfile を利用して、共通部分は Gemfile.common とか Gemfile.global とかに抽出して同じことをやる。

3 ファイル (current 用, next 用, common) 必要なことと、common に何を追い出すかを考えるのがめんどくさいことがイマイチ。

Gemfile.next の symlink を置いて切り替える

RailsConf 2018 で発表されていた方法。

speakerdeck.com

僕は Getting Ready for Rails 6.0: How to Dual Boot - FastRuby.io | Rails Upgrade Service に書いてあるので知った。

fastruby.io

Gemfile に next? を定義して、symlink 経由で呼ばれたかどうかで切り替える。

def next?
  File.basename(__FILE__) == "Gemfile.next"
end

if next?
  gem 'rails', git: 'https://github.com/rails/rails.git', branch: 'main'
else
  gem 'rails', '~> 6.1.0'
end
  • next? 用の lock ファイルは Gemfile.next.lock になる
    • 差分が別ファイルになるのでコミットできる
  • Gemfile だけで一元管理できる

という、上であげた 2 つの方法のデメリットをそれぞれ潰すことができている。

Shopify/bootboot

id:takkan_m が教えてくれた。

https://github.com/Shopify/bootboot

symlink を使わず、Bundler の plugin として実装したもの。

if ENV['DEPENDENCIES_NEXT']
  ...
end

の内容が Gemfile_next.lock に書き出される。

アプリ内の日付変更線をズラす系の実装

例えば日記を書くときに、午前 2 時に書いたものは前日分としたいことがある。またユーザがメチャクチャ多いサービスでは、0:00 を回ったら翌日のログインボーナスを配る、としていると、まだユーザが多い時間にサーバの処理が要求されて大変なので、28:00 を日付変更線にしたいことがある。

こういうときには

module AppTime
  def self.beginning_of_day(time)
    t = time.change(hour: 4)
    t <= time ? t : t - 1.day
  end
end

を作って、

AppTime.beginning_of_day(Time.current)

を使うと「アプリ内の日付変更線では何日なのか」が取れる。

# 02:00 は前日扱い
time = Time.zone.parse("2021-01-31 02:00")
AppTime.beginning_of_day(time)
# => Sat, 30 Jan 2021 04:00:00.000000000 JST +09:00

僕は Date 苦手なので何でも Time で処理したい派だけど、こういうメソッドがあっても便利だと思う。

module AppTime
  def self.today
    to_date(Time.current)
  end

  def self.to_date(time)
    beginning_of_day(time).to_date
  end
end
# 02:00 は前日扱い
time = Time.zone.parse("2021-01-31 02:00")
AppTime.to_date(time)
=> Sat, 30 Jan 2021

また、「今日日記を書いたか?」や「今日ログインボーナスを受け取ったか?」を以下のようなメソッドを用意することで表現できる。

module AppTime
  # AppTime.all_day(Time.current) は以下の Range を返す
  # => Sun, 31 Jan 2021 04:00:00.000000000 JST +09:00..Mon, 01 Feb 2021 03:59:59.999999999 JST +09:00
  def self.all_day(time)
    beginning_of_day(time)..end_of_day(time)
  end

  def self.end_of_day(time)
    (beginning_of_day(time) + 1.day - 1.second).change(usec: Rational(999999999, 1000))
  end
end
class User < ApplicationRecord
  has_many :diaries
end

class Diary < ApplicationRecord
  belongs_to :user
  scope :of_day, ->(time) { where(published_at: AppTime.all_day(time)) }
end
user.diaries.of_day(Time.current).exists?
# Dairy Exists? (2.2ms)  SELECT 1 AS one FROM `diaries` WHERE `diaries`.`user_id` = 1 AND `diaries`.`published_at` BETWEEN '2021-01-30 19:00:00' AND '2021-01-31 18:59:59' LIMIT 1

肝は「DB には UTC で保存して、アプリ内では JST で扱う、というのは変えない」「日付変更線に沿った Time や Range を返すモジュールを用意することで、保存時に 04:00 を入れるし、検索時には 04:00〜04:00 (UTC では 19:00〜19:00) で検索する」です。

アプリケーション側で変換するのだと限界もあって、日記だと Habit Tracker っぽい (いわゆる「草」) 見せ方をしたいし、その実装に groupdate gem を使うことが多いと思うけど、そういうのはアプリ内日付変更線に基づいた日付カラムを DB に入れてしまうと楽だと思います。

エクスキューズ

ここから始めてリファクタリングして育てていきましょう、です。 この記事ではモジュールにしたけど、クラスにしてアプリ内日付変更線を持ったインスタンスを作っていくこともあるだろう。

all_day 難しい

ActiveSupport の実装に寄せて end_of_dayall_day03:59:59.999999999 を返して書いたけど、時間を区切るというのは半開区間でないと困るんですよ。

↑↑でも

`diaries`.`published_at` BETWEEN '2021-01-30 19:00:00' AND '2021-01-31 18:59:59'

SQL として見えてるけど、BETWEEN では表現したくなくて、

WHERE '2021-01-30 19:00:00' <= published_at AND published_at < '2021-01-31 19:00:00'

であって欲しい。

時間は連続なので、切断点はどちらかのみに所属しなければならない=閉区間だと表現できないのだ。詳しくはデデキントカットを見てくれ。何故かニコニコ大百科が詳しい。

dic.nicovideo.jp

じゃあどう実装すれば良いかで言うと、終端を含まない ... で作られた Range を渡すといい感じになります。

module AppTime
  def self.all_day(time)
    beginning_of_day(time)...beginning_of_day(time + 1.day)
  end
end
user.diaries.of_day(Time.current).exists?
# Diary Exists? (2.0ms)  SELECT 1 AS one FROM `diaries` WHERE `diaries`.`user_id` = 1 AND `diaries`.`published_at` >= '2021-01-30 19:00:00' AND `diaries`.`published_at` < '2021-01-31 19:00:00' LIMIT 1

おうちのスマートホーム化メモ

f:id:onk:20210130180604p:plain

現状

各部屋のグッズはこんなモン。

  1. 玄関
    • 天井に人感センサー電球
    • 外には Netatmo Weather の室外モジュール
  2. 寝室
  3. 仕事部屋
  4. LDK

以上で

  • 照明
    • Philips Hue、Nature Remo、SwithBot 指ロボットを駆使して、すべて声が届くようになった
    • ほとんど Google Next Mini/Hub 経由で声で操作している
      • 物理スイッチの場所に移動する必要が無いのは Life Changing
  • エアコン
    • 付属のリモコンは収納の奥にしまって、スマートスピーカー経由か、スマホの Nature Remo アプリ経由で操作している
      • 最高に便利。リモコン探さなくて良いし、布団の中から操作できるのは Life Changing
    • Nature Remo で測った温度を Mackerel に送ることで、付いているかどうかをいつでも把握できるようになった
  • ホットカーペット
    • スマートスピーカー&スマートプラグ経由で操作している
      • 「ねぇグーグル、カーペット消して」
    • だいたい手を伸ばした方が早いが、布団に入った後に声で消せるのはかなり便利
  • テレビ
    • 「ねぇグーグル、おやすみ」で以下が発動するようにしている

とそれぞれを操作するようになった。

特に照明とエアコンは、スマホ or 声で操作できない状態には絶対に戻りたくないぐらい便利で良い。

ルーティンで操作するのもピタゴラスイッチを眺めているかのような気持ち良さがある。発話の必要すらなく全自動になるともっと便利なんだが、例えばベッドに感圧センサー仕込むとか、スマートウォッチの睡眠をトリガーにするとかは独り暮らしじゃないと上手くいかない。。2 人以上の生活空間では人感センサーは無価値だと思う。

次にやっておきたいこと

  • Philips Hue のシーンをスマートスピーカー経由で部屋ごとに設定したい
    • 「ねぇグーグル、月の光に設定して」と言うと 2 部屋とも暗くなる
    • 細かく調整するときはスマホ開いてる
  • 寝室、仕事部屋にスマートスピーカーが 1 つしか無い
    • 寝室の Google Nest Mini のみで操作しているので、仕事部屋のものを操作するのは少し面倒
      • 「ねぇグーグル、仕事部屋の照明を消して」的な操作になる
    • 部屋ごとにスマートスピーカーある状態にする方が自然だよなぁ。買い足すか。とはいえ置く場所とケーブル周りが……。壁に埋め込みたい
  • スマートロックもやりたかったんだけど、新型コロナウイルスの影響で外出すること自体が減ったのでメリットが少ない
    • GPS 連動で家の何かを弄ることも無い
  • タイマー等を用いて何かをプログラミングすることは今のところ需要がない……?
    • 規則正しい生活をしていない問題がな。。
  • 隣の部屋との会話をスマートスピーカー経由で行うのは便利そうだけど、まだ使いこなしていない
    • 移動してノックするよりも声かけやすい、はず
  • Wi-Fi を必要とする子が多すぎ問題
    • 30 台弱が 1 AP にぶら下がっている
    • そろそろメッシュ Wi-Fi 導入するか……?

センサー

Nature Remo 3 には温度、湿度、人感、照度センサーが付いているので、せっかくだし有効活用したいよね。

ということで雑に Mackerel に投げている。Lambda を EventBridge でスケジュール実行。

値を取得しだしたら CO2 濃度も測りたくなったので Netatmo WeatherStation も購入した。

f:id:onk:20210130224556p:plain

CO2 濃度も、気圧変化も、アラートだけは投げてるんだけど自分の体は鈍感なのでそもそも影響が分かっていないが、測ること自体が目的なのでそれでいいのだ。GARMIN の body battery をライフログとして投げているので、そのうち突き合わせて眺めてみよう。

今年買ったもの2020

去年 に引き続き、今年買ったものコーナー。

はてなに入社して明らかに変わったのが「日常をブログに残すようになった」ことで、その中でも「今年買ってよかったもの」はついタグを追ってしまうし追ったら買っちゃうし経済がどんどん回ってしまう。よくない。

だいたい買った順です。

といった感じで現在の机はこんな。

f:id:onk:20201231143058j:plain
まったく飾らずに今写真撮った。缶コーヒーは正月の巣籠もり用

机周りが一通り揃った (モニタ、椅子、キーボード、マウス) ので、来年は IYH することは減るはず、きっと、多分。

って去年言ったけど、モニタも椅子もキーボードもマウスも買ったね。そしてスマートスピーカーを起点にリモコンが全部発話で動かせるように変わった一年だった。