GitHub ActionsとAnsibleでGitOpsな構成管理(#2)

はじめに

研究室で「複数サーバーへのユーザー追加を自動化したい」という要望がありました。

本記事では、AnsibleとGitHub Actions(self-hosted runner)を使用して認知負荷が低いGitOpsな構成管理を実現する方法を紹介します。

1. 要件定義

目的は、面倒なユーザー追加のオペレーションを自動化することです。研究室に新しいメンバーがjoinした際の既存のオペレーションを以下に示します。

既存のユーザー追加のオペレーション

  1. 対象のサーバーにSSH接続する
  2. rootユーザー(あるいはroot権限を持つユーザー)に切り替える
  3. useraddコマンドでユーザーを追加する(人数分繰り返す)
  4. usermodコマンドでユーザーをdockerグループに追加する(人数分繰り返す)
  5. SSH接続を切断する

1年に1回とはいえ、手作業でこのオペレーションを行うのは大変です。さらに対象のサーバーが複数台ある場合は、この作業を台数分だけ繰り返さなければなりません。

機能要件として、管理者が1箇所を直感的に編集するだけで自動的に全てのサーバーが変更(ユーザーの追加や削除など)されるようにしたいです。

2. 技術選定

2-1. LDAP

調査を行ったところ、ディレクトリサービスを使用してネットワークに接続した資源(ユーザー情報など)を統一管理するという方法がありました。LDAP(Lightweight Directory Access Protocol)は、そのディレクトリサービスに接続するための標準プロトコルです。

また、LDAPを導入してサーバーのユーザー管理を行なっている企業のテックブログもありました。

engineering.dena.com

OpenLDAPは、LDAPを実装したオープンソースのソフトウェアの1つです。公式ドキュメントもありますが、実際に導入する際には以下の記事(3部構成)が最も分かりやすいと思います。

server-network-note.net

イメージとしては、LDAPサーバーが電話帳、LDAPクライアントが電話帳の利用者の役割を果たします。電話帳には多くの人々の連絡先情報が集中管理されており、利用者は電話帳から必要な情報を検索したり、新しい情報を追加したり、既存の情報を更新したりすることができます。

さて、LDAPを導入することになった場合、先生または学生のうち最低でも1人は技術的なキャッチアップが必要になります。本来の目的が研究であることや、インフラに興味のある学生がjoinするとは限らないことから、技術的には面白そうだと思いながらもLDAPの採用は見送ることになりました。

2-2. Ansible

もう1つの候補として、Ansibleが挙げられます。Ansibleは言わずと知れたオープンソースの構成管理ツールです。Inventoryに書かれた管理対象ノードに対して、YAML形式のPlaybookに従ってオペレーションが実行されます。

Ansibleの仕組み(引用:Getting started with Ansible — Ansible Community Documentation

一般的には、管理者がコントロールノードでAnsibleコマンドを実行することで管理対象ノードに設定が適用されます。

研究室内のサーバーに限らず、VPN接続した自分のPC(MacBookなど)をコントロールノードにしても良いのですが、依然として管理者がAnsibleコマンドの使い方を覚える必要があります。

この認知負荷を下げるための工夫として、2つの方法が考えられました。

  1. ansible-pullを全ての監視対象ノードに導入する
  2. VPN内のノードにGitHub Actionsのself-hosted runnerを構築する

両者の共通点は、GitリポジトリをSingle Source of TruthとするGitOpsの考え方に基づいている点です。

GitOpsはKubernetes環境でよく採用されます*1が、Ansibleでもシステムの望ましい状態を宣言的に記述できるため同様にGitOpsを採用できるはずです。

GitOpsを採用することで、管理者がGitリポジトリにPlaybookの変更をpushするだけで適用されるだけでなく、誰がいつどのような設定を適用したのか把握でき可視性が向上します。

1.のansible-pullというコマンドラインツールは、Ansibleの公式ドキュメントを眺めていた時に偶然発見しました。

Used to pull a remote copy of ansible on each managed node, each set to run via cron and update playbook source via a source repository. This inverts the default push architecture of ansible into a pull architecture, which has near-limitless scaling potential.

と書かれているように、AnsibleのPush型のアーキテクチャをPull型に反転させることができそうです。

Push型アーキテクチャとPull型アーキテクチャ

公式ドキュメントには、監視対象となる各ノード上でansible-pullを実行すると書かれています。この場合、全ての監視対象ノードにansibleのインストールとcronの設定が必要になり、ノード数が多い場合は導入コストの増加が見込まれます*2

それから、2.のGitHub Actionsはお馴染みのツールです。

一般的なGitHub-hosted runnerを利用する場合は、VPN内へのインバウンド接続を許可するためにSSH秘密鍵VPNのパスワードをSecretsに保存しなければなりません。一方でself-hosted runnerを利用する場合はクラウドサービスやローカルマシン上でワークフローを実行することができ、GitHubにクレデンシャルを保存する必要もありません。ただし、runnerマシンのメンテナンスコストは自己負担となるため注意が必要です。

これまでに2つのアイデアを紹介しました。個人的にはansible-pullを導入する方が技術的に面白いと思いましたが、研究室のために少し冷静になって考えました。メンテナンスコスト(runnerマシンのみ vs 全監視対象ノード)や実行頻度(1年に数回)を考慮して、総合的にself-hosted runnerを構築するのがベターだという結論に至りました。

3. self-hosted runnerの構築

以前、監視対象ノードとは別にRaspberry Piで監視サーバーを構築していたので、このマシンにself-hosted runnerを構築することにしました。

Raspberry Pi 4BとPironmanによる監視サーバー

GitHubで適当なリポジトリを1つ作成し、SettingsActionsRunnersを開いてNew self-hosted runnerボタンをクリックします。表示されているいくつかのコマンドを実行するだけでインストールと設定が完了するので本当に便利です。

Raspberry Piを使用するのでARM64を選択

./run.shで実行しても良いのですが、いずれサービスとして起動したい場合は、以下の公式ドキュメントを参照してください。シェルスクリプトが提供されていて、systemctlコマンドを直接実行することなくサービスの起動・ステータス確認・削除ができます。

docs.github.com

サービスとして有効化されているかsystemctlコマンドでも確認

公式ドキュメントに従ってrunnerマシンにAnsibleをインストールしたら、runnerマシンから監視対象ノードにSSH接続できるように公開鍵の登録などを完了させておきます。

self-hosted runner上でAnsilbeを実行し、監視対象ノード上でHello Worldをファイル出力できるか検証してみます。ディレクトリ構造はシンプルです。

$ tree -a -L 3 -I '.git|README.md' .
.
├── .github
│   └── workflows
│       └── test.yaml
├── hosts
└── playbook.yaml

ワークフローファイルである.github/workflows/test.yamlは以下の通りです。直接ansible-playbookコマンドを実行しても良かったのですが、可読性が上がりそうなのでdawidd6/action-ansible-playbookを使用しました。

name: Test Action
on:
  push:
    branches:
      - main

jobs:
  deploy:
    # https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/using-self-hosted-runners-in-a-workflow#using-custom-labels-to-route-jobs
    runs-on: [self-hosted, linux, ARM64]
    steps:
      - uses: actions/checkout@v4
      - name: Run playbook
        uses: dawidd6/action-ansible-playbook@v2
        with:
          playbook: playbook.yaml
          directory: ./
          options: |
            --inventory hosts
            --verbose

Inventoryファイルであるhostsには、以下のように適当な監視対象のホストを追加します。エイリアスfoo)やホストに接続する際に使用するユーザー名(bar)は適宜変更してください。

[all]
foo ansible_host=192.168.1.xxx ansible_user=bar

playbook.yamlは以下の通りです。

- name: Write "Hello World" to a file
  hosts: all
  tasks:
    - name: Create or overwrite ~/results.txt with "Hello World"
      copy:
        content: "Hello World\n"
        dest: "{{ ansible_env.HOME }}/results.txt"

検証なのでmainブランチに直接pushさせていただきます。

追加したself-hosted runner上でワークフローが実行された

指定したホストのホームディレクトリ上にもHello Worldと書かれたresults.txtというファイルが存在しているはずです。

4. ユーザー追加の自動化

先述のシンプルなHello Worldを、ユーザー追加のPlaybookに変更して完成です。

このタイミングで、ディレクトリ構成もAnsibleのベストプラクティス*3になるべく沿うようにしました。

$ tree -a -L 4 -I '.git|README.md' .
.
├── .github
│   └── workflows
│       └── test.yaml
├── hosts
├── roles
│   └── user
│       ├── tasks
│       │   └── main.yaml
│       └── vars
│           └── main.yaml
├── servers.yaml
└── site.yaml

メインとなるroles/user/tasks/main.yamlは以下の通りです。ansible.builtin.userモジュールを使用します。

---
- name: Ensure users are present
  ansible.builtin.user:
    name: "{{ item.name }}"
    uid: "{{ item.uid }}"
    password: "{{ item.password | password_hash('sha512') }}"
    update_password: "on_create"
    append: true
    groups: "docker"
    state: "present"
  with_items: "{{ users }}"

キーとなるパラメータはpasswordupdate_passwordの2つです。passwordについてLinuxでは暗号化ハッシュを指定する必要があり不便なので、password_hashフィルタを利用します。update_password: "on_create"(デフォルトは"always")は、ユーザーが変更したパスワードを初期パスワードで上書きしないために必須です。GitHub Actionsでワークフローが何回実行されても、ユーザーが存在している状態は変わらないという冪等性を担保します。

なおservers.yamlでは、hostsに書かれた[servers]グループとuserロールのマッピングのみを行います。

---
- hosts: servers
  become: true
  roles:
    - user

特権ユーザーになるためにbecome: trueとしていますが、vars: ansible_become_password:GitHubのSecretsから環境変数として読み込むことは面倒だったので、監視対象ノードで以下のコマンドを実行することで省略することができました。

# 実行ユーザーが`infra`の場合
$ echo "infra ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/infra

追加するユーザーの情報はroles/user/vars/main.yamlに追記していきます。

---
users:
  - name: foo
    uid: 2147483646
    password: foo
  - name: bar
    uid: 2147483647
    password: bar

このYAMLファイルを変更してmainブランチにpushするだけで、全ての監視対象ノードでユーザーが存在している状態になります。

3台の監視対象ノードでfooとbarというユーザーが作成された

なお、ユーザー削除については研究データを長期保存するという特性上、ほとんどニーズが無いので今回は敢えてPlaybookに含めませんでした。

まとめ

Ansibleとself-hosted runnerを使用して、ユーザー追加をはじめとした構成管理を簡単に行えるようになりました。

runnerマシンは、通常のコントロールノードとして各種ツールのインストールやOSアップデートにも活用する予定です。とても楽しみです。

*1:代表的なGitOpsツールにArgoCDFluxなどがあります

*2:Pull型を採用する場合でも、初期設定のみPush型のアーキテクチャを利用すれば導入コストを削減できそうです

*3:v2.9ではBest Practicesとして紹介されていましたが、最新のv10ではSample directory layoutという表現に変更されていました