Skip to content

Commit c12c6fd

Browse files
committed
add: use SO_REUSEPORT on platform supporting it
1 parent ca17636 commit c12c6fd

27 files changed

Lines changed: 291 additions & 10 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. From versio
1515
- Add config `db-timezone-enabled` for optional querying of timezones by @taimoorzaeem in #4751
1616
- Log schema cache queries timings on `log-level=debug` by @steve-chavez in #4805
1717
- Add GHC runtime metrics to the metrics endpoint by @mkleczek in #4862
18+
- Enable starting multiple PostgREST instances using the same ports on platforms supporting it by @mkleczek in #4703 #4694
1819

1920
### Fixed
2021

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
.. _zero_downtime_upgrades:
2+
3+
Zero-Downtime Upgrades
4+
======================
5+
6+
When :ref:`server-reuseport` is enabled on an operating system that supports
7+
``SO_REUSEPORT``, PostgREST can start more than one process on the same
8+
:ref:`server-host` and :ref:`server-port`. This allows a new PostgREST process
9+
to start and become ready before the old process is stopped.
10+
11+
While both processes are running, the operating system distributes new
12+
connections between them. After the old process exits, the new process receives
13+
all new connections.
14+
15+
This is useful for upgrades and restarts:
16+
17+
1. Keep the old PostgREST process serving requests.
18+
2. Start the new PostgREST process on the same host and port.
19+
3. Wait for the new process to report ``/ready``.
20+
4. Stop the old process.
21+
22+
Configuration
23+
-------------
24+
25+
Both processes should use the same public host and port:
26+
27+
.. code-block:: ini
28+
29+
# /etc/postgrest/postgrest.conf
30+
server-host = "127.0.0.1"
31+
server-port = 3000
32+
server-reuseport = true
33+
34+
admin-server-host = "127.0.0.1"
35+
admin-server-port = 3001
36+
37+
The second process can use the same configuration file and override only the
38+
admin server port:
39+
40+
.. code-block:: bash
41+
42+
PGRST_ADMIN_SERVER_PORT=3002 postgrest /etc/postgrest/postgrest.conf
43+
44+
.. important::
45+
46+
Use a different :ref:`admin-server-port` for each PostgREST process during
47+
the handover. Admin ports are not shared between processes. This keeps
48+
readiness checks unambiguous: ``/ready`` on the new admin port can only be
49+
answered by the new process.
50+
51+
Before using this in production, keep these details in mind:
52+
53+
- This works for host and port based servers. It does not apply when
54+
:ref:`server-unix-socket` is used.
55+
- If :ref:`server-reuseport` is disabled, the new process will fail to start
56+
with an address-in-use error and the old process will keep serving requests.
57+
- If :ref:`server-reuseport` is enabled on an operating system that does not
58+
support ``SO_REUSEPORT``, PostgREST will fail to start because the
59+
configuration is not supported on that platform.
60+
- If the new process uses the same :ref:`admin-server-port` as the old process,
61+
it will fail to start because that admin port is already in use.
62+
- Each PostgREST process has its own :ref:`db-pool`. During the handover, the
63+
total possible database connections can temporarily double.
64+
- The old and new processes may both serve requests for a short time. Database
65+
migrations should be compatible with both versions while they overlap.
66+
67+
Manual Handover
68+
---------------
69+
70+
Assuming the old process is already serving on ``127.0.0.1:3000`` and its PID
71+
is stored in ``OLD_PID``:
72+
73+
.. code-block:: bash
74+
75+
PGRST_ADMIN_SERVER_PORT=3002 postgrest /etc/postgrest/postgrest.conf &
76+
NEW_PID=$!
77+
78+
curl --fail http://127.0.0.1:3002/ready
79+
80+
kill -TERM "$OLD_PID"
81+
82+
The ``curl`` request checks the new process through its own admin server port.
83+
If the new process cannot load its configuration, connect to the database, or
84+
load the schema cache, ``/ready`` will not return a successful response and the
85+
old process can keep serving traffic.
86+
87+
Example Script
88+
--------------
89+
90+
The following script shows the full sequence for a setup that stores the old
91+
process PID in a PID file. Adapt the start and stop commands to your process
92+
manager.
93+
94+
.. code-block:: bash
95+
96+
#!/usr/bin/env bash
97+
set -euo pipefail
98+
99+
POSTGREST=${POSTGREST:-postgrest}
100+
CONFIG=${CONFIG:-/etc/postgrest/postgrest.conf}
101+
PID_FILE=${PID_FILE:-/run/postgrest.pid}
102+
103+
ADMIN_HOST=${ADMIN_HOST:-127.0.0.1}
104+
NEW_ADMIN_PORT=${NEW_ADMIN_PORT:-3002}
105+
READY_TIMEOUT=${READY_TIMEOUT:-30}
106+
STOP_TIMEOUT=${STOP_TIMEOUT:-30}
107+
108+
if [[ ! -s "$PID_FILE" ]]; then
109+
echo "PID file not found or empty: $PID_FILE" >&2
110+
exit 1
111+
fi
112+
113+
OLD_PID=$(<"$PID_FILE")
114+
115+
if ! kill -0 "$OLD_PID" 2>/dev/null; then
116+
echo "Old PostgREST process is not running: $OLD_PID" >&2
117+
exit 1
118+
fi
119+
120+
PGRST_ADMIN_SERVER_HOST="$ADMIN_HOST" \
121+
PGRST_ADMIN_SERVER_PORT="$NEW_ADMIN_PORT" \
122+
"$POSTGREST" "$CONFIG" &
123+
NEW_PID=$!
124+
125+
cleanup_new_process() {
126+
kill "$NEW_PID" 2>/dev/null || true
127+
}
128+
trap cleanup_new_process EXIT INT TERM
129+
130+
READY_URL="http://$ADMIN_HOST:$NEW_ADMIN_PORT/ready"
131+
READY_DEADLINE=$((SECONDS + READY_TIMEOUT))
132+
133+
until curl --fail --silent --show-error --output /dev/null "$READY_URL"; do
134+
if ! kill -0 "$NEW_PID" 2>/dev/null; then
135+
echo "New PostgREST process exited before it became ready" >&2
136+
exit 1
137+
fi
138+
139+
if (( SECONDS >= READY_DEADLINE )); then
140+
echo "New PostgREST process did not become ready at $READY_URL" >&2
141+
exit 1
142+
fi
143+
144+
sleep 1
145+
done
146+
147+
printf '%s\n' "$NEW_PID" > "$PID_FILE"
148+
149+
kill -TERM "$OLD_PID" 2>/dev/null || true
150+
151+
STOP_DEADLINE=$((SECONDS + STOP_TIMEOUT))
152+
153+
while kill -0 "$OLD_PID" 2>/dev/null; do
154+
if (( SECONDS >= STOP_DEADLINE )); then
155+
echo "Old PostgREST process did not stop after SIGTERM; sending SIGKILL" >&2
156+
kill -KILL "$OLD_PID"
157+
break
158+
fi
159+
160+
sleep 1
161+
done
162+
163+
trap - EXIT INT TERM
164+
echo "PostgREST handover complete: $OLD_PID -> $NEW_PID"

