今天努力了一個下午,終於算是勉強搞出了一組能動的 playbook,這邊就來記錄一下過程以及就我所知可以改進的地方。
首先來回憶一下,架設一個新的沙盒的流程:
接下來就是要搜尋一下,會需要用到哪些 Ansible 的 module,以上面的流程來說我用到了這些:
來簡單介紹一下這幾個 module 的用途。
首先第一個部分是要來設定變數,因為目前的其中一個需求是要能夠設定沙盒使用的 token,但若是直接幫所有的沙盒都使用同樣的 token 那可能會造成一些安全性的隱憂。另外因為有些值在整份 playbook 裡面會出現多次,所以把他們定義成變數也能避免重複。
在 playbook 裡面要定義變數的話可以使用 vars
這個 key,我寫出來的設定長得會像這樣:
vars:
token: "{{ lookup('password', 'secret/' + inventory_hostname + '/token') }}"
project_dir: /srv/normal-oj-sandbox
venv_dir: /tmp/.sandbox-venv
req_path: /tmp/sandbox-requirements.txt
之後只要使用 {{ var_name }}
這樣的格式,就可以使用變數的值了。
那,撇開其他變數不看,先來看看 token
這個變數,他長得比較特別一點,也有一組 {{ }}
把中間的值框起來,但是這裡的 lookup
不是變數,在 Ansible 裡面這稱作 Lookups,是一種從外部取值的方式。而 lookup 裡面放的第一個參數,代表的是 lookup plugin,像是 這邊的 password 就是一個 Ansible 內建的 lookup plugin,用來產生隨機的密碼,並把它存進指定的檔案內。然後在後面,我使用了 inventory_hostname
這個 Ansible 的內建變數,來確保不同的 host,會拿到不同的密碼,以此來避免共用密碼的情形發生。
接下來是跟 git repo 相關的,因為我是把沙盒的 repo 放在 /srv
底下,因此需要先使用 root
創建一個可以給一般使用者存取的資料夾,這部分就需要使用 ansible.builtin.file,它的用途是進行 host 上的檔案相關的操作。task 會長這樣:
name: Create project dir
become: yes
ansible.builtin.file:
path: "{{ project_dir }}"
owner: bogay
group: bogay
mode: 0744
state: directory
上面透過了 become: yes
來讓我可以使用 root
權限執行這個 task,透過 path
跟 state
指定了要在哪裡創建資料夾。另外還要記得指定 owner
跟 group
確保之後有權限操作。比較需要注意的是 mode
,需要設定成 0744
而不是一般檔案比較常看到的 0644
,多出來的 x
權限是為了可以 cd
進去而加上的。
創建完資料夾,下一步就是要取得 source code,這時候需要使用的是 ansible.builtin.git,來做 git 相關的操作。task 定義如下:
name: Checkout sandbox repo
ansible.builtin.git:
repo: "https://github.com/Normal-OJ/Sandbox.git"
dest: "{{ project_dir }}"
version: 56a1bf
force: yes
設定更新 repo 的相關參數,就可以 checkout 到指定的版本,不過這邊因為我後面會動到 repo 底下的檔案,因此需要加上 force: yes
來確保它會更新,不然整次執行就會失敗。
更新完 repo 之後,還需要把設定檔複製進去,使用的是 ansible.builtin.template 這個 module,它會使用 Jinja2 作為模板引擎,並且把使用當前 playbook 的變數去處理我們預先定義的模板,把他們複製到 host 上。task 定義如下:
name: Copy configs
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ project_dir }}/{{ item.dest }}"
mode: 0644
loop:
- src: docker-compose.yml.j2
dest: docker-compose.yml
- src: .config/dispatcher.json
dest: .config/dispatcher.json
- src: .config/submission.json.j2
dest: .config/submission.json
就是把 control node 的 src
,複製到 managed node 的 dest
。比較特別的是這裡的 loop
,因為我有多個檔案要複製,可是撰寫好幾個 task 就顯得有點冗長,loop
可以幫助我們把參數定義成一個陣列,然後依序把他們填進 task 裡面去執行。
另外,在比較舊版的 Ansible,是使用 with_*
的語法來做到迴圈的,關於他們的比較,可以參考官方文件的說明。
至此,算是把所有 repo 相關的設定準備好了。
因為這份 playbook 需要使用到一些 docker 相關的 module,因此需要安裝額外的依賴,像是 docker-py 還有 docker compose,所以需要先安裝這些東西,因為兩者剛好都能透過 pip 安裝,因此我們需要使用 ansible.builtin.pip 來執行 pip,但是因為我有將依賴寫成 requirements.txt,因此需要先將檔案複製到 host 上。這部分的 task 定義如下:
- name: Copy requirements.txt
ansible.builtin.copy:
src: requirements.txt
dest: "{{ req_path }}"
mode: 0644
- name: Setup python environment
ansible.builtin.pip:
requirements: "{{ req_path }}"
virtualenv: "{{ venv_dir }}"
virtualenv_command: 'python3 -m venv'
這邊因為我不希望影響到整個 host,所以有使用 virtualenv 來隔離環境,話說在這邊安裝的時候有遇到 No module named pkg_resources
之類的錯誤,後來在這邊找到應該是因為 setuptools
的問題,或許在寫 playbook 時也要考慮這個情形。
到這邊就安裝好相關的依賴了,可以進行後續的操作。(其實還有 docker 啦,這就是這份 playbook 沒處理好的部分之一)
下一步是要讓 Ansible 使用 virtualenv 內的 Python interpreter,畢竟剛剛那些依賴是裝在他身上。我們除了修改 ansible.cfg
以外,也可以透過修改 ansible_python_interpreter
這個變數來修正。而要修改變數,我們可以使用 ansible.builtin.set_fact。task 定義如下:
name: Update intepreter path
ansible.builtin.set_fact:
ansible_python_interpreter: "{{ venv_dir }}/bin/python"
這樣在之後的 task 中,Ansible 就都會使用我們現在所定義的 interpreter 了。BTW,這邊的 "fact" 在 Ansible 中跟 variable 是稍微有點不同的概念,相關說明可以參考官方文件。
最後,就是要實際來部署沙盒的容器了,首先,因為 NOJ 上執行 submission 也是透過 docker container,所以需要先確保對應的 docker image 存在。這可以透過 community.docker.docker_image_info 來檢查,task 如下:
name: Check sandbox image existence
community.docker.docker_image_info:
name:
- noj-c-cpp
- noj-py3
register: inspect_res
這邊的 register
也是 Ansible 裡面宣告變數的方式,會把這個 module 的回傳值存進 inspect_res
裡面,讓後面的 task 可以用來檢查。
下一步是當 image 不存在的時候要去 build image,需要的是 ansible.builtin.command,幫我執行預先寫好的 shell script,task 如下:
name: Build sandbox image
ansible.builtin.command:
chdir: "{{ project_dir }}"
cmd: build.sh
when: inspect_res.images | length != 2
這邊用到兩個參數,chdir
是指定命令要在哪邊執行,而 cmd
就是要執行的命令。不過其實在 Ansible 裡面還有其他幾個同樣也可以執行命令的 module,關於他們的比較大致上是這樣(來源):
另外,除了上面這些 module,還有一個 ansible.builtin.script 也是用來執行 command 的,不過它是將 managed node 上的 script 先複製一份到 host 上再執行的。
最後還有那個 when
,因為 ansible.builtin.command 在不傳 creates
或 removes
參數的情況下是不支援 check mode 的,這就意味著他每次都會執行。但這並非必要,我們只需要在那兩個 docker image 不存在時再執行就好,此時就需要加上條件判斷。when
後面接的是一個表達式,當它是 true 的時候才會執行這個 task。inspect_res.images | length != 2
這句的意思是,inspect_res.images
的個數不等於 2,其中 |
表示 jinja 的 filter,而 length
是其中一個內建的 filter,用來取元素數量。
最後一步,就是要把沙盒跑起來,需要使用 community.docker.docker_compose,顧名思義,這個 module 就是用來管理 docker compose 的服務的。task 定義如下:
name: Run service
community.docker.docker_compose:
project_src: "{{ project_dir }}"
這邊只需要指定 project_src
一個參數,讓 Ansible 知道在哪邊找 docker-compose.yml
就好。
終於是寫完了,因為剛學 Ansible 所以需要花不少時間去翻閱相關文件,對我來說算是不小的挑戰。而且目前寫出來的 playbook 我想也是相當粗糙,沒有考慮到足夠多的場景,接下來應該會再研究看看如何進行模組化。