How to add flask, gunicorn, packages to a distroless docker container
Tags: docker, flask, gunicorn
Published: 2021-11-25 19:57:13

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](#distroless)
* [2-How to work with theys](#howtowork)
* [3-Adding Packages](#packages)
* [4-Security Scanning](#scanning)


## 1) Distroless Concept <a name="distroless"></a>


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](https://github.com/GoogleContainerTools/distroless)

The concept is clear: Just only add the application runtime and the accesories libraries.


## 2) How to work with theys <a name="howtowork"></a>

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](https://docs.docker.com/develop/develop-images/multistage-build/)

### 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.

```python
# 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:

```python
#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.

![Json Output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ul3w02njgmt4qdtxbgfh.png)

### 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:

[Gunicorn Custom Application](https://docs.gunicorn.org/en/stable/custom.html)


The idea is subclass `gunicorn.app.base.BaseApplication` and overload `load_config` and `load`.

```python
# 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.

![Json Output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ul3w02njgmt4qdtxbgfh.png)

## 3) Vulnerability Scanning <a name="scanning"></a>

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](https://github.com/arminc/clair-scanner/releases)

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

![flask_gunicorn_distroless](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/621whgo4kkld1bsvxu05.png)

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:



![python:3.9-slim](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bbhozdoc9dpxgzw8qo9z.png)