タケユー・ウェブ日報

Webシステム受託会社の業務の中での気づきや調べごとのメモ。

コレクションキャッシュでキャッシュのキーをカスタマイズする方法

コレクションキャッシュについてコードリーディングを行ったところ、キャッシュキーのカスタマイズ方法がわかったのでメモ。

この記事のまとめ

Railsガイドには書いてありませんが、:cached に callable なオブジェクトを渡すと、その結果をキャッシュキーとして使ってくれます。

# cached: true と同じ
<%= render partial: 'posts/post', collection: @posts, cached: -> (post) { post } %>
# posts.comments の結果に変更があった場合も違うキーになる
<%= render partial: 'posts/post', collection: @posts, cached: -> (post) { [post, post.comments] } %>
# locals はキャシュキーに影響しないため、locals によってキーを変えるときは自分で指定してやる必要がある
<%= render partial: 'posts/post', collection: @posts, locals: { flag: true }, cached: -> (post) { [{flag: true}, post] } %>

github.com

コレクションキャッシュ

一覧などを表示する際に便利な :collection オプションは :cached オプションを渡すことで、各アイテムの描画でキャッシュを使ってくれます。

たとえば

<%= render partial: 'posts/post', collection: @posts, cached: true %>

とすると、ログは次のようになります。

Rendered collection of posts/_post.html.erb [0 / 10 cache hits] (Duration: 1733.3ms | Allocations: 2034040)
Rendered collection of posts/_post.html.erb [10 / 10 cache hits] (Duration: 1.2ms | Allocations: 546)

キャッシュのキー

キャッシュのキーは、ビューファイルのパス、内容のハッシュ値、コレクションアイテム(今回の場合Postインスタンスオブジェクト)による配列となります。(6.0.3現在)

