├── .gitignore
├── .hgignore
├── .travis.yml
├── MANIFEST.in
├── NEWS.txt
├── README.rst
├── rainbowsaddle
└── __init__.py
├── setup.py
├── test-requirements.txt
├── tests
├── .gitignore
├── __init__.py
├── test_functional.py
├── wsgi_1.py
└── wsgi_2.py
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 |
3 | .installed.cfg
4 | bin
5 | develop-eggs
6 |
7 | *.egg-info
8 |
9 | tmp
10 | build
11 | dist
12 |
13 | /.cache
14 | /.tox
15 |
--------------------------------------------------------------------------------
/.hgignore:
--------------------------------------------------------------------------------
1 | syntax: glob
2 |
3 | .installed.cfg
4 | bin
5 | develop-eggs
6 |
7 | *.egg-info
8 |
9 | tmp
10 | build
11 | dist
12 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: python
3 | python:
4 | - "2.7"
5 | - "3.4"
6 | - "3.5"
7 | install: pip install tox-travis
8 | script: tox
9 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 | include NEWS.txt
3 |
--------------------------------------------------------------------------------
/NEWS.txt:
--------------------------------------------------------------------------------
1 | News
2 | ====
3 |
4 | 0.4.0
5 | -----
6 |
7 | Support new binary upgrade method used by Gunicorn, since version 19.6.0. Drops
8 | support for all previous versions of Gunicorn. See commit
9 | benoitc/gunicorn@418f140 for more info, thanks to Jacob Magnusson.
10 |
11 | 0.3.1
12 | -----
13 |
14 | Minor Python3 compatibility fix, thanks to Justin Locsei.
15 |
16 | 0.3.0
17 | -----
18 |
19 | Thanks to Rafael Floriano da Silva for the following:
20 |
21 | * add --gunicorn-pidfile
22 | * fix race condition for slow starting apps
23 | * update psutil
24 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Rainbow Saddle
2 | ==============
3 |
4 | .. image:: https://travis-ci.org/flupke/rainbow-saddle.svg
5 | :target: https://travis-ci.org/flupke/rainbow-saddle
6 |
7 | .. note::
8 | I no longer use rainbow-saddle, and thus cannot actively maintain it.
9 |
10 | rainbow-saddle is a wrapper around `Gunicorn `_ to
11 | simplify code reloading without dropping requests.
12 |
13 | Installation
14 | ------------
15 |
16 | Install from pypi::
17 |
18 | $ sudo pip install rainbow-saddle
19 |
20 | Or from source::
21 |
22 | $ sudo ./setup.py install
23 |
24 | Why?
25 | ----
26 |
27 | Sometimes doing a ``kill -HUP `` is not sufficient to reload your
28 | code. For example it doesn't work well `if you host your code behind a symlink
29 | `_, or if a `.pth in your
30 | installation is updated to point to a different directory
31 | `_.
32 |
33 | The correct way to reload code in such situations is a bit complicated::
34 |
35 | # Reexec a new master with new workers
36 | /bin/kill -s USR2 `cat "$PID"`
37 | # Graceful stop old workers
38 | /bin/kill -s WINCH `cat "$PIDOLD"`
39 | # Graceful stop old master
40 | /bin/kill -s QUIT `cat "$PIDOLD"`
41 |
42 | It also has the downside of changing the "master" process PID, which confuses
43 | tools such as supervisord.
44 |
45 | rainbow-saddle handles all of this for you, and never changes its PID.
46 | Reloading code becomes as simple as sending a ``SIGHUP`` again::
47 |
48 | $ rainbow-saddle --pid /tmp/mysite.pid gunicorn_paster development.ini --log-level debug
49 | $ kill -HUP `cat /tmp/mysite.pid`
50 |
--------------------------------------------------------------------------------
/rainbowsaddle/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function
2 |
3 | import os
4 | import os.path as op
5 | import sys
6 | import atexit
7 | import subprocess
8 | import signal
9 | import time
10 | import argparse
11 | import tempfile
12 | import functools
13 | import traceback
14 |
15 | import psutil
16 |
17 | try:
18 | import Queue as queue
19 | except ImportError:
20 | import queue
21 |
22 |
23 | def signal_handler(func):
24 | @functools.wraps(func)
25 | def wrapper(*args, **kwargs):
26 | try:
27 | return func(*args, **kwargs)
28 | except:
29 | print('Uncaught exception in signal handler %s' % func,
30 | file=sys.stderr)
31 | traceback.print_exc()
32 | return wrapper
33 |
34 |
35 | class RainbowSaddle(object):
36 |
37 | def __init__(self, options):
38 | self._arbiter_pid = None
39 | self.hup_queue = queue.Queue()
40 | self.stopped = False
41 | # Create a temporary file for the gunicorn pid file
42 | if options.gunicorn_pidfile:
43 | self.pidfile = options.gunicorn_pidfile
44 | else:
45 | fp = tempfile.NamedTemporaryFile(prefix='rainbow-saddle-gunicorn-',
46 | suffix='.pid', delete=False)
47 | fp.close()
48 | self.pidfile = fp.name
49 | # Start gunicorn process
50 | args = options.gunicorn_args + ['--pid', self.pidfile]
51 | process = subprocess.Popen(args)
52 | self.arbiter_pid = process.pid
53 | # Install signal handlers
54 | signal.signal(signal.SIGHUP, self.handle_hup)
55 | for signum in (signal.SIGTERM, signal.SIGINT):
56 | signal.signal(signum, self.stop)
57 |
58 | @property
59 | def arbiter_pid(self):
60 | return self._arbiter_pid
61 |
62 | @arbiter_pid.setter
63 | def arbiter_pid(self, pid):
64 | self._arbiter_pid = pid
65 | self.arbiter_process = psutil.Process(self.arbiter_pid)
66 |
67 | def run_forever(self):
68 | while self.is_running():
69 | if not self.hup_queue.empty():
70 | with self.hup_queue.mutex:
71 | self.hup_queue.queue.clear()
72 | self.restart_arbiter()
73 | time.sleep(1)
74 |
75 | def is_running(self):
76 | if self.stopped:
77 | return False
78 | try:
79 | pstatus = self.arbiter_process.status()
80 | except psutil.NoSuchProcess:
81 | return False
82 | else:
83 | if pstatus == psutil.STATUS_ZOMBIE:
84 | self.log('Gunicorn master is %s (PID: %s), shutting down '
85 | 'rainbow-saddle' % (pstatus, self.arbiter_pid))
86 | return False
87 | return True
88 |
89 | @signal_handler
90 | def handle_hup(self, signum, frame):
91 | self.hup_queue.put((signum, frame))
92 |
93 | def restart_arbiter(self):
94 | # Fork a new arbiter
95 | self.log('Starting new arbiter')
96 | os.kill(self.arbiter_pid, signal.SIGUSR2)
97 |
98 | # Wait until the new master is up
99 | new_pidfile = self.pidfile + '.2'
100 | while True:
101 | if op.exists(new_pidfile):
102 | self.log('New pidfile found: {}'.format(new_pidfile))
103 | break
104 | time.sleep(0.3)
105 |
106 | # Read new arbiter PID, being super paranoid about it (we read the PID
107 | # file until we get the same value twice)
108 | _verification_pid = None
109 | while True:
110 | with open(new_pidfile) as fp:
111 | try:
112 | new_pid = int(fp.read())
113 | except ValueError:
114 | pass
115 | else:
116 | if _verification_pid == new_pid:
117 | break
118 | _verification_pid = new_pid
119 | time.sleep(0.3)
120 |
121 | # Gracefully kill old workers
122 | self.log('Stopping old arbiter with PID %s' % self.arbiter_pid)
123 | os.kill(self.arbiter_pid, signal.SIGTERM)
124 | self.wait_pid(self.arbiter_pid)
125 |
126 | self.arbiter_pid = new_pid
127 | self.log('New arbiter PID is %s' % self.arbiter_pid)
128 |
129 | def stop(self, signum, frame):
130 | os.kill(self.arbiter_pid, signal.SIGTERM)
131 | self.wait_pid(self.arbiter_pid)
132 | self.stopped = True
133 |
134 | def log(self, msg):
135 | print('-' * 78, file=sys.stderr)
136 | print(msg, file=sys.stderr)
137 | print('-' * 78, file=sys.stderr)
138 |
139 | def wait_pid(self, pid):
140 | """
141 | Wait until process *pid* exits.
142 | """
143 | try:
144 | os.waitpid(pid, 0)
145 | except OSError as err:
146 | if err.errno == 10:
147 | while True:
148 | try:
149 | process = psutil.Process(pid)
150 | if process.status() == psutil.STATUS_ZOMBIE:
151 | break
152 | except psutil.NoSuchProcess:
153 | break
154 | time.sleep(0.1)
155 |
156 |
157 | def main():
158 | # Parse command line
159 | parser = argparse.ArgumentParser(description='Wrap gunicorn to handle '
160 | 'graceful restarts correctly')
161 | parser.add_argument('--pid', help='a filename to store the '
162 | 'rainbow-saddle PID')
163 | parser.add_argument('--gunicorn-pidfile', help='a filename to store the '
164 | 'gunicorn PID')
165 | parser.add_argument('gunicorn_args', nargs=argparse.REMAINDER,
166 | help='gunicorn command line')
167 | options = parser.parse_args()
168 |
169 | # Write pid file
170 | if options.pid is not None:
171 | with open(options.pid, 'w') as fp:
172 | fp.write('%s\n' % os.getpid())
173 | atexit.register(os.unlink, options.pid)
174 |
175 | # Run script
176 | saddle = RainbowSaddle(options)
177 | saddle.run_forever()
178 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from setuptools import setup, find_packages
3 | import os
4 |
5 |
6 | here = os.path.abspath(os.path.dirname(__file__))
7 | README = open(os.path.join(here, 'README.rst')).read()
8 | NEWS = open(os.path.join(here, 'NEWS.txt')).read()
9 | version = '0.4.0'
10 | install_requires = [
11 | 'psutil>=4.2.0',
12 | ]
13 |
14 |
15 | setup(
16 | name='rainbow-saddle',
17 | version=version,
18 | description=(
19 | 'A wrapper around gunicorn to handle graceful restarts correctly'
20 | ),
21 | long_description=README + '\n\n' + NEWS,
22 | classifiers=[
23 | 'License :: OSI Approved :: BSD License',
24 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Server',
25 | ],
26 | keywords='gunicorn wrapper graceful restart',
27 | author='Luper Rouch',
28 | author_email='luper.rouch@gmail.com',
29 | url='https://github.com/flupke/rainbow-saddle',
30 | license='BSD',
31 | packages=find_packages(),
32 | include_package_data=True,
33 | zip_safe=False,
34 | install_requires=install_requires,
35 | entry_points={
36 | 'console_scripts':
37 | ['rainbow-saddle=rainbowsaddle:main']
38 | }
39 | )
40 |
--------------------------------------------------------------------------------
/test-requirements.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | gunicorn>=19.6.0
3 | requests
4 |
--------------------------------------------------------------------------------
/tests/.gitignore:
--------------------------------------------------------------------------------
1 | /wsgi.py
2 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flupke/rainbow-saddle/bf78df6cc8279ba963bcbb3f60b6a74e8847bf1f/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_functional.py:
--------------------------------------------------------------------------------
1 | import os
2 | import os.path as op
3 | import shutil
4 | import subprocess
5 | import tempfile
6 | import random
7 | import time
8 | import signal
9 |
10 | import requests
11 |
12 |
13 | THIS_DIR = op.dirname(__file__)
14 |
15 |
16 | def test_reload_copy():
17 | # Create first wsgi file
18 | pid_fp = tempfile.NamedTemporaryFile(delete=False)
19 | pid_file = pid_fp.name
20 | wsgi_file_1 = op.join(THIS_DIR, 'wsgi_1.py')
21 | wsgi_file_2 = op.join(THIS_DIR, 'wsgi_2.py')
22 | target_wsgi_file = op.join(THIS_DIR, 'wsgi.py')
23 | shutil.copy(wsgi_file_1, target_wsgi_file)
24 |
25 | # Start rainbow-saddle
26 | port = random.randint(32000, 64000)
27 | bind_address = '127.0.0.1:%s' % port
28 | url = 'http://%s' % bind_address
29 | rs_proc = subprocess.Popen([
30 | 'rainbow-saddle',
31 | '--pid=%s' % pid_file,
32 | 'gunicorn',
33 | 'tests.wsgi:simple_app',
34 | '--bind=%s' % bind_address,
35 | ])
36 |
37 | # Wait until app responds
38 | assert_responds(url, 200, 'one')
39 |
40 | # Create second wsgi file, also remove .pyc files
41 | shutil.copy(wsgi_file_2, target_wsgi_file)
42 | try:
43 | os.unlink(target_wsgi_file + 'c')
44 | except OSError:
45 | pass
46 | shutil.rmtree(op.join(THIS_DIR, '__pycache__'), ignore_errors=True)
47 |
48 | # Send HUP to rainbow-saddle, and wait until response changes
49 | rs_proc.send_signal(signal.SIGHUP)
50 | assert_responds(url, 200, 'two')
51 |
52 | # Terminate rainbow-saddle
53 | rs_proc.send_signal(signal.SIGQUIT)
54 | rs_proc.wait()
55 |
56 |
57 | def assert_responds(url, status_code=None, text=None, method='GET',
58 | timeout=5, reqs_timeout=0.1):
59 | '''
60 | Assert *url* responds under *timeout*.
61 |
62 | *status_code* and *text* can be used to add additional assertions on the
63 | response.
64 | '''
65 | start_time = time.time()
66 | resp = None
67 | while time.time() - start_time < timeout:
68 | try:
69 | resp = requests.request(method, url, timeout=reqs_timeout)
70 | except requests.RequestException as exc:
71 | pass
72 | else:
73 | if status_code is None and text is None:
74 | break
75 | elif status_code is not None and text is not None:
76 | if resp.status_code == status_code and resp.text == text:
77 | break
78 | elif status_code is not None:
79 | if resp.status_code == status_code:
80 | break
81 | elif text is not None:
82 | if resp.text == text:
83 | break
84 | if resp is None:
85 | raise AssertionError('got no response from %s after %s seconds, last '
86 | 'exception was:\n%s' % (url, timeout, exc))
87 | if status_code is not None:
88 | assert resp.status_code == status_code
89 | if text is not None:
90 | assert resp.text == text
91 |
--------------------------------------------------------------------------------
/tests/wsgi_1.py:
--------------------------------------------------------------------------------
1 | def simple_app(environ, start_response):
2 | status = '200 OK'
3 | response_headers = [('Content-type', 'text/plain')]
4 | start_response(status, response_headers)
5 | return [b'one']
6 |
--------------------------------------------------------------------------------
/tests/wsgi_2.py:
--------------------------------------------------------------------------------
1 | def simple_app(environ, start_response):
2 | status = '200 OK'
3 | response_headers = [('Content-type', 'text/plain')]
4 | start_response(status, response_headers)
5 | return [b'two']
6 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py27, py35, py34
3 |
4 | [testenv]
5 | deps = -rtest-requirements.txt
6 | commands = py.test
7 |
--------------------------------------------------------------------------------