การจัดการ Python development-deploy environment ด้วย docker multi-stage builds + pipenv
Published: 2021-11-21 10:06:36

คนที่ทำงานด้วยกับการ develop แบบ container base ที่ทำกันตั้งแต่เลเวล local develop คงมีปัญหาการกับจัดการ env และ dependency ต่างๆ เพื่อให้ง่าย และแน่นอนอยู่ไม่น้อย

ในบทความนี้เราจะสร้าง develop environment และการผลิต image ที่พร้อมใช้ของ local และ prod โดยมี requirement ต่อไปนี้
- การ build docker image ที่สม่ำเสมอ สามารถทำซ้ำได้ ไม่ depend on build env
- มีข้อที่ไม่เกี่ยวข้องอยู่ใน image น้อยที่สุด
- develop env มีความคล้ายกับ production มากที่สุด โดยไม่เพิ่มความยากในการ development ให้เรา
- ไม่แยก Dockerfile ของ Dev และ Prod
- ง่ายและสดวกต่อการในไปใช้ใน CI CD

## 1 Setup develop env ด้วย pipenv
คิดว่าคงไม่ต้องพูดถึงมากเพราะ python developer คงชินกับ pipenv กันอยู่แล้ว และก็มีหลายบทความพูดถึงอย่างมาก
เรามาพูดถึงว่าทำไมเรายังควรใช้ pipenv อยู่ต่อให้เราย้ายไปใช้ container base กันแล้ว
- ข้อแรกเลยแม้ว่า code จะถูกรันใน docker container แล้วก็ตาม แต่ว่าเราไม่สามารถนำ IDE ตามเข้าไปใน container ได้ครับ ซึ่งพอ IDE เราไม่รู้จัก Lib ที่เราใช้ หรือ version บนเครื่องและ container ไม่ตรงกัน มันจะผิดโจทย์เราตรง "ไม่เพิ่มความยากในการ development ให้เรา"
- จากข้อที่แล้ว ถ้าเราต้อง manage หลาย project ที่มี Lib และ python version ต่างกัน จะยิ่งเพิ่มความยากให้เราครับ เช่น `async.run()` มีแค่ใน python > 3.7
- การลง Lib ตรงๆผ่าน pip เราไม่สามารถ lock version ของ dependency Lib อีกที่ได้ เช่น เราล็อค version Lib `foo='1'` แต่ว่า Lib `foo` จะใช้งานได้ก็ต่อเมื่อมี Lib `bar` ซึ่งเวลาเราสั้ง install pip ก็จะลงให้ทั้ง `foo` และ `bar` ซึ่งถ้า `foo` เนี้ยไม่ได้ล็อค version ของ `bar`ไว้แล้ว การ build ครั้งแรกของเราอาจจะได้ `bar version 1` ต่อในเดือนถัดมา เราจะได้ `bar version 1.1` ก็ได้ครั้ง ซึ่งทำให้เราไม่ได้ image ที่เหมือนในการ build แต่ละครั้ง
- จากข้อข้างบนซึ่งการย้ายจากการลงผ่าน pip ไปเป็นลงผ่าน pipenv ผ่าน Pipfile.lock จะช่วยเรื่องนี้ได้ครับ

ซึ่งขั้นตอนนี้ก็ไม่มีอะไรมากครั้ง แบ่ง development Lib และ prod lib ไว้แยกกัน แล้วก็ลงทั้งหมดใน Pipenv นั้นหล่ะครับ ซึ่งจบขั้นตอนนี้ ควรจะได้ virtual env ที่ลงของพร้อมไว้ครับ

## 2 Build image สำหรับ Production
อันนี้ ต้นแบบ ไอเดียของการทำ multi stage ของผมครับ