docs/postgrest.dict

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ DSL
3434
DevOps
3535
Dramatiq
3636
dockerize
37+
downtime
3738
enum
3839
Enums
3940
Entra
@@ -59,6 +60,7 @@ HMAC
5960
htmx
6061
Htmx
6162
Homebrew
63+
handover
6264
hstore
6365
HTTP
6466
HTTPS
@@ -113,6 +115,7 @@ ov
113115
parametrized
114116
passphrase
115117
PBKDF
118+
PID
116119
PgBouncer
117120
pgcrypto
118121
pgjwt
@@ -144,6 +147,7 @@ Redux
144147
refactor
145148
reloadable
146149
Reloadable
150+
reuseport
147151
requester's
148152
RESTful
149153
RLS

docs/references/admin_server.rst

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@ Two endpoints ``live`` and ``ready`` will then be available. Both these endpoint
1616

1717
.. important::
1818

19-
If you have a machine with multiple network interfaces and multiple PostgREST instances in the same port, you need to specify a unique :ref:`hostname <server-host>`
20-
in the configuration of each PostgREST instance for the health check to work correctly. Don't use the special values(``!4``, ``*``, etc) in this case because the health check
21-
could report a false positive.
19+
Multiple PostgREST instances can share the same public API host and port when
20+
:ref:`server-reuseport` is enabled on operating systems that support
21+
``SO_REUSEPORT``. Admin ports are not shared: give each instance a different
22+
:ref:`admin-server-port`, otherwise the new instance will fail to start.
23+
24+
If the machine has multiple network interfaces, configure concrete
25+
:ref:`server-host` and :ref:`admin-server-host` values when you need health
26+
checks to target a specific process. Avoid special values (``!4``, ``*``, etc)
27+
in this case because the health check could report a false positive.
2228

