Compare commits

..

57 Commits

Author SHA1 Message Date
David Heinemeier Hansson
57e49bb26c Bump version for 0.0.2 2023-01-10 19:16:34 +01:00
David Heinemeier Hansson
1609b43ef8 Temporary fix for #2 2023-01-10 19:15:40 +01:00
David Heinemeier Hansson
f9010c1b75 Only run Traefik on web role 2023-01-10 19:04:35 +01:00
David Heinemeier Hansson
73b7c691d6 Fix references 2023-01-10 18:56:30 +01:00
David Heinemeier Hansson
3473ec7a86 Explain running job servers separately 2023-01-10 17:31:36 +01:00
David Heinemeier Hansson
e8beb362d0 Add role concern with specialized cmds for job running 2023-01-10 17:27:56 +01:00
David Heinemeier Hansson
1cee87d440 Latest bundler 2023-01-10 15:02:25 +01:00
David Heinemeier Hansson
c2e09b9b2f Added debug 2023-01-10 14:24:25 +01:00
David Heinemeier Hansson
78a5d08d3f Switch to host naming
Servers concept will encompass custom cmd and labels. Host is just the IP address.
2023-01-10 14:15:16 +01:00
David Heinemeier Hansson
5ca6f32ee7 Use debug gem 2023-01-10 13:17:28 +01:00
David Heinemeier Hansson
6b098a1e2e Ruby 3.2.0 compatibility 2023-01-10 13:17:18 +01:00
David Heinemeier Hansson
ff5ccac8fe Cleanup 2023-01-09 20:44:54 +01:00
David Heinemeier Hansson
b4edf8eef9 Ignore nil command bits
They might come from conditional options
2023-01-09 18:08:34 +01:00
David Heinemeier Hansson
fe52ce6547 Add command execution 2023-01-09 14:36:33 +01:00
David Heinemeier Hansson
9641ce0edd Update README.md 2023-01-08 18:38:55 +01:00
David Heinemeier Hansson
1dab9c1fb5 More documentation 2023-01-08 16:50:06 +01:00
David Heinemeier Hansson
10d973200d Add command to list containers (to ease rollback) 2023-01-08 16:45:41 +01:00
David Heinemeier Hansson
fdfdff65e9 Explain command map overwrite 2023-01-08 16:33:09 +01:00
David Heinemeier Hansson
94d61f3d9a Proper param array separation 2023-01-08 16:32:31 +01:00
David Heinemeier Hansson
483f686efc Test config labels 2023-01-08 16:29:59 +01:00
David Heinemeier Hansson
51adbc032e Test app#run 2023-01-08 16:29:51 +01:00
David Heinemeier Hansson
dcb3e4d491 Switch envs and labels to param array 2023-01-08 16:29:44 +01:00
David Heinemeier Hansson
55445ae110 Style 2023-01-08 16:22:50 +01:00
David Heinemeier Hansson
998525c93d Switch to cmd array so we can redact 2023-01-08 16:20:06 +01:00
David Heinemeier Hansson
4ec04f8959 Language 2023-01-08 15:13:51 +01:00
David Heinemeier Hansson
3ddf2b9c41 Distinguish run from start 2023-01-08 15:13:45 +01:00
David Heinemeier Hansson
d4210b66d0 Language 2023-01-08 15:13:28 +01:00
David Heinemeier Hansson
7f37abac59 If already started, just carry on 2023-01-08 15:13:24 +01:00
David Heinemeier Hansson
399d32d7d0 Known VERSION means we've already pushed 2023-01-08 15:13:14 +01:00
David Heinemeier Hansson
8d16271150 Make run resilient to same version having already been run 2023-01-08 15:13:03 +01:00
David Heinemeier Hansson
e1724e0cd9 Clarify output 2023-01-08 14:55:51 +01:00
David Heinemeier Hansson
43eac9d414 Use DRY extraction 2023-01-08 14:55:14 +01:00
David Heinemeier Hansson
ffb532a50d Add remove tasks to clean up 2023-01-08 14:55:06 +01:00
David Heinemeier Hansson
23c2cb898c Explain need to match with Dockerfile LABEL 2023-01-08 14:38:03 +01:00
David Heinemeier Hansson
14867a2f61 Allow logging out of registry 2023-01-08 14:18:00 +01:00
David Heinemeier Hansson
4b46449fdf Split out repository to be used alone 2023-01-08 14:07:29 +01:00
David Heinemeier Hansson
87ca059f32 Fix dangling parenthesis 2023-01-08 14:07:08 +01:00
David Heinemeier Hansson
1fcc2d3cfd Remember to use Shellwords later 2023-01-08 13:39:38 +01:00
David Heinemeier Hansson
d43ceb975f Create config stub with mrsk:init 2023-01-08 13:39:29 +01:00
David Heinemeier Hansson
4f06b5f99b Clarify that one server needn't an LB 2023-01-08 12:14:46 +01:00
David Heinemeier Hansson
21df2aefe5 Prune containers first to release images 2023-01-08 12:13:19 +01:00
David Heinemeier Hansson
5979f1d43e Prune by default after deploy 2023-01-08 12:11:44 +01:00
David Heinemeier Hansson
9e7ce59b85 Use a shared prune 2023-01-08 12:08:28 +01:00
David Heinemeier Hansson
6e853786eb Prepare for auto-pruning 2023-01-08 11:54:43 +01:00
David Heinemeier Hansson
e378e9a6dd Not used 2023-01-08 11:54:32 +01:00
David Heinemeier Hansson
6c3a4b1792 Explain rollback 2023-01-08 11:47:04 +01:00
David Heinemeier Hansson
73019bedfb Keep containers around for quick rollback + restarting
Now need to deal with pruning.
2023-01-08 11:45:48 +01:00
David Heinemeier Hansson
e8fc046537 Update README.md 2023-01-08 11:33:09 +01:00
David Heinemeier Hansson
a45a40b996 Done 2023-01-08 11:29:55 +01:00
David Heinemeier Hansson
3cad095e2b Add ERB eval so we can use credentials 2023-01-08 11:11:57 +01:00
David Heinemeier Hansson
cc3619173d Split out push/pull and aggregate in deliver 2023-01-08 10:07:32 +01:00
David Heinemeier Hansson
ddb4d549f2 Need setup 2023-01-08 10:07:13 +01:00
David Heinemeier Hansson
7f220ea987 Bootstrap entirely clean new server 2023-01-08 10:07:08 +01:00
David Heinemeier Hansson
4cbc4aa9b7 Update README.md 2023-01-08 09:37:12 +01:00
David Heinemeier Hansson
9c6cd33dec Ensure we're logged in 2023-01-08 09:35:55 +01:00
David Heinemeier Hansson
ef87cd5634 Explain registry configuration 2023-01-08 09:35:45 +01:00
David Heinemeier Hansson
9d9a9c4116 Only need absolute_image 2023-01-07 22:02:28 +01:00
23 changed files with 732 additions and 108 deletions

