Dựng container Incus để chạy GitHub runner

Từ khi phong trào vibe code ra đời, dịch vụ GitHub thường bị quá tải (do sự khai thác quá mức từ các agent AI), cao điểm là cách đây mấy hôm, quy trình CI/CD ở công ty mình bị tê liệt khi GitHub không thể cấp phát các runner để chạy CI/CD. May sao công ty dùng server bare-metal, còn dư công suất nên mình ngắt ra một phần, dựng các container Incus để chạy runner self-hosted cho GitHub Actions. Bài này sẽ hướng dẫn cách setup.

Tại sao là Incus?

Incus là chương trình quản lý tạo và chạy container hoặc máy ảo. Mình cần có nhiều GitHub Runner để chạy được nhiều job CI/CD cùng lúc. Giải pháp là chia nhỏ máy thật ra thành nhiều "máy ảo", và cài GitHub Runner trong đó. Tuy nhiên, tạo máy ảo virtual machine như trên QEMU, VirtualBox thì hơi cồng kềnh, một "system container" sẽ gọn nhẹ hơn. Nói về container, có lẽ cái tên Docker được biết đến rộng rãi nhất, nhưng nó không phải là giải pháp container duy nhất, lại càng không phải cái đầu tiên. Mình dùng Incus vì mình cần "system container", là loại container chạy được systemd bên trong để quản lý nhiều dịch vụ mà không cần sửa đổi gì. Nói thêm một chút, loại container được tạo bởi Docker là "application container", hướng đến đóng gói một ứng dụng cụ thể nên nó không thể chạy systemd bên trong và khi dùng nó để chạy các phần mềm database như Postgres, Redis, người ta phải dùng thêm mấy lệnh "của nợ" như sau:

options: >-
  --health-cmd="pg_isready -h localhost -p 5432 -U myuser -d mydatabase"
  --health-interval=10s
  --health-timeout=5s
  --health-retries=5

hay:

while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do
  sleep 0.1
done

hay:

healthcheck:
  test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]

Các lệnh trên kia chỉ là để kiểm tra xem PostgreSQL, Redis đã khởi động xong xuôi trong container Docker và sẵn sàng để sử dụng chưa, đó là vì Docker không quản lý được trạng thái dịch vụ một cách chính xác như systemd.

Thêm một lý do nữa khiến system container phù hợp với ý đồ của mình, đó là khi chạy test case trong CI/CD, mình ưa gom source code và database trong cùng một "máy", dùng kết nối Unix domain socket để chạy cho nhanh. Mình cũng muốn cài sẵn PostgreSQL, Redis trong container để giảm số bước cài đặt. Dùng system container thì gom các dịch vụ đó vào trong một container rất dễ dàng vì mình chả cần thay đổi gì so với cài trên máy thật. Đã vậy system container đã chạy một lần là chạy luôn, không tắt, không giống như Docker hễ chạy xong app là container tắt, bị xóa, và lần sau chạy là phải chờ Docker tạo các container mới từ image, chờ các dịch vụ khởi động.

Cài đặt Incus

Bài này dùng các lệnh trong Ubuntu. Nếu bạn dùng distro khác (Fedora, Arch...) thì cách làm cũng tương tự, đổi package manager, đổi tên gói thôi.

Trước tiên, cài Incus:

sudo apt install incus

Theo hướng dẫn "Install and initialize Incus" để làm các bước setup đầu tiên (incus admin init trước khi tạo container)

Với Ubuntu 25.04+, có một trục trặc với cấu hình quá khắt khe của AppArmor, khiến nó cản trở hoạt động của Incus. Chưa có cách sửa (viết profile AppArmor phù hợp) mà chỉ có cách tạm thời "vượt rào", bạn đọc bài "Fix commands hanging in Incus container when host OS is Ubuntu" để làm.

Khi đã setup Incus xong, tạo một container, đặt tên là gh-runner-1:

incus launch images:ubuntu/26.04/cloud gh-runner-1