2329
Live
2430
----

docs/references/configuration.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ admin-server-port
176176

177177
Specifies the port for the :ref:`admin_server`. Cannot be equal to :ref:`server-port`.
178178

179+
When running multiple PostgREST instances on the same :ref:`server-port`, use
180+
a different ``admin-server-port`` for each instance. Admin ports are not shared
181+
between instances, so readiness checks always target one specific PostgREST
182+
instance. See :ref:`zero_downtime_upgrades`.
183+
179184
.. _app.settings.*:
180185

181186
app.settings.*
@@ -899,6 +904,50 @@ server-port
899904

900905
The TCP port to bind the web server. Use ``0`` to automatically assign a port.
901906

907+
When :ref:`server-reuseport` is enabled on an operating system that supports
908+
``SO_REUSEPORT``, you can start multiple PostgREST instances on the same
909+
:ref:`server-host` and ``server-port``. For example, two PostgREST processes
910+
can use the same configuration:
911+
912+
.. code:: ini
913+
914+
server-host = "127.0.0.1"
915+
server-port = 3000
916+
server-reuseport = true
917+
918+
New connections are then distributed by the operating system between the
919+
running PostgREST processes. This can be used to start a replacement process
920+
before stopping the old one, or to run several PostgREST processes behind one
921+
port.
922+
923+
If ``server-reuseport`` is disabled, starting another PostgREST process on
924+
the same host and port will fail with the usual address-in-use error.
925+
926+
For a step-by-step example, see :ref:`zero_downtime_upgrades`.
927+
928+
.. _server-reuseport:
929+
930+
server-reuseport
931+
----------------
932+
933+
=============== =================================
934+
**Type** Bool
935+
**Default** false
936+
**Reloadable** N
937+
**Environment** PGRST_SERVER_REUSEPORT
938+
**In-Database** `n/a`
939+
=============== =================================
940+
941+
Enables ``SO_REUSEPORT`` on the TCP server socket. This allows multiple
942+
PostgREST processes to bind to the same :ref:`server-host` and
943+
:ref:`server-port` when the operating system supports it.
944+
945+
Enabling this setting on an operating system that does not support
946+
``SO_REUSEPORT`` is a configuration error. PostgREST will fail to start
947+
instead of falling back to a normal TCP socket.
948+
949+
This setting does not apply when :ref:`server-unix-socket` is used.
950+
902951
.. _server-trace-header:
903952

904953
server-trace-header

src/PostgREST/App.hs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ import PostgREST.Version (docsVersion, prettyVersion)
6565

6666
import qualified Data.ByteString.Char8 as BS
6767
import qualified Data.List as L
68-
import Data.Streaming.Network (bindPortTCP)
68+
import Data.Streaming.Network (HostPreference,
69+
bindPortGenEx,
70+
bindPortTCP)
6971
import qualified Data.Text as T
7072
import qualified Network.HTTP.Types as HTTP
7173
import qualified Network.HTTP.Types.Header as HTTP
@@ -265,11 +267,18 @@ initServerSocket AppConfig{..} = case configServerUnixSocket of
265267
-- I'm not using `streaming-commons`' bindPath function here because it's not defined for Windows,
266268
-- but we need to have runtime error if we try to use it in Windows, not compile time error
267269
Just path -> createAndBindDomainSocket path configServerUnixSocketMode
268-
Nothing -> bindPortTCP configServerPort (fromString $ T.unpack configServerHost)
270+
Nothing
271+
| configServerReusePort -> bindPortTCPWithReusePort configServerPort (fromString $ T.unpack configServerHost)
272+
| otherwise -> bindPortTCP configServerPort (fromString $ T.unpack configServerHost)
269273

