Skip to content

Migrate from uWSGI to gunicorn. Closes #891#904

Merged
regulartim merged 15 commits intoGreedyBear-Project:developfrom
SupRaKoshti:feature/migrate-uwsgi-to-gunicorn
Mar 16, 2026
Merged

Migrate from uWSGI to gunicorn. Closes #891#904
regulartim merged 15 commits intoGreedyBear-Project:developfrom
SupRaKoshti:feature/migrate-uwsgi-to-gunicorn

Conversation

@SupRaKoshti
Copy link
Copy Markdown
Contributor

Description

Migrated the server gateway interface from uWSGI (which is now in maintenance-only mode) to Gunicorn. Gunicorn is a well-established WSGI server and a drop-in replacement for uWSGI for standard Django applications. The server runs on 0.0.0.0:8001 with 16 workers, matching the previous uWSGI configuration.

Changes made:

Python Requirements

  • Replaced uwsgi and uwsgitop with gunicorn==25.1.0 in requirements/project-requirements.txt

Docker

  • Created docker/entrypoint_gunicorn.sh (replacing docker/entrypoint_uwsgi.sh) with the gunicorn server command
  • Updated docker/Dockerfile to remove uWSGI log directory and related comments
  • Updated docker/default.yml — renamed uwsgi service to gunicorn, updated container name, entrypoint, command, and removed uWSGI-specific volume mount and stats port
  • Updated docker/local.override.yml — renamed uwsgi service to gunicorn
  • Updated docker/stag.override.yml — renamed uwsgi service to gunicorn
  • Updated docker/version.override.yml — renamed uwsgi service to gunicorn
  • Updated docker/elasticsearch.yml — renamed uwsgi service to gunicorn

Nginx Configuration

  • Updated configuration/nginx/https.conf — replaced uwsgi_pass/uwsgi_cache/uwsgi_params with proxy_pass/proxy_cache equivalents (protocol change from uWSGI binary to HTTP)
  • Updated configuration/nginx/http.conf — same uwsgi→proxy conversion as https.conf
  • Updated configuration/nginx/django_server.conf — updated upstream hostname and comment

Management Script

  • Updated gbctl — renamed all uwsgi container references to gunicorn (affects check_downgrade, cmd_logs, cmd_health, and cmd_create_admin functions)

Deleted Files

  • Deleted docker/entrypoint_uwsgi.sh
  • Deleted configuration/uwsgi/greedybear.ini

Related issues

Closes #891

Type of change

  • Chore (refactoring, dependency updates, CI/CD changes, code cleanup, docs-only changes).

Checklist

Formalities

  • I have read and understood the rules about how to Contribute to this project.
  • I chose an appropriate title for the pull request in the form: <feature name>. Closes #999
  • My branch is based on develop.
  • The pull request is for the branch develop.
  • I have reviewed and verified any LLM-generated code included in this PR.

Docs and tests

  • All the tests gave 0 errors.

GUI changes

N/A - No GUI changes were made.

@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Hi @regulartim, I have completed the migration from uWSGI to gunicorn. Could you please review the PR when you get a chance?

Also, I noticed the documentation at https://intelowlproject.github.io/docs/GreedyBear/Contribute/ still references the old greedybear_uwsgi container name.

image

Copy link
Copy Markdown
Member

@regulartim regulartim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @SupRaKoshti ! Thanks for your work. Aside from the other comments, I checked the Gunicorn guide for docker deployments. They do some things a little different and I think we should match that in our default.yml:

What do you think of that?

Comment thread docker/default.yml
Comment thread docker/default.yml Outdated
Comment thread docker/default.yml Outdated
Comment thread docker/Dockerfile Outdated

# separation is required to avoid to re-execute os installation in case of change of python requirements
RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/uwsgi \
RUN mkdir -p ${LOG_PATH}/django \
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where will gunicorn create its logs ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ${LOG_PATH}/uwsgi directory was specifically created for uWSGI logs.
Since we are removing uWSGI, this directory is no longer needed.
Gunicorn logs go to stdout/stderr by default, so no separate log directory is required for gunicorn.
Should I keep it that way or create a specific log directory?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should create a specific log directory for gunicorn, the same way we did for uwsgi.

@regulartim
Copy link
Copy Markdown
Member

There is also support for the uWSGI Protocol in Gunicorn, which seems to have advantages over http. Should be maybe use that?

@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Hey @SupRaKoshti ! Thanks for your work. Aside from the other comments, I checked the Gunicorn guide for docker deployments. They do some things a little different and I think we should match that in our default.yml:

What do you think of that?

Hi @regulartim , that sounds great! I will look into the gunicorn Docker deployment guide and implement dynamic worker count calculation, health checks, and graceful shutdown. Give me some time to read through the documentation and I will update the PR accordingly.

