pallets/flask

Blueprints and import into gunicorn.conf.py results in gunicorn reload bug

mkmoisen opened this issue · 0 comments

This is a very interesting bug.

When using Blueprints with flask, and importing something into gunicorn.conf.py from the same top level module in which the flask application resides, Gunicorn's --reload functionality breaks. Specifically, the reloading functionality appears to work- gunicorn informs us that the application has been reloaded; but the run time code is actually cached, and the application in effect does not reload.

This bug is an issue because it makes development inconvenient, and it is time consuming to isolate.

This bug cannot be replicated if:

  • you don't use blueprints,
  • or if you use blueprints but do not import into gunicorn.conf.py from a module that exists inside the application module
  • or if you use blueprints, and lazy import into gunicorn.conf.py

The reason I believe this is a flask bug, instead of a gunicorn bug, is that this bug only exists when using blueprints.

I've replicated this on Python 3.11.5, Flask 2.3.3 and 2.0.0, Gunicorn 21.2.0 and 21.0.0, and RHEL and Ubuntu.

Docker

I've uploaded a repo with a Dockerfile and docker-compose.yaml that can be used to quickly reproduce the bug. The application runs on port 8080.

git clone https://github.com/mkmoisen/blueprintbug.git
docker compose up --build

Directory Structure

blueprintbug/
    app/
        __init__.py
    gunicorn.conf.py
    requirements.txt

blueprintbug/app/init.py

from flask import Flask, Blueprint

def init_application():
    print("Application initialized.")


def cleanup_application():
    print("Application cleaned up.")


def create_app():
    """
    Creates & initializes a Flask application instance.
    :return:
    """
    app = Flask(__name__, static_folder=None, template_folder=None)

    app.register_blueprint(bp)

    return app


bp = Blueprint('home', __name__)

@bp.get('/')
def home():
    raise Exception("foo")

blueprintbug/gunicorn.conf.py

# The root cause of this bug is these imports
# Moving these imports to inside of post_worker_init and worker_exit eliminates the bug
from app import init_application
from app import cleanup_application

bind = '0.0.0.0:8080'

preload_app = False

worker_class = 'gthread'
workers = 1
threads = 1


def post_worker_init(worker):
    init_application()


def worker_exit(server, worker):
    cleanup_application()

blueprintbug/requirements.txt

Flask==2.3.3
gunicorn==21.2.0

Running the application

Run the application with the reload option:

gunicorn -c gunicorn.conf.py "app:create_app()" --reload

With the above code, if we call GET /, we can see the following exception, which we expect:

curl http://localhost:8080/
blueprintbug  |   File "/usr/src/app/app/__init__.py", line 27, in home
blueprintbug  |     raise Exception("foo")
blueprintbug  | Exception: foo

Now let's change blueprintbug/app/__init__.py to raise bar instead of foo:

@bp.get('/')
def home():
    raise Exception("bar")

And we can confirm that gunicorn appears to have reloaded the application:

blueprintbug  | [2023-09-20 17:08:29 +0000] [7] [INFO] Worker reloading: /usr/src/app/app/__init__.py modified
blueprintbug  | [2023-09-20 17:08:30 +0000] [7] [INFO] Worker exiting (pid: 7)
blueprintbug  | Application initialized.
blueprintbug  | Application cleaned up.
blueprintbug  | [2023-09-20 17:08:30 +0000] [10] [INFO] Booting worker with pid: 10

Next we can call GET / which will result in this strange result:

curl http://localhost:8080/
blueprintbug  |   File "/usr/src/app/app/__init__.py", line 27, in home
blueprintbug  |     raise Exception("bar")
blueprintbug  | Exception: foo

Notice how the stack trace says raise Exception("bar"), but how the actual run time exception raised is foo.

This implies that gunicorn is caching the run time code, even though it recognizes that the code has changed.

We would have expected the following console output, which does not happen:

blueprintbug  |   File "/usr/src/app/app/__init__.py", line 27, in home
blueprintbug  |     raise Exception("bar")
blueprintbug  | Exception: bar

The quickest workaround appears to be using lazy loading inside of gunicorn.conf.py.

# Do not import here, instead import inside the functions

def post_worker_init(worker):
    from app import init_application
    init_application()


def worker_exit(server, worker):
    from app import cleanup_application
    cleanup_application()

Alternatively, you can move the init_application and cleanup_application to a separate directory from /app.

Or you can not use blueprints.