Ở đây mình chọn image là "ubuntu/26.04/cloud". Bạn có thể chọn image dựa trên các distro khác, dùng lệnh incus image list images: để xem những image nào Incus có sẵn. Ví dụ muốn tìm các image của AlpineLinux, có thể dùng lệnh:

incus image list images: alpine amd64

incus-list-image-long

Mặc định lệnh này sẽ trả về kết quả dạng bảng và nếu màn hình của bạn nhỏ, bảng này sẽ khó nhìn, nên có thể pipe qua less để dễ nhìn hơn:

incus image list images: alpine amd64 | less --chop-long-lines

incus-image-short

Các image có đuôi ["cloud"](https://linuxcontainers.org/incus/docs/main/cloud-init/#clo ud-init-support-in-images) là image chứa sẵn phần mềm cloud-init.

Sau khi lệnh incus launch xong việc, ta dùng lệnh incus list để liệt kê các container, kiểm tra xem có cái nào được tạo chưa:

❯ incus list
+-------------+---------+-----------------------+------------------------------------------------+-----------+-----------+
|    NAME     |  STATE  |         IPV4          |                      IPV6                      |   TYPE    | SNAPSHOTS |
+-------------+---------+-----------------------+------------------------------------------------+-----------+-----------+
| gh-runner-1 | RUNNING | 10.152.160.239 (eth0) | fd42:dc3c:2bf0:4abf:1266:6aff:fe3d:30e7 (eth0) | CONTAINER | 0         |
+-------------+---------+-----------------------+------------------------------------------------+-----------+-----------+

Để chạy một lệnh nào đó trong container Incus thì dùng lệnh incus exec, ví dụ:

incus exec gh-runner-1 lsb_release -- -a

Để truy cập vào shell của user ubuntu trong container, dùng lệnh:

incus exec gh-runner-1 su -- -l ubuntu

Bạn cũng có thể dùng lệnh:

incus exec gh-runner-1 --user 1000 bash

Để ý tham số --user của lệnh exec bạn sẽ phải truyền ID dạng số, thay vì username.

Còn khi mình dùng su -l là vì mình muốn dùng username và mình muốn dùng shell mặc định đã được cấu hình cho user đó, thay vì dùng Bash. Mình luôn cài đặt Fish làm shell mặc định trên các server do mình quản lý.

Cài đặt phần mềm ban đầu cho container

Mình sẽ cài đặt trước các phần mềm sẽ cần bởi các job CI/CD của mình vào container: PostgreSQL, Redis, uv, bun và một số thư viện hệ thống khác, để các job CI/CD đỡ phải tốn thời gian cài lại. Do có nhiều container nên mình sẽ tạo script Ansible để có thể lặp lại nhanh với các container khác. Các container mới tạo sẽ chưa được cấu hình SSH (trừ khi mình đã tạo sẵn một template với cloud-init) nên mình sẽ cho Ansible dùng kết nối Incus.

Dùng Ansible

Tạo file _inventory.yml để cấu hình kết nối cho Ansible:

gh_runners:
  hosts:
    gh-runner-1:
      ansible_user: ubuntu
      ansible_become: yes
      # We use the Incus connection method, instead of SSH
      ansible_connection: community.general.incus
      # Replace the project name with what you see in `incus project list`.
      # Can omit this config if you belong to `incus-admin` group.
      ansible_incus_project: user-1000
      ansible_python_interpreter: /usr/bin/python3

Lưu ý thông số ansible_incus_project, nếu ở bước "Install and initialize Incus" bạn đã đưa chính mình vào group incus-admin rồi thì bạn có thể bỏ dòng này đi, không thì sẽ phải thêm vào và đổi lại giá trị.

Sau đây là file playbook prepare-github-runner.yml:

- name: Install softwares for a server to be used as GitHub runner
  # The server can be an Incus container.
  hosts: all
  remote_user: ubuntu
  gather_facts: true
  gather_subset:
    - "!all"
  tasks:
    - name: Install software from APT
      ansible.builtin.apt:
        name:
          - git
          - curl
          - wget
          - python3-dev
          - python3-venv
          - python3-pip
          - python3-paramiko
          - libpq-dev
          - python3-psycopg2
          - python3-apt
          - python3-debian
          - postgresql
          - redis-server
          - gettext
          - unzip
          - zstd
          - ripgrep
          - fd-find
        state: present
        update_cache: true
        cache_valid_time: 3600
      become: true
    - name: Create symlink for fd command
      ansible.builtin.file:
        src: /usr/bin/fdfind
        dest: /usr/local/bin/fd
        state: link
      become: true
      when:
        - ansible_facts['distribution'] == 'Ubuntu'
        - ansible_facts['distribution_version'] is version('26.04', '<')
    - name: Add Fish v4 repo
      ansible.builtin.deb822_repository:
        name: fish-shell
        uris: https://ppa.launchpadcontent.net/fish-shell/release-4/ubuntu
        suites: "{{ ansible_facts['distribution_release'] }}"
        components: main
        signed_by: https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x88421e703edc7af54967ded473c9fcc9e2bb48da
      become: true
    - name: Add NodeSource repo for Node.js v24
      ansible.builtin.deb822_repository:
        name: nodesource
        uris: https://deb.nodesource.com/node_24.x
        suites: nodistro
        components: main
        signed_by: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key
      become: true
        - name: Add Nushell repo
      ansible.builtin.deb822_repository:
        name: nushell
        uris: https://apt.fury.io/nushell
        suites: /
        signed_by: https://apt.fury.io/nushell/gpg.key
      become: true

      become: true
    - name: Install Fish v4, Redis v8, Node.js v24, Nushell, and system packages
      ansible.builtin.apt:
        name:
          - fish
          - nodejs
          - nushell
        state: present
        update_cache: true
      become: true
    - name: Make Redis listen on Unix socket
      ansible.builtin.replace:
        path: /etc/redis/redis.conf
        regexp: '#\s*unixsocket'
        replace: "unixsocket"
      become: true
    - name: Change Redis socket path
      ansible.builtin.replace:
        path: /etc/redis/redis.conf
        regexp: 'redis\.sock'
        replace: "redis-server.sock"
      become: true
    - name: Change Redis socket permission
      ansible.builtin.replace:
        path: /etc/redis/redis.conf
        regexp: "perm 700"
        replace: "perm 766"
      become: true
    - name: Check Helix existence
      ansible.builtin.stat:
        path: /usr/bin/hx
      register: hx_stats
    - name: Install Helix
      ansible.builtin.apt:
        deb: https://github.com/helix-editor/helix/releases/download/25.07.1/helix_25.7.1-1_amd64.deb
      become: true
      when: not hx_stats.stat.exists
    # We use pipx to install uv and other Python tools. But the APT repo contains too old pipx.
    - name: Install pipx
      ansible.builtin.pip:
        name: pipx
        break_system_packages: true
      become: true
    - name: Install uv
      community.general.pipx:
        name: uv
    - name: Download Bun installer
      ansible.builtin.get_url:
        url: https://bun.sh/install
        dest: /tmp/bun-install.sh
        mode: '0755'
    - name: Install Bun
      ansible.builtin.command:
        cmd: bash /tmp/bun-install.sh
        creates: "{{ ansible_facts['user_dir'] }}/.bun/bin/bun"
      register: bun_install_result
      failed_when:
        - bun_install_result.rc != 0
    - name: Set Fish as default shell for user
      ansible.builtin.user:
        name: "{{ ansible_user }}"
        shell: /usr/bin/fish
      become: true

Trong playbook này bạn sẽ thấy mình cài thêm một số phần mềm mà bạn ít nghe thấy, ví dụ:

  • Cài fd-find để dễ dàng tìm kiếm file, folder theo tên, ripgrep để tìm file theo nội dung.

  • Cài Helix để làm text editor chính.

  • Cài Fish để làm shell chính.

  • Cài Nushell để chạy một số script viết bằng Nushell. Mặc dù dùng Fish làm shell chính nhưng mình chỉ dùng Fish để chạy từng lệnh một thôi, khi cần viết lệnh dài, viết thành script thì mình vẫn ưa Nushell hơn. Trong dự án công ty mình cũng viết script Nushell nhiều.

Trong playbook đáng lẽ còn bước này nữa, để tạo sẵn một user trong PostgreSQL trùng tên với user của Linux, để tận dụng phương pháp kết nối PostgreSQL mà không cần tạo password:

- name: Create Postgres user
  community.postgresql.postgresql_user:
    name: '{{ ansible_user }}'
    role_attr_flags: SUPERUSER
  become: true
  become_user: postgres

Tuy nhiên, bước này bị lỗi nếu dùng kết nối Incus. Để dùng được bước này thì ta phải dùng kết nối SSH, bật tính năng pipeline, với file inventory như sau:

gh_runners:
  hosts:
    gh-runner-1:
      ansible_user: ubuntu
      ansible_become: yes
      ansible_python_interpreter: /usr/bin/python3
      ansible_ssh_pipelining: true

Điểm đáng chú ý là thông số ansible_ssh_pipelining.

Để chạy playbook trên, ta dùng lệnh:

ansible-playbook -i inventory.yml prepare-github-runner.yml -l gh_runners

Dùng cloud-init

Đầu bài viết, mình có khuyến nghị dùng các image có đuôi "/cloud", đó là để chúng chứa cloud-init. Ta có thể viết template để Incus chuyển vào cho cloud-init và khi container mới được tạo dựng, cloud-init sẽ nhân tiện cài đặt luôn một số phần mềm mà mình yêu cầu trong file template. Lấy ví dụ, các image "ubuntu" của Incus thường không cài sẵn openssh-server. Mình sẽ cấu hình cloud-init để cài openssh-server từ đầu.

Trước khi tạo container, dùng lệnh này để sửa profile "default" của Incus:

 incus profile edit default

Đầu file sẽ có nội dung config trống như thế này:

config: {}
description: Default Incus profile

Ta sẽ sửa khóa đó thành:

config:
  cloud-init.user-data: |
    #cloud-config
    package_upgrade: true
    packages:
      - openssh-server
      - avahi-daemon
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINgckycYE2OD1BeBNrIIR7sz1Z93I+Qs09U1DH2M+Kna quan@Beaver

Lưu lại, từ bây giờ mỗi khi chạy incus launch thì container mới sẽ được cài sẵn các gói mà ta liệt kê bên trên.

Trong ví dụ trên, ngoài cài đặt gói openssh-server, mình còn cài gói avahi-daemon để container có thể truy cập được bằng tên miền, ví dụ gh-runner-1.local, thay vì địa chỉ IP. Ngoài ra mình dùng tham số ssh_authorized_keys để cài sẵn public key SSH vào container, cho đỡ phải gõ password sau này.

Thế là mình có thể truy cập SSH vào container như sau:

ssh ubuntu@gh-runner-1.local

cloud-init còn làm được nhiều trò nữa, có thể tham khảo "Module reference" để biết thêm. Ngày xưa, khi mới biết đến cloud-init, điều làm mình ấn tượng nhất là nó giúp mình tự kéo giãn filesystem cho VPS (virtual private server) khi mình nâng cấp dung lượng ổ cứng. Dùng VPS nghĩa là bạn không chạm tay vào server, không tự đút USB vào, khởi động server vào Live USB để mở rộng phân vùng root (/) được. May là có cloud-init làm cho không thì cái dung lượng ổ cứng mua thêm cũng chẳng xài được.

Mục hướng dẫn cài phần mềm ban đầu này không hướng dẫn cài GitHub Runner, vì bạn nên copy lệnh từ hướng dẫn của GitHub hơn, nó có dính tới thông tin token để đảm bảo GitHub Runner có quyền truy cập vào dự án của bạn.

Kết quả

Sau khi chuyển GitHub Action qua dùng runner self-host, các job CI/CD chạy nhanh hơn nhiều, vì:

  • Không cần phải chờ GitHub Action cấp phát runner.
  • Không cần phải chạy một số bước cài phần mềm trong kịch bản CI/CD.

github-self-hosted-runners

Thực ra thì bạn có thể kết hợp phương pháp cloud-init và Ansible để có giải pháp hoàn hảo hơn.