shuttlebay 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Cargo.lock +24 -24
- data/README.md +77 -6
- data/docs/guide.md +282 -0
- data/ext/shuttlebay/Cargo.toml +4 -4
- data/ext/shuttlebay/src/codec.rs +140 -29
- data/ext/shuttlebay/src/interned.rs +93 -0
- data/ext/shuttlebay/src/lib.rs +16 -0
- data/lib/shuttlebay/cache.rb +61 -0
- data/lib/shuttlebay/engine.rb +25 -0
- data/lib/shuttlebay/handler.rb +8 -6
- data/lib/shuttlebay/logger.rb +5 -0
- data/lib/shuttlebay/railtie.rb +42 -1
- data/lib/shuttlebay/runtime.rb +45 -3
- data/lib/shuttlebay/server.rb +80 -29
- data/lib/shuttlebay/version.rb +1 -1
- data/lib/shuttlebay/worker.rb +2 -2
- data/lib/shuttlebay.rb +1 -0
- metadata +21 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65929907b2e26eb80f96fbb87c41903626e320d301d8cc0e0bd36ddbe29228ae
|
|
4
|
+
data.tar.gz: c4ce7ea0de33c5acb37315645b895a25654f51699c283533aa07bb75a01ce408
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3258cb81038a1687dfccf846fceb21fd517ff4bf89f383fcf5668de0b558da2834d8be5be9c0f61f8b23e4eb01cec508f6637407665f7ebb520b0335be0c36e1
|
|
7
|
+
data.tar.gz: 9986d6fa8615b58f4e08b7f205e2cdee43e5dbae170f7287cf6ef842dd3cfe991900b78ee724f57d74cfcd528502a66853b8b786864878c721012ebf3185c68f
|
data/Cargo.lock
CHANGED
|
@@ -69,9 +69,9 @@ dependencies = [
|
|
|
69
69
|
|
|
70
70
|
[[package]]
|
|
71
71
|
name = "either"
|
|
72
|
-
version = "1.
|
|
72
|
+
version = "1.19.0"
|
|
73
73
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
74
|
-
checksum = "
|
|
74
|
+
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|
75
75
|
|
|
76
76
|
[[package]]
|
|
77
77
|
name = "glob"
|
|
@@ -102,9 +102,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
|
|
102
102
|
|
|
103
103
|
[[package]]
|
|
104
104
|
name = "libc"
|
|
105
|
-
version = "0.2.
|
|
105
|
+
version = "0.2.191"
|
|
106
106
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
107
|
-
checksum = "
|
|
107
|
+
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
|
108
108
|
|
|
109
109
|
[[package]]
|
|
110
110
|
name = "libloading"
|
|
@@ -118,9 +118,9 @@ dependencies = [
|
|
|
118
118
|
|
|
119
119
|
[[package]]
|
|
120
120
|
name = "magnus"
|
|
121
|
-
version = "0.9.
|
|
121
|
+
version = "0.9.3"
|
|
122
122
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
123
|
-
checksum = "
|
|
123
|
+
checksum = "40ef424404d166cbb71c8b8d19c9c589c748c4547c1afa1928c5504299f6c713"
|
|
124
124
|
dependencies = [
|
|
125
125
|
"bytes",
|
|
126
126
|
"magnus-macros",
|
|
@@ -154,9 +154,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|
|
154
154
|
|
|
155
155
|
[[package]]
|
|
156
156
|
name = "mothership-docking-protocol"
|
|
157
|
-
version = "0.
|
|
157
|
+
version = "0.3.0"
|
|
158
158
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
159
|
-
checksum = "
|
|
159
|
+
checksum = "e43cb62ea01813b2fe88036c2e65c724a0020f97139023f4b8aea5e78626461b"
|
|
160
160
|
dependencies = [
|
|
161
161
|
"serde",
|
|
162
162
|
"serde_json",
|
|
@@ -289,14 +289,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|
|
289
289
|
dependencies = [
|
|
290
290
|
"proc-macro2",
|
|
291
291
|
"quote",
|
|
292
|
-
"syn 3.0.
|
|
292
|
+
"syn 3.0.9",
|
|
293
293
|
]
|
|
294
294
|
|
|
295
295
|
[[package]]
|
|
296
296
|
name = "serde_json"
|
|
297
|
-
version = "1.0.
|
|
297
|
+
version = "1.0.154"
|
|
298
298
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
299
|
-
checksum = "
|
|
299
|
+
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
|
300
300
|
dependencies = [
|
|
301
301
|
"itoa",
|
|
302
302
|
"memchr",
|
|
@@ -319,7 +319,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
|
|
319
319
|
|
|
320
320
|
[[package]]
|
|
321
321
|
name = "shuttlebay"
|
|
322
|
-
version = "0.1.
|
|
322
|
+
version = "0.1.4"
|
|
323
323
|
dependencies = [
|
|
324
324
|
"magnus",
|
|
325
325
|
"mothership-docking-protocol",
|
|
@@ -330,9 +330,9 @@ dependencies = [
|
|
|
330
330
|
|
|
331
331
|
[[package]]
|
|
332
332
|
name = "state-machines"
|
|
333
|
-
version = "0.
|
|
333
|
+
version = "0.32.1"
|
|
334
334
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
335
|
-
checksum = "
|
|
335
|
+
checksum = "f9b6f45e43f20a6eba0d7c03779b53775e0d9b40a452b89e55ea7aae87fa7a14"
|
|
336
336
|
dependencies = [
|
|
337
337
|
"state-machines-core",
|
|
338
338
|
"state-machines-macro",
|
|
@@ -340,19 +340,19 @@ dependencies = [
|
|
|
340
340
|
|
|
341
341
|
[[package]]
|
|
342
342
|
name = "state-machines-core"
|
|
343
|
-
version = "0.
|
|
343
|
+
version = "0.13.1"
|
|
344
344
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
345
|
-
checksum = "
|
|
345
|
+
checksum = "f7a38d0afcf79a49438b4342ea90beec6490664a0725b2932f6e816d83bc46fe"
|
|
346
346
|
|
|
347
347
|
[[package]]
|
|
348
348
|
name = "state-machines-macro"
|
|
349
|
-
version = "0.
|
|
349
|
+
version = "0.32.1"
|
|
350
350
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
351
|
-
checksum = "
|
|
351
|
+
checksum = "604a644fc18fffd8845eb7f48e332d79a24750ac9d463a39d9b3cb10c44fcc28"
|
|
352
352
|
dependencies = [
|
|
353
353
|
"proc-macro2",
|
|
354
354
|
"quote",
|
|
355
|
-
"syn
|
|
355
|
+
"syn 3.0.9",
|
|
356
356
|
]
|
|
357
357
|
|
|
358
358
|
[[package]]
|
|
@@ -368,9 +368,9 @@ dependencies = [
|
|
|
368
368
|
|
|
369
369
|
[[package]]
|
|
370
370
|
name = "syn"
|
|
371
|
-
version = "3.0.
|
|
371
|
+
version = "3.0.9"
|
|
372
372
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
373
|
-
checksum = "
|
|
373
|
+
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
|
374
374
|
dependencies = [
|
|
375
375
|
"proc-macro2",
|
|
376
376
|
"quote",
|
|
@@ -394,14 +394,14 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
|
|
394
394
|
dependencies = [
|
|
395
395
|
"proc-macro2",
|
|
396
396
|
"quote",
|
|
397
|
-
"syn 3.0.
|
|
397
|
+
"syn 3.0.9",
|
|
398
398
|
]
|
|
399
399
|
|
|
400
400
|
[[package]]
|
|
401
401
|
name = "unicode-ident"
|
|
402
|
-
version = "1.0.
|
|
402
|
+
version = "1.0.27"
|
|
403
403
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
404
|
-
checksum = "
|
|
404
|
+
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
|
405
405
|
|
|
406
406
|
[[package]]
|
|
407
407
|
name = "windows-link"
|
data/README.md
CHANGED
|
@@ -15,6 +15,10 @@ flowchart TD
|
|
|
15
15
|
master --> w1["worker 1<br/>threads"]
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
**New here?** [docs/guide.md](docs/guide.md) walks a Rails app from install to
|
|
19
|
+
production: the manifest, coming from Puma, moving `Rack::Attack` rules,
|
|
20
|
+
caching, TLS and HTTP/2, Docker and Kamal, several hosts.
|
|
21
|
+
|
|
18
22
|
## Install
|
|
19
23
|
|
|
20
24
|
```ruby
|
|
@@ -57,9 +61,59 @@ bundle exec mothership
|
|
|
57
61
|
| Real client IP | `RemoteIp` + proxy config | `REMOTE_ADDR` resolved (Forwarded / PROXY protocol) |
|
|
58
62
|
| Load balancing to workers | kernel accept / `wait_for_less_busy_worker` | idle-thread checkout; queueing in Mothership |
|
|
59
63
|
| Request body buffering | App server | Mothership reads the whole body before claiming a thread |
|
|
64
|
+
| `ETag` / `If-None-Match` → 304 | `Rack::ETag`, `Rack::ConditionalGet` | Mothership hashes complete bodies and answers 304 |
|
|
65
|
+
| IP allowlists, rate limits | `Rack::Attack` | `ip_allowlist`, `rate_limits`, `trusted_proxies` in the manifest |
|
|
66
|
+
| Page / API caching | CDN, `Rack::Cache` | `[mothership.response_cache]`, purged by tag |
|
|
67
|
+
|
|
68
|
+
When Rails is loaded, the bundled Railtie removes the middleware Mothership
|
|
69
|
+
makes redundant:
|
|
70
|
+
|
|
71
|
+
- `Rack::Sendfile`: the engine offloads `to_path` bodies itself.
|
|
72
|
+
- `ActionDispatch::Static`: when Mothership serves the app's `public/` as a
|
|
73
|
+
static dir (`MS_STATIC_ROOTS`). `config.public_file_server.headers` it
|
|
74
|
+
does not send (per `MS_STATIC_HEADERS`) are logged at boot: move them to
|
|
75
|
+
the static dir's `headers` in the manifest.
|
|
76
|
+
- `Rack::ETag` and `Rack::ConditionalGet`: when Mothership validates
|
|
77
|
+
conditionals (`MS_CONDITIONAL_GET=1`), so bodies are not hashed twice.
|
|
78
|
+
|
|
79
|
+
Other Rack apps (Sinatra, Roda, Hanami) ask the same in `config.ru`;
|
|
80
|
+
`Shuttlebay.offloaded?` is false under any other server:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
use Rack::ETag unless Shuttlebay.offloaded?(:conditional_get)
|
|
84
|
+
use Rack::ConditionalGet unless Shuttlebay.offloaded?(:conditional_get)
|
|
85
|
+
use Rack::Static, urls: [""], root: "public", cascade: true unless Shuttlebay.offloaded?(:static, "public")
|
|
86
|
+
use Rack::Sendfile unless Shuttlebay.offloaded?(:sendfile)
|
|
87
|
+
run App
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Rate limiting and allowlists in `Rack::Attack` are app configuration: drop
|
|
91
|
+
them yourself once the manifest enforces the same rules.
|
|
92
|
+
|
|
93
|
+
## Caching at the edge
|
|
94
|
+
|
|
95
|
+
Mothership's response cache keeps entries until their data changes when the
|
|
96
|
+
app tags responses with what they show and purges the tags on change:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class BooksController < ApplicationController
|
|
100
|
+
def show
|
|
101
|
+
@book = Book.find(params[:id])
|
|
102
|
+
Shuttlebay::Cache.tag(response.headers, "book-#{@book.id}", "books")
|
|
103
|
+
expires_in 1.hour, public: true # or a manifest rule
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Book < ApplicationRecord
|
|
108
|
+
after_commit { Shuttlebay::Cache.purge(tags: ["book-#{id}", "books"]) }
|
|
109
|
+
end
|
|
110
|
+
```
|
|
60
111
|
|
|
61
|
-
|
|
62
|
-
|
|
112
|
+
`purge` also takes `urls:` (path and query), `prefixes:`, `all: true`, and
|
|
113
|
+
`soft: true` (mark stale and revalidate instead of dropping). It posts to
|
|
114
|
+
`MS_ADMIN_URL`, returns the number purged, is a no-op returning `nil` where
|
|
115
|
+
there is no Mothership, and raises `Shuttlebay::Error` when Mothership refuses
|
|
116
|
+
(e.g. no cache configured) or a network error when it is unreachable.
|
|
63
117
|
|
|
64
118
|
## Hooks
|
|
65
119
|
|
|
@@ -67,7 +121,7 @@ engine offloads `to_path` bodies itself.
|
|
|
67
121
|
|
|
68
122
|
```ruby
|
|
69
123
|
Shuttlebay.before_fork { ActiveRecord::Base.connection_pool.disconnect! }
|
|
70
|
-
Shuttlebay.on_worker_boot { |index| Rails.logger.info("worker #{index} up") }
|
|
124
|
+
Shuttlebay.on_worker_boot { |index, ship| Rails.logger.info("#{ship} worker #{index} up") }
|
|
71
125
|
Shuttlebay.on_worker_shutdown { |index| Rails.logger.info("worker #{index} drained") }
|
|
72
126
|
Shuttlebay.out_of_band { GC.start } # runs when a worker has no request in flight
|
|
73
127
|
run Rails.application
|
|
@@ -152,18 +206,35 @@ For HTTPS, run Mothership with a `tls` bind and the app as a docking upstream
|
|
|
152
206
|
- **Large uploads:** bodies over 1 MiB are spooled to an unlinked temp file
|
|
153
207
|
(`rack.input` is a `Tempfile`). Memory stays bounded however large uploads
|
|
154
208
|
are allowed to be.
|
|
209
|
+
- **Warmup:** the master calls `Process.warmup` once the app is loaded, so
|
|
210
|
+
workers fork from a compacted heap. `Shuttlebay.warmup = false` in
|
|
211
|
+
`config.ru` skips it.
|
|
212
|
+
- **Forks:** a `fork_from` bay in the manifest is served by this master: it
|
|
213
|
+
loads that bay's app too and forks its workers, sharing the preloaded heap.
|
|
214
|
+
A fork app that fails to load is logged and skipped. Each worker serves one
|
|
215
|
+
bay: it holds only that bay's socket, logs and sees (`MS_SHIP`) its name,
|
|
216
|
+
and hooks get it as their second argument (`|index, ship|`; hooks taking
|
|
217
|
+
only the index keep working).
|
|
155
218
|
- **Signals:** on `TERM`, workers stop accepting, drop idle links, and finish
|
|
156
219
|
in-flight requests within `MS_BAY_DRAIN_TIMEOUT` (Mothership passes the
|
|
157
220
|
bay's `request_timeout`). A dead worker is re-forked with backoff.
|
|
158
221
|
- **Deploys:** `kill -USR1` the mothership process to roll the bay to new code
|
|
159
222
|
with no dropped requests (phased restart).
|
|
160
|
-
- **TLS:** terminated by Mothership on a `tls` bind
|
|
161
|
-
`rack.url_scheme = "https"`.
|
|
223
|
+
- **TLS:** terminated by Mothership on a `tls` bind (guardship 0.2.1 and
|
|
224
|
+
later ship with TLS); the app sees `rack.url_scheme = "https"`.
|
|
225
|
+
- **HTTP/2:** browsers get it from Mothership's `tls` binds (ALPN), or from
|
|
226
|
+
the CDN or proxy in front; cleartext binds also take HTTP/2 with prior
|
|
227
|
+
knowledge. The app is unaffected: every request reaches it the same way,
|
|
228
|
+
as a Rack env, whatever the client spoke.
|
|
162
229
|
- **Logs:** JSON lines on stdout, which Mothership passes through.
|
|
163
230
|
|
|
164
231
|
## Environment (set by Mothership)
|
|
165
232
|
|
|
166
|
-
`MS_SOCKET_PATH`, `MS_SHIP`, `MS_BAY_WORKERS`, `MS_BAY_THREADS`, `MS_BAY_DRAIN_TIMEOUT
|
|
233
|
+
`MS_SOCKET_PATH`, `MS_SHIP`, `MS_BAY_WORKERS`, `MS_BAY_THREADS`, `MS_BAY_DRAIN_TIMEOUT`,
|
|
234
|
+
`MS_STATIC_ROOTS` (directories Mothership serves at `/`, one per line),
|
|
235
|
+
`MS_STATIC_HEADERS` (the headers their files carry, JSON by path),
|
|
236
|
+
`MS_CONDITIONAL_GET=1` (Mothership answers conditionals),
|
|
237
|
+
`MS_ADMIN_URL` (admin endpoint for cache purges).
|
|
167
238
|
|
|
168
239
|
## Development
|
|
169
240
|
|
data/docs/guide.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Running a Rails app on Shuttlebay
|
|
2
|
+
|
|
3
|
+
From `rails new` (or an app on Puma) to production behind Mothership. Any
|
|
4
|
+
Rack app works the same way; the Rails-specific parts are marked.
|
|
5
|
+
|
|
6
|
+
Mothership is the HTTP server: it terminates connections, serves `public/`,
|
|
7
|
+
compresses, limits, caches and routes. Shuttlebay runs your app on threads
|
|
8
|
+
that Mothership hands finished requests to. There is no Puma, no Thruster
|
|
9
|
+
and no nginx left in the request path.
|
|
10
|
+
|
|
11
|
+
## 1. Install
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "shuttlebay"
|
|
16
|
+
gem "guardship" # the mothership binary, prebuilt for macOS, Linux, FreeBSD 15+
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle install
|
|
21
|
+
bundle exec mothership --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Keep Puma in the Gemfile if you like: nothing below removes it, and
|
|
25
|
+
`rails server` without `-u` still uses it.
|
|
26
|
+
|
|
27
|
+
## 2. Development and system tests
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bin/rails server -u shuttlebay
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# test/application_system_test_case.rb
|
|
35
|
+
require "shuttlebay/capybara"
|
|
36
|
+
Capybara.server = :shuttlebay
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both start a Mothership attached to the app, so what you test is the stack
|
|
40
|
+
you deploy.
|
|
41
|
+
|
|
42
|
+
## 3. The manifest
|
|
43
|
+
|
|
44
|
+
Mothership reads `ship-manifest.toml` from the directory it starts in:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
[mothership]
|
|
48
|
+
compression = true
|
|
49
|
+
|
|
50
|
+
[mothership.bind]
|
|
51
|
+
http = "0.0.0.0:3000"
|
|
52
|
+
|
|
53
|
+
[[mothership.static_dirs]]
|
|
54
|
+
path = "./public"
|
|
55
|
+
prefix = "/"
|
|
56
|
+
headers = { "cache-control" = "public, max-age=31536000" }
|
|
57
|
+
|
|
58
|
+
[[bays.http]]
|
|
59
|
+
name = "web"
|
|
60
|
+
command = "bundle"
|
|
61
|
+
args = ["exec", "shuttlebay", "config.ru"]
|
|
62
|
+
env = { RAILS_ENV = "production" }
|
|
63
|
+
workers = "auto"
|
|
64
|
+
threads = 5
|
|
65
|
+
routes = [{ bind = "http", pattern = "/.*" }]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bundle exec mothership clearance # validate the manifest
|
|
70
|
+
bundle exec mothership chart # what is routed where
|
|
71
|
+
bundle exec mothership # run
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `workers = "auto"` forks one worker per CPU the process may use, as many as
|
|
75
|
+
memory affords (512 MiB each); a 1 CPU or 1 GiB container serves from one
|
|
76
|
+
process. A number, or `"$WEB_CONCURRENCY"`, works too.
|
|
77
|
+
- `threads` is the threads per worker, each with its own docking link to
|
|
78
|
+
Mothership. A request goes only to an idle thread; when all are busy it
|
|
79
|
+
waits in Mothership (`queue_timeout`, 30 s, then `503`).
|
|
80
|
+
- The static dir serves `public/` before any request reaches Rails, `/about`
|
|
81
|
+
from `about.html` and precompressed `.br`/`.gz` files included. The
|
|
82
|
+
Railtie sees that and drops `ActionDispatch::Static`. Give it the headers
|
|
83
|
+
your `config.public_file_server.headers` sets: the Railtie logs a warning at
|
|
84
|
+
boot for any it does not see there.
|
|
85
|
+
|
|
86
|
+
## 4. Coming from Puma
|
|
87
|
+
|
|
88
|
+
| `config/puma.rb` | With Shuttlebay |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `workers ENV.fetch("WEB_CONCURRENCY")` | `workers = "$WEB_CONCURRENCY"` (or `"auto"`) |
|
|
91
|
+
| `threads n, n` / `RAILS_MAX_THREADS` | `threads = "$RAILS_MAX_THREADS"` |
|
|
92
|
+
| `port` / `bind` | `[mothership.bind]` |
|
|
93
|
+
| `preload_app!` | always: `config.ru` loads in the master, then workers fork |
|
|
94
|
+
| `before_fork`, `on_worker_boot`, `on_worker_shutdown`, `out_of_band` | `Shuttlebay.before_fork { }` and friends, in `config.ru` |
|
|
95
|
+
| `worker_timeout` | the bay's `request_timeout` (`504`, then the stuck worker is recycled) |
|
|
96
|
+
| `pumactl phased-restart` / `plugin :tmp_restart` | `kill -USR1` the mothership process |
|
|
97
|
+
| `wait_for_less_busy_worker` | built in: requests only go to idle threads |
|
|
98
|
+
|
|
99
|
+
Hooks go in `config.ru`, before `run`:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# config.ru
|
|
103
|
+
require_relative "config/environment"
|
|
104
|
+
|
|
105
|
+
Shuttlebay.on_worker_boot { |index| Rails.logger.info("worker #{index} up") }
|
|
106
|
+
Shuttlebay.out_of_band { GC.start } # when a worker has no request in flight
|
|
107
|
+
|
|
108
|
+
run Rails.application
|
|
109
|
+
Rails.application.load_server
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`config/puma.rb` can stay for `rails server`; Shuttlebay never reads it.
|
|
113
|
+
|
|
114
|
+
## 5. Let Mothership do the middleware's work
|
|
115
|
+
|
|
116
|
+
Under Mothership the Railtie removes, on its own, what Mothership already does:
|
|
117
|
+
`Rack::Sendfile`, `ActionDispatch::Static` (when the static dir serves
|
|
118
|
+
`public/`) and `Rack::ETag` / `Rack::ConditionalGet` (Mothership tags pages and
|
|
119
|
+
answers revalidations itself, off the GVL). Nothing changes under Puma.
|
|
120
|
+
|
|
121
|
+
`Rack::Attack` rules are your configuration, so you move them yourself:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# config/initializers/rack_attack.rb, before
|
|
125
|
+
Rack::Attack.throttle("api", limit: 60, period: 1.minute) do |req|
|
|
126
|
+
req.ip if req.path.start_with?("/api/")
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```toml
|
|
131
|
+
# ship-manifest.toml, after
|
|
132
|
+
[[mothership.rate_limits]]
|
|
133
|
+
pattern = "^/api/"
|
|
134
|
+
limit = 60
|
|
135
|
+
period = "1m"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Mothership keeps the counts in memory, with no cache-store write per request,
|
|
139
|
+
and answers `429` with `Retry-After`. Allowlists move to
|
|
140
|
+
`[[mothership.ip_allowlist]]`; see the Mothership README for both.
|
|
141
|
+
|
|
142
|
+
## 6. Behind a CDN or a proxy
|
|
143
|
+
|
|
144
|
+
Behind Cloudflare, Kamal proxy or a load balancer, tell Mothership which
|
|
145
|
+
proxies to see through, so allowlists and rate limits apply to the visitor,
|
|
146
|
+
not to the proxy:
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[mothership]
|
|
150
|
+
trusted_proxies = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] # + your CDN's ranges
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The app's `REMOTE_ADDR` is the address that connected to Mothership (or the
|
|
154
|
+
PROXY-protocol one, with `proxy_protocol = true` on the bind), and the
|
|
155
|
+
forwarding headers pass through. Rails resolves `request.remote_ip` from them
|
|
156
|
+
as it always has: keep the configuration you need for that today (for
|
|
157
|
+
Cloudflare, `cloudflare-rails`).
|
|
158
|
+
|
|
159
|
+
## 7. Caching
|
|
160
|
+
|
|
161
|
+
Mothership can cache whole responses in memory, the way a CDN does, purged by
|
|
162
|
+
tag when the data changes:
|
|
163
|
+
|
|
164
|
+
```toml
|
|
165
|
+
[mothership]
|
|
166
|
+
metrics_port = 9090 # also serves the purge endpoint, on 127.0.0.1
|
|
167
|
+
|
|
168
|
+
[mothership.response_cache]
|
|
169
|
+
status_header = true # Cache-Status on responses
|
|
170
|
+
|
|
171
|
+
[[mothership.response_cache.rules]]
|
|
172
|
+
pattern = "^/api/"
|
|
173
|
+
ttl = "5m"
|
|
174
|
+
stale = "1m"
|
|
175
|
+
bypass_cookies = ["_myapp_session"] # signed-in users skip the cache
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
class BooksController < ApplicationController
|
|
180
|
+
def show
|
|
181
|
+
@book = Book.find(params[:id])
|
|
182
|
+
Shuttlebay::Cache.tag(response.headers, "book-#{@book.id}")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class Book < ApplicationRecord
|
|
187
|
+
after_commit { Shuttlebay::Cache.purge(tags: ["book-#{id}"]) }
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A successful `POST`/`PUT`/`PATCH`/`DELETE` through Mothership already
|
|
192
|
+
invalidates its own URL. Requests with `Authorization`, or with a cookie a rule
|
|
193
|
+
does not let through, never touch a rule's cache. The README's caching section
|
|
194
|
+
covers the rest.
|
|
195
|
+
|
|
196
|
+
## 8. TLS and HTTP/2
|
|
197
|
+
|
|
198
|
+
Behind a CDN or Kamal proxy, they terminate TLS and speak HTTP/2 to browsers;
|
|
199
|
+
Mothership listens on plain HTTP behind them. To terminate TLS in Mothership
|
|
200
|
+
itself (guardship 0.2.1 and later):
|
|
201
|
+
|
|
202
|
+
```toml
|
|
203
|
+
[mothership.bind]
|
|
204
|
+
http = "0.0.0.0:80"
|
|
205
|
+
https = { addr = "0.0.0.0:443", tls = { cert = "/etc/letsencrypt/live/app/fullchain.pem", key = "/etc/letsencrypt/live/app/privkey.pem" } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Browsers then get HTTP/2 (negotiated over ALPN), the app sees
|
|
209
|
+
`rack.url_scheme = "https"`, and renewed certificates are picked up without a
|
|
210
|
+
restart.
|
|
211
|
+
|
|
212
|
+
## 9. Docker and Kamal
|
|
213
|
+
|
|
214
|
+
Mothership replaces `./bin/thrust ./bin/rails server` in the Dockerfile Rails
|
|
215
|
+
generates:
|
|
216
|
+
|
|
217
|
+
```dockerfile
|
|
218
|
+
EXPOSE 80
|
|
219
|
+
CMD ["bundle", "exec", "mothership"]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
with the bind on port 80 (`http = "0.0.0.0:80"`), where kamal-proxy expects
|
|
223
|
+
the app. Rails' `bin/docker-entrypoint` prepares the database only when the
|
|
224
|
+
command is `./bin/rails server`, so run that as a prelaunch job instead:
|
|
225
|
+
|
|
226
|
+
```toml
|
|
227
|
+
[[mothership.prelaunch]]
|
|
228
|
+
name = "db-prepare"
|
|
229
|
+
command = "bin/rails"
|
|
230
|
+
args = ["db:prepare"]
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
# config/deploy.yml
|
|
235
|
+
proxy:
|
|
236
|
+
host: app.example.com
|
|
237
|
+
ssl: true
|
|
238
|
+
healthcheck:
|
|
239
|
+
path: /up
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Mothership runs fine as PID 1: on Linux it reaps and forwards signals itself
|
|
243
|
+
(`docker run --init` is still the safer default elsewhere). Kamal deploys by
|
|
244
|
+
starting a new container and moving traffic to it once `/up` answers, so
|
|
245
|
+
phased restarts (`kill -USR1`) are for servers you deploy to in place.
|
|
246
|
+
|
|
247
|
+
On several hosts:
|
|
248
|
+
|
|
249
|
+
- Run prelaunch jobs (migrations) once: tag one host and elect it.
|
|
250
|
+
|
|
251
|
+
```yaml
|
|
252
|
+
servers:
|
|
253
|
+
web:
|
|
254
|
+
- 10.0.0.5: flagship
|
|
255
|
+
- 10.0.0.6
|
|
256
|
+
env:
|
|
257
|
+
tags:
|
|
258
|
+
flagship:
|
|
259
|
+
MS_FLAGSHIP: "true"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```toml
|
|
263
|
+
[mothership.flagship]
|
|
264
|
+
enabled = true
|
|
265
|
+
election = "static"
|
|
266
|
+
static_flagship = "$MS_FLAGSHIP"
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- With a response cache, let the hosts relay purges and invalidations to one
|
|
270
|
+
another (`[mothership.response_cache.peers]` in the Mothership README).
|
|
271
|
+
|
|
272
|
+
## 10. Watching it run
|
|
273
|
+
|
|
274
|
+
- `bundle exec mothership health` prints the fleet's health as JSON (needs
|
|
275
|
+
`metrics_port`); `/metrics` serves Prometheus metrics, `mothership_http_bay_*`
|
|
276
|
+
among them.
|
|
277
|
+
- Both Mothership and Shuttlebay log JSON lines to stdout.
|
|
278
|
+
- `503 Starting`: the bay is still booting (`boot_timeout`, 120 s).
|
|
279
|
+
- `503` once running: every thread was busy for `queue_timeout`; add
|
|
280
|
+
`threads` (I/O-bound apps) or `workers` (CPU-bound ones).
|
|
281
|
+
- `504`: no response head within `request_timeout` (60 s); the stuck worker
|
|
282
|
+
is recycled.
|
data/ext/shuttlebay/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "shuttlebay"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0" # x-release-please-version
|
|
4
4
|
edition.workspace = true
|
|
5
5
|
rust-version.workspace = true
|
|
6
6
|
license.workspace = true
|
|
@@ -25,7 +25,7 @@ expect_used = "deny"
|
|
|
25
25
|
|
|
26
26
|
[dependencies]
|
|
27
27
|
magnus = { version = "0.9", features = ["bytes"] }
|
|
28
|
-
rb-sys = "0.9.
|
|
29
|
-
mothership-docking-protocol = "0.
|
|
28
|
+
rb-sys = "0.9.130"
|
|
29
|
+
mothership-docking-protocol = "0.3.0"
|
|
30
30
|
serde_json = "1.0"
|
|
31
|
-
state-machines = "0.
|
|
31
|
+
state-machines = "0.32"
|