View File

@@ -1,9 +1,6 @@
source 'https://rubygems.org' source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" } git_source(:github) { |repo| "https://github.com/#{repo}.git" }
# Specify your gem's dependencies in importmap-rails.gemspec.
gemspec gemspec
group :test do gem "debug"
gem "byebug"
end

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.0.1) mrsk (0.0.2)
railties (>= 7.0.0) railties (>= 7.0.0)
sshkit (~> 1.21) sshkit (~> 1.21)
@@ -27,12 +27,17 @@ GEM
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
builder (3.2.4) builder (3.2.4)
byebug (11.1.3)
concurrent-ruby (1.1.10) concurrent-ruby (1.1.10)
crass (1.0.6) crass (1.0.6)
debug (1.7.1)
irb (>= 1.5.0)
reline (>= 0.3.1)
erubi (1.12.0) erubi (1.12.0)
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.6.0)
irb (1.6.2)
reline (>= 0.3.0)
loofah (2.19.1) loofah (2.19.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
@@ -41,7 +46,11 @@ GEM
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1) net-ssh (7.0.1)
nokogiri (1.13.10-arm64-darwin) nokogiri (1.14.0.rc1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.0.rc1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.0.rc1-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
racc (1.6.2) racc (1.6.2)
rack (2.2.5) rack (2.2.5)
@@ -60,6 +69,8 @@ GEM
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rake (13.0.6) rake (13.0.6)
reline (0.3.2)
io-console (~> 0.5)
sshkit (1.21.3) sshkit (1.21.3)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
@@ -77,8 +88,8 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
byebug debug
mrsk! mrsk!
BUNDLED WITH BUNDLED WITH
2.2.33 2.4.3

161
README.md
View File

@@ -1,26 +1,32 @@
# MRSK # MRSK
MRSK lets you do zero-downtime deploys of Rails apps packed as containers to any host running Docker. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is wound down. It works across multiple hosts at the same time, using SSHKit to execute commands. MRSK ships zero-downtime deploys of Rails apps packed as containers to any host. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is wound down. It works across multiple hosts at the same time, using SSHKit to execute commands.
## Installation ## Installation
Create a configuration file for MRSK in `config/deploy.yml` that looks like this: Add the gem with `bundle add mrsk`, then run `rake mrsk:init`, and then edit the new file in `config/deploy.yml` to use the proper service name, image reference, servers to deploy on, and so on. It could look something like this:
```yaml ```yaml
service: my-app service: hey
image: name/my-app image: 37s/hey
servers: servers:
- xxx.xxx.xxx.xxx - xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx - xxx.xxx.xxx.xxx
env: env:
DATABASE_URL: mysql2://username@localhost/database_name/ DATABASE_URL: mysql2://db1/hey_production/
REDIS_URL: redis://host:6379/1 REDIS_URL: redis://redis1:6379/1
registry:
server: registry.digitalocean.com
username: <%= Rails.application.credentials.registry["username"] %>
password: <%= Rails.application.credentials.registry["password"] %>
``` ```
Then first login to the Docker Hub registry on the servers: Then ensure your encrypted credentials have the registry username + password by editing them with `rails credentials:edit`:
``` ```
rake mrsk:registry:login DOCKER_USER=name DOCKER_PASSWORD=pw registry:
username: real-user-name
password: real-password
``` ```
Now you're ready to deploy a multi-arch image (FIXME: currently you need to manually run `docker buildx create --use` once first): Now you're ready to deploy a multi-arch image (FIXME: currently you need to manually run `docker buildx create --use` once first):
@@ -31,25 +37,142 @@ rake mrsk:deploy
This will: This will:
1. Build the image using the standard Dockerfile in the root of the application. 1. Log into the registry both locally and remotely
2. Push the image to the registry. 2. Build the image using the standard Dockerfile in the root of the application.
3. Pull the image on all the servers. 3. Push the image to the registry.
4. Ensure Traefik is running and accepting traffic on port 80. 4. Pull the image from the registry on the servers.
5. Stop any containers running a previous versions of the app. 5. Ensure Traefik is running and accepting traffic on port 80.
6. Start a new container with the version of the app that matches the current git version hash. 6. Stop any containers running a previous versions of the app.
7. Start a new container with the version of the app that matches the current git version hash.
8. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80, and you're ready to put them behind a load balancer to serve live traffic. Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## Operations
### Running job hosts separately
If your application uses separate job running hosts, or other roles beyond the default web running, you can specify these hosts and their custom command like so:
```yaml
servers:
web:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
job:
hosts:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
cmd: bin/jobs
```
The application will be deployed to all hosts, but only those in the `web` role will be labeled to run under traefik. If you want to run custom commands on all hosts in a role, you can use `rake mrsk:app:exec:rails CMD=about ROLES=job`.
### Executing commands
If you need to execute commands inside the Rails containers, you can use `rake mrsk:app:exec`, `rake mrsk:app:exec:once`, `rake mrsk:app:exec:rails`, and `rake mrsk:app:exec:once:rails`. Examples:
```bash
# Runs command on all servers
rake mrsk:app:exec CMD='ruby -v'
App Host: xxx.xxx.xxx.xxx
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
App Host: xxx.xxx.xxx.xxx
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
# Runs command on first server
rake mrsk:app:exec:once CMD='cat .ruby-version'
3.1.3
# Runs Rails command on all servers
rake mrsk:app:exec:rails CMD=about
App Host: xxx.xxx.xxx.xxx
About your application's environment
Rails version 7.1.0.alpha
Ruby version ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
RubyGems version 3.3.26
Rack version 2.2.5
Middleware ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Rails::Rack::Logger, ActionDispatch::ShowExceptions, ActionDispatch::DebugExceptions, ActionDispatch::Callbacks, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper
Application root /rails
Environment production
Database adapter sqlite3
Database schema version 20221231233303
App Host: xxx.xxx.xxx.xxx
About your application's environment
Rails version 7.1.0.alpha
Ruby version ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
RubyGems version 3.3.26
Rack version 2.2.5
Middleware ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Rails::Rack::Logger, ActionDispatch::ShowExceptions, ActionDispatch::DebugExceptions, ActionDispatch::Callbacks, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper
Application root /rails
Environment production
Database adapter sqlite3
Database schema version 20221231233303
# Runs Rails command on first server
rake mrsk:app:exec:once:rails CMD='db:version'
database: storage/production.sqlite3
Current version: 20221231233303
```
### Inspecting
You can see the state of your servers by running `rake mrsk:info`. It'll show something like this:
```
Traefik Host: xxx.xxx.xxx.xxx
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6195b2a28c81 traefik "/entrypoint.sh --pr…" 30 minutes ago Up 19 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp traefik
Traefik Host: 164.92.105.119
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
de14a335d152 traefik "/entrypoint.sh --pr…" 30 minutes ago Up 19 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp traefik
App Host: 164.90.145.60
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
badb1aa51db3 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 13 minutes ago Up 13 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
App Host: 164.92.105.119
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d3c91ed1f55 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 13 minutes ago Up 13 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
```
You can also see just info for app containers with `rake mrsk:app:info` or just for Traefik with `rake mrsk:traefik:info`.
### Rollback
If you've discovered a bad deploy, you can quickly rollback by reactivating the old, paused container image. You can see what old containers are available for rollback by running `rake mrsk:app:containers`. It'll give you a presentation similar to `rake mrsk:app:info`, but include all the old containers as well. Showing something like this:
```
App Host: 164.92.105.119
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d3c91ed1f51 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 19 minutes ago Up 19 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
539f26b28369 registry.digitalocean.com/user/app:e5d9d7c2b898289dfbc5f7f1334140d984eedae4 "/rails/bin/docker-e…" 31 minutes ago Exited (1) 27 minutes ago chat-e5d9d7c2b898289dfbc5f7f1334140d984eedae4
App Host: 164.90.145.60
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
badb1aa51db4 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 19 minutes ago Up 19 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
6f170d1172ae registry.digitalocean.com/user/app:e5d9d7c2b898289dfbc5f7f1334140d984eedae4 "/rails/bin/docker-e…" 31 minutes ago Exited (1) 27 minutes ago chat-e5d9d7c2b898289dfbc5f7f1334140d984eedae4
```
From the example above, we can see that `e5d9d7c2b898289dfbc5f7f1334140d984eedae4` was the last version, so it's available as a rollback target. We can perform this rollback by running `rake mrsk:rollback VERSION=e5d9d7c2b898289dfbc5f7f1334140d984eedae4`. That'll stop `6ef8a6a84c525b123c5245345a8483f86d05a123` and then start `e5d9d7c2b898289dfbc5f7f1334140d984eedae4`. Because the old container is still available, this is very quick. Nothing to download from the registry.
Note that by default old containers are pruned after 3 days when you run `rake mrsk:deploy`.
### Removing
If you wish to remove the entire application, including Traefik, containers, images, and registry session, you can run `rake mrsk:remove`. This will leave the servers clean.
## Stage of development ## Stage of development
This is alpha software. Lots of stuff is missing. Here are some of the areas we seek to improve: This is alpha software. Lots of stuff is missing. Here are some of the areas we seek to improve:
- Use of other registries than Docker Hub
- Adapterize commands to work with Podman and other container runners - Adapterize commands to work with Podman and other container runners
- Better flow for secrets and ENV
- Possibly switching to a bin/mrsk command rather than raw rake - Possibly switching to a bin/mrsk command rather than raw rake
- Integrate wirmth cloud CI pipelines - Integrate with cloud CI pipelines
## License ## License
Mrsk is released under the [MIT License](https://opensource.org/licenses/MIT). MRSK is released under the [MIT License](https://opensource.org/licenses/MIT).

View File

@@ -1,3 +1,5 @@
require "sshkit"
module Mrsk::Commands module Mrsk::Commands
class Base class Base
attr_accessor :config attr_accessor :config
@@ -5,6 +7,16 @@ module Mrsk::Commands
def initialize(config) def initialize(config)
@config = config @config = config
end end
private
def docker(*args)
args.compact.unshift :docker
end
# Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes
def redact(arg) # Used in execute_command to hide redact() args a user passes in
arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc
end
end end
end end

View File

@@ -1,22 +1,66 @@
class Mrsk::Commands::App < Mrsk::Commands::Base class Mrsk::Commands::App < Mrsk::Commands::Base
def push def push
# TODO: Run 'docker buildx create --use' when needed # TODO: Run 'docker buildx create --use' when needed
"docker buildx build --push --platform=linux/amd64,linux/arm64 -t #{config.absolute_image} ." # TODO: Make multiarch an option so Linux users can enjoy speedier builds
docker :buildx, :build, "--push", "--platform linux/amd64,linux/arm64", "-t", config.absolute_image, "."
end end
def pull def pull
"docker pull #{config.absolute_image}" docker :pull, config.absolute_image
end
def run(role: :web)
role = config.role(role)
docker :run,
"-d",
"--restart unless-stopped",
"--name", config.service_with_version,
"-e", redact("RAILS_MASTER_KEY=#{config.master_key}"),
*config.env_args,
*role.label_args,
config.absolute_image,
role.cmd
end end
def start def start
"docker run -d --rm --name #{config.service_with_version} #{config.envs} #{config.labels} #{config.absolute_image}" docker :start, config.service_with_version
end end
def stop def stop
"docker ps -q --filter label=service=#{config.service} | xargs docker stop" [ "docker ps -q #{service_filter.join(" ")} | xargs docker stop" ]
end end
def info def info
"docker ps --filter label=service=#{config.service}" docker :ps, *service_filter
end end
def logs
[ "docker ps -q #{service_filter.join(" ")} | xargs docker logs -f" ]
end
def exec(*command)
docker :exec,
"-e", redact("RAILS_MASTER_KEY=#{config.master_key}"),
*config.env_args,
config.service_with_version,
*command
end
def list_containers
docker :container, :ls, "-a", *service_filter
end
def remove_containers
docker :container, :prune, "-f", *service_filter
end
def remove_images
docker :image, :prune, "-a", "-f", *service_filter
end
private
def service_filter
[ "--filter", "label=service=#{config.service}" ]
end
end end

View File

@@ -1,5 +1,11 @@
class Mrsk::Commands::Registry < Mrsk::Commands::Base class Mrsk::Commands::Registry < Mrsk::Commands::Base
delegate :registry, to: :config
def login def login
"docker login #{config.registry["server"]} -u #{config.registry["username"]} -p #{config.registry["password"]}" docker :login, registry["server"], "-u", redact(registry["username"]), "-p", redact(registry["password"])
end
def logout
docker :logout, registry["server"]
end end
end end

View File

@@ -1,17 +1,35 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
docker :run, "--name traefik",
"-d",
"--restart unless-stopped",
"-p 80:80",
"-v /var/run/docker.sock:/var/run/docker.sock",
"traefik",
"--providers.docker"
end
def start def start
"docker run --name traefik " + docker :container, :start, "traefik"
"--rm -d " +
"-p 80:80 " +
"-v /var/run/docker.sock:/var/run/docker.sock " +
"traefik --providers.docker"
end end
def stop def stop
"docker container stop traefik" docker :container, :stop, "traefik"
end end
def info def info
"docker ps --filter name=traefik" docker :ps, "--filter", "name=traefik"
end
def logs
docker :logs, "traefik"
end
def remove_container
docker :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def remove_image
docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
end end

View File

@@ -1,60 +1,92 @@
require "active_support/ordered_options" require "active_support/ordered_options"
require "active_support/core_ext/string/inquiry"
require "erb"
class Mrsk::Configuration class Mrsk::Configuration
delegate :service, :image, :env, :registry, :ssh_user, to: :config, allow_nil: true delegate :service, :image, :servers, :env, :registry, :ssh_user, to: :config, allow_nil: true
def self.load_file(file) class << self
if file.exist? def load_file(file)
new YAML.load_file(file).symbolize_keys if file.exist?
else new YAML.load(ERB.new(IO.read(file)).result).symbolize_keys
raise "Configuration file not found in #{file}" else
raise "Configuration file not found in #{file}"
end
end
def argumentize(argument, attributes)
attributes.flat_map { |k, v| [ argument, "#{k}=#{v}" ] }
end end
end end
def initialize(config) def initialize(config, validate: true)
@config = ActiveSupport::InheritableOptions.new(config) @config = ActiveSupport::InheritableOptions.new(config)
ensure_required_keys_present ensure_required_keys_present if validate
end end
def servers
ENV["SERVERS"] || config.servers def roles
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) }
end end
def role(name)
roles.detect { |r| r.name == name.to_s }
end
def hosts
hosts =
case
when ENV["HOSTS"]
ENV["HOSTS"].split(",")
when ENV["ROLES"]
role_names = ENV["ROLES"].split(",")
roles.select { |r| role_names.include?(r.name) }.flat_map(&:hosts)
else
roles.flat_map(&:hosts)
end
if hosts.any?
hosts
else
raise ArgumentError, "No hosts found"
end
end
def primary_host
role(:web).hosts.first
end
def version def version
@version ||= ENV["VERSION"] || `git rev-parse HEAD`.strip @version ||= ENV["VERSION"] || `git rev-parse HEAD`.strip
end end
def absolute_image def repository
[ config.registry["server"], image_with_version ].compact.join("/") [ config.registry["server"], image ].compact.join("/")
end end
def image_with_version def absolute_image
"#{image}:#{version}" "#{repository}:#{version}"
end end
def service_with_version def service_with_version
"#{service}-#{version}" "#{service}-#{version}"
end end
def envs
parameterize "-e", \
{ "RAILS_MASTER_KEY" => master_key }.merge(env || {})
end
def labels def env_args
parameterize "--label", \ self.class.argumentize "-e", config.env if config.env.present?
"service" => service,
"traefik.http.routers.#{service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.services.#{service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{service}.retry.attempts" => "3",
"traefik.http.middlewares.#{service}.retry.initialinterval" => "500ms"
end end
def ssh_options def ssh_options
{ user: config.ssh_user || "root", auth_methods: [ "publickey" ] } { user: config.ssh_user || "root", auth_methods: [ "publickey" ] }
end end
def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key"))
end
private private
attr_accessor :config attr_accessor :config
@@ -68,11 +100,9 @@ class Mrsk::Configuration
end end
end end
def parameterize(param, hash) def role_names
hash.collect { |k, v| "#{param} #{k}=#{v}" }.join(" ") config.servers.is_a?(Array) ? [ "web" ] : config.servers.keys.sort
end
def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key"))
end end
end end
require "mrsk/configuration/role"

