Dockerize Python Web 應用

雖然「人生苦短,我用 Python」,可是不少時候一個 Python 新手寫完第一個 Web 項目以後會陷入 WSGI 是什麼?接下來要幹啥的矇蔽狀態中。不過好在有 Docker 這個神器,相信瞭解它以後,就能體驗 Python + Docker 的雙倍快樂並不html

本文只是一個嚮導,基於本地編排,一步一步來實現一個 Flask 應用的容器化,想要能順暢的閱讀,至少須要瞭解一些 Docker 的基本知識與鏡像構建命令。python

TL;DRgit

樣例項目:TomCzHen/Dockerize-Python-Web-Applicationgithub

典型的 Python Web (Flask) 項目的文件結構大體是這樣的:sql

.
├── app.py
├── Pipfile
├── Pipfile.lock
├── .gitignore
└── .venv
複製代碼
  • app.py
#!/usr/bin/env python3
from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World From Docker!"
複製代碼

注:基於我的偏好而使用了 pipenv 管理 Python 包,若是使用其餘 Python 包管理方式,請自行替換對應文件並修改相關的 Dockerfile 內容。docker

Dockerfile

參考資料shell

使用 Dockerfile 構建鏡像時默認會將當前路徑中全部文件做爲 context 發送到 Docker deamon,須要用 .dockerignore 配置構建過程當中忽略的路徑。數據庫

# .dockerignore
.env
.git
.venv
__pycache__
複製代碼

基礎鏡像

FROM python:3.7.3-slim
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    FLASK_APP="app"
COPY . /app WORKDIR /app RUN pip install pipenv && pipenv install --deploy --system CMD ["flask","run"] 複製代碼

上面的 Dockerfile 構建的鏡像是能夠運行的,不過有一個問題——每次項目代碼發生改變時都會執行安裝依賴,即使依賴並無變化。django

能夠將依賴安裝與更新代碼分離開,當依賴沒有發生變化時就會直接使用構建緩存而不是從新安裝。json

FROM python:3.7.3-slim
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    FLASK_APP="app"
COPY ["Pipfile","Pipfile.lock","/app/"] WORKDIR /app RUN pip install pipenv && pipenv install --deploy --system COPY . /app CMD ["flask","run"] 複製代碼

構建緩存

在 Docker 構建鏡像時會利用緩存加快構建,對緩存機制不注意時會產生一些問題。使用下面這個 Dockerfile 構建鏡像時,若是docker build 不使用 --no-cache 參數,也沒刪除已經構建過的鏡像,構建多個鏡像輸出時間是同樣的。

FROM debian
RUN echo $(date) > test.txt CMD ["cat","test.txt"] 複製代碼

一樣,在 Dockerfile 中使用 git clone 獲取代碼也會有同樣的狀況。

FROM python:3.7.3-slim
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    FLASK_APP="app"
RUN apt-get update && apt-get install -yqq git \ && git clone --depth=1 https://github.com/example/flask-example.git /app 
WORKDIR /app 
RUN pip install pipenv && pipenv install --deploy --system 
CMD ["flask","run"] 複製代碼

首先,在構建過程當中經過 git clone 獲取代碼是可行的,但因爲 Docker 鏡像構建緩存,代碼不必定是最新的,清除緩存雖然能夠避免這個問題,可是會增長構建時間。這樣作還產生另外一個問題——從私有庫獲取代碼時須要將私鑰添加到鏡像中。

能夠採用如下幾種解決方案:

  • 在 CI/CD 平臺中獲取代碼,而後從平臺本地路徑加入代碼到鏡像。

經過 Jenkins 或 GitLab 的 CI 腳本拉取代碼,這樣能夠避免因構建緩存而沒法獲取最新代碼的問題,同時也能夠有效利用緩存加快構建。

  • 下載指定版本的代碼歸檔文件構建。
