IAM roles for service accountをs3cmdに適用させる

## はじめに
S3のオブジェクトを管理するためのツールとして[s3cmd](https://github.com/s3tools/s3cmd)があります。
AWS CLIをインストールせずともS3を操作でき、バックアップやリストアのために使われるケースが多いです。

EKS上でs3cmdを使うにはPodにS3へのアクセス権限が必要となります。
これまではS3へのアクセス権限を与えるために、**NodeにIAM Roleを付与**したり、**kube2iamで一時的にクレデンシャルを取得**させたりしていました。
2019年に[IAM role for service account(IRSA)](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)が登場し各言語のSDKが対応しましたがs3cmdではSDKを使用していないため、自分で仕組みを実装してみました。


## 環境
macOS Mojabe 10.14.6
Pulumi 2.1.0
AWS CLI 1.16.292
EKS 1.15
s3cmd 2.1.0

## s3cmd改修
s3cmdのソースコードを修正し、DockerイメージをECRにpushしていきます。

s3cmdは下記コマンドでダウンロードしておきます。

```console
$ wget --no-check-certificate https://github.com/s3tools/s3cmd/releases/download/v2.1.0/s3cmd-2.1.0.tar.gz
$ tar xzvf s3cmd-2.1.0.tar.gz
$ cd s3cmd-2.1.0
```

s3cmd-2.1.0のディレクトリ構造は次のようになっています。

```
├── INSTALL.md
├── LICENSE
├── MANIFEST.in
├── NEWS
├── PKG-INFO
├── README.md
├── S3/
├── s3cmd
├── s3cmd.1
├── s3cmd.egg-info/
├── setup.cfg
└── setup.py
```

### コード修正
修正するのは`S3/Config.py`のみです。S3アクセス権限を得るまでの流れは次のようになります。

1. 環境変数から`AWS_ROLE_ARN`と`AWS_WEB_IDENTITY_TOKEN_FILE`の値を取得する。
2. URLパラメータにセットしAWS STS APIサーバへPOSTする。
3. レスポンスボディをparseしアクセスキー、シークレットアクセスキー、セッショントークンを取得する。
4. s3cmdの設定値に手順3で得たパラメータをセットする。

追記部分のみを以下に記述します。関数`role_config`のみ既存のものの書き換えとなります。

```python:S3/Config.py
import urllib.request
import urllib.parse
import xml.etree.cElementTree

def _get_url():
  stsUrl = "https://sts.amazonaws.com/"
  roleArn = os.environ.get('AWS_ROLE_ARN')
  path = os.environ.get('AWS_WEB_IDENTITY_TOKEN_FILE')
  with open(path) as f:
    webIdentityToken = f.read()
  params = { 
    "Action": "AssumeRoleWithWebIdentity",
    "Version": "2011-06-15",
    "RoleArn": roleArn,
    "RoleSessionName": "s3cmd",
    "WebIdentityToken": webIdentityToken
  }
  url = '{}?{}'.format(stsUrl, urllib.parse.urlencode(params))
  return url

def _build_name_to_xml_node(parent_node):
  if isinstance(parent_node, list):
    return build_name_to_xml_node(parent_node[0])
  xml_dict = {}
  for item in parent_node:
    key = re.compile('{.*}').sub('',item.tag)
    if key in xml_dict:
      if isinstance(xml_dict[key], list):
        xml_dict[key].append(item)
      else:
        xml_dict[key] = [xml_dict[key], item]
    else:
      xml_dict[key] = item
  return xml_dict

def _replace_nodes(parsed):
  for key, value in parsed.items():
    if list(value):
      sub_dict = _build_name_to_xml_node(value)
      parsed[key] = _replace_nodes(sub_dict)
    else:
      parsed[key] = value.text
  return parsed

def _parse_xml_to_dict(body):
  parser = xml.etree.cElementTree.XMLParser(target=xml.etree.cElementTree.TreeBuilder(), encoding='utf-8')
  parser.feed(body)
  root = parser.close()
  parsed = _build_name_to_xml_node(root)
  _replace_nodes(parsed)
  return parsed

class Config(object):
  def role_config(self):
    url = _get_url()
    req = urllib.request.Request(url, method='POST')
    with urllib.request.urlopen(req) as resp:
      body = resp.read()
    parsed = _parse_xml_to_dict(body)

    Config().update_option('access_key', parsed['AssumeRoleWithWebIdentityResult']['Credentials']['AccessKeyId'])
    Config().update_option('secret_key', parsed['AssumeRoleWithWebIdentityResult']['Credentials']['SecretAccessKey'])
    Config().update_option('access_token', parsed['AssumeRoleWithWebIdentityResult']['Credentials']['SessionToken'])
```

1つずつ見ていきます。
<br>
関数`_get_url`ではSTS APIにPOSTする際のURLを作成するためのものです。
IRSAをPodに適用すると環境変数`AWS_ROLE_ARN`、`AWS_WEB_IDENTITY_TOKEN_FILE`が作成されます。
後者はファイルパスとなっており中身のトークンを取得してURLパラメータに追加します。

```python
def _get_url():
  stsUrl = "https://sts.amazonaws.com/"
  roleArn = os.environ.get('AWS_ROLE_ARN')
  path = os.environ.get('AWS_WEB_IDENTITY_TOKEN_FILE')
  with open(path) as f:
    webIdentityToken = f.read()
  params = { 
    "Action": "AssumeRoleWithWebIdentity",
    "Version": "2011-06-15",
    "RoleArn": roleArn,
    "RoleSessionName": "s3cmd",
    "WebIdentityToken": webIdentityToken
  }
  url = '{}?{}'.format(stsUrl, urllib.parse.urlencode(params))
  return url
```

<br>
関数`_build_name_to_xml_node`、`_replace_nodes`はxmlをdictionaryへ変換する処理部分となります。

```python
def _build_name_to_xml_node(parent_node):
  if isinstance(parent_node, list):
    return build_name_to_xml_node(parent_node[0])
  xml_dict = {}
  for item in parent_node:
    key = re.compile('{.*}').sub('',item.tag)
    if key in xml_dict:
      if isinstance(xml_dict[key], list):
        xml_dict[key].append(item)
      else:
        xml_dict[key] = [xml_dict[key], item]
    else:
      xml_dict[key] = item
  return xml_dict

def _replace_nodes(parsed):
  for key, value in parsed.items():
    if list(value):
      sub_dict = _build_name_to_xml_node(value)
      parsed[key] = _replace_nodes(sub_dict)
    else:
      parsed[key] = value.text
  return parsed
```

<br>
関数`_parse_xml_to_dict`はSTS APIサーバから返ってきたxmlをparserにかけて上述した関数でdictionaryに変換するためのものです。

```python
def _parse_xml_to_dict(body):
  parser = xml.etree.cElementTree.XMLParser(target=xml.etree.cElementTree.TreeBuilder(), encoding='utf-8')
  parser.feed(body)
  root = parser.close()
  parsed = _build_name_to_xml_node(root)
  _replace_nodes(parsed)
  return parsed
```

<br>
関数`role_config`はs3cmdにIAM Roleを付与する処理として使われます。
dictionaryからアクセスキー、シークレットアクセスキー、セッショントークンを設定します。


```python
class Config(object):
  def role_config(self):
    url = _get_url()
    req = urllib.request.Request(url, method='POST')
    with urllib.request.urlopen(req) as resp:
      body = resp.read()
    parsed = _parse_xml_to_dict(body)

    Config().update_option('access_key', parsed['AssumeRoleWithWebIdentityResult']['Credentials']['AccessKeyId'])
    Config().update_option('secret_key', parsed['AssumeRoleWithWebIdentityResult']['Credentials']['SecretAccessKey'])
    Config().update_option('access_token', parsed['AssumeRoleWithWebIdentityResult']['Credentials']['SessionToken'])
```

### Dockerイメージ作成

コード修正をしたものを`s3cmd-2.1.0.tar.gz`として圧縮し、`Dockerfile`と同じディレクトリに配置します。

```console
├── Dockerfile
└── s3cmd-2.1.0.tar.gz
```

`Dockerfile`は次のようになります。

```dockerfile:Dockerfile
FROM python:3.8.2-alpine3.11
ARG VERSION=2.1.0
COPY s3cmd-${VERSION}.tar.gz /tmp/
RUN tar -zxf /tmp/s3cmd-${VERSION}.tar.gz -C /tmp && \
    cd /tmp/s3cmd-${VERSION} && \
    python setup.py install && \
    mv s3cmd S3 /usr/local/bin && \
    rm -rf /tmp/*
ENTRYPOINT ["s3cmd"]
CMD ["--help"]
```

イメージをビルドしてECRにpushします。`XXXXXXXXXXXX`は自分のAWSアカウントに置き換えてください。

```console
$ docker build -t XXXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/s3cmd:2.1.0 .
$ docker push XXXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/s3cmd:2.1.0
```

## デプロイ
今回の環境は全て[Pulumi](https://www.pulumi.com/)で構築します。
ディレクトリ構成は次のようになります。編集するのは`index.ts`と`k8s/s3cmd.yaml`のみです。

```console
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── index.ts *
├── k8s
│   └── s3cmd.yaml *
├── node_modules/
├── package-lock.json
├── package.json
├── stack.json
└── tsconfig.json
```

`index.ts`でKubernetesのマニフェストファイル以外を記述します。
EKSクラスタはOpenID Connect Providerの設定を入れないといけません。

```typescript:index.ts
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
import * as eks from "@pulumi/eks";
import * as k8s from "@pulumi/kubernetes";
import * as pulumi from "@pulumi/pulumi";


const vpc = new awsx.ec2.Vpc("custom", {
  cidrBlock: "10.0.0.0/16",
  numberOfAvailabilityZones: 3,
});

const cluster = new eks.Cluster("pulumi-eks-cluster", {
  vpcId: vpc.id,
  subnetIds: vpc.publicSubnetIds,
  deployDashboard: false,
  createOidcProvider: true,
  instanceType: aws.ec2.T3InstanceSmall,
});

const s3PolicyDocument = pulumi.all([cluster.core.oidcProvider?.arn, cluster.core.oidcProvider?.url]).apply(([arn, url]) => {
  return aws.iam.getPolicyDocument({
    statements: [{
      effect: "Allow",
      principals: [
        {
          type: "Federated",
          identifiers: [arn]
        },
      ],
      actions: ["sts:AssumeRoleWithWebIdentity"],
      conditions: [
        {
          test: "StringEquals",
          variable: url.replace('http://', '') + ":sub",
          values: [
            "system:serviceaccount:default:s3-full-access"
          ]
        },
      ],
    }]
  })
})

const s3FullAccessRole = new aws.iam.Role("s3FullAccessRole", {
  name: "s3-full-access-role",
  assumeRolePolicy: s3PolicyDocument.json,
})

new aws.s3.Bucket("pulumi-s3cmd-test", {
  bucket: "pulumi-s3cmd-test"
});

const s3FullAccessRoleAttachment = new aws.iam.RolePolicyAttachment("s3FullAccessRoleAttachment", {
  role: s3FullAccessRole,
  policyArn: aws.iam.AmazonS3FullAccess,
})

const myk8s = new k8s.Provider("myk8s", {
  kubeconfig: cluster.kubeconfig.apply(JSON.stringify),
});

const s3cmd = new k8s.yaml.ConfigFile("s3cmd", {
  file: "./k8s/s3cmd.yaml"
}, { provider: myk8s })
```

`k8s/s3cmd.yaml`ではServiceAccountとDeploymentを定義します。
ServiceAccountではannotationsを加えないといけません。

```yaml:s3cmd.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  namespace: default
  name: s3-full-access
  labels:
    app: s3cmd
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::XXXXXXXXXXXX:role/s3-full-access-role
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: default
  name: s3cmd
  labels:
    app: s3cmd
spec:
  selector:
    matchLabels:
      app: s3cmd
  replicas: 1
  template:
    metadata:
      labels:
        app: s3cmd
    spec:
      serviceAccountName: s3-full-access
      containers:
      - image: XXXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/s3cmd:2.1.0
        name: s3cmd
        command: ["/bin/sh"]
        args: ["-c", "while true; do echo hello; sleep 10; done"]
```

あとは下記コマンドでデプロイするだけです。

```
$ pulumi up
```

## 確認

作成したs3cmdのPodからs3cmdコマンドを打てることを確認します。
今回作成したS3バケットがきちんと表示されます。

```console
$ kubectl get pod
NAME                                                              READY   STATUS    RESTARTS   AGE
s3cmd-98985855f-h5lgl                                             1/1     Running   0          63s

$ kubectl exec -it s3cmd-98985855f-h5lgl -- s3cmd ls
2020-05-02 15:04  s3://pulumi-s3cmd-test
```

## おわりに
kube2iamを使用せず、IRSAでs3cmd PodにIAM Roleを付与できることを確認しました。
kube2iamではDaemonSetをデプロイする必要があり管理リソースが増えてしまうことを考えるとIRSAのメリットは大きいと思います。