View File

@@ -0,0 +1,63 @@
class Mrsk::Configuration::Role
delegate :argumentize, to: Mrsk::Configuration
attr_accessor :name
def initialize(name, config:)
@name, @config = name.inquiry, config
end
def hosts
@hosts ||= extract_hosts_from_config
end
def label_args
argumentize "--label", labels
end
def cmd
specializations["cmd"]
end
private
attr_accessor :config
def extract_hosts_from_config
if config.servers.is_a?(Array)
config.servers
else
servers = config.servers[name]
servers.is_a?(Array) ? servers : servers["hosts"]
end
end
def labels
if name.web?
default_labels.merge(traefik_labels)
else
default_labels
end
end
def default_labels
{ "service" => config.service, "role" => name }
end
def traefik_labels
{
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms"
}
end
def specializations
if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
{ }
else
config.servers[name].without("hosts")
end
end
end

View File

@@ -1,3 +1,3 @@
module Mrsk module Mrsk
VERSION = "0.0.1" VERSION = "0.0.2"
end end

View File

@@ -4,28 +4,95 @@ app = Mrsk::Commands::App.new(MRSK_CONFIG)
namespace :mrsk do namespace :mrsk do
namespace :app do namespace :app do
desc "Build and push app image to servers" desc "Deliver a newly built app image to servers"
task deliver: %i[ push pull ]
desc "Build locally and push app image to registry"
task :push do task :push do
run_locally { execute app.push } run_locally { execute *app.push } unless ENV["VERSION"]
on(MRSK_CONFIG.servers) { execute app.pull }
end end
desc "Start app on servers" desc "Pull app image from the registry onto servers"
task :pull do
on(MRSK_CONFIG.hosts) { execute *app.pull }
end
desc "Run app on servers (or start them if they've already been run)"
task :run do
MRSK_CONFIG.roles.each do |role|
on(role.hosts) do |host|
begin
execute *app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
puts "Container with same version already deployed on #{host}, starting that instead"
execute *app.start, host: host
else
raise
end
end
end
end
end
desc "Start existing app on servers"
task :start do task :start do
on(MRSK_CONFIG.servers) { execute app.start } on(MRSK_CONFIG.hosts) { execute *app.start, raise_on_non_zero_exit: false }
end end
desc "Stop app on servers" desc "Stop app on servers"
task :stop do task :stop do
on(MRSK_CONFIG.servers) { execute app.stop, raise_on_non_zero_exit: false } on(MRSK_CONFIG.hosts) { execute *app.stop, raise_on_non_zero_exit: false }
end end
desc "Restart app on servers" desc "Start app on servers (use VERSION=<git-hash> to designate which version)"
task restart: %i[ stop start ] task restart: %i[ stop start ]
desc "Display information about app containers" desc "Display information about app containers"
task :info do task :info do
on(MRSK_CONFIG.servers) { |host| puts "Host: #{host}\n" + capture(app.info) + "\n\n" } on(MRSK_CONFIG.hosts) { |host| puts "App Host: #{host}\n" + capture(*app.info) + "\n\n" }
end
desc "Execute a custom task on servers passed in as CMD='bin/rake some:task'"
task :exec do
on(MRSK_CONFIG.hosts) { |host| puts "App Host: #{host}\n" + capture(*app.exec(ENV["CMD"])) + "\n\n" }
end
namespace :exec do
desc "Execute Rails command on servers, like CMD='runner \"puts %(Hello World)\""
task :rails do
on(MRSK_CONFIG.hosts) { |host| puts "App Host: #{host}\n" + capture(*app.exec("bin/rails", ENV["CMD"])) + "\n\n" }
end
desc "Execute a custom task on the first defined server"
task :once do
on(MRSK_CONFIG.primary_host) { |host| puts capture(*app.exec(ENV["CMD"])) }
end
namespace :once do
desc "Execute Rails command on the first defined server, like CMD='runner \"puts %(Hello World)\""
task :rails do
on(MRSK_CONFIG.primary_host) { puts capture(*app.exec("bin/rails", ENV["CMD"])) }
end
end
end
desc "List all the app containers currently on servers"
task :containers do
on(MRSK_CONFIG.hosts) { |host| puts "App Host: #{host}\n" + capture(*app.list_containers) + "\n\n" }
end
desc "Tail logs from app containers"
task :logs do
on(MRSK_CONFIG.hosts) { execute *app.logs }
end
desc "Remove app containers and images from servers"
task remove: %i[ stop ] do
on(MRSK_CONFIG.hosts) do
execute *app.remove_containers
execute *app.remove_images
end
end end
end end
end end

