├── .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 | --------------------------------------------------------------------------------