はじめに
研究室で「複数サーバーへのユーザー追加を自動化したい」という要望がありました。
本記事では、AnsibleとGitHub Actions(self-hosted runner)を使用して認知負荷が低いGitOpsな構成管理を実現する方法を紹介します。
1. 要件定義
目的は、面倒なユーザー追加のオペレーションを自動化することです。研究室に新しいメンバーがjoinした際の既存のオペレーションを以下に示します。
- 対象のサーバーにSSH接続する
- rootユーザー(あるいはroot権限を持つユーザー)に切り替える
- useraddコマンドでユーザーを追加する(人数分繰り返す)
- usermodコマンドでユーザーをdockerグループに追加する(人数分繰り返す)
- SSH接続を切断する
1年に1回とはいえ、手作業でこのオペレーションを行うのは大変です。さらに対象のサーバーが複数台ある場合は、この作業を台数分だけ繰り返さなければなりません。
機能要件として、管理者が1箇所を直感的に編集するだけで自動的に全てのサーバーが変更(ユーザーの追加や削除など)されるようにしたいです。
2. 技術選定
2-1. LDAP
調査を行ったところ、ディレクトリサービスを使用してネットワークに接続した資源(ユーザー情報など)を統一管理するという方法がありました。LDAP(Lightweight Directory Access Protocol)は、そのディレクトリサービスに接続するための標準プロトコルです。
また、LDAPを導入してサーバーのユーザー管理を行なっている企業のテックブログもありました。
OpenLDAPは、LDAPを実装したオープンソースのソフトウェアの1つです。公式ドキュメントもありますが、実際に導入する際には以下の記事(3部構成)が最も分かりやすいと思います。
イメージとしては、LDAPサーバーが電話帳、LDAPクライアントが電話帳の利用者の役割を果たします。電話帳には多くの人々の連絡先情報が集中管理されており、利用者は電話帳から必要な情報を検索したり、新しい情報を追加したり、既存の情報を更新したりすることができます。
さて、LDAPを導入することになった場合、先生または学生のうち最低でも1人は技術的なキャッチアップが必要になります。本来の目的が研究であることや、インフラに興味のある学生がjoinするとは限らないことから、技術的には面白そうだと思いながらもLDAPの採用は見送ることになりました。
2-2. Ansible
もう1つの候補として、Ansibleが挙げられます。Ansibleは言わずと知れたオープンソースの構成管理ツールです。Inventoryに書かれた管理対象ノードに対して、YAML形式のPlaybookに従ってオペレーションが実行されます。
一般的には、管理者がコントロールノードでAnsibleコマンドを実行することで管理対象ノードに設定が適用されます。
研究室内のサーバーに限らず、VPN接続した自分のPC(MacBookなど)をコントロールノードにしても良いのですが、依然として管理者がAnsibleコマンドの使い方を覚える必要があります。
この認知負荷を下げるための工夫として、2つの方法が考えられました。
- ansible-pullを全ての監視対象ノードに導入する
- 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型に反転させることができそうです。
公式ドキュメントには、監視対象となる各ノード上で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を構築することにしました。
GitHubで適当なリポジトリを1つ作成し、Settings→Actions→Runnersを開いてNew self-hosted runnerボタンをクリックします。表示されているいくつかのコマンドを実行するだけでインストールと設定が完了するので本当に便利です。
./run.sh
で実行しても良いのですが、いずれサービスとして起動したい場合は、以下の公式ドキュメントを参照してください。シェルスクリプトが提供されていて、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させていただきます。
指定したホストのホームディレクトリ上にも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 }}"
キーとなるパラメータはpassword
とupdate_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するだけで、全ての監視対象ノードでユーザーが存在している状態になります。
なお、ユーザー削除については研究データを長期保存するという特性上、ほとんどニーズが無いので今回は敢えてPlaybookに含めませんでした。
まとめ
Ansibleとself-hosted runnerを使用して、ユーザー追加をはじめとした構成管理を簡単に行えるようになりました。
runnerマシンは、通常のコントロールノードとして各種ツールのインストールやOSアップデートにも活用する予定です。とても楽しみです。