The flask, gunicorn, nginx, stack is a powerfull toolchain.There are many tutorial about how to dockerizing it, but it is different. We will do it from a distroless way, with a security concern point of view.
Table of Contents
1) Distroless Concept
What are distroless containers ?
Think in slim down linux distribution, where are removed all unused things.
Don't have shell or package manager, come without: apt, apk, pip, bash, etc..
When remove all unnecessary things get a bonus of size saving, halving or even more the final size.So is more faster to upload it. From a security concern we obtain a small surface attack area.
To get more info about it: Google Container Tools
The concept is clear: Just only add the application runtime and the accesories libraries.
2) How to work with theys
The gcr.io/distroless/python3
is a batteries not included toy.
The classic recipe resolve all in one saucepan:
FROM python:3.6
ADD . /app
WORKDIR /app
RUN pip install flask gunicorn
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app"]
Now the build process change, is more like a crispy home fries recipe, first boil the potatoes in water, then drain and cool, add spices and finally send to oven.
First we will take a base system python:3.6-slim
and call build-env
, over this it will be added the needed packages. Second move the packages and our application to the distroless image gcr.io/distroless/python3
.
To do it, here is where multistage build come to help us: Docker: Use multi-stage builds
2.2) Package installing
The packages to install:
#file:requirements.txt
flask==1.1.4
gunicorn>19
To build our images, two stages are needed:
# file: flask_gunicorn_distroless.dockerfile
FROM python:3.6-slim AS build-env
WORKDIR /app
# First install packages to avoid re-run pip install
COPY requirements.txt ./
RUN pip install --disable-pip-version-check -r requirements.txt --target /packages
COPY ./versions.py /app
COPY ./app.py /app
COPY ./standalone.py /app
FROM gcr.io/distroless/python3
WORKDIR /app
COPY --from=build-env /app /app
COPY --from=build-env /packages /packages
#instead of COPY --from=build-env /packages /usr/local/lib/python/site-packages
ENV PYTHONPATH=/packages
#instead of ENV PYTHONPATH=/usr/local/lib/python/site-packages
CMD ["standalone.py"]
Some Tips:
Inside distroless the nexts command does not works:
RUN mkdir /app
RUN cp ./source /app
Instead must to use:
WORKDIR /app
it performs mkdir and cd implicitly.
COPY ./app /app
will create target directory
2.2) See what is inside
The image gcr.io/distroless/python3
come with some preinstalled packages, just to see what is inside of it, a small python file is writed.
# file version.py
import sys
import os
import argparse
from pathlib import Path
print("Versions:------------------")
parser = argparse.ArgumentParser()
parser.add_argument('folder', type=str, help='The folder to list.', nargs='?', default=os.getcwd())
#--------------------------
import flask
print("Flask Version:",flask.__version__)
import gunicorn
print("Gunicorn Version:",gunicorn.__version__)
#--------------------------
print("Getcwd: ",os.getcwd())
print("Sys.path count:",str(len(sys.path)))
print('\n'.join(sys.path))
def listFolders(str_path: str):
dir_path=Path(str_path).expanduser()
print(" Packages:------------------")
try:
for path in dir_path.iterdir():
if path.is_dir():
print(path)
except PermissionError:
pass
def main(args):
print('Folder:'+args.folder)
listFolders(args.folder)
if __name__ == "__main__":
main(parser.parse_args())
#listFolders('/packages')
In the dockerfile change the last line to:
CMD ["versions.py", "/usr/lib/python3.9"]
Rebuild the image:
docker build --tag flask_gunicorn_distroless --file flask_gunicorn_distroless.dockerfile .
And execute:
docker run flask_gunicorn_distroless
In the console will be listed the paths and the pre installed packages.
:docker run flask_gunicorn_distroless
Versions:------------------
Flask Version: 1.1.4
Gunicorn Version: 20.1.0
Getcwd: /app
Sys.path count: 5
/app
/packages
/usr/lib/python39.zip
/usr/lib/python3.9
/usr/lib/python3.9/lib-dynload
Folder:/usr/lib/python3.9
Folders:------------------
/usr/lib/python3.9/multiprocessing
/usr/lib/python3.9/zoneinfo
/usr/lib/python3.9/sqlite3
/usr/lib/python3.9/distutils
/usr/lib/python3.9/venv
/usr/lib/python3.9/http
/usr/lib/python3.9/xml
/usr/lib/python3.9/asyncio
/usr/lib/python3.9/collections
/usr/lib/python3.9/wsgiref
/usr/lib/python3.9/encodings
/usr/lib/python3.9/pydoc_data
/usr/lib/python3.9/ctypes
/usr/lib/python3.9/xmlrpc
/usr/lib/python3.9/test
/usr/lib/python3.9/email
/usr/lib/python3.9/importlib
/usr/lib/python3.9/curses
/usr/lib/python3.9/html
/usr/lib/python3.9/json
/usr/lib/python3.9/logging
/usr/lib/python3.9/dbm
/usr/lib/python3.9/lib-dynload
/usr/lib/python3.9/urllib
/usr/lib/python3.9/concurrent
/usr/lib/python3.9/unittest
/usr/lib/python3.9/__pycache__
2.3) Running Flask
Just for sanity check, see if our flask application can run:
#file:app.py
from time import gmtime, strftime
from flask import Flask
application = Flask(__name__)
@application.route("/")
def index():
return {'status':'Available','time':strftime("%H:%M:%S", gmtime()) }
if __name__ == "__main__":
application.run(host="0.0.0.0", port=5000,debug=True)
In the dockerfile change the last line to:
CMD ["app.py"]
Rebuild the image:
docker build --tag flask_gunicorn_distroless --file flask_gunicorn_distroless.dockerfile .
And execute:
docker run -p 80:5000 flask_gunicorn_distroless
In your preferred web browser open the adress: http://localhost
, if all is ok, a json will be showed.
2.4) Running Gunicorn
In the classic recipe, the usual way to run Gunicorn is:
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app"]
But now the ENTRYPOINT
is /usr/bin/python3.9
So need to run gunicorn from inside python:
The idea is subclass gunicorn.app.base.BaseApplication
and overload load_config
and load
.
# file:standalone.py
from app import application
import multiprocessing
import gunicorn.app.base
def number_of_workers():
return (multiprocessing.cpu_count() * 2) + 1
class StandaloneApplication(gunicorn.app.base.BaseApplication):
def __init__(self, app, options=None):
self.options = options or {}
self.application = app
super().__init__()
def load_config(self):
config = {key: value for key, value in self.options.items()
if key in self.cfg.settings and value is not None}
for key, value in config.items():
self.cfg.set(key.lower(), value)
def load(self):
return self.application
if __name__ == '__main__':
options = {
'bind': '%s:%s' % ('0.0.0.0', '8000'),
'workers': number_of_workers(),
}
StandaloneApplication(application, options).run()
In the dockerfile change the last line to:
CMD ["standalone.py"]
Rebuild the image:
docker build --tag flask_gunicorn_distroless --file flask_gunicorn_distroless.dockerfile .
And execute:
docker run -p 80:8000 flask_gunicorn_distroless
In your preferred web browser open the adress: http://localhost
, if all is ok, a json will be showed.
3) Vulnerability Scanning
We will use Clair to perform a static analysis of vulnerabilities.
1) Run the vulnerabilities database:
docker run -d --name db arminc/clair-db:latest
2) Run the service:
docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan
3) Run the client:
Download from:Clair Scanner
Retrieve the image id:
docker image ls flask_gunicorn_distroless
REPOSITORY TAG IMAGE ID CREATED SIZE
flask_gunicorn_distroless latest fa824d3ab0a6 3 minutes ago 70.8MB
Scan, passing the loopback our own ip, and the image id:
./clair-scanner_linux_386 --ip=127.0.0.1 fa824d3ab0a6 | grep Unapproved | wc -l
Get 0 vulnerabilities
Compare with the python:3.9-slim
Retrieve the image id:
docker image ls python:3.9-slim
REPOSITORY TAG IMAGE ID CREATED SIZE
python 3.9-slim 5e714d33137a 5 days ago 122MB
Scan, passing the loopback our own ip, and the image id:
./clair-scanner_linux_386 --ip=127.0.0.1 5e714d33137a | grep Unapproved | wc -l
Get 39 vulnerabilities:
Top comments (0)