270274
initAdminServerSocket :: AppConfig -> IO (Maybe NS.Socket)
271275
initAdminServerSocket AppConfig{..} =
272276
traverse (`bindPortTCP` adminHost) configAdminServerPort
273277
where
274278
adminHost = fromString $ T.unpack configAdminServerHost
275279

280+
bindPortTCPWithReusePort :: Int -> HostPreference -> IO NS.Socket
281+
bindPortTCPWithReusePort port hostPreference =
282+
bindPortGenEx [(NS.ReusePort, 1)] NS.Stream port hostPreference >>= listenSocket
283+
where
284+
listenSocket sock = NS.listen sock (max 2048 NS.maxListenQueue) $> sock

src/PostgREST/Config.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ data AppConfig = AppConfig
118118
, configServerCorsAllowedOrigins :: Maybe [Text]
119119
, configServerHost :: Text
120120
, configServerPort :: Int
121+
, configServerReusePort :: Bool
121122
, configServerTraceHeader :: Maybe (CI.CI BS.ByteString)
122123
, configServerTimingEnabled :: Bool
123124
, configServerUnixSocket :: Maybe FilePath
@@ -203,6 +204,7 @@ toText conf =
203204
,("server-cors-allowed-origins", q . maybe "" (T.intercalate ",") . configServerCorsAllowedOrigins)
204205
,("server-host", q . configServerHost)
205206
,("server-port", show . configServerPort)
207+
,("server-reuseport", T.toLower . show . configServerReusePort)
206208
,("server-trace-header", q . T.decodeUtf8 . maybe mempty CI.original . configServerTraceHeader)
207209
,("server-timing-enabled", T.toLower . show . configServerTimingEnabled)
208210
,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket)
@@ -318,6 +320,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
318320
<*> parseCORSAllowedOrigins "server-cors-allowed-origins"
319321
<*> (defaultServerHost <$> optString "server-host")
320322
<*> parseServerPort "server-port"
323+
<*> (fromMaybe False <$> optBool "server-reuseport")
321324
<*> (fmap (CI.mk . encodeUtf8) <$> optString "server-trace-header")
322325
<*> (fromMaybe False <$> optBool "server-timing-enabled")
323326
<*> (fmap T.unpack <$> optString "server-unix-socket")
@@ -779,6 +782,7 @@ exampleConfigFile = S.unlines
779782
, ""
780783
, "server-host = \"!4\""
781784
, "server-port = 3000"
785+
, "server-reuseport = false"
782786
, ""
783787
, "## Allow getting the request-response timing information through the `Server-Timing` header"
784788
, "server-timing-enabled = false"

test/io/configs/expected/aliases.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ openapi-server-proxy-uri = ""
3434
server-cors-allowed-origins = ""
3535
server-host = "!4"
3636
server-port = 3000
37+
server-reuseport = false
3738
server-trace-header = ""
3839
server-timing-enabled = false
3940
server-unix-socket = ""

test/io/configs/expected/boolean-numeric.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ openapi-server-proxy-uri = ""
3434
server-cors-allowed-origins = ""
3535
server-host = "!4"
3636
server-port = 3000
37+
server-reuseport = false
3738
server-trace-header = ""
3839
server-timing-enabled = false
3940
server-unix-socket = ""

test/io/configs/expected/boolean-string.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ openapi-server-proxy-uri = ""
3434
server-cors-allowed-origins = ""
3535
server-host = "!4"
3636
server-port = 3000
37+
server-reuseport = false
3738
server-trace-header = ""
3839
server-timing-enabled = false
3940
server-unix-socket = ""

0 commit comments

Comments
 (0)