FROM python:3.7.3-slim
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    FLASK_APP="app"
ARG version=1.0.0
RUN apt-get update && apt-get install -yqq curl tar WORKDIR /app RUN curl -L https://github.com/example/flask-example/releases/download/${version/flask-example-${version}.tar.gz \ | tar xz -C /app --strip-components 1 \ && pip install pipenv \ && pipenv install --deploy --system CMD ["flask","run"] 複製代碼
  • 使用多階段構建

多階段構建仍然存在緩存,不過這裏主要目的是保護私鑰。

FROM debian as builder
ARG tag="1.0.0"
ARG ssh_key=""
RUN apt-get update && apt-get install -yqq git \ && echo ${ssh_key} > ~/.ssh/id.rsa \ && chmod 700 ~/.ssh/id_rsa \ && git clone --depth=1 -b ${tag} ssh://github.com/example/flask-example.git /app \ && rm -rf app/.git 
FROM python:3.7.3-slim
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    FLASK_APP="app"
COPY --from=builder ["/app","/"] COPY . /app WORKDIR /app RUN pip install pipenv && pipenv install --deploy --system CMD ["flask","run"] 複製代碼

不管採起何種方式,若是想確保每次構建獲取的都是最新的代碼,要麼使用 --no-cache 參數,要麼在獲取代碼前引入參數變化。

優化結構

當存在多個項目時上面的 Dockerfile 會產生很是多的冗餘內容,並且須要更新一些環境工具依賴時須要修改多處 Dockerfile 才能實現。

能夠在 FROM 時使用本身構建的鏡像做爲基礎鏡像來解決這個問題。

# tomczhen/python-pipenv-base
FROM python:3.7.3-slim
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -yqq git \ && pip install -U pipenv WORKDIR /app ONBUILD COPY ["Pipfile","Pipfile.lock","/app"] ONBUILD RUN pipenv install --deploy --system CMD ["flask","run"] 複製代碼