# キャッシュのキー
[:views, "posts/_post:88c97eec416c8a7c8aaa37acb5edbbbd", #<Post id: 123, ... >]

rails/collection_caching.rb at b378bda17d710eb08949771390e7b7d77ee2b39d · rails/rails · GitHub

なお、配列のキーの場合、Railsのキャッシュ機構は配列要素から文字列のキーとキャッシュバージョンを取り出して使用します。(5.2から) 今回の場合、 Post#cache_version #=> updated_at から作った文字列 がキャッシュバージョンに含まれるため、 Post を更新すると新しいキャッシュとなります。

キャッシュのキーを変更したいケース

上記のような標準のキャッシュキーではうまくキャッシュを破棄できない場合があります。

コレクションアイテムの依存先の変更で描画内容を変化させたい

次のような Post#comments によって描画内容が変化するテンプレートの場合

<!-- posts/_post.html.erb -->
<h3><%= post.title %></h3>
<p><%= post.comments.count %> comments</p>

post.comments.count が変わってもにキャッシュキーが変化しないため、

<%= render partial: 'posts/post', collection: @posts, cached: true %>

ではキャッシュが入れ替わりません。

これを避けるためには、次のどちらかがよく採用されると思います。

  1. comments が変更される度に、 postupdated_at を更新してキャッシュバージョンを変化させる( post.touch
  2. キャッシュキーに comments も含める

1の方法はコールバックなどの仕組みが必要で、モデルの管理が難しくなるので、私は2の方が使いやすいと思います。

コレクションアイテム以外の入力で描画内容が変化するとき

次の例は、人気の記事と新着記事で同一のテンプレートを使い、一方では人気順のバッジを表示する、というものです。

<h2>人気の記事<h2>
<%= render partial: 'posts/post', collection: @popular_posts, locals: { show_rank_badge: true }, cache: true %>
<h2>新着記事</h2>
<%= render partial: 'posts/post', collection: @recent_posts, locals: { show_rank_badge: false }, cache: true %>

このような場合、たとえば @popular_posts@recent_posts に同じ記事が含まれていた場合、新着記事の表示欄でもバッジ付きの表示になってしまいます。 これを避けるためには、それぞれ違うキャッシュキーになるようにしなければいけません。

キャッシュのキーを変更する方法

ActionView::CollectionCaching のコードを確認すると次のようにしてキーを変更することができました。(Rails 6.0.3)

<%= render partial: 'posts/post', collection: @posts, cached: -> (post) { [post, post.comments] } %>
<%= render partial: 'posts/post', collection: @posts, locals: { show_rank_badge: true }, cached: -> (post) { [post, { show_rank_badge: true }] } %>

github.com

cron の代わり CloudWatch Events + Lambda + RunCommand でタスク実行を冗長化するCloudFormationテンプレート

f:id:uzuki05:20200814014902p:plain

前の記事では、実行したいコマンドをCloudWatch Events のターゲットのパラメータとして、JSONで流し込む方式で作りました。

blog.takeyuweb.co.jp

しかしながら、実行するコマンドという危険な内容を外部から受け取る点に、怖さがありました。また、JSON特殊文字を含むコマンドを指定できない問題がありました。 また、この方法だと複数のタスクを同一のステートマシン/Lambda関数で処理するため、ログが混ざってしまい、デバッグしづらい問題がありました。

そこで、これらの問題を解消するため、次のように変更を加えたCloudFormationテンプレートを作成しました。

  • タスクごとに別々のスタックを作成し、タスクごとに別々のステートマシン/Lambda関数で実行する
  • コマンドをLambda関数の環境変数として与える
続きを読む

EC2インスタンスにSSH接続せずに任意のLinuxコマンドを実行する

AWS Systems Manager(通称 SSM)Run Commandを使うと、EC2インスタンスに対しての様々な管理タスクの実行を自動化したり、多くのインスタンスにたいして大規模に実行したりできます。

管理タスクはコマンドドキュメントという形で定型化されていて、このうちの AWS-RunShellScript を使うと、任意のLinuxコマンドを、外部から実行することができます。

続きを読む

cron の代わり CloudWatch Events + Lambda + RunCommand でタスク実行を冗長化する

Webサーバーを運用していると、定期的にコマンドを実行させたいことはよくあります。 たとえば、このブログだと予約投稿で決められた日時を過ぎたら公開処理をする、みたいなのですね。

そういったものを簡単に実現する手法として、古くから cron が用いられてきました。

ja.wikipedia.org

しかしながら、負荷分散のために EC2 AutoScaling や OpsWorks など、同じ構成のインスタンスを複数並列起動する構成では、グループのインスタンスすべてで同じジョブが実行されてしまう問題があります。

1台をグループから外し、その1台だけでジョブを実行するようにする方法もありますが、cron実行が単一障害点となり、複数台構成の恩恵を十分に受けられません。

複数台構成に対応するジョブスケジューラ製品もいろいろありますが、ここではAWSのサービスだけで実現する方法を考えてみます。

続きを読む

AWS WAF v2 を使って特定のURLパスにアクセス元IPアドレス制限をかける

やりたかったこと

  • ALB 配下のWebサーバーの特定のURLパス( /admin/ )には特定のIPアドレスからのみアクセスできるようにする
  • IPアドレスの数は1個~たくさん

断念したこと

CloudFormation テンプレート

ポイント

  • AWS::WAFv2::IPSetIPアドレス(プレフィクス付き)を指定すること
  • CloudFrontで使う場合は Scope: "CLOUDFRONT" にすることと、米国東部 (バージニア北部) リージョン (us-east-1) にリソースを作成すること。ALBで使う場合は Scope: "REGIONAL"
  • ルールの順番は次の通り
    1. 「特定のURLパス」以外なら許可
    2. 「特定のIPアドレス」なら許可
    3. どちらでもない場合は拒否

YAML

Parameters:
  LoadBalancerArn:
    Type: String
  PathPattern:
    Type: String
    Default: /admin/

Resources:
  WlitelistIpAddressSet:
    Type: "AWS::WAFv2::IPSet"
    Properties:
      Addresses:
        -  200.100.0.0/24
        -  100.200.100.200/32
      IPAddressVersion: IPV4
      Scope: "REGIONAL"

  WhitelistPathPatternSet:
    Type: AWS::WAFv2::RegexPatternSet
    Properties: 
      RegularExpressionList: 
        - !Sub "^${PathPattern}*"
      Scope: "REGIONAL"

  WebACLAssociation:
    Type: AWS::WAFv2::WebACLAssociation
    Properties: 
      ResourceArn: !Ref LoadBalancerArn
      WebACLArn: !GetAtt WhitelistWAFv2WebACL.Arn

  WhitelistWAFv2WebACL:
    Type: "AWS::WAFv2::WebACL"
    Properties:
      DefaultAction:
        Block: {}
      Rules:
        - Name: "WhitelistWAFv2WebACLRulePathPattern"
          Action:
              Allow: {}
          Priority: 100
          Statement:
            NotStatement:
              Statement:
                RegexPatternSetReferenceStatement:
                  Arn: !GetAtt WhitelistPathPatternSet.Arn
                  FieldToMatch: 
                    UriPath: {}
                  TextTransformations: 
                    - Type: "URL_DECODE"
                      Priority: 0
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            MetricName: "WhitelistWAFv2WebACLRulePathPatternMetric"
            SampledRequestsEnabled: true
        - Name: "WhitelistWAFv2WebACLRuleIPSet"
          Action:
              Allow: {}
          Priority: 1000
          Statement:
            IPSetReferenceStatement:
              Arn: !GetAtt WlitelistIpAddressSet.Arn
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            MetricName: "WhitelistWAFv2WebACLRuleIPSetMetric"
            SampledRequestsEnabled: true
      Scope: "REGIONAL"
      VisibilityConfig:
        CloudWatchMetricsEnabled: true
        MetricName: "WhitelistWAFv2WebACLMetric"
        SampledRequestsEnabled: true

Rails + PostGIS (activerecord-postgis-adapter) で矩形内に含まれるレコードを検索する

# == Schema Information
#
# Table name: places
#
#  id                    :bigint           not null, primary key
#  geom                  :geography        not null, point, 4326
#
# Indexes
#
#  index_places_on_geom                   (geom) USING gist

class Place < ApplicationRecord
end

class CreatePlaces < ActiveRecord::Migration[6.0]
  def change
    create_table :places do |t|
      t.st_point :geom, geographic: true, null: false
      t.index :geom, using: :gist
    end
  end
end
Place.where("geom && ST_MakeEnvelope(:min_lng, :min_lat, :max_lng, :max_lat, 4326)", min_lat: min_lat, min_lng: min_lng, max_lat: max_lat, max_lng: max_lng)

postgis.net

blog.takeyuweb.co.jp

Docker Compose で PostGIS を使った開発環境を構築する

postgis/postgis イメージがあるのでそれを使います。

hub.docker.com

このイメージは公式のpostgresイメージをベースに作成されていて、安心感があります。 Postgres の各バージョンと、PostGIS の各バージョンそれぞれの組み合わせから選べるので、多くの場面で使えるでしょう。

利用例

github.com

docker-compose.yml

version: "3"
volumes:
  pg_data:
    driver: local
services:
  pg:
    image: postgis/postgis:11-2.5-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - pg_data:/var/lib/postgresql/data
  app:
    # 省略

Rails との統合

config/database.yml

adapter: postgisactiverecord-postgis-adapter を使います。 これは標準のアダプタに空間データとRubyオブジェクトに変換などの機能を追加するものです。

default: &default
  adapter: postgis
  encoding: unicode
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  database: rails_postgis_sample_development
  username: <%= ENV.fetch("DB_USERNAME") { "postgres" } %>
  password: <%= ENV.fetch("DB_PASSWORD") { "password" } %>
  host: <%= ENV.fetch("DB_HOST") { "pg" } %>

development:
  <<: *default

test:
  <<: *default
  database: rails_postgis_sample_test

production:
  <<: *default
  database: rails_postgis_sample_production
migration

enable_extension 'postgis'

class CreateExtensionPostgis < ActiveRecord::Migration[6.0]
  def up
    enable_extension 'postgis' unless extension_enabled?('postgis')
  end

  def down
    disable_extension 'postgis' if extension_enabled?('postgis')
  end
end