View File

@@ -1,12 +1,26 @@
require_relative "setup"
namespace :mrsk do namespace :mrsk do
desc "Deploy app for the first time to a fresh server"
task fresh: %w[ server:bootstrap registry:login app:deliver traefik:run app:stop app:run ]
desc "Push the latest version of the app, ensure Traefik is running, then restart app" desc "Push the latest version of the app, ensure Traefik is running, then restart app"
task deploy: [ "app:push", "traefik:start", "app:restart" ] task deploy: %w[ registry:login app:deliver traefik:run app:stop app:run prune ]
desc "Rollback to VERSION=x that was already run as a container on servers"
task rollback: %w[ app:restart ]
desc "Display information about Traefik and app containers" desc "Display information about Traefik and app containers"
task info: [ "traefik:info", "app:info" ] task info: %w[ traefik:info app:info ]
desc "Create config stub" desc "Create config stub in config/deploy.yml"
task :init do task :init do
Rails.root.join("config/deploy.yml") require "fileutils"
FileUtils.cp_r \
Pathname.new(File.expand_path("templates/deploy.yml", __dir__)),
Rails.root.join("config/deploy.yml")
end end
desc "Remove Traefik, app, and registry session from servers"
task remove: %w[ traefik:remove app:remove registry:logout ]
end end