@regulartim
Copy link
Copy Markdown
Member

Hey @SupRaKoshti ! :) Please also take a look at the uwsgi protocol support I mentioned:

There is also support for the uWSGI Protocol in Gunicorn, which seems to have advantages over http. Should be maybe use that?

If I understand it correctly using it would be more efficient and result in a smaller diff (=less changes to the code).

@regulartim
Copy link
Copy Markdown
Member

Also, I noticed the documentation at https://intelowlproject.github.io/docs/GreedyBear/Contribute/ still references the old greedybear_uwsgi container name.

Yeah, I'll just remove the command, it is outdated anyway.

@regulartim regulartim marked this pull request as draft March 6, 2026 07:20
… into feature/migrate-uwsgi-to-gunicorn

t
exit

quit
Docker & Compose:
- renamed service from `uwsgi` to `app` in default.yml and updated
  all references in nginx depends_on and qcluster depends_on

Gunicorn configuration:
- added dynamic worker count using $(( 2 * $(nproc) + 1 ))
- added graceful shutdown with --graceful-timeout 30 and --timeout 120
- added stop_grace_period: 30s to app service to match graceful timeout
- enabled uWSGI binary protocol via --protocol uwsgi flag
- added custom gunicorn log directory (/var/log/greedybear/gunicorn)

Nginx:
- updated http.conf and https.conf to use uwsgi_pass instead of
  proxy_pass and uwsgi_cache instead of proxy_cache to match
  gunicorn uWSGI binary protocol
- updated upstream server name from uwsgi:8001 to app:8001
- django_server.conf kept as proxy_pass (used only in local dev
  with Django runserver which speaks plain HTTP)

Health checks:
- replaced curl HTTP healthcheck with Python TCP socket check in
  both default.yml and Dockerfile since port 8001 now speaks
  uWSGI binary protocol, not HTTP
- updated Dockerfile comment accordingly

gbctl script:
- updated all container name references from greedybear_gunicorn
  to greedybear_app in cmd_logs, cmd_health, cmd_create_admin
  and check_downgrade functions
@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Hey @regulartim! I have gone through the official Gunicorn documentation for Docker deployment and uWSGI protocol and implemented everything you suggested. Here's a summary of what was done:

Dynamic Worker Count

  • Implemented using $((2 * $(nproc) + 1)) at runtime so workers automatically scale based on available CPUs in the container

Health Checks

  • Since port 8001 now speaks uWSGI binary protocol (not HTTP), curl would fail
  • Replaced with a Python TCP socket check in both default.yml and Dockerfile:
    python3 -c "import socket; socket.create_connection(('localhost', 8001), timeout=2)"
    

Graceful Shutdown

  • Added --graceful-timeout 30 and --timeout 120 to the gunicorn command
  • Added stop_grace_period: 30s to the app service to match

uWSGI Protocol

  • Added --protocol uwsgi to the gunicorn command
  • Updated http.conf and https.conf to use uwsgi_pass, uwsgi_cache and uwsgi_params instead of proxy_pass/proxy_cache
  • django_server.conf intentionally kept as proxy_pass since it's only used in local dev with manage.py runserver which speaks plain HTTP

Gunicorn Log Directory

  • Added ${LOG_PATH}/gunicorn directory creation in Dockerfile, same as we had for uWSGI

Service Renamed

  • Renamed service from gunicorn to app across all compose files and updated all references in gbctl, nginx depends_on and qcluster depends_on

Ready for review! 🙏

@SupRaKoshti SupRaKoshti marked this pull request as ready for review March 8, 2026 04:30
@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Also @regulartim, I noticed that the app service currently runs as root, while qcluster already uses user: "2000:82". The Dockerfile already sets up www-data (uid 2000) and chowns all necessary directories, but USER www-data is never set and app service has no user: defined.

Should we implement the non-root user for the app service as well?

Pros:

  • Security best practice — containers should not run as root
  • Consistent with qcluster which already uses user: "2000:82"
  • Recommended by the official Gunicorn Docker docs
  • All file permissions are already correctly set up in the Dockerfile

Cons / Risks:

  • entrypoint_gunicorn.sh runs chown -R 2000:82 /var/log/greedybear which requires root — this would need to be handled differently if we switch to non-root

Happy to implement this in a follow-up PR if you think it's worth doing!

@regulartim
Copy link
Copy Markdown
Member

regulartim commented Mar 9, 2026

Also @regulartim, I noticed that the app service currently runs as root, while qcluster already uses user: "2000:82". The Dockerfile already sets up www-data (uid 2000) and chowns all necessary directories, but USER www-data is never set and app service has no user: defined.
Should we implement the non-root user for the app service as well?