อาจจะดูข้ามขั้นไปหน่อย แต่เดียวจะอธิบายครับ เริ่มจาก Dockerfile เราควรจะมีหน้าตาประมาณนี้ครับ
```Dockerfile
FROM kennethreitz/pipenv as build
COPY / /app
WORKDIR /app
RUN git init .
RUN pipenv install --dev \
    && pipenv lock -r > requirements.txt \
    && pipenv run python setup.py bdist_wheel

FROM python:3.7-slim as prod
COPY --from=build /app/dist/*.whl .
COPY --from=build /app/requirements.txt /app/requirements.txt
RUN set -xe \
    && apt-get update -q \
    && python3 -m pip install *.whl \
    && apt-get remove -y python3-pip python3-wheel \
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -f *.whl \
    && rm -rf /var/lib/apt/lists/*
COPY /src /usr/src
WORKDIR /usr/src
```

ถ้าอยากอ่านแบบละเอียด แนะนำให้อ่านบทความที่คุณ Yoan Blanc จากลิ้งต้นแบบที่ผมลงไว้ครับ แต่สามารถสรุปคร่าวๆได้ว่า เค้าทำอะไรบ้างได้ดั้งนี้
- เริ่มจากเค้า import image ของ pipenv มาก่อนให้มันเป็น builder ที่เราจะ install ทุก Lib ที่เราใช้ครับ
- หลังจากนั้นเราทำการ install Library ผ่าน Pipfile และ verify ด้วย hash ใน Pipfile.lock
- สร้าง Python Package ขึ้นมาใหม่ โดยมี Lib ทั้งหมดที่เราลงไป และทำให้มันเป็น Python wheel (ต้อง git init ก่อนเพิ่อให้ python เข้าใจด้วยครับ)
- จากนั่นเราถึงจะเริ่ม image ของเราจริงๆโดยที่ผมใช้เป็น python:3.7-slim
- เราก้อป wheel จาก builder image ในตอนแรกแล้ว install มันลง image ตัวปัจจุบัน
- Remove สิ่งที่ไม่ควรจะต้องใช้แล้ว และ package ออก
- copy source code ของเราเข้า image

เท่านี้เราก็จะได้ image ที่ clean ระดับนึงเพราะว่าเราไม่ต้อง install ของพวก build-essential ยกตัวอย่างเช่น psycopg มักจะต้องการ gcc ในตอน build ครับ แต่พอรันจริงไม่จำเป็นต้องมี

ซึ่งตอนนี้ถ้าใครรัน docker build ก็จะ…. error ครับ (นอกจาก Pipfile error นะครับ image kennethreitz/pipenv เนี้ย req ว่าต้องมี Pipfile กับ Pipfile.lock ที่ root context ของ docker นะครับ)

เพราะไอคำสั่ง pipenv run python setup.py เนี้ยมันคาดหวังว่า setup.py เป็นสิ่งที่เราต้องบอกมันครับ ว่าจะสร้าง package ชื่ออะไร
ฉะนั้นเราต้องมาสร้างมันกันก่อน ซึ่งหน้าตาก็ราวๆนี้
```py
from setuptools import setup

setup(setup_requires=["pbr"], pbr=True)
```
ซึ่งไอ setup.py เนี้ยก็จะถามหา setup.cfg อีก เราก็สร้างมันไว้ข้างๆกันครับหน้าตาราวๆนี้
```toml
[metadata]
name = prod_packages
[files]
packages = prod_packages
```
ไว้ที่ root context นะครับเพราะเค้า `COPY / /app` เข้าไป
ตอนนี้เราก็สามารถสั้ง `docker build` ได้แล้วครับ

