🍪 🖼️ Optimiser l'utilisation des derivation endpoints
Aujourd'hui, nous allons vous présenter comment nous avons drastiquement amélioré la performance de nos applications en optimisant la gestion et le redimensionnement des images uploadées. Découvrez les techniques et solutions que nous avons mises en place avec le plugin derivation_endpoint
de Shrinerb et Sidekiq pour un chargement d'images ultra-rapide et une réduction significative de la consommation de bande passante.
Au programme aujourd’hui :
Optimiser l'utilisation des derivation endpoints par Ismaël
Temps de lecture : 8 minutes
Hello les petits Biscuits !
Bienvenue sur la 24ème édition de Ruby Biscuit.
Vous êtes maintenant 496 abonnés 🥳
Maintenant Ruby biscuit, c’est aussi votre meilleur allier pour recruter des devs Ruby !
Si vous n’avez pas encore rejoint le club, RDV sur https://recrutement.rubybiscuit.fr
Bonne lecture.
Optimiser l'utilisation des derivation endpoints
Un peu de contexte
Pour optimiser la performance de l'application, nous n'affichons jamais la version originale d'une image chargée depuis l'extérieur. À la place, nous affichons une version optimisée qui a été redimensionnée spécifiquement pour son usage. Cela permet d'économiser beaucoup de bande passante et d'avoir ainsi une page qui charge rapidement.
Ainsi, si on souhaite afficher à un endroit de l'application une version miniature de l'image et à un autre la même image en plus grand alors nous génèrons 2 versions de l'image. Ce redimensionnement est réalisé à la demande (lazy) grâce au plugin derivation_endpoint de Shrinerb, la librairie open-source que l'on utilise pour gérer l'upload de fichiers dans nos app rails.
Prenons un exemple concret :
J'uploade une image produit de 3000x2000px, cette image est affichée à 3 endroits en front :
sur la carte produit dans la liste des produits en format 375x250px
sur le banner de la page du produit en format 750x300px
sur la liste de mes commandes, en format miniature 150x150px
Ces versions optimisées de l'image ne seront crée qu'à la demande, c'est à dire la première fois qu'un utilisateur affichera une page qui contient cette version. Dés la première génération, la version générée sera uploadée sur AWS S3 (notre espace de stockage en ligne) et ainsi les prochains utilisateurs se verront servir via S3 directement l'image déjà générée. Dans tous les cas, la requête passe forcement par le serveur (https://mon-domaine/derivations/image/....), si la version existe déjà, on redirige sur le lien S3 de celle-ci, si elle n'existe pas, le serveur la génère puis la stocke puis redirige sur le lien S3.
Comment ca fonctionne ?
Le plugin vous permet de créer des urls pour demander une image avec un retraitement spécifique. Seule l'image originale doit exister, la version retraitée sera générée à la volée lors de la première demande puis stockée sur votre service de stockage de fichiers (chez nous s3) et directement servie pour les prochaines demandes.
Il suffit d'ajouter le plugin dans votre initializer Shrine :
# config/initializers/shrine.rb
# [...]
Shrine.plugin(
:derivation_endpoint,
secret_key: ENV["SECRET_KEY_BASE"],
prefix: "derivations/image",
upload: true,
)
Puis de créer la route associée :
# config/routes.rb
Rails.application.routes.draw do
# [...]
get "/derivations/image/*rest" => "derivations#image"
end
Puis d'ajouter la derivation dans votre uploader :
require "image_processing/vips"
class ImageUploader < ApplicationUploader
Attacher.validate do
validate_max_size 10.megabytes
validate_extension_inclusion %w[png jpg jpeg PNG JPG JPEG]
validate_mime_type_inclusion %w[image/jpg image/jpeg image/png]
end
derivation :resize_to_fill do |file, width, height|
ImageProcessing::Vips
.source(file)
.resize_to_fill!(
width.presence&.to_i,
height.presence&.to_i
)
end
end
Vous pouvez désormais utiliser facilement votre derivation dans vos différentes vues sans vous préoccuper de vérifier que la version de l'image est disponible puisqu'elle sera générée à la volée si ce n'est pas le cas :
# app/views/samples/index.html.erb
<%= image_tag(
@sample.image.derivation_url(:resize_to_fill, 120, 120),
class: "w-full h-full object-cover aspect-square"
) %>
Ce qui vous retourne quelque chose comme :
<img
class="w-full h-full object-cover aspect-square"
src="http://localhost:3000/derivations/image/resize_to_fill/120/120/eyJpZ6I[..]yZSJ9?signature=04df[...]a1cf5"
>
Récemment chez Capsens, nous avons changé notre infra pour passer d'un hébergement composé d'un unique serveur par app via AWS Opsworks Stacks à une Kubernetes ; de multiples pods hébergés sur de multiples serveurs afin de pouvoir très facilement adapter les ressources aux besoins via un autoscaling. Chaque pod pouvant être de taille variable mais pour permettre un réel fine tuning des ressources, le but est qu'ils soient relativement petits.
Avec ce changement, nous avons rapidement était confrontés à un problème : la génération de ces versions d'images se faisant à la demande et en synchrone, or ces petits pods ne pouvaient pas se permettre de supporter de générer des dizaines de versions en parallèles car celles-ci consomment trop de RAM. Par exemple, si vous arriviez sur une page contenant plusieurs images qui n'avaient jamais été demandée, alors le serveur tentait de toutes les générer en même temps et crashait.
Déléguer à Sidekiq la génération des derivations
La solution que l'on propose ici est de passer en tâche de fond via Sidekiq la génération de ces versions d'images. De cette manière, on va pouvoir contrôler combien d'images peuvent être générées au maximum en parallèle.
Cependant, si on reprend le parcours type pour afficher une dérivation, cela va nous poser un souci : si je passe la génération en asynchrone alors comment je gère la première fois qu'on affiche à l'utilisateur une version qui n'est pas encore générée ?
On pourrait les générer à l'avance ? ❌
On pourrait se dire que les générations ne sont plus faites à la demande mais à l'avance, dans ce cas c'est un autre plugin de Shrine : les derivatives mais c'est vraiment compliqué à gérer. À chaque fois que vous avez besoin de changer la taille affichée, vous devez créer une nouvelle version et la générer pour toutes vos images via une rake task ou une migration même si la plupart d'entre elles ne seront jamais utilisées : ce n'est pas très éco-friendly ! 🌍
La solution que l'on a adopté chez Capsens est de se dire que la première fois que l'image est demandée, alors on va déclencher un job Sidekiq pour générer cette nouvelle version et servir à l'utilisateur la version originale. Les utilisateurs suivants se verront ensuite fournir une version optimisée.
Comment implémenter cela ?
Pour que cet article ne vous prenne pas 50min de votre temps en lecture, nous partirons du principe que vous avez déjà une app Rails avec Shrine et les derivation_endpoint d'implémentées.
Pour rappel, nous souhaitons overrider le comportement natif de Shrine et des derivation_endpoints afin que si la version de l'image n'est pas déjà, on retourne l'image originale et que l'on programme un job Sidekiq qui va s'occuper de processer l'image en arrière-plan.
Overrider le comportement natif de Shrine
# config/initializers/shrine.rb
require "shrine"
Shrine.plugin :activerecord
Shrine.plugin :rack_response
Shrine.plugin :validation_helpers
Shrine.plugin :determine_mime_type, analyzer: :marcel
Shrine.plugin(
:derivation_endpoint,
secret_key: ENV["SECRET_KEY_BASE"],
prefix: "derivations/image",
upload: true,
)
if Rails.env.test?
require "shrine/storage/memory"
Shrine.storages = {
cache: Shrine::Storage::Memory.new,
store: Shrine::Storage::Memory.new
}
else
require "shrine/storage/s3"
s3_options = {
bucket: ENV["S3_BUCKET"],
region: ENV.fetch("S3_REGION", "eu-west-1"),
access_key_id: ENV["S3_KEY"],
secret_access_key: ENV["S3_SECRET"]
}
Shrine.storages = {
cache: Shrine::Storage::S3.new(prefix: "cache", **s3_options),
store: Shrine::Storage::S3.new(prefix: "store", **s3_options)
}
end
class Shrine
class Derivation::Response < Derivation::Command
private
def upload_response(env)
uploaded_file = upload_redirect ? (
derivation.retrieve
) : derivation.opened
unless uploaded_file
@force_redirect = true
Shrine::ProcessDerivationJob.perform_async({
name: @derivation.name,
args: @derivation.args,
shrine_class: @derivation.source.shrine_class.to_s,
source_data: @derivation.source.as_json,
options: @derivation.options
}.as_json)
uploaded_file = derivation.source
end
if upload_redirect || @force_redirect
redirect_url = uploaded_file.url(
**upload_redirect_url_options
)
[302, {"Location" => redirect_url}, []]
else
uploaded_file.to_rack_response(
type: type,
disposition: disposition,
filename: filename,
range: env["HTTP_RANGE"]
)
end
end
end
end
À partir de la ligne 39, nous overridons la méthode upload_response
de Shrine::Derivation::Response
afin de faire en sorte que si la version n'est pas encore processée, nous programmons le job Sidekiq qui va s'en occuper et servons l'image originale à la place. En plus de cela, nous forçons le code HTTP de la requête vers 302 (temporary redirect) afin de s'assurer que le navigateur ne mette pas en cache cette version non optimisée de l'image.
Créer notre job Sidekiq
Nous devons maintenant créer le job Sidekiq qui va s'occuper de faire ce processing en arrière-plan tout en s'assurant que seul 3 jobs pourront s'éxécuter en parallèle. Pour cela, nous allons utiliser la gem Sidekiq Throttled.
# app/jobs/shrine/process_derivation_job.rb
class Shrine::ProcessDerivationJob < ApplicationJob
include Sidekiq::Throttled::Job
sidekiq_options(
queue: :low,
retry: false
)
sidekiq_throttle(
concurrency: {limit: 3}
)
def perform(arguments = {})
derivation = Shrine::Derivation.new(
name: arguments["name"].to_sym,
args: arguments["args"],
source: source_from(arguments),
options: arguments["options"]
)
derivation.upload(
delete: derivation.option(:upload_redirect)
)
rescue Shrine::Derivation::SourceNotFound
end
private
def source_from(arguments)
shrine_class = arguments["shrine_class"].constantize
shrine_class.uploaded_file({
**arguments["source_data"],
storage: "store"
})
end
end
Quelques tests unitaires de ce nouveau job :
# spec/jobs/shrine/process_derivation_job_spec.rb
require "rails_helper"
RSpec.describe Shrine::ProcessDerivationJob, type: :job do
let(:sample) do
create(
:sample,
image: File.open(
Dir[Rails.root.join("spec/fixtures/images/*.*")].sample
)
)
end
let(:shrine_file) { sample.image }
describe ".perform" do
subject do
described_class.new.perform({
name: :resize_to_fill,
args: [300, 300],
shrine_class: shrine_file.shrine_class.to_s,
source_data: shrine_file.as_json,
options: {}
}.as_json)
end
context "given this version already exists in store" do
before do
attacher = ImageUploader::Attacher.retrieve(
model: sample,
name: :image,
file: sample.image_data
)
shrine_file.derivation(:resize_to_fill, 300, 300).retrieve
end
it "creates and upload a derivative" do
subject
expect(
shrine_file.derivation(:resize_to_fill, 300, 300).retrieve
).not_to be_nil
end
end
context "given this version does not exists in store" do
it "creates and upload a derivative" do
expect(
shrine_file.derivation(:resize_to_fill, 300, 300).retrieve
).to be_nil
end
end
end
end
And that's it !
Il serait cependant intéressant d'ajouter quelques optimisations pour que votre setup soit vraiment le plus optimisé possible :
Utiliser un CDN tel que Cloudfront par exemple afin d'éviter que chaque image ne fasse une requête au serveur
Ajouter une vérification dans le job afin de s'assurer que la même version ne peut pas être processée deux fois en parallèle
— Ismaël