假設構建後的鏡像標籤爲 `tomczhen/python-pipenv-base:3.7.3

FROM tomczhen/python-pipenv-base:3.7.3
ENV FLASK_APP="app"
COPY . /app CMD ["flask","run"] 複製代碼

優化體積

須要說明的是 docker images 顯示的體積並不是傳輸時的大小。其次,基於 Docker 鏡像分層的機制,傳輸和保存時會複用已經存在的分層,因此體積並不是一個很大的問題。

須要儘可能減小鏡像體積時能夠選擇 python:3.7.3-alpine3.9 這個基於 alpine 構建的 python 基礎鏡像。

FROM python:3.7.3-alpine3.9
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    FLASK_APP="app"
COPY ["Pipfile","Pipfile.lock","/app/"] WORKDIR /app RUN pip install pipenv && pipenv install --deploy --system COPY . /app CMD ["flask","run"] 複製代碼
代價是什麼?

參考資料:

alpine 使用的是 musl-libc 而不是 glibc,對於 CPython ,有些函數使用了 C library 的基礎功能,不一樣的實現有差別存在——包括性能和運行結果。而 alpine 體積小的優點是依靠大量精簡來實現的,好比默認只有 sh 做爲解釋器,一些常見外部命令也沒法使用,須要本身安裝運行依賴來確保代碼正常運行。

考慮到所以帶來的代價,只有當體積是一個不得不解決的問題時,才使用 alpine 做爲基礎鏡像。不然,除非有能力解決 musl-libc 差別引發的問題,而且願意接受所以帶來的時間成本與風險才使用它。

編譯安裝

Python 包正常使用的依賴區分爲兩種:安裝依賴、運行依賴——當安裝依賴有問題時,安裝會出錯;當運行依賴不存在時運行會出錯。

在 alpine 中未安裝 ca-certificates 以前,系統沒有配置 CA 證書,若是 Python 包依賴系統 CA 證書,那麼 HTTPS 鏈接會出現校驗錯誤。若是 Python 包存在 Building wheel 階段,缺乏編譯依賴時會沒法安裝,好比 psycopg2。

注:固然,能夠安裝 psycopg2-binary 來解決問題,不過官方文檔明確的說明:

The binary package is a practical choice for development and testing but in production it is advised to use the package built from sources.

當 Python 包中存在 build wheel 階段而且須要安裝 gcc 之類的編譯環境時有兩種選擇。

  • 發行版包管理器

    發行版中通常存在 python3-xxxx 的包,能夠經過發行版的包管理器進行安裝。若是符合須要,那麼直接經過包管理安裝也是很好的選擇,能夠節省不少時間。

  • 編譯安裝

    發行版提供的二進制安裝包版本不能知足須要,或者乾脆就沒有提供(alpine 常見)對應的包,而 pip 安裝又有 build wheel 階段,那麼只能選擇根據文檔安裝好編譯依賴。

多階段構建

在須要編譯安裝的場合,推薦使用多階段構建的方式構建 Docker 鏡像。同時,可使用虛擬環境來進一步壓縮鏡像體積,以安裝 psycopg2 爲例。

FROM python:3.7.3-slim as builder
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -yqq gcc \ python3-dev \ libpq-dev \ && pip install pipenv 
COPY ["Pipfile","Pipfile.lock","/app/"] WORKDIR /app RUN mkdir .venv \ && pipenv install --deploy 
FROM python:3.7.3-slim
ENV PATH="/app/.venv/bin:${PATH}" \
    PYTHONDONTWRITEBYTECODE=1
    FLASK_APP="app"
RUN apt-get update && apt-get install libpq5 -yqq COPY --from=builder ["/app","/"] COPY . /app CMD ["flask", "run"] 複製代碼

WSGI Server

參考資料

實現鏡像構建以後,開發環境中項目文件結構大體會變成這樣:

.
├── Dockerfile
├── app.py
├── Pipfile
├── Pipfile.lock
├── .dockergitignore
├── .gitignore
└── .venv
複製代碼

顯然部署 Python Web 項目時是不能使用 flask run 方式運行的,通常會使用 Nginx/Caddy + WSGI Server 的方式,因此 Dockerfile 還須要進行修改。另外還有一個問題是 Docker 中默認以 root 用戶權限運行,爲了安全方面的考量,須要切換爲非 root 用戶。

根據一個服務一個容器的原則,Web Server 容器和 WSGI Server 容器須要分開運行。但這不是必須的,若是確實有必要,將多個應用放在同一個容器中運行也是可行的。

爲了後續多個 Dockerfile 的管理,須要調整一下項目結構:

.
├── docker
│   ├── Dockerfile
│   ├── docker-entrypoint.sh
│   └── caddy
│       └── Dockerfile
├── app.py
├── docker-compose.yaml
├── Pipfile
├── Pipfile.lock
├── .dockergitignore
├── .env
├── .gitignore
└── .venv
複製代碼

注:在 .gitignore .dockergitignore 中添加忽略 .env。

uWSGI

參考資料:

注:因爲 Caddy 的 uwsgi 協議支持只是仍是非正式的,因此 uWSGI 的配置與使用 Nginx uwsgi module 時有些差異。

ARG python_version="3.7.3"
FROM python:${python_version}-slim as builder
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -yqq gcc \ python3-dev \ libpq-dev \ && pip install pipenv 
COPY ["Pipfile","Pipfile.lock","/app/"] WORKDIR /app ARG uwsig_version="2.0.18"
RUN mkdir .venv \ && pipenv install --deploy \ && pipenv install uwsgi==${uwsig_version} --skip-lock 
FROM python:${python_version}-slim
RUN apt-get update && apt-get install libpq5 -yqq 
WORKDIR /app 
ENV PATH="/app/.venv/bin:${PATH}" \
    PYTHONDONTWRITEBYTECODE=1 \
    UWSGI_HTTP_SOCKET=":3031" \
    UWSGI_MASTER=1 \
    UWSGI_WORKERS=2 \
    UWSGI_THREADS=4 \
    UWSGI_WSGI_FILE="app.py" \
    UWSGI_CALLABLE="app" \
    UWSGI_VIRTUALENV="/app/.venv" \
    UWSGI_STATUS=":9191"
COPY --from=builder ["/app","/app"] COPY . /app EXPOSE 3031 9191
USER nobody
CMD ["uwsgi"] 複製代碼

ENTRYPOINT

經過 ENV 能夠爲容器添加環境變量,前面已經使用了 PIP_NO_CACHE_DIRPYTHONDONTWRITEBYTECODE 來配置 pip 使用 cache,Python 不產生字節碼文件,uWSGI/Gunicorn 也支持環境變量來配置,Flask 也有不少變量能夠用於配置。

除此以外,可能還須要在啓動以前作一些處理,那麼就須要 ENTRYPOINT 了,一個典型的場景是等待數據庫服務可用後才啓動。

#!/usr/bin/env bash
# docker/docker-entrypoint.sh

retry_times=0
sleep_sec=5

check_database_uri() {
    local database_uri="${DATABASE_DRIVER}://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}"

    if [[ -z "${database_uri}" ]]; then
        echo "SQLALCHEMY_DATABASE_URI not set or empty"
        exit 1
    fi

    python -c "from sqlalchemy import create_engine;engine = create_engine('${database_uri}');conn=engine.connect();conn.execute('SELECT 1');conn.close()"

}

main() {

    if [[ ${1} = "uwsgi" && ${#} = 1 ]];then

        until check_database_uri; do
            if [[ ${retry_times} -lt 3 ]]; then
                >&2 echo "Database server is unavailable - retry after ${sleep_sec} sec"
                retry_times=$((${retry_times} + 1))
                sleep ${sleep_sec}
            else
                exit 1
            fi
        done
    fi

    exec $@
}

main "$@"
複製代碼
ARG python_version="3.7.3"
FROM python:${python_version}-slim as builder
ENV PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1
RUN apt update && apt install -yqq gcc \ python3-dev \ libpq-dev \ && pip install pipenv 
COPY ["Pipfile","Pipfile.lock","/app/"] WORKDIR /app ARG uwsig_version="2.0.18"
RUN mkdir .venv \ && pipenv install --deploy \ && pipenv install uwsgi==${uwsig_version} --skip-lock 
FROM python:${python_version}-slim

COPY --chown=root:root ["docker/docker-entrypoint.sh","/usr/local/bin/"] RUN apt update && apt install libpq5 -yqq \ && chmod +x "/usr/local/bin/docker-entrypoint.sh" 
WORKDIR /app 
ENV PATH="/app/.venv/bin:${PATH}" \
    PYTHONDONTWRITEBYTECODE=1 \
    UWSGI_HTTP_SOCKET=":3031" \
    UWSGI_MASTER=1 \
    UWSGI_WORKERS=2 \
    UWSGI_THREADS=4 \
    UWSGI_WSGI_FILE="app.py" \
    UWSGI_CALLABLE="app" \
    UWSGI_VIRTUALENV="/app/.venv" \
    UWSGI_STATUS=":9191"

COPY --from=builder ["/app","/app"] COPY . /app ENTRYPOINT ["docker-entrypoint.sh"] EXPOSE 3031 9191
USER nobody
CMD ["uwsgi"] 複製代碼

注意:須要保證 docker-entrypoint.sh 有可執行權限。

Caddy

Web Server 選擇基本就是 Nginx 了,可是本文使用 Caddy :doge:,考慮到 Caddy 的插件是在編譯時引入的,那麼經過自定義 Dockerfile 來構建 Caddy 鏡像是有必要的。

# docker/caddy/Dockerfile
FROM alpine:latest as builder
RUN apk add --no-cache curl bash gnupg 
ARG plugins="http.cache,http.cors,http.expires,http.realip,http.git"
RUN curl https://getcaddy.com | bash -s personal ${plugins} 
FROM alpine:latest

RUN apk add --no-cache openssh-client ca-certificates git 
COPY --from=builder ["/usr/local/bin/caddy","/usr/local/bin/"] 
ENV CADDYPATH="/caddy"

WORKDIR /caddy 
RUN mkdir -p "etc" "www" "logs" VOLUME ["/caddy"] EXPOSE 80 443 2015
CMD ["caddy","-agree","-conf","etc/Caddyfile","--log","stdout"] 複製代碼
  • Caddyfile
flask.exmaple.com {
    root /caddy/www/flask
    tls flask@example.com
    proxy / flask:3031 {
        except /static /robots.txt
        transparent
    }
}
複製代碼

Docker Compose

最後經過 docker-compose.yaml 編排文件來把全部的應用組合起來。

# docker-compose.yaml
version: "3.7"
services:
 flask:
 build:
 context: .
 dockerfile: docker/Dockerfile
 restart: on-failure
 networks:
 - caddy-network
 - flask-network
 depends_on:
 - caddy
 - postgres
 volumes:
 - type: volume
 source: flask-static-data
 target: /app/static
 env_file:
 - .env
 logging: &logging
 driver: "json-file"
 options:
 max-size: "20m"
 max-file: "10"
 postgres:
 image: postgres:11.3-alpine
 environment:
 POSTGRES_USER: ${DATABASE_USER}
 POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
 POSTGRES_DB: ${DATABASE_NAME}
 networks:
 flask-network:
 aliases:
 - ${DATABASE_HOST}
 volumes:
 - type: volume
 source: postgres-data
 target: /var/lib/postgresql/data
 logging:
      <<: *logging
 caddy:
 build:
 context: docker/caddy
 restart: unless-stopped
 networks:
 - caddy-network
 volumes:
 - type: volume
 source: flask-static-data
 target: /caddy/www/flask/static
 read_only: true
 - type: volume
 source: caddy-data
 target: /caddy
 - type: bind
 source: ./docker/caddy/Caddyfile
 target: /caddy/etc/Caddyfile
 ports:
 - target: 80
 published: 80
 protocol: tcp
 mode: host
 - target: 443
 published: 443
 protocol: tcp
 mode: host
 logging:
      <<: *logging

networks:
 caddy-network:
 name: caddy-network
 flask-network:
 name: flask-network

volumes:
 flask-static-data:
 postgres-data:
 caddy-data:
複製代碼

經過加載 .env 文件對容器進行配置,須要注意,開發環境下若是使用了 pipenv ,激活虛擬環境時也會自動加載 .env 配置環境變量。

注意:docker-compose 自動加載 .env 文件只對 docker-compose.yaml 內容產生影響,並不會傳入容器內部,可使用 docker-compose config 查看完整內容。若是想加載到容器內部,要麼使用 env_file 加載,要麼在 environment 再指定一次變量。

# .env
# Flask Config

FLASK_APP=app
 # Database Config

DATABASE_DRIVER=postgresql+psycopg2
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=flask
DATABASE_USER=flask
DATABASE_PASSWORD=P@ssw0rd
 # uWSGI Config

UWSGI_WORKERS=4
UWSGI_THREADS=2
複製代碼

這樣只須要編排文件,加上 .env 配置,就能在不一樣配置下運行了。若是狀況容許,能夠經過將 Caddyfile 添加到 Caddy 鏡像中,並經過 Caddy 支持的變量方式來配置。

{$CADDY_DOMAIN} {
    root /caddy/www/{$CADDY_DOMAIN}
    gzip
    tls {$CADDY_TLS_EMAIL}
    proxy / flask:3031 {
        except /static /robots.txt
        transparent
    }
}
import /caddy/etc/*.Caddyfile
複製代碼
相關文章
相關標籤/搜索