yamachan.log

紫陽花

unicorn-worker-killerを導入

Railsのプロダクトでサーバーのメモリが足りなくなる問題が頻発しました。

構成はnginx + unicorn

unicornのワーカープロセスは、起動後ユーザーからのリクエストを処理し、再起動されることがありません。 長期間の運用を続けるとメモリが徐々に食いつぶされ次第に枯渇していきます。 結果、リクエストが集中していない時間帯でもdatadogのアラートな鳴りまくる事態に陥っていました。

この状況を「unicorn-worker-killer」を導入し、解決しました。

f:id:Yamakichi:20170710225857p:plain

下記ブログを参考にさせていただきました。

Unicorn-worker-killerが便利だった件

unicorn-worker-killerを使うことで、ワーカプロセスが以下の条件の場合に、自動的に再起動してくれます。 - ワーカプロセスが指定回数のリクエストを処理した場合 - ワーカプロセスが指定量のメモリを使用している場合

Gemの導入

gem 'unicorn-worker-killer'

config.ruに設定を記述 [参考]

unless ENV['RAILS_ENV'] == 'development'
  require 'unicorn/worker_killer'
  CHECK_CYCLE = 16
  use Unicorn::WorkerKiller::Oom, (400*(1024**2)), (500*(1024**2)), CHECK_CYCLE
end
  • graceful restartとは?

graceful restart (緩やかな再起動)

グレースフル・リスタート

Apacheのrestartやgraceful、stopなどの違い

AmazonCloudFrontでRailsのAssets周りにある静的ファイルをCDN配信する

AmazonCloudFrontでRailsのAssets周りにある静的ファイルをCDN配信する

RailsでアプリのassetsをCloudFront経由で配信した時の話

問題と解決

Railsのアクションキャッシュ導入時、Webサーバ内からassetsの配信を行っていた。 その場合、Deploy時にassets precompileを実行しmanifestの更新を行うと、 キャッシュしていたviewのCSSがmanifest更新に伴って参照できなくなり、表示が崩れてしまうという問題が発生した。 この問題を解決するために、Deploy時にassets以下のファイルをS3に置いてCloudFront経由で配信する形式に変更することにした。

CDN

CDN (コンテンツデリバリーネットワーク) Akamaiが1990年代に提唱したコンテンツを配信するネットワーク

参考: 第1回 CDN の 仕組み (CDNはどんな技術で何が出来るのか)

Amazon CloudFront

AWSが提供するCDNサービス 今回はCloudFrontを使いました

CloundFront

Amazon S3

SimpleStrageService Sが3つでS3 「超便利ファイル置き場」

今回は静的ファイルをS3に置いてCloudFrontが参照する

S3

CDNを導入すると何が良いの?

パフォーマンスが良くなる。(高速化) 世界中のどこからアクセスされてもある程度一定のレスポンス速度を担保できる。

やること

  • S3バケットを用意
  • CloudFront側の設定
  • Rails側の設定
  • sprocketsのmanifestファイル
  • S3でクロスオリジンの設定

S3バケットを用意

assetsファイルはS3バケットに置くので、バケットを用意します

ドメイン

assets.service-domain.com

CloudFrontの設定

CIでprecompileを行いS3にファイルを置く

DeployフローでCIを通す前提の話です。普段はCircleCIを利用しています

  • 1.テスト実行
  • 2.テスト成功
  • 3.assets precompile を実行
  • 4.compileファイルをs3にupload
  • 5.デプロイ
machine:
  environments:
  timezone:
    Japan
  ruby:
    version: 2.2.3
  services:
    - redis

deployment:
  production:
    branch: master
    commands:
     - ./deploy/circleci/upload_assets.sh
     - ./deploy/circleci/deployment.sh
#!/bin/sh
EXPIRE=`ruby -e "puts (Time.now + (60*60*24*30)).strftime('%Y-%m-%d')"`