Yeah, I also thought about this. I would prefer to do that in a separate issue / PR though (feel free to open an issue). I am a bit scared that such a change might break existing instance.

Copy link
Copy Markdown
Member

@regulartim regulartim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very good already! Just some minor healthcheck related details left.

Comment thread docker/Dockerfile Outdated
Comment thread docker/default.yml Outdated
@regulartim
Copy link
Copy Markdown
Member

Also, there seems to be a bug! When I try to run the container, I get:

Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "./docker/entrypoint_gunicorn.sh": permission denied

The reason might be that the file permissions of entrypoint_gunicorn.sh changed due to the renaming. Please check that. Maybe the executable bit is missing.

…optimize healthcheck

- removed redundant HEALTHCHECK from Dockerfile since default.yml
  healthcheck already overrides it when running via Docker Compose
- added missing executable bit (+x) to entrypoint_gunicorn.sh
- optimized healthcheck by adding a separate HTTP bind on port 8002
  so healthcheck requests bypass the uWSGI queue on port 8001
  and use curl -f http://localhost:8002 instead of Python TCP socket
@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Hey @regulartim! I have implemented all the changes and suggestions you mentioned:

  • Removed the redundant HEALTHCHECK from the Dockerfile since default.yml healthcheck already overrides it when running via Docker Compose
  • Implemented the dual bind approach for the healthcheck — gunicorn now binds to port 8001 (uWSGI binary for nginx traffic) and port 8002 (plain HTTP for healthcheck only), so healthcheck requests bypass the uWSGI queue completely and use curl -f http://localhost:8002
  • Fixed the missing executable bit (+x) on entrypoint_gunicorn.sh

Could you please review and check if everything is running fine? 🙏

@regulartim
Copy link
Copy Markdown
Member

Hey @SupRaKoshti ! The application does not even start. How are you testing your changes?

greedybear_app       | [2026-03-10 17:06:39 +0000] [112] [ERROR] Error handling request (no URI read)
greedybear_app       | Traceback (most recent call last):
greedybear_app       |   File "/usr/local/lib/python3.13/site-packages/gunicorn/workers/sync.py", line 141, in handle
greedybear_app       |     req = next(parser)
greedybear_app       |   File "/usr/local/lib/python3.13/site-packages/gunicorn/http/parser.py", line 56, in __next__
greedybear_app       |     self.mesg = self.mesg_class(self.cfg, self.unreader, self.source_addr, self.req_count)
greedybear_app       |                 ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
greedybear_app       |   File "/usr/local/lib/python3.13/site-packages/gunicorn/uwsgi/message.py", line 68, in __init__
greedybear_app       |     unused = self.parse(self.unreader)
greedybear_app       |   File "/usr/local/lib/python3.13/site-packages/gunicorn/uwsgi/message.py", line 104, in parse
greedybear_app       |     raise UnsupportedModifier(self.modifier1)
greedybear_app       | gunicorn.uwsgi.errors.UnsupportedModifier: Unsupported uWSGI modifier1: 71

@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Hey @regulartim , I apologize for that! I realized my mistake — I was testing with local.override.yml the whole time which uses Django's runserver instead of gunicorn, so the healthcheck was hitting port 8001 with plain HTTP and working fine on my end.

The root cause of the bug is that --protocol uwsgi applies globally to ALL binds including port 8002, so when curl sends an HTTP request to port 8002, gunicorn rejects it with UnsupportedModifier: 71 (modifier 71 = HTTP request on a uWSGI port).

I am now testing properly with docker compose -f docker/default.yml up and working through the issues step by step:

  1. Replaced the curl healthcheck with nc -z localhost 8001 — container is now healthy. The InvalidUWSGIHeader: incomplete header errors in logs are just noise since nc -z sends no data.

  2. Currently investigating ForbiddenUWSGIRequest — gunicorn's uWSGI protocol only trusts 127.0.0.1 by default and is rejecting requests from Nginx's Docker network IP, causing the Internal Server Error. Working on the fix now.

Will keep you updated as I progress. Sorry for the inconvenience!

@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

SupRaKoshti commented Mar 12, 2026

Hi @regulartim ,

Update: After replacing the healthcheck with nc -z, I investigated the ForbiddenUWSGIRequest issue further and explored the following solutions:

  • Option A (keep uWSGI): Fix the IP allowlist with --forwarded-allow-ips and properly quote the wildcard to avoid shell expansion issues. Keeps the original uWSGI design but is fragile — any mismatch in headers, IPs, or params breaks everything and is harder to debug.
  • Option B (Unix socket): Bind gunicorn to a Unix socket to avoid the IP allowlist issue entirely. Needs extra volume mounting and path management.
  • Option C (plain HTTP): Remove --protocol uwsgi from gunicorn and switch nginx from uwsgi_pass to proxy_pass with proxy_cache. Simplest to configure, debug, and maintain.

