Triển khai tự động ứng dụng web Python

Gần đây nghe sự cố máy chủ DeepSeek bị lộ dữ liệu do để mở cổng database toang hoác, làm tôi nhớ đến cách triển khai ứng dụng web của mình, trong đó mình đóng cổng database luôn, chỉ cho truy cập qua Unix domain socket (dạng file), và cũng không cần tạo password, không cần nhớ, không cần giấu password. Thủ thuật này đã được nhắc đến trong một bài blog khác bằng tiếng Anh, nay dịch ra cho anh em tham khảo. Bài viết đó nói về một chủ đề rộng hơn là "cách triển khai ứng dụng web Python một cách tự động".

Gần đây tôi thấy một câu hỏi từ một đồng nghiệp Python, làm thế nào để triển khai ứng dụng Django mà không cần phải SSH thủ công vào máy chủ và chạy các lệnh. Phong cách này là "single server deployment". triển khai trên một máy chủ duy nhất, nơi bạn đặt tất cả các thành phần, từ mã ứng dụng, cơ sở dữ liệu, đến các tệp tĩnh, tệp media, trên cùng một máy chủ. Không có Docker tham gia. Với kiểu triển khai này, chúng ta sẽ cần một cách nào đó để cung cấp phiên bản mới của ứng dụng mỗi khi mã mới được đẩy lên nhánh "release" của kho lưu trữ Git.

Tại sao cần tự động hóa? Vì làm đi làm lại những việc này rất nhàm chán:

  • SSH vào máy chủ, cd vào thư mục cài đặt.
  • Chạy git pull.
  • Chạy các lệnh để dừng các dịch vụ của ứng dụng web của ta.
  • Kiểm tra và cài đặt các gói Python nếu cần.
  • Migrate cơ sở dữ liệu.
  • Kiểm tra và sao chép các tệp tĩnh (CSS, JS, hình ảnh) vào một thư mục dành cho Nginx.
  • Chạy các lệnh để khởi động các dịch vụ vừa nãy.
  • Và nhiều hơn nữa, tùy thuộc vào độ phức tạp của dự án.

Có một số công cụ để làm điều này tự động. Công cụ tôi ưa thích là Ansible. Trước khi tôi đi sâu vào một script Ansible chi tiết, hãy thỏa thuận một quy ước về cách ứng dụng được cài đặt trên server, vì script Ansible sẽ nương theo cách cài đặt đó.

1. Bố cục thư mục

Thư mục cài đặt là /opt/ProjectName, trong đó cấu trúc cây thư mục như sau:

ProjectName/
├── project-name/
│  ├── pyproject.toml
│  └── manage.py
├── public/
└── venv/

Bên trong thư mục này, chúng ta có thư mục con project-name cho mã nguồn, thư mục public cho JS, CSS hoặc bất kỳ tệp nào được Nginx lưu trữ. Vì là một dự án Python, chúng ta cũng có venv cho môi trường ảo Python.

Tại sao lại bố trí thế này?

  • Những người áp dụng mô hình single server này thường cũng chạy nhiều dự án khác trên cùng một server. Vì vậy, có một thư mục để tập hợp mọi file của một dự án sẽ dễ quản lý, bảo trì hơn.

  • venv nằm ngoài thư mục mã nguồn để ngăn chúng ta sao chép / nén nó một cách vô tình khi chúng ta cần sao chép / di chuyển dự án đến nơi khác. Và khi chúng ta cần thực hiện một số tác vụ để quét / tìm kiếm mã nguồn của mình, chúng ta không lãng phí thời gian quét "venv". Bằng cách đặt "venv" làm thư mục cùng cấp, chúng ta có thể nhanh chóng kích hoạt môi trường với lệnh này:

    $ . ../venv/bin/activate
    

Thư mục public được đặt bên ngoài với lý do tương tự như venv. Lưu ý rằng, bạn cần đặt quyền thích hợp cho /opt/ProjectName/public để Nginx có thể phục vụ các file từ đó.

2. Quản lý tiến trình

Để ứng dụng tự động chạy khi máy chủ khởi động, tôi sử dụng Systemd. Trong khi các đồng nghiệp khác sử dụng các công cụ như supervisord, pm2 tôi sử dụng Systemd vì:

  • Có sẵn trong các HĐH Linux, không cần cài đặt.
  • Máy chủ Linux sử dụng nó để quản lý tất cả các tiến trình hệ thống khác. Sử dụng chung một công cụ trung tâm thì khỏi cần nhớ lệnh của các công cụ khác.
  • Nó có thể khởi động / dừng các dịch vụ theo thứ tự. Ví dụ ta muốn, ứng dụng của chúng ta chạy sau khi các hệ thống cơ sở dữ liệu (PostgreSQL, Redis) đã khởi động, hoặc khi chúng ta khởi động lại máy chủ, chúng ta muốn ứng dụng của chúng ta dừng trước khi các hệ thống cơ sở dữ liệu được dừng. Sẽ vô nghĩa nếu bật ứng dụng của ta khi các cơ sở dữ liệu (PostgreSQL, Redis) chưa sẵn sàng, phải không?
  • systemd được sử dụng để quản lý hệ thống, nó rất mạnh mẽ. Nó hỗ trợ nhiều trường hợp lắt léo để kiểm soát khi nào ứng dụng của chúng ta có thể chạy hoặc khi nào ứng dụng của chúng ta cần được khởi động lại (như khi ứng dụng của chúng ta treo do một số lỗi bí ẩn).
  • Nó kiểm soát những yếu tố về bảo mật cho ứng dụng của chúng ta, như tài nguyên nào ứng dụng của chúng ta được dùng và dùng bao nhiêu. Điều này là cần thiết để ngăn chặn thảm họa khi ứng dụng của chúng ta bị xâm phạm.
  • Dưới sự quản lý của systemd, ứng dụng của chúng ta có thể tích hợp với journald để ghi log và tận hưởng các tính năng của journald khi debug qua log. Có thể xem thêm về journald trong bài viết khác của tôi "Khởi đầu dự án Python như thế nào để thuận tiện phát triển lên".

Ta sẽ không tận dụng được systemd nếu ta chạy ứng dụng của mình trong container Docker, đơn giản vì container Docker không thể chạy systemd. Docker có một số tính năng gần với systemd, nhưng không phong phú và chính xác như systemd. Ví dụ khi quản lý thứ tự khởi động các dịch vụ tiên quyết, có những dịch vụ chạy lên rồi nhưng socket của nó chưa chấp nhận kết nối, trong trường hợp đó phải coi nó "chưa sẵn sàng" và phải trì hoãn việc khởi động ứng dụng của ta lên cho tới khi socket đó sẵn sàng. Chỉ có systemd mới quản lý chi li đến vậy.

Để sử dụng systemd, chúng ta sẽ cần tạo một file .service như thế này:

[Unit]
Description=Our web backend
After=redis-server.service postgresql.service

[Service]
User=dodo
Group=dodo

Type=simple
WorkingDirectory=/opt/ProjectName/project-name
# Create directory /run/project-name and set appropriate permission
RuntimeDirectory=project-name
ExecStart=/opt/ProjectName/project-name/bin/gunicorn project.wsgi -b unix:/run/project-name/web.sock
TimeoutStopSec=20
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Trong file này, có thể thấy:

  • Ứng dụng được chạy với người dùng bình thường. Đừng chạy nó với người dùng quyền cao, kẻo nếu ứng dụng của bạn bị xâm phạm, hacker có thể lợi dụng nó để gây thiệt hại nhiều hơn cho hệ thống.

  • Ứng dụng của chúng ta không lắng nghe trên một cổng TCP (như 8000), mà là một socket Unix domain, thông qua file /run/project-name/web.sock. Tại sao không sử dụng cổng số? Bởi vì nếu chúng ta có nhiều dự án, chúng ta không thể nhớ cổng nào là của dự án nào. Có một thứ với tên dạng text sẽ dễ quản lý hơn. Trên Linux còn có một loại socket đặc thù nữa (không có trên Unix), là "abstract domain socket", loại socket này cũng có tên nhưng không tạo ra file. Tôi chưa dùng loại socket này vì Nginx không hỗ trợ.

  • Khi chúng ta sử dụng Unix domain socket, điều quan trọng là không quên RuntimeDirectory. Nó báo cho systemd tạo một thư mục nơi ứng dụng của chúng ta có thể tạo file socket, và systemd sẽ xóa nó sau khi ứng dụng của chúng ta dừng.

File *.service này nên được cài vào /usr/local/lib/systemd/system. Một số bài viết trên Internet mách đặt tệp vào /etc/systemd. Đừng làm vậy, bởi vì đôi khi ta không muốn ứng dụng của mình tự động khởi động (chẳng hạn hôm nào đó ta bất ngờ phát hiện ra ứng dụng có một số lỗi và cần được sửa trước khi phục vụ người dùng), chúng ta có thể không cho tự động khởi động bằng lệnh:

$ sudo systemctl disable my-app

và sau khi sửa xong rồi, cho phép tự khởi động bằng lệnh:

$ sudo systemctl enable my-app

Khi ứng dụng của ta lắng nghe trên Unix domain socket, cấu hình Nginx sẽ như thế này:

location / {
    include proxy_params;
    proxy_pass http://unix:/run/project-name/web.sock;
}

Chúng ta có thể tận dụng Unix domain socket hơn nữa bằng cách kết nối đến PostgreSQL chỉ qua loại socket này. Bằng cách này, chúng ta có thể đóng cổng PostgreSQL dành cho kết nối từ bên ngoài, giảm nguy cơ bị tấn công. Bài viết khác của tôi với "mánh" này là "Truy cập nhanh giao diện dòng lệnh của PostgreSQL".

Đối với các tác vụ cần chạy định kỳ, chúng ta nên triển khai nó với Systemd timer, thay vì cron job. Nó có những lợi ích sau so với cron:

  • Được kiểm soát bởi systemd như các dịch vụ khác, có nghĩa là nó an toàn, nó được tích hợp với journald để ghi log.

  • Ta có thể tạm thời vô hiệu hóa tác vụ đó, bằng:

    $ sudo systemctl stop my-task.timer
    
  • Ta có thể kích hoạt tác vụ bất kỳ lúc nào, ngoài thời điểm định kỳ, bằng:

    $ sudo systemctl start my-task.service
    
  • Ta có thể xem lần cuối cùng tác vụ của chúng ta chạy là khi nào, và khi nào nó sẽ chạy tiếp theo, bằng:

    $ sudo systemctl list-timers
    

Dưới đây là một ví dụ với một trong các dự án của tôi:

sytemd-timer

3. Script Ansible

Ansible dễ cài đặt lắm. Chỉ cần làm:

$ sudo apt install ansible

Ansible mạnh mẽ đến mức tài liệu của nó lớn và khó biết bắt đầu từ đâu. Nói một cách đơn giản, chúng ta sẽ cần ít nhất hai file:

  • Một file inventory, tạm đặt tên nó là inventory.yml, để liệt kê các máy chủ chúng ta sẽ triển khai ứng dụng lên.

  • Một file playbook, tạm đặt tên nó là playbook.yml, để mô tả các bước mà Ansible cần thực hiện để triển khai ứng dụng của chúng ta.

Trong những dự án phức tạp hơn, playbook có thể là nhiều tệp, thư mục con, cả inventory cũng vậy.

Lưu ý rằng, bạn phải cấu hình máy chủ trước sao cho có thể SSH bằng khóa công khai, không phải mật khẩu.

Inventory

Tệp inventory.yml trông như thế này:

prod:
  hosts:
    prod.agriconnect.vn:
      ansible_user: dodo

staging:
  hosts:
    staging.agriconnect.vn:
      ansible_user: dodo

Trong inventory này, ta có hai nhóm, prod cho production và staging cho các máy chủ staging. Nếu bạn không có máy chủ staging, chỉ cần xóa nhóm staging. Mỗi nhóm phải có field hosts để liệt kê các máy chủ. Để xác định máy chủ, có thể sử dụng tên miền hoặc địa chỉ IP. Chúng ta cũng cần chỉ định ansible_user, là user Linux của máy chủ (không phải máy tính trên bàn làm việc của chúng ta) mà ta thường SSH vào (nó có thể là cùng một user mà ứng dụng web của chúng ta chạy dưới quyền).

Playbook

Tệp playbook.yml sẽ trông như thế này:

---
- hosts: '{{ target|default(staging) }}'
  remote_user: dodo
  # This is needed to make ansible_env work
  gather_facts: true
  gather_subset:
    - '!all'

  vars:
    target: staging

  tasks:
    - name: Say hello
    ansible.builtin.command: echo Hello

  environment:
    VIRTUAL_ENV: '/opt/ProjectName/venv'

Tại tham số hosts:, ta chọn nhóm máy chủ nào trong inventory.yml để chạy playbook này. Nếu ta chỉ có một nhóm, ta có thể sử dụng một giá trị cố định ở đây. Nhưng vì ta có hai nhóm, ta sử dụng code Jinja để tạo giá trị động. Giá trị này phụ thuộc vào biến target mà chúng ta khai báo trong phần vars, và ta truyền giá trị của nó từ dòng lệnh khi chạy Ansible.

Sau này, khi muốn triển khai lên các máy chủ prod, ta chạy:

$ ansible-playbook -i inventory.yml playbook.yml -e "target=prod ansible_become_pass=$REMOTE_USER_PASS"

và nếu muốn triển khai lên các máy chủ staging, ta chạy:

$ ansible-playbook -i inventory.yml playbook.yml -e "target=staging ansible_become_pass=$REMOTE_USER_PASS"

Các lệnh mà Ansible sẽ thực thi trên máy chủ sẽ cần một số thông tin, như đường dẫn tệp, đường dẫn thư mục, vì vậy hãy định nghĩa chúng làm biến, để làm cho các lệnh ngắn gọn:

  vars:
    target: staging
    base_folder: /opt/ProjectName
    webapp_folder: '{{ base_folder }}/project-name'
    bin_folder: '{{ base_folder }}/venv/bin/'

Phần tasks sau đó là:

  tasks:
    - name: Clean source
      ansible.builtin.command: git reset --hard
      args:
        chdir: '{{ webapp_folder }}'

    - name: Update source
      ansible.builtin.git:
        repo: 'git@gitlab.com:our-company/project-name.git'
        dest: '{{ webapp_folder }}'
        version: "{{ lookup('env', 'CI_COMMIT_REF_NAME')|default('develop', true) }}"
      register: git_out

    - name: Get changed files
      ansible.builtin.command: git diff --name-only {{ git_out.before }}..{{ git_out.after }}
      args:
        chdir: '{{ webapp_folder }}'
      register: changed_files
      when: git_out.changed

    - name: Stop ProjectName services
      ansible.builtin.systemd: name='{{ item }}' state=stopped
      loop:
        - my-web.service
        - my-ws-server.service
        - my-asynctask.service
      become: true
      become_method: sudo
      when:
        - git_out.changed
        - changed_files.stdout is search('.py|.po|.lock|.toml')

    - name: Install python libs
      ansible.builtin.command: poetry install --no-root --only main -E systemd
      args:
        chdir: '{{ webapp_folder }}'
      when: git_out.changed and changed_files.stdout is search('poetry|pyproject')

    - name: Migrate database
      ansible.builtin.command: '{{ bin_folder }}python3 manage.py migrate --no-input'
      args:
        chdir: '{{ webapp_folder }}'
      when:
        - git_out.changed
        - changed_files.stdout is search('poetry|pyproject|models|migrations|settings')

    - name: Compile translation
      ansible.builtin.command: '{{ bin_folder }}python3 manage.py compilemessages'
      args:
        chdir: '{{ webapp_folder }}'
      when:
        - git_out.changed
        - changed_files.stdout is search('locale')

    - name: Collect static
      ansible.builtin.command: '{{ bin_folder }}python3 manage.py collectstatic --no-input'
      args:
        chdir: '{{ webapp_folder }}'

    - name: Start ProjectName services
      ansible.builtin.systemd: name='{{ item }}' state=started
      loop:
        - my-ws-server.service
        - my-web.service
        - my-asynctask.service
      become: true
      become_method: sudo
      when: git_out.changed

Bước đầu tiên ("Clean source"), chúng ta xóa bỏ tất cả các thay đổi chưa được commit trong thư mục mã nguồn của mình, để ngăn ngừa lỗi Git có thể xảy ra ở bước tiếp theo.