## 3 สร้าง "BASE" Image สำหรับ Development
ถ้าถามว่าทำไมเราถึงทำทีหลังก็เพราะว่าความจริงแล้ว Development image มี Lib เยอะกว่าตัว Prod ครับปกติในการ Dev ผมจะลง package ที่ไม่จำเป็นต้องมีใน PROD เช่น
```toml
pytest = "*"
pytest-aiohttp = "*"
pytest-cov = "*"
ipdb = "*"
pyre-check = "*"
```
ขั้นตอนนี้เริ่มจากแยก Folder ก่อนหน่ะครับแล้วเรา ทำขั้นตอนที่ #2 ซ้ำอีกรอบครับ โดยให้ใน Pipfile มีแค่ dev package แบบข้างบนผมครับ แก้แค่ `setup.cfg` ให้ชื่อ package มันต่างกันแบบนี้ครับ
```toml
[metadata]
name = prod_packages
[files]
packages = prod_packages
```
หลังจากนั้นเราก็ build ให้ development base image กันครับด้วย docker build -t miz/python-3.7-dev:latest

## 4 ทำให้ Dockerfile สามารถสร้างได้ 2 image
ถึงตอนนี้ย้อนกลับไปที่ folder หลักเราได้ครับ เราจะมาแก้ Docker file กันหน่อยให้เป็นแบบนี้
```Dockerfile
ARG BASE_IMAGE=${BASE_IMAGE:-miz/python-3.7-dev:latest}
FROM kennethreitz/pipenv as build
COPY / /app
WORKDIR /app
RUN git init .
RUN pipenv install --dev \
    && pipenv lock -r > requirements.txt \
    && pipenv run python setup.py bdist_wheel
 
FROM $BASE_IMAGE
COPY --from=build /app/dist/*.whl .
COPY --from=build /app/requirements.txt /app/requirements.txt
RUN set -xe \
    && apt-get update -q \
    && python3 -m pip install *.whl \
    && apt-get remove -y python3-pip python3-wheel \
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -f *.whl \
    && rm -rf /var/lib/apt/lists/*
COPY /src /usr/src
WORKDIR /usr/src
```
ที่เพิ่มก็คือ `ARG BASE_IMAGE=${BASE_IMAGE:-miz/python-3.7-dev:latest}` ในบรรทัดแรก และ แก้จาก `FROM python:3.7-slim as prod` เป็น `FROM $BASE_IMAGE`

พอเห็นภาพกันมั้ยครับ ซึ่งแบบเนี้ยจะทำให้เราสามารถส่ง Base image เนี้ยได้สองแบบ คือถ้าคุณส่ง Pure python เข้ามาก็ลงแค่ Prod lib แล้วเอาไปรันบน Staging server กับ Prod server ได้ แต่ถ้าจะ local เราก็ไม่ต้องส่งอะไรมาเพราะ เราเซ็ต Development เป็น base image อยู่แล้ว ทำให้เราเอาไปรัน Unit test หรืออะไรได้ครับ

เราก็สามารถแยกที่คำสั้ง build ครับ ถ้าเราจะใช้ local develop ก็
docker-compose build
แต่ถ้าเราจะทำเอาขึ้น server ก็ `docker-compose build --pull --build-arg BASE_IMAGE=python:3.7-slim`

## สรุป
ซึ่งวิธีที่ใช้นี้ก็จะมีข้อดีหลักๆ นอกจาก Requirement ที่เขียนไว้ข้างบนก็คือ build ได้เร็วนะครับเพราะ pipenv image เนี้ยลงของมาเยอะมากครับแทบไม่ต้องลงเองเพิ่มเลย ทำให้ไม่เสียเวลาพวก apt-get update / upgrade อีกและพึ่ง internet น้อยลง นี้ก็ช่วยได้เยอะครับ

ข้อเสียก็แค่ไอ builder เนี้ยมันจะตกกลายเป็น cache ทุกรอบครับ แถมมีขนาดใหญ่ด้วย ตอนผมทำ ผม improve จาก python ขนาดราวๆ 600 MB กลายเป็น builder cache 1.2 GB กับ python 250 MB ซึ่งขนาด image ลดไปเกินเท่า แต่จะได้ขยะเพิ่มใน เครื่องที่ build แทน

ถ้ามีความผิดพลาดได้สามารถชี้แนะได้ครับ