Option C fully resolves all the errors — no more ForbiddenUWSGIRequest or protocol-related issues.

My question: is the uWSGI protocol a hard requirement here? If not, I'd like to go with Option C (plain HTTP) as it eliminates all the protocol complexity. Happy to keep uWSGI if there's a specific reason for it!

@regulartim
Copy link
Copy Markdown
Member

Hey @SupRaKoshti ! :) UWSGI has some performance benefits over http. Therefore I would prefer to use it. The unix socket approach (option B) sounds smart. That would also have a positive effect on performance, I guess.

- bind gunicorn to two UNIX sockets instead of TCP port 8001:
  - unix:/run/gunicorn-main.sock for all application traffic
  - unix:/run/gunicorn-health.sock for health checks only
  (separate health socket avoids queuing behind busy workers,
  see benoitc/gunicorn#1417)
- add shared `gunicorn_sockets` volume mounted at /run in both
  app and nginx containers so both can access the socket files
- replace app healthcheck with `test -S /run/gunicorn-health.sock`
  since there is no longer a TCP port to probe
- update nginx upstreams in http.conf and https.conf:
  - django_main -> unix:/run/gunicorn-main.sock
  - django_health -> unix:/run/gunicorn-health.sock
  - move /hc location from locations.conf into http/https.conf
    and route it to django_health upstream

fixes ForbiddenUWSGIRequest errors that occurred when gunicorn
rejected TCP connections from nginx's Docker network IP (172.20.0.x),
since UNIX socket connections bypass the uWSGI IP allowlist entirely
without --preload, each gunicorn worker independently loads the
app and calls get_random_secret_key(), resulting in every worker
having a different SECRET_KEY. when a login request is handled
by worker A and the next request is handled by worker B, the
SECRET_KEY mismatch causes Django to flush the session and
redirect back to login.

with --preload, the app is loaded once in the master process
before forking workers, so all workers inherit the same
SECRET_KEY via fork().
@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Hi @regulartim , Ready for review! here's a full summary of what i have done.

What was changed:

  • Migrated Gunicorn from TCP (0.0.0.0:8001) to two UNIX sockets:
  • Added --preload flag to Gunicorn (explained below)
  • Added shared gunicorn_sockets volume mounted at /run in both app and nginx containers
  • Replaced app healthcheck with test -S /run/gunicorn-health.sock
  • Updated nginx upstreams in http.conf and https.conf to use the two UNIX sockets
  • Added dedicated /hc location in nginx routed to django_health upstream

Why UNIX sockets:
Gunicorn's uWSGI protocol only trusts 127.0.0.1 by default. Since nginx runs in a separate container with its own Docker network IP (172.20.0.x), every request was rejected with ForbiddenUWSGIRequest. UNIX socket connections bypass the IP allowlist entirely, and also offer better performance by eliminating TCP overhead. See: https://gunicorn.org/uwsgi/#using-unix-sockets

Why --preload:
Without --preload, each Gunicorn worker independently loads the app and calls get_random_secret_key(), giving every worker a different SECRET_KEY. When a login request is handled by worker A and the next request hits worker B, the SECRET_KEY mismatch causes Django to flush the session → user gets redirected to login on every click. With --preload, the app loads once in the master process before forking, so all workers inherit the same SECRET_KEY via fork(). This doesn't happen with the uWSGI server since it pre-loads by default.

Tested with docker compose -f docker/default.yml up — all containers healthy, http://localhost/ working, admin sessions persisting correctly.

Copy link
Copy Markdown
Member

@regulartim regulartim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks! I tested it and have some changes that I will push later. You can consider your work done, good job!

@regulartim regulartim merged commit 51e3679 into GreedyBear-Project:develop Mar 16, 2026
4 checks passed
@SupRaKoshti
Copy link
Copy Markdown
Contributor Author

Nice, thanks! I tested it and have some changes that I will push later. You can consider your work done, good job!

Hi @regulartim , Thanks! Glad to hear it's working well!

Yeah, I also thought about this. I would prefer to do that in a separate issue / PR though (feel free to open an issue). I am a bit scared that such a change might break existing instance.

I’ll do some more research on the non-root users topic, and if it looks like a worthwhile improvement, I’ll open a separate issue for it then we can discuss about it!

@regulartim
Copy link
Copy Markdown
Member

I’ll do some more research on the non-root users topic, and if it looks like a worthwhile improvement, I’ll open a separate issue for it then we can discuss about it!

Cool, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants