iT邦幫忙

2021 iThome 鐵人賽

DAY 27
1
DevOps

這個 site 就是遜啦 - SRE 30 天登大人之旅系列 第 27

Day 27:開始撰寫 Playbook

今天努力了一個下午,終於算是勉強搞出了一組能動的 playbook,這邊就來記錄一下過程以及就我所知可以改進的地方。

首先來回憶一下,架設一個新的沙盒的流程:

  1. ssh 連上機器
  2. clone 沙盒 repo(如果不存在的話)
  3. 更新 repo
  4. 設定或是更新 token(第一次或是有需要的時候)
  5. 把沙盒跑起來或是重開
  6. 替 NOJ 設定新的沙盒(填 token 跟 URL)

接下來就是要搜尋一下,會需要用到哪些 Ansible 的 module,以上面的流程來說我用到了這些:

  • ansible.builtin.copy
  • ansible.builtin.pip
  • ansible.builtin.set_fact
  • ansible.builtin.file
  • ansible.builtin.git
  • ansible.builtin.template
  • ansible.builtin.command
  • community.docker.docker_image_info
  • community.docker.docker_compose

來簡單介紹一下這幾個 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,會拿到不同的密碼,以此來避免共用密碼的情形發生。

處理 repo 相關

接下來是跟 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,透過 pathstate 指定了要在哪裡創建資料夾。另外還要記得指定 ownergroup 確保之後有權限操作。比較需要注意的是 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 是稍微有點不同的概念,相關說明可以參考官方文件

創建 container

最後,就是要實際來部署沙盒的容器了,首先,因為 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,關於他們的比較大致上是這樣(來源):

  • ansible.builtin.command:執行指令,但不會透過 shell,因此一些像是環境變數或是 pipe、redirect 之類的特性皆不能使用。適合執行簡單命令的情形,另外它通常相較其他選項來得安全且一致(不受 host 環境影響)。
  • ansible.builtin.shell:透過 shell 執行命令,基本上就像在本機上執行那樣。
  • ansible.builtin.raw:也是透過 shell 執行命令,但不透過 Python interpreter,而是直接使用 ssh。通常情況下不建議使用這個 module,但在少數案例中我們還是會需要它,像是在 host 上安裝 Python(不然其他 module 就沒辦法使用了)。

另外,除了上面這些 module,還有一個 ansible.builtin.script 也是用來執行 command 的,不過它是將 managed node 上的 script 先複製一份到 host 上再執行的。

最後還有那個 when,因為 ansible.builtin.command 在不傳 createsremoves 參數的情況下是不支援 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 我想也是相當粗糙,沒有考慮到足夠多的場景,接下來應該會再研究看看如何進行模組化。


上一篇
Day 26:Container != Docker Container
下一篇
Day 28:Ansible Vault
系列文
這個 site 就是遜啦 - SRE 30 天登大人之旅30

尚未有邦友留言

立即登入留言