18
lib/tasks/mrsk/prune.rake Normal file
View File

@@ -0,0 +1,18 @@
require_relative "setup"
namespace :mrsk do
desc "Prune unused images and stopped containers"
task prune: %w[ prune:containers prune:images ]
namespace :prune do
desc "Prune unused images older than 30 days"
task :images do
on(MRSK_CONFIG.hosts) { execute "docker image prune -f --filter 'until=720h'" }
end
desc "Prune stopped containers for the service older than 3 days"
task :containers do
on(MRSK_CONFIG.hosts) { execute "docker container prune -f --filter label=service=#{MRSK_CONFIG.service} --filter 'until=72h'" }
end
end
end

View File

@@ -6,8 +6,13 @@ namespace :mrsk do
namespace :registry do namespace :registry do
desc "Login to the registry locally and remotely" desc "Login to the registry locally and remotely"
task :login do task :login do
run_locally { execute registry.login } run_locally { execute *registry.login }
on(MRSK_CONFIG.servers) { execute registry.login } on(MRSK_CONFIG.hosts) { execute *registry.login }
end
desc "Logout of the registry remotely"
task :logout do
on(MRSK_CONFIG.hosts) { execute *registry.logout }
end end
end end
end end

View File

@@ -0,0 +1,11 @@
require_relative "setup"
namespace :mrsk do
namespace :server do
desc "Setup Docker on the remote servers"
task :bootstrap do
# FIXME: Detect when apt-get is not available and use the appropriate alternative
on(MRSK_CONFIG.hosts) { execute "apt-get install docker.io -y" }
end
end
end

