├── LICENSE ├── README.md ├── deploy.sh ├── docker-compose-swarm.yml ├── docker-compose.yml └── services ├── db ├── Dockerfile └── create.sql ├── nginx ├── Dockerfile └── prod.conf └── web ├── Dockerfile ├── manage.py ├── project ├── __init__.py ├── api │ ├── main.py │ ├── models.py │ └── users.py └── config.py └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 TestDriven.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Running Flask on Docker Swarm 2 | 3 | ## Want to learn how to build this? 4 | 5 | Check out the [tutorial](https://testdriven.io/running-flask-on-docker-swarm). 6 | 7 | ## Want to use this project? 8 | 9 | 1. Fork/Clone 10 | 11 | 1. [Sign up](https://m.do.co/c/d8f211a4b4c2) for Digital Ocean and [generate](https://www.digitalocean.com/docs/api/create-personal-access-token/) an access token 12 | 13 | 1. Add the token to your environment: 14 | 15 | ```sh 16 | $ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_token] 17 | ``` 18 | 19 | 1. Spin up four droplets and deploy Docker Swarm: 20 | 21 | ```sh 22 | $ sh deploy.sh 23 | ``` 24 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | echo "Spinning up four droplets..." 5 | 6 | for i in 1 2 3 4; do 7 | docker-machine create \ 8 | --driver digitalocean \ 9 | --digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \ 10 | --digitalocean-region "nyc1" \ 11 | --digitalocean-image "debian-10-x64" \ 12 | --digitalocean-size "s-4vcpu-8gb" \ 13 | --engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \ 14 | node-$i; 15 | done 16 | 17 | 18 | echo "Initializing Swarm mode..." 19 | 20 | docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1) 21 | 22 | 23 | echo "Adding the nodes to the Swarm..." 24 | 25 | TOKEN=`docker-machine ssh node-1 docker swarm join-token worker | grep token | awk '{ print $5 }'` 26 | 27 | for i in 2 3 4; do 28 | docker-machine ssh node-$i \ 29 | -- docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377; 30 | done 31 | 32 | 33 | echo "Creating secret..." 34 | 35 | eval $(docker-machine env node-1) 36 | echo "foobar" | docker secret create secret_code - 37 | 38 | 39 | echo "Deploying the Flask microservice..." 40 | 41 | docker stack deploy --compose-file=docker-compose-swarm.yml flask 42 | 43 | 44 | echo "Create the DB table and apply the seed..." 45 | 46 | sleep 15 47 | NODE=$(docker service ps -f "desired-state=running" --format "{{.Node}}" flask_web) 48 | eval $(docker-machine env $NODE) 49 | CONTAINER_ID=$(docker ps --filter name=flask_web --format "{{.ID}}") 50 | docker container exec -it $CONTAINER_ID python manage.py recreate_db 51 | docker container exec -it $CONTAINER_ID python manage.py seed_db 52 | 53 | 54 | echo "Get the IP address..." 55 | eval $(docker-machine env node-1) 56 | docker-machine ip $(docker service ps -f "desired-state=running" --format "{{.Node}}" flask_nginx) 57 | -------------------------------------------------------------------------------- /docker-compose-swarm.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | web: 6 | image: mjhea0/flask-docker-swarm_web:latest 7 | deploy: 8 | replicas: 1 9 | restart_policy: 10 | condition: on-failure 11 | placement: 12 | constraints: [node.role == worker] 13 | expose: 14 | - 5000 15 | environment: 16 | - FLASK_ENV=production 17 | - APP_SETTINGS=project.config.ProductionConfig 18 | - DB_USER=postgres 19 | - DB_PASSWORD=postgres 20 | secrets: 21 | - secret_code 22 | depends_on: 23 | - db 24 | networks: 25 | - app 26 | healthcheck: 27 | test: curl --fail http://localhost:5000/ping || exit 1 28 | interval: 10s 29 | timeout: 2s 30 | retries: 5 31 | 32 | db: 33 | image: mjhea0/flask-docker-swarm_db:latest 34 | deploy: 35 | replicas: 1 36 | restart_policy: 37 | condition: on-failure 38 | placement: 39 | constraints: [node.role == manager] 40 | volumes: 41 | - data-volume:/var/lib/postgresql/data 42 | expose: 43 | - 5432 44 | environment: 45 | - POSTGRES_USER=postgres 46 | - POSTGRES_PASSWORD=postgres 47 | networks: 48 | - app 49 | 50 | nginx: 51 | image: mjhea0/flask-docker-swarm_nginx:latest 52 | deploy: 53 | replicas: 1 54 | restart_policy: 55 | condition: on-failure 56 | placement: 57 | constraints: [node.role == worker] 58 | ports: 59 | - 80:80 60 | depends_on: 61 | - web 62 | networks: 63 | - app 64 | 65 | visualizer: 66 | image: dockersamples/visualizer:latest 67 | ports: 68 | - 8080:8080 69 | volumes: 70 | - "/var/run/docker.sock:/var/run/docker.sock" 71 | deploy: 72 | placement: 73 | constraints: [node.role == manager] 74 | networks: 75 | - app 76 | 77 | networks: 78 | app: 79 | driver: overlay 80 | 81 | volumes: 82 | data-volume: 83 | driver: local 84 | 85 | secrets: 86 | secret_code: 87 | external: true 88 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | web: 6 | build: 7 | context: ./services/web 8 | dockerfile: Dockerfile 9 | expose: 10 | - 5000 11 | environment: 12 | - FLASK_ENV=production 13 | - APP_SETTINGS=project.config.ProductionConfig 14 | - DB_USER=postgres 15 | - DB_PASSWORD=postgres 16 | - SECRET_CODE=myprecious 17 | depends_on: 18 | - db 19 | networks: 20 | - app 21 | 22 | db: 23 | build: 24 | context: ./services/db 25 | dockerfile: Dockerfile 26 | volumes: 27 | - data-volume:/var/lib/postgresql/data 28 | expose: 29 | - 5432 30 | environment: 31 | - POSTGRES_USER=postgres 32 | - POSTGRES_PASSWORD=postgres 33 | networks: 34 | - app 35 | 36 | nginx: 37 | build: 38 | context: ./services/nginx 39 | dockerfile: Dockerfile 40 | restart: always 41 | ports: 42 | - 80:80 43 | depends_on: 44 | - web 45 | networks: 46 | - app 47 | 48 | networks: 49 | app: 50 | driver: bridge 51 | 52 | volumes: 53 | data-volume: 54 | driver: local 55 | -------------------------------------------------------------------------------- /services/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:16 2 | 3 | # run create.sql on init 4 | ADD create.sql /docker-entrypoint-initdb.d 5 | -------------------------------------------------------------------------------- /services/db/create.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE users; 2 | -------------------------------------------------------------------------------- /services/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.25 2 | 3 | RUN rm /etc/nginx/conf.d/default.conf 4 | COPY /prod.conf /etc/nginx/conf.d 5 | -------------------------------------------------------------------------------- /services/nginx/prod.conf: -------------------------------------------------------------------------------- 1 | server { 2 | 3 | listen 80; 4 | 5 | location / { 6 | proxy_pass http://web:5000; 7 | proxy_redirect default; 8 | proxy_set_header Host $host; 9 | proxy_set_header X-Real-IP $remote_addr; 10 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 11 | proxy_set_header X-Forwarded-Host $server_name; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /services/web/Dockerfile: -------------------------------------------------------------------------------- 1 | ########### 2 | # BUILDER # 3 | ########### 4 | 5 | # Base Image 6 | FROM python:3.12 as builder 7 | 8 | # Lint 9 | RUN pip install flake8==6.1.0 black==23.10.1 10 | WORKDIR /home/app 11 | COPY project ./project 12 | COPY manage.py . 13 | RUN flake8 --ignore=E501 . 14 | RUN black --check . 15 | 16 | # Install Requirements 17 | COPY requirements.txt . 18 | RUN pip wheel --no-cache-dir --no-deps --wheel-dir /home/app/wheels -r requirements.txt 19 | 20 | 21 | ######### 22 | # FINAL # 23 | ######### 24 | 25 | # Base Image 26 | FROM python:3.12-slim 27 | 28 | # Install curl 29 | RUN apt-get update && apt-get install -y curl 30 | 31 | # Create directory for the app user 32 | RUN mkdir -p /home/app 33 | 34 | # Create the app user 35 | RUN groupadd app && useradd -g app app 36 | 37 | # Create the home directory 38 | ENV HOME=/home/app 39 | ENV APP_HOME=/home/app/web 40 | RUN mkdir $APP_HOME 41 | WORKDIR $APP_HOME 42 | 43 | # Install Requirements 44 | COPY --from=builder /home/app/wheels /wheels 45 | COPY --from=builder /home/app/requirements.txt . 46 | RUN pip install --no-cache /wheels/* 47 | 48 | # Copy in the Flask code 49 | COPY . $APP_HOME 50 | 51 | # Chown all the files to the app user 52 | RUN chown -R app:app $APP_HOME 53 | 54 | # Change to the app user 55 | USER app 56 | 57 | # run server 58 | CMD gunicorn --log-level=debug -b 0.0.0.0:5000 manage:app 59 | -------------------------------------------------------------------------------- /services/web/manage.py: -------------------------------------------------------------------------------- 1 | from flask.cli import FlaskGroup 2 | 3 | from project import create_app, db 4 | from project.api.models import User 5 | 6 | app = create_app() 7 | cli = FlaskGroup(create_app=create_app) 8 | 9 | 10 | @cli.command("recreate_db") 11 | def recreate_db(): 12 | db.drop_all() 13 | db.create_all() 14 | db.session.commit() 15 | 16 | 17 | @cli.command("seed_db") 18 | def seed_db(): 19 | """Seeds the database.""" 20 | db.session.add(User(username="michael", email="michael@notreal.com")) 21 | db.session.commit() 22 | 23 | 24 | if __name__ == "__main__": 25 | cli() 26 | -------------------------------------------------------------------------------- /services/web/project/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask 4 | from flask_sqlalchemy import SQLAlchemy 5 | from flask_migrate import Migrate 6 | 7 | 8 | # instantiate the extensions 9 | db = SQLAlchemy() 10 | migrate = Migrate() 11 | 12 | 13 | def create_app(script_info=None): 14 | # instantiate the app 15 | app = Flask(__name__) 16 | 17 | # set config 18 | app_settings = os.getenv("APP_SETTINGS") 19 | app.config.from_object(app_settings) 20 | 21 | # set up extensions 22 | db.init_app(app) 23 | migrate.init_app(app, db) 24 | 25 | # register blueprints 26 | from project.api.main import main_blueprint 27 | 28 | app.register_blueprint(main_blueprint) 29 | from project.api.users import users_blueprint 30 | 31 | app.register_blueprint(users_blueprint) 32 | 33 | # shell context for flask cli 34 | app.shell_context_processor({"app": app, "db": db}) 35 | return app 36 | -------------------------------------------------------------------------------- /services/web/project/api/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from flask import Blueprint, jsonify, request 5 | 6 | 7 | LOGGER = logging.getLogger("gunicorn.error") 8 | SECRET_CODE = open("/run/secrets/secret_code", "r").read().strip() 9 | 10 | main_blueprint = Blueprint("main", __name__) 11 | 12 | 13 | @main_blueprint.route("/ping", methods=["GET"]) 14 | def ping_pong(): 15 | LOGGER.info('Hitting the "/ping" route') 16 | return jsonify( 17 | {"status": "success", "message": "pong!", "container_id": os.uname()[1]} 18 | ) 19 | 20 | 21 | @main_blueprint.route("/secret", methods=["POST"]) 22 | def secret(): 23 | LOGGER.info('Hitting the "/secret" route') 24 | response_object = { 25 | "status": "success", 26 | "message": "nay!", 27 | "container_id": os.uname()[1], 28 | } 29 | if request.get_json().get("secret") == SECRET_CODE: 30 | response_object["message"] = "yay!" 31 | return jsonify(response_object) 32 | -------------------------------------------------------------------------------- /services/web/project/api/models.py: -------------------------------------------------------------------------------- 1 | from project import db 2 | 3 | 4 | class User(db.Model): 5 | __tablename__ = "users" 6 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 7 | username = db.Column(db.String(128), unique=True, nullable=False) 8 | email = db.Column(db.String(128), unique=True, nullable=False) 9 | active = db.Column(db.Boolean, default=True, nullable=False) 10 | admin = db.Column(db.Boolean, default=False, nullable=False) 11 | 12 | def __init__(self, username, email): 13 | self.username = username 14 | self.email = email 15 | 16 | def to_json(self): 17 | return { 18 | "id": self.id, 19 | "username": self.username, 20 | "email": self.email, 21 | "active": self.active, 22 | "admin": self.admin, 23 | } 24 | -------------------------------------------------------------------------------- /services/web/project/api/users.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from flask import Blueprint, jsonify 5 | 6 | from project.api.models import User 7 | 8 | 9 | users_blueprint = Blueprint("users", __name__) 10 | 11 | LOGGER = logging.getLogger("gunicorn.error") 12 | 13 | 14 | @users_blueprint.route("/users", methods=["GET"]) 15 | def get_all_users(): 16 | LOGGER.info('Hitting the "/users" route') 17 | response_object = { 18 | "status": "success", 19 | "users": [user.to_json() for user in User.query.all()], 20 | "container_id": os.uname()[1], 21 | } 22 | return jsonify(response_object), 200 23 | -------------------------------------------------------------------------------- /services/web/project/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | USER = os.environ.get("DB_USER") 4 | PASSWORD = os.environ.get("DB_PASSWORD") 5 | 6 | 7 | class ProductionConfig: 8 | """Production configuration""" 9 | 10 | SQLALCHEMY_TRACK_MODIFICATIONS = False 11 | SQLALCHEMY_DATABASE_URI = f"postgresql://{USER}:{PASSWORD}@db:5432/users" 12 | -------------------------------------------------------------------------------- /services/web/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.0 2 | Flask-SQLAlchemy==3.1.1 3 | Flask-Migrate==4.0.5 4 | gunicorn==21.2.0 5 | psycopg2-binary==2.9.9 6 | --------------------------------------------------------------------------------