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

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

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.

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.