View File

@@ -3,6 +3,14 @@ require "sshkit/dsl"
include SSHKit::DSL include SSHKit::DSL
MRSK_CONFIG = Mrsk::Configuration.load_file(Rails.root.join("config/deploy.yml")) if (config_file = Rails.root.join("config/deploy.yml")).exist?
MRSK_CONFIG = Mrsk::Configuration.load_file(config_file)
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = MRSK_CONFIG.ssh_options } SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = MRSK_CONFIG.ssh_options }
# No need to use /usr/bin/env, just clogs up the logs
SSHKit.config.command_map[:docker] = "docker"
else
# MRSK is missing config/deploy.yml run 'rake mrsk:init'
MRSK_CONFIG = Mrsk::Configuration.new({}, validate: false)
end

View File

@@ -0,0 +1,21 @@
# Name of your application will be used for uniquely configuring Traefik and app containers.
# Your Dockerfile should set LABEL service=the-same-value to ensure image pruning works.
service: my-app
# Name of the container image
image: user/chat
# All the servers targeted for deploy. You can reference a single server for a command by using SERVERS=xxx.xxx.xxx.xxx
servers:
- xxx.xxx.xxx.xxx
# The following envs are made available to the container when started
env:
# Remember never to put passwords or tokens directly into this file, use encrypted credentials
# REDIS_URL: redis://x/y
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
username: <%= Rails.application.credentials.registry["username"] %>
password: <%= Rails.application.credentials.registry["password"] %>

