--- title: Dựng container Incus để chạy GitHub runner date: 2026-07-16 09:58:09.308027 UTC --- 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](https://linuxcontainers.org/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: ```yml options: >- --health-cmd="pg_isready -h localhost -p 5432 -U myuser -d mydatabase" --health-interval=10s --health-timeout=5s --health-retries=5 ``` hay: ```sh while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do sleep 0.1 done ``` hay: ```yml 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](https://linuxcontainers.org/incus/docs/main/tutorial/first_steps/#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](/post/2025/11/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`: ```sh 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: ```sh incus image list images: alpine amd64 ``` ![incus-list-image-long](https://quan-images.b-cdn.net/blogs/2026/07/incus-list-image-long.png) 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: ```sh incus image list images: alpine amd64 | less --chop-long-lines ``` ![incus-image-short](https://quan-images.b-cdn.net/blogs/2026/07/incus-list-image-short.png) Các image có đuôi ["cloud"](https://linuxcontainers.org/incus/docs/main/cloud-init/#cloud-init-support-in-images) là image chứa sẵn phần mềm [cloud-init](https://cloud-init.io/). 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ụ: ```sh incus exec gh-runner-1 lsb_release -- -a ``` Để truy cập vào shell của user `ubuntu` trong container, dùng lệnh: ```sh incus exec gh-runner-1 su -- -l ubuntu ``` Bạn cũng có thể dùng lệnh: ```sh 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: ```yaml 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](https://linuxcontainers.org/incus/docs/main/tutorial/first_steps/#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*: ```yaml - 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: ```sh - 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: ```yaml 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: ```sh 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: ```sh incus profile edit default ``` Đầu file sẽ có nội dung `config` trống như thế này: ```yaml config: {} description: Default Incus profile ``` Ta sẽ sửa khóa đó thành: ```yaml 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: ```sh 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](https://docs.cloud-init.io/en/latest/reference/modules.html)" để 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](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) 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](https://quan-images.b-cdn.net/blogs/2026/07/github-self-host-runners.png) 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.