bundle exec rake assets:precompile RAILS_ENV=production
mv ./public/assets/.sprockets-manifest-* ./public/assets/.sprockets-manifest-8jednoecyllbeucnvekdwhbqlkjwhhdq.json
aws s3 sync ./public/assets s3://assets.service-domain.com/assets --region ap-northeast-1 --expires $EXPIRE

shellファイルの先頭に #!/bin/sh (シバン))を記述することはマナー (一応記述がなくても動く)

EXPIREでS3内のファイル有効期限を指定 (今回は30日間にセット)

mv コマンドで生成したmanifestの32文字の乱数部分を同一のものに変更 (今回は決めの値を記述しましたが、32文字であれば何でも大丈夫)

Rails側の設定

Rails側でassetsの配信設定を行います

config/environments/production.rb

config.action_controller.asset_host = ""

デフォルトではコメントアウトになっています、asset_hostに配信のURLを指定します。

config.acrion_controller.asset_host = "https://assets.service-domain.com"

precompile後のファイルはS3に置きますが、manifestファイルはWebサーバーのpublic/assets以下に置かなければうまく参照できず正しく表示されません。 Deployのタスク内でmanifestファイルのみをs3から取ってくる処理を書きます。

execute "aws s3 cp assets manifest" do
  cwd release_path
  command "aws s3 cp s3://assets.service-domain.com/assets/.sprockets-manifest-8jednoecyllbeucnvekdwhbqlkjwhhdq.json ./public/assets/"
  environment "RAILS_ENV" => rails_env
end

今回はaws s3 cpコマンドで設置方法

sprocketsのmanifestファイル

  1. そもそもmanifestファイルってなんのために存在するの?
  2. manifestファイルではコンパイルされたassetsファイルのルートを指示する
.sprockets-manifest-8jednoecyllbeucnvekdwhbqlkjwhhdq.json

S3でクロスオリジンの設定

RailsでFontawsomeなどを利用している場合、上記の手順でDeployし表示できるが、Fontawsomeのアイコンが豆腐になってしまう問題が発生する。 この問題はクロスオリジンの設定をすることで解決できる。

うまく設定できるまで幾つか地雷を踏みましたがうまく設定できました。

Rails Routing Constraints

Constraintsとは

  • Railsのroutingに存在する機能
  • routingに制限を設けることができる
  • デフォルトで定義されている
  • カスタムで制約を作成することも可能

セグメントの制限

get "books/:id" => "photos#show", constraints: { id: /[A-Z]\d{5} }

上記はconstraintsでidに制約を追加している。 /books/A12345 のようなパスにはマッチするが、 /books/12345にはマッチしない。

リクエストに応じた制限

get "books", constraints: { subdomain: "admin" }

サブドメインadminが含まれていることを制限している。

ブロックでも表現できる。

namespace :admin do
  constraints subdomain: 'admin' do
    resources :photos
  end
end

カスタムconstraints

制約用のクラスを設けて対応する lib/constraint以下にクラスを置く self.matches?メソッド内に制約を定義する 下記ではrequestを引数で取り、その中に、admin が含まれているかどうかを判定する

module Constraint::Subdomain
  class Admin
    def self.matches?(request)
      request.subdomains.include?("admin")
    end
  end
end

ルーティング側には下記のように定義することで制約を加える

Rails.application.routes.draw do
  constraints Constraint::Subdomain::Admin do
    get "dashboard" => "homes#dashboard"
  end
end

dashboardへのリクエストパスのサブドメインにadminが含まれているかを判定する

社会人1年目

無事3月に大学を卒業し、今日から社会人になりました。 都内のWeb系の企業でRailsアプリの企画と開発をやっていきます。 まだまだひよっこエンジニアなので日々精進していきたいです。 今年の目標は、去年よりもブログ記事を多く書くことなので、日々の開発で得た気づきや、学びを記事に落とし込んで書いていきたいと思います。

日付変わる前に更新できてよかった。w

がんばります!

Rails includes joins eager_load preload merge

INNER JOIN (内部結合)

結合する両方のテーブルどちらにも同じキーが存在するレコードのみを残し、それ以外は切り捨て。

OUTER JOIN (外部結合)

