Running migrations with Rails in a Docker container with multiple container instances
Especially with Rails I don't have any experience, but let's look from a docker and software engineering point of view.
The Docker team advocates, sometimes quite aggressively, that containers are about shipping applications. In this really great statement, Jerome Petazzoni says that it is all about separation of concerns. I feel that this is exactly the point you already figured out.
Running a rails container which starts a migration or setup might be good for initial deployment and probably often required during development. However, when going into production, you really should consider separating the concerns.
Thus I would say have one image, which you use to run N rails container and add a tools/migration/setup whatever container, which you use to do administrative tasks. Have a look what the developers from the official rails image say about this:
It is designed to be used both as a throw away container (mount your source code and start the container to start your app), as well as the base to build other images off of.
When you look at that image there is no setup or migration command. It is totally up to the user how to use it. So when you need to run several containers just go ahead.
From my experience with mysql this works fine. You can run a data-only container to host the data, run a container with the mysql server and finally run a container for administrative tasks like backup and restore. For all three containers you can use the same image. Now you are free to access your database from let's say several Wordpress containers. This means clear separation of concerns. When you use docker-compose
it is not that difficult to manage all those containers. Certainly there are already many third party containers and tools to also support you with setting up a complex application consisting of several containers.
Finally, you should decide whether docker and the micro-service architecture is right for your problem. As outlined in this article there are some reasons against. One of the core problems being that it adds a whole new layer of complexity. However, that is the case with many solutions and I guess you are aware of this and willing to except it.
docker run <container name> rake db:migrate
Starts you standard application container but don't run the CMD (rails server
), but rake db:migrate
UPDATE: Suggested by Roman, the command would now be:
docker exec <container> rake db:migrate
Having the same pb publishing to a docker swarm, I put here a solution partially grabbed from others.
Rails has already a mechanism to detect concurrent migrations by using a lock on the database. But it triggers ConcurrentException where it should just wait.
One solution is then to have a loop, that whenever a ConcurrentException is thrown, just wait for 5s et then redo the migration.This is especially important that all containers perform the migration as the migration fails, all containers must fails.
Solution from coffejumper
namespace :db do namespace :migrate do desc 'Run db:migrate and monitor ActiveRecord::ConcurrentMigrationError errors' task monitor_concurrent: :environment do loop do puts 'Invoking Migrations' Rake::Task['db:migrate'].reenable Rake::Task['db:migrate'].invoke puts 'Migrations Successful' break rescue ActiveRecord::ConcurrentMigrationError puts 'Migrations Sleeping 5' sleep(5) end end end end
And sometimes you have other processes you want to execute also one by one to perform the migration like after_party, cron setup, etc... The solution is then to use the same mechanism as Rails to embed rake tasks around a database lock:
Below, based on Rails 6 code, the migrate_without_lock performs the needed migrations while with_advisory_lock
gets database lock (triggering ConcurrentMigrationError if lock cannot be acquired).
module Swarm class Migration def migrate with_advisory_lock { migrate_without_lock } end private def migrate_without_lock **puts "Database migration" Rake::Task['db:migrate'].invoke puts "After_party migration" Rake::Task['after_party:run'].invoke ... puts "Migrations successful"** end def with_advisory_lock lock_id = generate_migrator_advisory_lock_id MyAdvisoryLockBase.establish_connection(ActiveRecord::Base.connection_config) unless MyAdvisoryLockBase.connected? connection = MDAdvisoryLockBase.connection got_lock = connection.get_advisory_lock(lock_id) raise ActiveRecord::ConcurrentMigrationError unless got_lock yield ensure if got_lock && !connection.release_advisory_lock(lock_id) raise ActiveRecord::ConcurrentMigrationError.new( ActiveRecord::ConcurrentMigrationError::RELEASE_LOCK_FAILED_MESSAGE ) end end MIGRATOR_SALT = 1942351734 def generate_migrator_advisory_lock_id db_name_hash = Zlib.crc32(ActiveRecord::Base.connection_config[:database]) MIGRATOR_SALT * db_name_hash end end # based on rails 6.1 AdvisoryLockBase class MyAdvisoryLockBase < ActiveRecord::AdvisoryLockBase # :nodoc: self.connection_specification_name = "MDAdvisoryLockBase" endend
Then as before, do a loop to wait
namespace :swarm do desc 'Run migrations tasks after acquisition of lock on database' task migrate: :environment do result = 1 (1..10).each do |i| **Swarm::Migration.new.migrate** puts "Attempt #{i} sucessfully terminated" result = 0 break rescue ActiveRecord::ConcurrentMigrationError seconds = rand(3..10) puts "Attempt #{i} another migration is running => sleeping #{seconds}s" sleep(seconds) rescue => e puts e e.backtrace.each { |m| puts m } break end exit(result) endend
Then in your startup script just launch the rake tasks
set -ebundle exec rails swarm:migrateexec bundle exec rails server -b "0.0.0.0"
At the end, as your migrations tasks are run by all containers, they must have a mechanism to do nothing when it's already done. (like does db:migrate)
Using this solution, the order in which Swarm launches containers doesn't matter anymore AND if something goes wrong, all containers know the problem :-)