Ruby on Rails has come a long way since its inception, and building scalable microservices architectures using Rails and Docker is now a popular approach for modern web applications. Let’s dive into how we can leverage these technologies to create robust, scalable systems.
First things first, we need to understand what microservices are and why they’re beneficial. Microservices architecture is an approach where an application is built as a collection of small, independent services that communicate with each other. This approach offers better scalability, flexibility, and easier maintenance compared to monolithic applications.
Now, let’s get our hands dirty with some code. We’ll start by setting up a basic Rails application that we’ll later containerize with Docker. Fire up your terminal and run:
rails new my_microservice --api
cd my_microservice
This creates a new Rails API-only application. We’re using the —api flag because microservices typically don’t need views or asset pipeline.
Next, let’s create a simple model and controller:
rails g model User name:string email:string
rails g controller Users index show create
Now, let’s define some routes in config/routes.rb:
Rails.application.routes.draw do
resources :users, only: [:index, :show, :create]
end
And implement our controller actions in app/controllers/users_controller.rb:
class UsersController < ApplicationController
def index
@users = User.all
render json: @users
end
def show
@user = User.find(params[:id])
render json: @user
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def create
@user = User.new(user_params)
if @user.save
render json: @user, status: :created
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email)
end
end
Great! We now have a basic microservice that can handle CRUD operations for users. But how do we make this scalable and deployable? Enter Docker.
Docker allows us to containerize our application, making it easy to deploy and scale. Let’s create a Dockerfile in our project root:
FROM ruby:3.0.0
RUN apt-get update -qq && apt-get install -y nodejs postgresql-client
WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock
RUN bundle install
COPY . /myapp
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
This Dockerfile sets up our Ruby environment, installs dependencies, copies our application code, and specifies how to run our app.
Now, let’s create a docker-compose.yml file to define our services:
version: '3'
services:
db:
image: postgres
volumes:
- ./tmp/db:/var/lib/postgresql/data
web:
build: .
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/myapp
ports:
- "3000:3000"
depends_on:
- db
This sets up two services: a PostgreSQL database and our Rails application. The web service depends on the db service, ensuring the database is up before our app starts.
To run our containerized application, we can use:
docker-compose up
Now we have a scalable microservice running in a Docker container! But we’re not done yet. To truly embrace the microservices architecture, we need to consider how our services will communicate with each other.
One popular approach is to use RESTful APIs. We’ve already set up our Users service to respond to RESTful requests. Other services can communicate with it using HTTP requests.
For example, if we had an Orders service that needed user information, it could make a GET request to our Users service:
require 'net/http'
require 'json'
class OrdersController < ApplicationController
def create
user_id = params[:user_id]
user_url = URI("http://users_service:3000/users/#{user_id}")
response = Net::HTTP.get(user_url)
user = JSON.parse(response)
# Create order logic here...
end
end
This approach works, but as our system grows, we might want to consider using a message broker like RabbitMQ or Apache Kafka for asynchronous communication between services.
Let’s add RabbitMQ to our docker-compose.yml:
version: '3'
services:
db:
image: postgres
volumes:
- ./tmp/db:/var/lib/postgresql/data
web:
build: .
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/myapp
ports:
- "3000:3000"
depends_on:
- db
- rabbitmq
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
Now we can use a gem like Bunny to publish and consume messages. Here’s an example of how we might publish a message when a new user is created:
require 'bunny'
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
publish_user_created
render json: @user, status: :created
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def publish_user_created
connection = Bunny.new(hostname: 'rabbitmq')
connection.start
channel = connection.create_channel
exchange = channel.fanout('user.created')
exchange.publish(@user.to_json)
connection.close
end
def user_params
params.require(:user).permit(:name, :email)
end
end
Other services can then consume these messages and react accordingly.
As our microservices architecture grows, we’ll need to consider other aspects like service discovery, load balancing, and centralized logging. Tools like Consul for service discovery, Nginx for load balancing, and the ELK stack (Elasticsearch, Logstash, Kibana) for logging can be incredibly helpful.
Let’s add Consul to our docker-compose.yml:
version: '3'
services:
db:
image: postgres
volumes:
- ./tmp/db:/var/lib/postgresql/data
web:
build: .
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/myapp
ports:
- "3000:3000"
depends_on:
- db
- rabbitmq
- consul
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
consul:
image: consul:latest
ports:
- "8500:8500"
command: agent -server -ui -node=server-1 -bootstrap-expect=1 -client=0.0.0.0
Now we can use the Consul gem to register our service:
require 'consul'
Consul::Client.configure do |config|
config.url = 'http://consul:8500'
end
Consul::Service.register(
name: 'users',
port: 3000,
tags: ['rails', 'api']
)
This registers our Users service with Consul, making it discoverable by other services.
As our microservices architecture evolves, we’ll also need to think about testing. Each microservice should have its own comprehensive test suite. We can use RSpec for unit and integration tests:
require 'rails_helper'
RSpec.describe UsersController, type: :controller do
describe "POST #create" do
it "creates a new user" do
expect {
post :create, params: { user: { name: "John Doe", email: "[email protected]" } }
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)["name"]).to eq("John Doe")
end
end
end
For end-to-end testing of our microservices ecosystem, we might consider tools like Cucumber or Capybara, which allow us to write high-level, behavior-driven tests.
Security is another crucial aspect of microservices architecture. We need to ensure that communication between services is secure. One approach is to use JSON Web Tokens (JWT) for authentication. We can use the jwt gem to implement this:
require 'jwt'
class ApplicationController < ActionController::API
def authenticate
token = request.headers['Authorization']&.split&.last
begin
@decoded = JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
@current_user = User.find(@decoded["user_id"])
rescue JWT::DecodeError
render json: { errors: ["Invalid token"] }, status: :unauthorized
rescue ActiveRecord::RecordNotFound
render json: { errors: ["User not found"] }, status: :unauthorized
end
end
end
Then, we can use this in our controllers:
class ProtectedController < ApplicationController
before_action :authenticate
def index
render json: { message: "This is a protected endpoint", user: @current_user }
end
end
As our microservices grow in number and complexity, monitoring becomes increasingly important. We can use tools like Prometheus for metrics collection and Grafana for visualization. Let’s add these to our docker-compose.yml:
version: '3'
services:
# ... other services ...
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
ports:
- "3000:3000"
depends_on:
- prometheus
We’ll need to create a prometheus.yml file to configure Prometheus:
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'rails'
static_configs:
- targets: ['web:3000']
To expose metrics from our Rails application, we can use the prometheus-client gem. Here’s a basic setup:
require 'prometheus/client'
require 'prometheus/client/rack/collector'
require 'prometheus/client/rack/exporter'
# in config/application.rb
config.middleware.use Prometheus::Client::Rack::Collector
config.middleware.use Prometheus::Client::Rack::Exporter
This sets up basic request metrics that Prometheus can scrape.
As we continue to scale our microservices architecture, we might consider implementing a Circuit Breaker pattern to handle failures gracefully. The circuit_breaker gem can help with this:
require 'circuit_breaker'
class ExternalServiceClient
include CircuitBreaker
circuit_method :call_external_service do |method|
method.failure_threshold = 5
method.failure_timeout = 10
method.invocation_timeout = 2
end
def call_external_service
# Make external service call here
end
end
This will automatically “break the circuit” if the external service fails too many times, preventing cascading failures in our system.
Implementing all of these concepts - containerization, service discovery, message queues