View File

@@ -4,22 +4,35 @@ traefik = Mrsk::Commands::Traefik.new(MRSK_CONFIG)
namespace :mrsk do namespace :mrsk do
namespace :traefik do namespace :traefik do
desc "Start Traefik" desc "Run Traefik on servers"
task :run do
on(MRSK_CONFIG.role(:web).hosts) { execute *traefik.run, raise_on_non_zero_exit: false }
end
desc "Start existing Traefik on servers"
task :start do task :start do
on(MRSK_CONFIG.servers) { execute traefik.start, raise_on_non_zero_exit: false } on(MRSK_CONFIG.role(:web).hosts) { execute *traefik.start, raise_on_non_zero_exit: false }
end end
desc "Stop Traefik" desc "Stop Traefik on servers"
task :stop do task :stop do
on(MRSK_CONFIG.servers) { execute traefik.stop, raise_on_non_zero_exit: false } on(MRSK_CONFIG.role(:web).hosts) { execute *traefik.stop, raise_on_non_zero_exit: false }
end end
desc "Restart Traefik" desc "Restart Traefik on servers"
task restart: %i[ stop start ] task restart: %i[ stop start ]
desc "Display information about Traefik containers" desc "Display information about Traefik containers from servers"
task :info do task :info do
on(MRSK_CONFIG.servers) { |host| puts "Host: #{host}\n" + capture(traefik.info) + "\n\n" } on(MRSK_CONFIG.role(:web).hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*traefik.info) + "\n\n" }
end
desc "Remove Traefik container and image from servers"
task remove: %i[ stop ] do
on(MRSK_CONFIG.role(:web).hosts) do
execute *traefik.remove_container
execute *traefik.remove_image
end
end end
end end
end end

18
test/app_command_test.rb Normal file
View File