結合する両方のテーブルどちらにしか存在しないキーがあっても切り捨てずに取得する。 どちらのテーブルのレコードを取得するかで2通りの書き方が存在する。

  • LEFT OUTER JOIN (左外部結合)

    メインテーブルに存在するキーのレコードは、結合したいテーブルになくても表示する

  • RIGHT OUTER JOIN (右内部結合)

    結合したテーブルに存在するキーのレコードはメインテーブルになくても表示する

includes

includesはデータの先行読み込み。主に関連したmodelに対して参照を持ちたい場合に使う

N+1問題を解決する時などに用いる。

Topic.includes(:tags).each { |topic| p topic.tags.map(&:name) }
#=> SELECT "topics".* FROM "topics"
#=> SELECT "topic_tags".* FROM "topic_tags"  WHERE "topic_tags"."topic_id" IN (`取得したtopicsのID`)
#=> SELECT "tags".* FROM "tags"  WHERE "tags"."id" IN (`取得したtopic_tgasのid`)

これだと効率が悪い。

SELECT "topics".* FROM "topics"
SELECT "tags".* FROM "tags" INNER JOIN "topic_tags" ON "tags"."id" = "topic_tags"."tag_id" WHERE "topic_tags"."topic_id" = `topicのidのうち一つ`

N+1

joins

joinsはシンプルに、INNER JOINしてくれる。(内部結合)

INNER JOINなので結合できないデータは捨てられてしまうので、 countなどを使った時に数字が合わないなどの問題が発生する可能性があることに気をつけておく。

left_joinsとすると、LEFT OUTER JOINになる。(外部結合)

joinsはassociationをキャッシュしない。

Category.joins(:topics)
#=> SELECT  `categories`.* FROM `categories` INNER JOIN `topics` ON `topics`.`category_id` = `categories`.`id`

1レコードが1つのオブジェクトにマッピングされるため注意する必要がある。

merge

joinsで結合したテーブルに対して条件式を定義することができる。

