Tạo kho lưu cho các gói thư viện Python

Python là ngôn ngữ chính cho hệ thống backend của AgriConnect. Với đội ngũ giàu kinh nghiệm về Python, luôn đẩy sự khai thác, "bóc lột" Python đến mức cao nữa, cao nữa, nên quá trình vận hành của AgriConnect thường dẫn đến những nhu cầu "không giống ai". Một trong số đó là nhu cầu dựng một kho chứa gói thư viện Python "tại nhà". Bài dưới đây xin chia sẻ kinh nghiệm như thế.

Khi hệ thống của AgriConnect vận hành, tác vụ cài đặt các gói thư viện Python được thực hiện lặp đi lặp lại rất nhiều lần. Chúng đến từ:

  • Hệ thống chạy test tự động, kích hoạt mỗi khi có code mới được đẩy lên Git.

  • Việc deploy bản cập nhật đến các server nội bộ đặt rải rác ở các trang trại khách hàng.

Đa số các máy mà quá trình cài đặt thư viện Python diễn ra, đều nằm ở Việt Nam, nên việc để pip kéo phần mềm từ kho chính thức của Python (https://pypi.org/) làm tôi thấy chưa được tối ưu. Không những phí thời gian truyền tải (đường xa), làm chật chội đường truyền đi ra nước ngoài mà còn tạo áp lực lên hạ tầng của PyPI. Tôi không muốn tạo gánh nặng về chi phí bảo trì lên họ, khi tôi đã được sử dụng dịch vụ miễn phí của họ. Ngoài ra còn có một lý do thiết thực khác là nhu cầu về sự đa dạng nền tảng cần hỗ trợ, ví dụ:

  • Quá trình test tự động, được chạy trong các Docker container, build từ Alpine Linux. Khác với các bản Linux phổ biến khác (Ubuntu, RedHat v.v..), bản Linux này sử dụng thư viện chuẩn C musl. Các gói thư viện Python build sẵn (dưới dạng file *.whl) có trên PyPI đều dành cho thư viện chuẩn C glibc nên không sử dụng được. Nếu build lại từ source trong container luôn thì cực tốn thời gian. Một host riêng là cần thiết để chứa các gói build sẵn này, nhưng dành cho musl.

  • Cloud server phục vụ các khách hàng không có nhu cầu dùng server nội bộ. Server này dùng Ubuntu thông thường nên không có vấn đề gì.

  • Các server nội bộ triển khai cho khách hàng, sử dụng BeagleBone, một loại máy tính Linux nhúng với CPU ARM. Phần lớn các gói thư viện Python build sẵn trên PyPI cũng đều không có bản dành cho ARM. Build các gói ngay trên BeagleBone, trong lúc deploy, là việc không thể, đơn giản vì quá tải (Tôi phải thuê server bare metal với CPU ARM từ Scaleway để làm công đoạn build, trước khi phân phối đến các board nhúng này).

Trước đây tôi tận dụng một số dịch vụ cloud, để chứa các gói Python này. Các dịch vụ gồm có: PackageCloud, Gemfury, CloudSmith. Ưu điểm của chúng:

  • Nhanh gọn, không phải setup server này nọ. Đăng ký là dùng được ngay thôi. Tất nhiên vẫn cần cài phần mềm client để upload gói lên.
  • Giao diện Admin để quản lý các gói (đang chứa các gói nào, phiên bản bao nhiêu, dành cho nền tảng nào, nội dung bên trong gồm file gì).
  • Có tài khoản miễn phí

Thế nhưng dần dần tôi thấy những dịch vụ kia vẫn còn trở ngại nhỏ:

  • PackageCloud có giới hạn dung lượng download mỗi tháng cho tài khoản miễn phí, và với hoạt động của AgriConnect thì chúng hết rất nhanh.
  • Gemfury không cho chia nhỏ các kho ra. Mỗi tài khoản chỉ có một kho để tống hết tất cả các gói vào, không tiện quản lý. Mỗi lần upload một gói mới lên (không trùng tên với gói đã có) thì phải vào website để chuyển gói đó sang chế độ public.
  • CloudSmith tốc độ upload gói rất chậm, khi upload một gói trùng với phiên bản đã có trên kho thì nó coi là lỗi và hiện rất nhiều báo lỗi trên trang quản lý.

Nên tôi quyết định tận dụng các server sẵn có (hầu hết đặt trong nước) đề làm kho lưu luôn.

Tôi không có nhu cầu setup một trang với chức năng đầy đủ, y xì như PyPI. Cái tôi cần là:

  • Setup nhanh, không cần tạo database, người dùng, xác thực.
  • Upload bằng công cụ dòng lệnh sẵn có (ví dụ scp, lftp).
  • Có thể upload cùng lúc nhiều file (công cụ của CloudSmith không có chức năng này nên tôi phải dùng mẹo, kết hợp với fd-find).

Sau một lúc rà qua các phần mềm khác nhau thì tôi thấy dumb-pypi gần với ý đồ của tôi nhất. Đặc điểm của dump-pypi:

  • Dùng phương thức host file tĩnh để phục vụ download các gói. Điều đó có nghĩa tôi chỉ cần một web server thông thường, như Nginx. Chỉ đối xử các gói như file tĩnh nên không cần ngôn ngữ lập trình gì hết (không cần chạy một trang PHP chẳng hạn).
  • Để tạo một trang danh sách các gói mà pip có thể hiểu được thì dump-pypi sẽ giúp sinh ra một trang HTML tĩnh. Không cần có một trang web động, ví dụ PHP, để sinh ra danh sách này.
  • Vì chỉ host file tĩnh nên chỉ cần dùng bất cứ công cụ nào để upload file lên server, đặt vô đúng thư mục. Tức là tôi có thể dùng scp, tận dụng SSH để xác thực.

Chỉ có điều, trái với mong đợi của tôi, cách setup ban đầu của nó không nhanh tí nào. Lí do là tác giả ưu tiên tình huống lưu file trên S3 nên tài liệu tập trung vào nó hơn. Hướng dẫn với Nginx thì sơ sài, thậm chí cũ, chẳng biết dành cho Nginx đời nào mà đem cho Nginx mới (trên Ubuntu 19+) thì bị báo lỗi. Đó là lí do tôi phải ghi ra bài này.

Giả sử tôi dùng thư mục /srv/PyPI làm kho lưu các gói. Do nhu cầu "hỗ trợ đa nền tảng" ở trên mà trong thư mục này tôi lại chia ra thêm 2 thư mục con:

  • glibc: Dành cho các gói *.whl được build cho hệ thống nào dùng glibc làm thư viện C chuẩn. Áp dụng cho cả CPU x86_64armhf.
  • musl: Dành cho các gói *.whl được build cho hệ thống nào dùng musl làm thư viện C chuẩn, tức là trong Docker container. Chỉ cần tương thích CPU x86_64.

Do tên các gói của Python không phân biệt -_ nên ta phải cấu hình cho Nginx sao cho khi pip yêu cầu gói vietnam_provinces thì Nginx vẫn trả về vietnam-provinces. Để thực hiện được tính năng này thì ta sẽ viết một hàm Lua ngắn phụ trợ cho Nginx. Trước tiên, cần cài module Lua cho Nginx. Trên Ubuntu/Debian thì cài bằng lệnh sau:

sudo apt install libnginx-mod-http-lua

Tạo một file cấu hình virtual host cho Nginx. Ví dụ, trên Ubuntu thì tạo file /etc/nginx/sites-available/pypi.conf với nội dung như sau:

server {
    server_name pypi.agriconnect.vn;

    root /srv/PyPI;
    index index.html;
    sendfile on;
    autoindex on;

    location / {
        set_by_lua_block $canonical_uri {return string.gsub(string.lower(ngx.var.uri), '[-_.]+', '-')}
        try_files $uri $uri/index.html $uri/ $canonical_uri $canonical_uri/index.html =404;
    }

    location ~*  /.+/json$ {
        default_type "application/json; charset=utf-8";
    }

    listen 80;
    listen 443 ssl http2;
    ssl_certificate /etc/letsencrypt/live/pypi.agriconnect.vn/fullchain.cer;
    ssl_certificate_key /etc/letsencrypt/live/pypi.agriconnect.vn/pypi.agriconnect.vn.key;
}

Lưu ý rằng cấu hình này cũng bật HTTPS cho kho lưu của chúng ta (ví dụ pypi.agriconnect.vn), không thì sẽ phải nghe pip càm ràm khi tải gói qua HTTP trần.

Trong mỗi thư mục trên, ta lại sẽ bố trí các file như sau:

/srv/PyPI/glibc$ tree -L 1
.
├── gen.sh
├── packages/
├── packages.txt

Trong đó thư mục packages là nơi ta sẽ upload các file *.whl lên. File gen.sh là script mà ta sẽ chạy để sinh ra cấu trúc thư mục tương tự nhìn thấy trên PyPI qua con mắt của pip, để nó biết có thể download được gói nào từ server này.

Nội dung của gen.sh như sau:

#!/bin/sh

dumb-pypi --package-list packages.txt --packages-url ../../packages --output-dir .

Không biết vì lý do gì mà tác giả của dumb-pypi không cho nó tự nhìn vào thư mục packages để tự lập danh sác các gói, mà phải nhận đầu vào là một file chứa sẵn danh sách các gói, là file packages.txt. Mặc dù dumb-pypi hơi ngu (đúng với cái tên dumb) nhưng ta có thể sinh ra file đó rất dễ dàng như sau:

$ cd packages
$ ls > ../packages.txt

Mỗi lần upload gói mới, ta sẽ cần tạo lại file packages.txt, chạy lại ./gen.sh để sinh ra các thư mục như sau:

$ broot
/srv/PyPI/glibc
├──gen.sh
├──index.html
├──packages
│  ├──asyncpg-0.18.3-cp37-cp37m-linux_armv7l.whl
│  ├──Brotli-1.0.7-cp37-cp37m-linux_armv7l.whl
│  ├──cchardet-2.1.5-cp37-cp37m-linux_armv7l.whl
│  ├──cffi-1.13.2-cp37-cp37m-linux_armv7l.whl
│  └──60 unlisted
├──packages.txt
├──pypi
│  ├──aiohttp …
│  ├──asyncpg …
│  ├──brotli …
│  ├──cchardet …
│  ├──cffi …
│  └──30 unlisted
└──simple
   ├──aiohttp …
   ├──asyncpg …
   ├──brotli …
   ├──cchardet …
   ├──cffi …
   └──32 unlisted

Từ đây trở đi, mỗi lần sử dụng pip, ta thêm tham số như sau:

pip install --extra-index-url=https://pypi.agriconnect.vn/glibc/simple

thì pip sẽ ưu tiên dò tìm và tải gói từ kho riêng của chúng ta.

Nếu ta không trực tiếp dùng pip, mà quản lý gói phụ thuộc bằng công cụ khác, như Poetry chẳng hạn, thì ta truyền cấu hình kho riêng qua biến môi trường, ví dụ:

export PIP_EXTRA_INDEX_URL=https://pypi.agriconnect.vn/glibc/simple
poetry add asyncpg
poetry install --no-root

Hình ảnh cho thấy pip đang download gói từ kho riêng:

pip_custom_repo

Nhân đây, pypi.agriconnect.vn là một địa chỉ kho thật và bạn có thể dùng chung nó với chúng tôi (chỉ được download thôi nhé) nếu có cùng đam mê với Python.

Cập nhật

  • Để đỡ công nhảy ra nhảy vào tạo danh sách gói, có thể đưa bước đó vào script gen.sh như sau:

    #!/bin/bash
    
    pushd packages
    ls > ../packages.txt
    popd
    rm -rf pypi simple
    dumb-pypi --package-list packages.txt --packages-url ../../packages --output-dir .