@@ -0,0 +1,18 @@
require "test_helper"
require "mrsk/configuration"
require "mrsk/commands"
ENV["VERSION"] = "123"
ENV["RAILS_MASTER_KEY"] = "456"
class AppCommandTest < ActiveSupport::TestCase
setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config)
end
test "run" do
assert_equal \
[:docker, :run, "-d", "--restart unless-stopped", "--name", "app-123", "-e", "RAILS_MASTER_KEY=456", "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:123"], @app.run
end
end

View File

@@ -0,0 +1,45 @@
require "test_helper"
require "mrsk/configuration"
ENV["VERSION"] = "123"
class ConfigurationRoleTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1", "1.1.1.2" ]
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => {
"hosts" => [ "1.1.1.3", "1.1.1.4" ],
"cmd" => "bin/jobs"
}
}
})
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end
test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.role(:web).hosts
assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.role(:workers).hosts
end
test "label args" do
assert_equal [ "--label", "service=app", "--label", "role=workers" ], @config_with_roles.role(:workers).label_args
end
test "special label args for web" do
assert_equal [ "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms"], @config.role(:web).label_args
end
test "cmd" do
assert_nil @config.role(:web).cmd
assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd
end
end

View File

@@ -2,28 +2,116 @@ require "test_helper"
require "mrsk/configuration" require "mrsk/configuration"
ENV["VERSION"] = "123" ENV["VERSION"] = "123"
ENV["RAILS_MASTER_KEY"] = "456"
class ConfigurationTest < ActiveSupport::TestCase class ConfigurationTest < ActiveSupport::TestCase
setup do setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" } } @deploy = {
service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" },
env: { "REDIS_URL" => "redis://x/y" },
servers: [ "1.1.1.1", "1.1.1.2" ]
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.3", "1.1.1.4" ] } } })
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end end
test "ensure valid keys" do test "ensure valid keys" do
assert_raise(ArgumentError) do assert_raise(ArgumentError) do
Mrsk::Configuration.new(@config.tap { _1.delete(:service) }) Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) })
Mrsk::Configuration.new(@config.tap { _1.delete(:image) }) Mrsk::Configuration.new(@deploy.tap { _1.delete(:image) })
Mrsk::Configuration.new(@config.tap { _1.delete(:registry) }) Mrsk::Configuration.new(@deploy.tap { _1.delete(:registry) })
Mrsk::Configuration.new(@config.tap { _1[:registry].delete("username") }) Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("username") })
Mrsk::Configuration.new(@config.tap { _1[:registry].delete("password") }) Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("password") })
end end
end end
test "absolute image" do test "roles" do
configuration = Mrsk::Configuration.new(@config) assert_equal %w[ web ], @config.roles.collect(&:name)
assert_equal "dhh/app:123", configuration.absolute_image assert_equal %w[ web workers ], @config_with_roles.roles.collect(&:name)
end
configuration = Mrsk::Configuration.new(@config.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) test "role" do
assert_equal "ghcr.io/dhh/app:123", configuration.absolute_image assert_equal "web", @config.role(:web).name
assert_equal "workers", @config_with_roles.role(:workers).name
assert_nil @config.role(:missing)
end
test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2"], @config.hosts
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts
end
test "hosts from ENV" do
ENV["HOSTS"] = "1.1.1.5,1.1.1.6"
assert_equal [ "1.1.1.5", "1.1.1.6"], @config.hosts
ensure
ENV["HOSTS"] = nil
end
test "hosts from ENV roles" do
ENV["ROLES"] = "web,workers"
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts
ENV["ROLES"] = "workers"
assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts
ensure
ENV["ROLES"] = nil
end
test "primary host" do
assert_equal "1.1.1.1", @config.primary_host
assert_equal "1.1.1.1", @config_with_roles.primary_host
end
test "version" do
assert_equal "123", @config.version
end
test "repository" do
assert_equal "dhh/app", @config.repository
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app", config.repository
end
test "absolute image" do
assert_equal "dhh/app:123", @config.absolute_image
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app:123", config.absolute_image
end
test "service with version" do
assert_equal "app-123", @config.service_with_version
end
test "env args" do
assert_equal [ "-e", "REDIS_URL=redis://x/y" ], @config.env_args
end
test "ssh options" do
assert_equal "root", @config.ssh_options[:user]
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:ssh_user] = "app" })
assert_equal "app", @config.ssh_options[:user]
end
test "master key" do
assert_equal "456", @config.master_key
end
test "erb evaluation of yml config" do
config = Mrsk::Configuration.load_file Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__))
assert_equal "my-user", config.registry["username"]
end end
end end

11
test/fixtures/deploy.erb.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
service: app
image: dhh/app
servers:
- 1.1.1.1
- 1.1.1.2
env:
REDIS_URL: redis://x/y
registry:
server: registry.digitalocean.com
username: <%= "my-user" %>
password: <%= "my-password" %>

View File

@@ -1,6 +1,7 @@
require "bundler/setup" require "bundler/setup"
require "active_support/test_case" require "active_support/test_case"
require "active_support/testing/autorun" require "active_support/testing/autorun"
require "debug"
ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"]