Bước thứ hai, chúng ta kéo source code mới từ dịch vụ lưu trữ Git, đến phiên bản của code mà chúng ta đã chuẩn bị triển khai đây. Trong dự án của tôi, tôi thường sử dụng nhánh main cho mã ổn định để triển khai vào môi trường production và nhánh develop cho môi trường thử nghiệm. Bạn có thể đặt tên nhánh cố định tại version, ví dụ như version: main, nếu bạn có cấu hình đơn giản hơn. Trong trường hợp của tôi, Ansible được kích hoạt bởi sự kiện push Git, và tôi muốn kéo chính xác phiên bản Git đã kích hoạt tác vụ triển khai này. GitLab CI cung cấp thông tin này qua biến CI_COMMIT_REF_NAME, vì vậy tôi sử dụng đoạn mã lookup('env', 'CI_COMMIT_REF_NAME') để lấy nó. Đoạn mã default('develop', true) là để quay về nhánh develop khi chúng ta chạy Ansible thủ công từ dòng lệnh (không phải qua push Git). Chúng ta sử dụng tham số register để lưu kết quả của Git, điều này cần thiết cho bước tiếp theo.

Bước thứ ba, chúng ta kiểm tra xem những tệp nào đã thay đổi kể từ lần triển khai cuối cùng. Sau đó, chúng ta sẽ dựa trên thông tin này để quyết định bỏ qua lệnh nào.

Bước 4, chúng ta dừng các dịch vụ ứng dụng của mình, tương ứng với các tệp .service của systemd. Ví dụ này minh họa cách sử dụng loop để thực hiện hành động trên nhiều đối tượng. Nếu không có nó, chúng ta sẽ phải định nghĩa từng bước cho mỗi dịch vụ, làm cho playbook trở nên dài dòng. Một điều nữa cần lưu ý là, vì các lệnh systemctl start / stop cần được chạy với quyền sudo, chúng ta sử dụng các tham số becomebecome_method để yêu cầu Ansible thực hiện sudo. Tại đây, chúng ta cũng sử dụng when để xác định điều kiện khi nào cần thực hiện bước này. Nếu mã nguồn chỉ thay đổi một số tệp JS, CSS, chúng ta không cần dừng dịch vụ.

Các bước tiếp theo chắc cũng dễ hiểu rồi, dựa trên giải thích của bốn bước đầu tiên.

Phần thứ ba của playbook là thiết lập một số biến môi trường mà các bước trên cần:

  environment:
    # Modify PATH so that poetry can be found.
    PATH: '{{ base_folder }}/venv/bin:{{ ansible_env.PATH }}:{{ ansible_env.HOME }}/.local/bin'
    # Tell poetry to use our virtual env folder
    VIRTUAL_ENV: '{{ base_folder }}/venv'

Trước đây, trong một số cấu hình máy chủ, PATH không được điền giá trị ~/.local/bin trong môi trường mà Ansible đăng nhập vào, làm cho Ansible không thể chạy poetry ở bước thứ 5. Tôi không chắc liệu vấn đề này còn tồn tại hay không.

Chúng ta sẽ không thể viết một playbook chính xác ngay từ lần đầu tiên. Vì vậy, hãy tạo một máy ảo với VirtualBox để kiểm tra và sửa playbook. Khi thử nghiệm với máy ảo, chúng ta sẽ không có sự kiện Git push, chúng ta phải chạy Ansible trực tiếp từ dòng lệnh. Trong các lệnh tôi đã đưa ra ở trên, REMOTE_USER_PASS là biến môi trường, chứa mật khẩu của người dùng trên máy chủ. Bạn có thể thiết lập nó bằng cách:

$ export REMOTE_USER_PASS=mypassword

trước khi chạy Ansible.

Dưới đây là ảnh chụp màn hình Ansible đang hoạt động, từ một trong các dự án của tôi:

Ansible in action

Và đây là khi nó được chạy với tư cách một phần của GitLab Pipeline:

GitLab Pipeline

Vậy là tôi đã có một hướng dẫn ngắn về cách tận dụng Ansible và SystemD để triển khai ứng dụng Django. Hy vọng rằng nó giúp cuộc đời developer của bạn dễ thở hơn.