Book.joins(:tags).merge(Tag.where(id: [1,2,3])

includes + references

良いとこ取りのincludes 状況によって挙動が変化する。

includesは先読み referencesを付けることで、eager_load と同じ挙動になりLEFT OUTER JOINになる。 OUTER JOIN (外部結合)は片方にしか存在しないレコードも結果に含まれる。 INNER JOINよりも広い範囲でレコードを取得する。

Topic.includes(:tags).references(:tags).where('tags.topic_id < ?', 100)
#=> SELECT  DISTINCT `topics`.`id` FROM `topics` LEFT OUTER JOIN `tags` ON `tags`.`topic_id` = `topics`.`id` WHERE (tags.topic_id < 100)

eager_load

JOINして取得する

指定したassociationをLEFT OUTER JOINで取得し、キャッシュする。 JOINしているので、指定したテーブルに対しての絞り込みが行える。 クエリの数が1つで完結するのでpreloadより早い。

preload

preloadは、指定したassociationを複数のクエリ(SELECT文)に分けてキャッシュする。 指定したテーブルに対しての絞り込みが行えない。行おうとすると例外を投げる。

scope・joins・merge

Topic.joins(:tags).merge(Tag.published)

まとめ

ActiveRecordは、クエリを意識しなくても使えるため便利。 しかし、複雑なクエリや複数のテーブルに関連したデータを取得する際には用意されているメソッドの振る舞いをよく理解しなければパフォーマンス的に問題が生じ、振るまえているものの欲しいデータが取得できていなかったりと苦い経験をすることになるので注意すること。

Rails ActionCacheを導入する

Webアプリケーションのパフォーマンス改善として、RailsのActionCacheを導入しました。

Railsのキャッシュに関する概要はRailsGuidesが一番わかりやすかったです。

Rails のキャッシュ: 概要

「キャッシュ(caching)」とは、リクエスト・レスポンスのサイクルの中で生成されたコンテンツを保存しておき、次回同じようなリクエストが発生したときのレスポンスでそのコンテンツを再利用することを指します。

キャッシュの手法は 3つ

  • ページキャッシュ (ページ単位のキャッシュ)
  • アクションキャッシュ (アクション単位のキャッシュ)
  • フラグメントキャッシュ (部品単位のキャッシュ)

今回紹介するのは「アクションキャッシュ」です。

アクションキャッシュの機能はRails3までは標準で機能として存在していましたが、Rails4からはGemとして切り出されて独立して存在しています。

actionpack-action_caching

Rails4以降で開発している場合はGemfileに記述しinstallします。

gem "actionpack-action_caching"
bundle install
class BooksController
  caches_action :index  

  def index
    @book = Book.find(1)
  end
end

これでキャッシュを導入できます。 初回アクセス時はindexメソッドが実行されますが、2回目以降は初回アクセス時にレンダリングされた結果が返ってきます。

キャッシュがなかった場合

  1. キャッシュの存在をチェックする
  2. indexアクションを実行
  3. キャッシュを生成

キャッシュがあった場合

  1. キャッシュの存在チェック
  2. キャッシュを表示

キャッシュはアクションよりも先に実行されるため、コールバックで値を取得しておく必要がある。

class BooksController
  before_action :set_book, only: :index
  caches_action :index  

  def index
  end
  
  private
    def set_book
     @book = Book.find(params[:id])
    end
end

表示される内容が変更される場合には少し工夫が必要です。

内容が更新されたにもかかわらず更新前に保存されたキャッシュが効いてしまい、最新の内容を表示することができない。という状況を想定します

その場合には、キャッシュする情報に更新情報を渡すことによって解決します。

class BooksController
  caches_action :index, cache_path: ->(c) { build_path(c, @book) }

  def index
  end
  
  private
    def build_path(controller, model: nil)
        cache_path = ActionController::Caching::Actions::ActionCachePath.new(controller).path
        cache_path = "#{base_path}:#{model.update_at.try(:strftime, "%Y%m%d-%H%M%S")}" if model.present?
        cache_path
    end
end

@bookの値が更新されupdated_atが変更されていればcache_pathは新しくなりキャッシュ自体も変更され最新のキャッシュが呼び出されるようになるため、内容が変更されていてもキャッシュも更新されるます。

開発環境でキャッシュを有効にし、機能しているか確認する場合は以下を config/environments/*.rb に記述

config.action_controller.perform_caching = true

キャッシュを有効にし、キャッシュを効かせたいメソッドを呼び出せばキャッシュが効いていることをログで確認することができる。

その際に生成されるキャッシュファイルは、tmp/caches以下に生成されていきます。

キャッシュをexpireするオプションがあります。

expires_in: 30.minutes

とすることで30ごとにexpireされます。

class BooksController
  before_action :set_book, only: :index
  caches_action :index, cache_path: ->(c) { build_path(c, @book) }, expires_in: 30.minutes

  def index
  end
  
  private
    def set_book
    end
    
    def build_path(controller, model: nil)
        cache_path = ActionController::Caching::Actions::ActionCachePath.new(controller).path
        cache_path = "#{base_path}:#{model.update_at.try(:strftime, "%Y%m%d-%H%M%S")}" if model.present?
        cache_path
    end
end

このままでも動作しますが、挙動としてファイルキャッシュになります。(tmp/cache以下にキャッシュ用のファイルが生成される)

複数サーバーでアプリケーションを運用する場合は現状のままだと各アプリケーションでキャッシュが行われてしまいAP1でキャッシュされているが、AP2ではキャッシュされていないのでファイルを生成する。など無駄な挙動が増えパフォーマンスに統一性が持てません。 この問題の解決としてキャッシュサーバとしてredisを用いて複数サーバー運用でも良い感じに挙動する仕組みに変更します。

Railsからredisを使うためredis-railsを導入します。

gem 'redis-rails'
bundle install
config.cache_store = :redis_store, "redis://localhost:3001/0/cache"
# redisの参照先はそれぞれの環境に合わせて変更する 上記は例

caches_action側のexpires_in: でexpireの期間を定義しているためapplication.rb側への定義は不要です。

なかなか早くなるので良いですね。

Rails CarrierWave アップロード画像を良い感じに圧縮するGem作った

Rails CarrierWave アップロード画像を良い感じに圧縮するGem作った

Railsで画像アップロードを行うGemにCarrierWaveがあります。 そのCarrierWaveのextension的位置付けの圧縮機能を簡単に実装するGemを作りました。

carrierwave-optimize_image

Ruby Gems

作った背景

単純に画像圧縮機能が欲しくなった場面があったため作った。

あと、PageSpeed Insightsでサイトの計測を行うと画像の圧縮を推奨されるため最適化のためです。

この手のGemは実は既に何個か作られていたりして、調べた限りでは以下のGemが類似しているものです。 Gem名も似た感じですね。

これらのGemに気になる点がみつかり、自分でそれらの点を解決したものを作ろうと思ったので今回新しく自分で作りました。 それぞれで気になった点を簡単に紹介します。

  • Pietの気になった点

    実際のプロダクトにはPietを採用しました。 Pietを採用した理由は、GemのDL数の多さから信用性があると自分で判断したことと。pngquantに対応しているため圧縮効率が良い 2017/2の時点で 247,136 DL pngquantの圧縮機能はPietに付随した別のGemを流用しており、共通のインターフェースを提供していないため使い勝手が悪い。

    ``` Piet.optimize

    Piet.pngquant ```

    また、現状問題なく動いているものの付随したGemのpng_quantizatorは4年ほど前に作られており、 その後にはほとんど手もつけられていないため不安しかなかった。

  • carrierwave-imageoptimizerの気になった点

    carrierwave-imageoptimizerは最初に導入を検討していました。 2017/2の時点で 108,841 DL 圧縮効率の良いpngquantではなくoptpngを使っていた。pngquantの方がoptpngよりも半分近く圧縮できる。 その結果、パフォーマンス的に良くないため不採用 またGem内で画像拡張子による圧縮対象の分岐(png用, jpeg用)などの処理がなく。 ただ単純にimage_optimizerに処理を丸投げ(委譲)してるだけの仕様はなんか嫌だった。 もともとimage_optimizerがメインで、そのおまけ感覚でcarrierwave用のを作ったみたい

  • carrierwave-imageoptimの気になった点

    一番最近作られたGem 見た感じ色々なオプションを選択できる仕様になっていて良さそう。 今回は単純にjpegpngを効率良く圧縮できるシンプルなインターフェースを持ったものを欲しかった。 なので複雑なオプションなどは不要だったため不採用。

  • まとめると

    「CarrierWaveを簡単に拡張した画像圧縮機能で、かつ圧縮効率が良く。使い勝手の良い統一的なインターフェースを持った。 2017年2月現在の環境で安心して使うことができるGem」を目指して作ったのがcarrierwave-optimize_imageです。

導入方法は簡単で、

brew install for Mac

brew install jpegoptim pngquant

Gem install

gem "carrierwave-optimize_image"

CarrierWave

  class ImageUploader < CarrierWave::Uploader::Base
    include CarrierWave::OptimizeImage
    ...
    process :optimize
  end

optimizeで簡単に画像をアップロードするタイミングで圧縮処理がかかり、画像が圧縮されます。

今後検討したい機能

  • pngjpegそれぞれで画像の圧縮効率をオプションで変更できるようにする。現状は統一した圧縮効率 例: pngが60%圧縮 jpegが40%圧縮

  • エラー処理の対応をもっと親切なものにする

  • pngjpeg以外の拡張子にも対応していく

  • 今後さらに圧縮効率の良いものがそれらを対応していく

他にもあれば随時変更していきたい。

まとめ

  • 初めてGemを作って公開できて良い経験になった
  • Gem作成〜公開〜導入までの流れが一通り理解できた
  • 画像圧縮上手くいった
  • 課題意識とそれを解決する手段を考える意識がついた
  • 今後も何かアイデアが浮かんだときに作りたい

試しに使ってみてもらえたら嬉しいです! PullRequestや要望、フィードバックなども大歓迎です!