AWSにおけるEC2(Linux)ユーザの管理簡略化計画

## 1 はじめに
[フューチャー Advent Calendar 2019（2）](https://qiita.com/advent-calendar/2019/future2)の13日目です。  

　2019年4月に新卒で入社し、7月からAWSを触り始めました。AWSを触り始めて半年ほどたち、AMI(Amazon Machine Image)管理について考えなければならない場面に直面しました。その際にEC2(Linux)のユーザ管理に悩まされたので思ったこと、最終的に行きついた管理方法を書いていきます。AWS re:Invent 2019で[EC2 Image Builder](https://aws.amazon.com/jp/blogs/aws/automate-os-image-build-pipelines-with-ec2-image-builder/)が発表されたことによって、Packerを使用する機会が減っていくかもしれないので、供養の意味も込めてPackerについても軽く触れます。

## 2 AMI管理
### 2.1 Packerとは何か
　PackerはHashiCorp社が出しており、AWSのみならず、GCP、Azure、Dockerといった様々な環境のイメージを作成できるツールです。JSONでどんな設定をしたいか記述しておき、`packer build`コマンドを実行することで、インスタンスを起動しJSONに記述された手順通りに設定がおこなわれます。設定がすべて完了したらそのインスタンスをイメージ化し、使用したインスタンスはterminateされます。  
以下は、Packerでshellやansibleを使う際の記述例です。

```json
"provisioners": [
    {
      "type": "shell",
      "inline": [
         "sudo yum update -y"
      ]
    }
]
```

```json
"provisioners": [
    {
      "type": "ansible",
      "playbook_file": "./playbook.yml"
    }
]
```

非常に簡単な例しか提示していませんが、他にもprovisionersは多く用意されており、Packerを使うことでコードベースで自由にAMIを構築・管理することが可能になります。

### 2.2 Packerの利点・欠点
　ここではAMIを手動作成する場合と比較します。手動でAMIを作成する場合、AWSマネジメントコンソール上でインスタンスを右クリックし、イメージの作成を選択することでインスタンスをイメージ化することが可能です。
<img width="470" alt="create_ami.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525823/c0311ec8-20de-4554-3df5-47fbfa412e07.png">

**利点**

1 .AMIの構築手順をコード化し、gitなどでversion管理することが可能
2. コードからAMIを簡単に再現できるためEC2のスナップショットを取る必要がない

　手動でAMIを作成する場合、改修を加えていく過程でどうやって作成されたかわからなくなりがちです。これを避けるためには、作成手順を逐一ドキュメント化する必要があり、非常に面倒です。また、定期的にEC2のスナップショットをとるといったような、障害時対応も考える必要があります。Packerはこれらの問題を解決してくれます。(ログなどの起動後に生成されるファイルを保持する場合は別途S3などにエクスポートする必要があります)

**欠点**
一方で、Packerを使ううえで面倒だなと感じたこともいくつかありました。

1. Packer実行時にAWS上のインスタンスへのsshが可能である必要があること
2. ユーザ作成など軽微な変更でもPackerのコードを変更する必要があること

　1に関して、社内ネットワークから自由にsshできない場面もあるのではないでしょうか。Packerで一時的に立ち上がるインスタンスのグローバルIPは毎回変更されるため、特定IPのみssh許可するといった対策もとれません。私の場合は社用PCのローカルからPackerを実行することを諦めました。余談ですが、デフォルトVPCを削除した状態だとPackerが実行できなくなるらしいです。
　2に関して、EC2のユーザ追加や削除、Keyの追加、変更といった単純な処理でも、git commit、push、mergeといった一連の流れをおこなう必要があり、頻度が高くなるとストレスを感じます。

### 2.3 構築したもの
 前節で述べたようにPackerの実行環境と、AMIから作成するEC2のユーザ管理に悩まされた結果、これらを解決するために以下のものを構築しました。今回はLinuxユーザとIAMユーザと同期させるスクリプトについて述べます。

* CodeBuild上でPackerを実行しAMIの作成⇒Terraformでインスタンスに適用の自動化(いつか機会があれば紹介)
* LinuxユーザとIAMユーザと同期させるスクリプト(以後、ユーザ同期スクリプト)

## 3 ユーザ同期スクリプト
### 3.1 概要
　今回、Linuxユーザを作成する際のマスタとしてIAMユーザを採択しました。理由としては、別途Linuxユーザの一覧といったものを管理するのが面倒だったのが正直なところです。Amazon Relational Database Service (Amazon RDS)では、IAM DB認証を利用することで、各DBごとにユーザを管理するのではなく、IAMユーザ情報をもとにRDSに接続することが可能です。
<img width="343" alt="iam_db.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525823/94fbd438-73c3-fab9-05a8-161c15067f6f.png">
これと同じことをEC2でも実現したいというモチベーションです。絶対にすでに誰かがやっていると思ったのでまずは先駆者を探しました。

### 3.2 参考記事
[IAMで踏み台ホストのユーザーとSSH公開鍵を一元管理する](https://qiita.com/noda_sin/items/c3b24f9f2a1037e50e56)

こちらの記事では、`/home`直下のユーザディレクトリ名とaws-cliで取得したIAMユーザ一覧を比較しユーザの追加・削除を行っています。基本部分はこの記事を参考にしているため、今回は追加点・変更点などについて記述します。

**Linuxユーザ一覧取得**

```sh
find /home -maxdepth 1 -type d | grep '^/home/' | sed -e 's/^\/home\/\(.\)/\1/'
```

**IAMユーザ一覧取得**

```
aws iam list-users | jq -r '.Users[].UserName'
```

### 3.3 追加要望
　上記で紹介した記事のままでも問題なく動作したのですが、私たちの環境では以下の要望が生まれました。

1. 特定のIAMグループに属するユーザのみを対象としたい
2. EC2が複数台ある場合の対応
3. ssh keyの生成・ユーザへの配布までやってほしい

**1.特定のIAMグループに属するユーザのみを対象としたい**
　すべてのIAMユーザをLinuxユーザとする場合は問題ないのですが、AWSコンソールしか触らないユーザやAWSのシステムユーザが全員Linuxユーザになるのは避けたいためです。こちらもaws-cliで実現できます。先ほど紹介したユーザの一覧取得と合わせると以下のように既述することが出来ます。
※今回は実行時間を気にせず作成してしています。速さを求める方は適宜変更してください。

```sh
host_users=$(find /home -maxdepth 1 -type d | grep '^/home/' | sed -e 's/^\/home\/\(.\)/\1/')
iam_users=$(aws iam list-users | jq -r '.Users[].UserName')
for iam_user in ${iam_users[@]}; do
    user_exists=(`contains ${iam_user} ${host_users[@]}`)
    groups=$(aws iam list-groups-for-user --user-name ${iam_user} | jq -r '.Groups[].GroupName')
    group_exists=(`contains ${target_group_name} ${groups[@]}`)
    if [ $user_exists = 0 -a $group_exists = 1 ]; then
        #IAMユーザがホストには存在せず、指定のIAMグループに存在する場合はユーザを追加
        #ここに追加処理を記述する
    fi
done
```

**2.EC2が複数台ある場合の対応**
　参考記事ではIAMユーザーのCodeCommit用SSH公開鍵を取得するという方法でKeyを管理しています。
<img width="649" alt="iam_ssh_key.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525823/3d2238d5-2682-1487-d8fa-ef23fc2bd68b.png">

実際には以下のコマンドで取得する流れになるのですが、複数のインスタンスが存在する場合、すべてのインスタンスにおいて同じKeyを使いまわす状況になってしまいます。

**変更前**

```sh
key_ids=$(aws iam list-ssh-public-keys --user-name $iam_user | jq -r 'select(.SSHPublicKeys[].Status == "Active") | .SSHPublicKeys[].SSHPublicKeyId')
for key_id in $key_ids; do
  aws iam get-ssh-public-key --user-name $iam_user --ssh-public-key-id $key_id --encoding SSH | jq -r '.SSHPublicKey.SSHPublicKeyBody' >> authorized_keys
done
```
インスタンスごとにIAMユーザを作ることでも対応可能ですが、今回はIAMユーザは1人１つで実現したかったためS3に公開鍵を配置するよう変更しました。ここでは`aws s3 ls`コマンドを用いて、その結果が空か否かでkeyの存在を判定しています。`{{}}`で囲まれている変数に関してはPacker実行時に定義された環境変数を埋め込むようにしています。

**変更後**

```sh
user_ssh_dir="/home/$iam_user/.ssh"
key_exists=$(aws s3 ls s3://{{s3_bucket_name}}/{{env}}/public/${iam_user}/)
if [ -z "$key_exists" ]; then
    # 公開鍵がS3に存在しない場合の処理を記述
else
    # 公開鍵がS3に存在する場合の処理を記述
    aws s3 cp "s3://{{s3_bucket_name}}/{{env}}/public/${iam_user}/authorized_keys" "$user_ssh_dir/authorized_keys" 
fi
```

**3.ssh keyの生成・ユーザへの配布までやってほしい**
　これに関しては賛否両論あると思います。秘密鍵の管理をどうするかはユースケースによります。今回はEC2へのアクセスにMFAを強制しており、秘密鍵を置くS3へのアクセス制限も厳しいことからS3で管理を行っています。秘密鍵を絶対にローカル以外に置きたくないという場合はローカルで公開鍵、秘密鍵を作成し公開鍵をS3にアップロードするという形が良いと思います。
　私たちの間でも議論になりましたが、結局ユーザを作成した後に秘密鍵をIP制限されたGoogle Drive経由などで渡すくらいであれば、IAM・IPによってアクセス制限されたS3に置くのも変わらないのではないかという判断です。また、出来るだけユーザ発行、削除におけるオペレーションを減らしたかったので秘密鍵の配布までスクリプトで行っています。

```sh
# 鍵を作成する際の処理(初回のみ)
ssh-keygen -t rsa -N "" -f "$user_ssh_dir/${iam_user}"
sudo mv "$user_ssh_dir/${iam_user}.pub" "$user_ssh_dir/authorized_keys"
echo "ssh-key created"
aws s3 cp "$user_ssh_dir/authorized_keys" "s3://{{s3_bucket_name}}/{{env}}/public/${iam_user}/authorized_keys"
aws s3 mv "$user_ssh_dir/${iam_user}" "s3://{{s3_bucket_name}}/{{env}}/secret/${iam_user}/{{env}}_${iam_user}.pem"
```

公開鍵がS3に存在しない場合のみ上記の処理を実行し、鍵をS3に配置します。AutoHealingやAMIの更新によってインスタンスが再作成されたときは、S3にある公開鍵をインスタンスに登録するため、秘密鍵は共通のものを使い続けることが出来ます。

### 3.4 各ユーザに自分の名前が入っているS3のBucketObject内のみ参照させるIAMポリシー
　次に、IAMユーザの権限設定について述べます。IAMポリシーでは`${aws:username}`のように変数が用意されており利用できます。これを利用することで、各ユーザ毎にポリシーを作成する必要はなく、以下のようなポリシーをIAMグループにアタッチしておくことで各ユーザは自分の名前の付いたObject配下のリソースのみをGetすることが可能になります。これを利用して、ユーザが自分の秘密鍵のみS3からGetできる仕組みを実現しています。

```json
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": "s3:GetObject*",
            "Resource": "arn:aws:s3:::{{s3_bucket_name}}/{{env}}/secret/${aws:username}/*",
        }
```

### 3.5 Packerへの組み込み
　provisionerとしてansibleを利用しています。templateモジュールを使うとファイルをコピーする際に、ファイル内の`{{}}`で囲われた変数が、Packerで定義されている環境変数に置き換わるので便利です。今はcronで毎時実行していますが、Linuxユーザの反映に最大1時間かかってしまうため、このあたりはユースケースに合わせて変更してください。また、定期実行だけでなく、Auto Healingした場合やAMI更新時にLinuxユーザが即時反映されるように、reboot時にjobを実行する設定も必要です。

```yaml
---
- name: copy sync_users.sh
  template:
    src: ./files/sync_users.sh
    dest: /home/system/sync_users.sh

- name: setup cron (every hour)
  cron:
    name: sync user (every hour)
    user: "system"
    minute: "0"
    job: "sh /home/system/sync_users.sh {{iam_group_name}}"

- name: setup cron (reboot)
  cron:
    name: sync user (reboot)
    user: "system"
    special_time: reboot
    job: "sh /home/system/sync_users.sh {{iam_group_name}}"
```

## 4 まとめ
　Packerを用いることでAMIをコードで管理できますが、コード上でLinuxユーザの管理を行うのは変更頻度、Key管理の観点からあまり良い方法ではないと感じました。そこで今回作成したスクリプトを使うと以下のことが可能になり、Packerと組み合わせることで運用が容易なAMIを作成することが出来ます。

* Linuxユーザをコード上で管理する必要がなくなる
* インスタンスにsshしなくても、マネジメントコンソールでIAMグループの付け替えをすることで、Linuxユーザの追加、削除が可能になる
* 秘密鍵の発行、共有を管理者が意識する必要がなくなる

## 5 参考

* [IAMで踏み台ホストのユーザーとSSH公開鍵を一元管理する](https://qiita.com/noda_sin/items/c3b24f9f2a1037e50e56)