SendGridでは、受信者メールアドレスの大文字小文字はどうなるか試してみた

メールアドレスの形式について調べる機会があったため、RFC5321(日本語訳)を見たところ、

動詞と引数の値(例えば RCPT コマンドにおける "TO:" または "to:" や拡張名キーワード)は大文字・小文字を区別されないが、メールボックスの local-part の指定が唯一の例外である(SMTP 拡張は大文字・小文字を区別する要素を明示的に規定してもよい)。つまり、コマンド動詞、メールボックスの local-part 以外の引数、自由形式のテキストは、その意味に影響を与えることなく、大文字、小文字、または大文字・小文字の任意の組み合わせで符号化されてよい(MAY)ということである。メールボックスの local-part は大文字・小文字を区別されなければならない(MUST)。したがって SMTP 実装は、メールボックスの local-part の大文字・小文字が保持されるよう注意しなければならない(MUST)。具体的にいうと、一部のホストにとってユーザー "smith" はユーザー "Smith" と異なるということである。しかしながら、メールボックスの local-part の大文字・小文字の区別の濫用は相互運用性を妨げるため、推奨されない。メールボックスドメインは通常の DNS 規則にしたがい、大文字・小文字を区別しない。

http://srgia.com/docs/rfc5321j.html#p2.4

とありました。

また、

「仕様としては、"区別する"だけど、メールボックスの運用実態としては"区別しない"運用になっている」

http://babyp.blog55.fc2.com/blog-entry-982.html

との記載もありました。

 
SendGridでどうなるか調べたところ、それらしいドキュメントが見当たりませんでした。

そこで今回、受信者のメールアドレスの大文字小文字がどうなるか試してみました。

 

目次

 

環境

前回の記事同様、Pythonスクリプトで試したため、以下の環境となります。

  • Python 3.8
  • sendgrid 6.6.0
  • smtpapi 0.4.7
  • python-dotenv 0.16.0

 

試したソースコード

SendGridのSMTPサーバを使って、以下のような 'send' 関数を用意しました。

後は呼び出し元で、受信者のメールアドレスを変えてみればよいとしました。

import os
import smtplib
from email.mime.text import MIMEText

from smtpapi import SMTPAPIHeader
from dotenv import load_dotenv
load_dotenv()


def send(to_email):
    server = create_smtp_server()

    from_email = os.environ['FROM_EMAIL']
    to_email = [to_email]

    body = 'hello'
    message = MIMEText(body)
    message['From'] = from_email
    message['TO'] = ','.join(to_email)

    message['Subject'] = 'メールドロップテスト'

    server.sendmail(from_email, to_email, message.as_string())
    server.quit()


def create_smtp_server():
    host = 'smtp.sendgrid.net'
    port = 587
    user = 'apikey'
    password = os.environ['SENDGRID_API_KEY']
    server = smtplib.SMTP(host, port)
    server.starttls()
    server.login(user, password)
    return server


if __name__ == '__main__':
    main()

 

正常に届くメールについて

存在するメールアドレスをすべて大文字で指定した FOO@EXAMPLE.COM 宛にメールを送信したところ、

となっていました。

 
また、 Foo@Example.com と大文字小文字を混ぜたところ、

となっていました。

正常に届くメールはドメイン部分は全て小文字になるようです。

 

Event Webhookで受け取った時の値について

次に、存在しないメールアドレス notfound@example.comBounceした時、SendGridのEvent Webhookではどのような値が返ってくるかを調べてみました。

メールアドレスに対し

  • すべて大文字
  • すべて小文字
  • 大文字小文字混在

の条件でそれぞれ送信したところ、

{status=5.1.1, smtp-id=xxx, ip=149.72.71.211, tls=0.0, 
reason=550 5.1.1 <notfound@example.com>: Recipient address rejected: User unknown in virtual mailbox table,
email=notfound@example.com, 
sg_event_id=xxx, event=bounce, 
sg_message_id=xxx, 
timestamp=1.617030019E9, 
type=bounce}

のようにすべて小文字 ( notfound@example.com ) となっていました。

 

まとめ

上記より、現時点のSendGridでは

  • 届くメール
    • ユーザ部分はそのまま・ドメイン部分は小文字化にて、メールが送信される
  • 届かないメール
    • Event Webhookのメールアドレスは、すべて小文字化される

のようです。

 

ソースコード

Githubに上げました。 lettercase の中が今回のファイルです。
https://github.com/thinkAmi-sandbox/sendgrid_event_webhook-sample

SendGridのEvent Webhookでメールを識別するため、X-SMTPAPIヘッダのUnique Argumentsを使ってみた

SendGridにはEvent Webhookがあり、メール送信のイベントをWebhookとして拾うことができます。

 
ただ、Webhookで取得できるデフォルトの項目が送信先メールアドレスや発生日時などに限られていました。

項目を増やせないかを調べたところ、

などがありました。

このうち、Categoriesについては

カテゴリはUS-ASCII文字セットを使った7bitエンコードを使用する必要があります。 カテゴリはメッセージをグループ化するために使用されます。メッセージにユニークなデータや識別子を付加したい場合、代わりにUnique Argumentsを使用してください。

https://sendgrid.kke.co.jp/docs/API_Reference/SMTP_API/categories.html

とのことです。

できれば日本語の件名を識別に使いたかったことから、X-SMTPAPIヘッダのUnique Argumentsを試してみました。

 

目次

 

環境

 

Event Webhook受信アプリの作成

アプリの基盤

Event Webhookの受信はWebアプリを用意すればよかったので、今回はお手軽に

としました。

 

Basic認証について

SendGridのEvent WebhookではBasic認証をサポートしています。

Event Webhook は HTTP のBasic認証をサポートしています。この認証を使用する場合は、Settings画面で、認証情報を含めたURLを HTTP Post URL に指定してください。

http(s)://username:password@domain/foo.php

https://sendgrid.kke.co.jp/docs/API_Reference/Webhooks/event.html#-Setup

一方、Google Apps ScriptのWebアプリではヘッダ情報を取得できないことから、Basic認証のサポートは難しそうです。
https://issuetracker.google.com/issues/67764685

ただ今回はお手軽なアプリのため、Basic認証はかけずに使ってみます。

 

Google Apps ScriptによるWebアプリの作成

doPost() 関数を用意し、Webアプリとして公開します。

公開した際にリクエスト用のURLが発行されますので、これを使います。

なお、今回はGoogleスプレッドシートの所有者として動かし、誰でもアクセスできるWebアプリとします。

 
用意したWebアプリは以下のような感じです。

 

// main.gs

function doPost(e) {
  const id = 'YOUR SHEET ID';
  const ss = SpreadsheetApp.openById(id);
  const sheet = ss.getSheetByName('シート1');
  
  // 一行目に最終更新日時をセット
  sheet.getRange('A1').setValue(Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'))
  
  const rangeValues = sheet.getRange('A:A').getValues();
  let targetRow = rangeValues.filter(String).length + 1;  // A列の最終行の次の行へ入力
  
  const posts = JSON.parse(e.postData.getDataAsString());
  for (const post of posts) {
    sheet.getRange(`A${targetRow}`).setValue(toDateTime(post.timestamp));
    sheet.getRange(`B${targetRow}`).setValue(post.email);
    sheet.getRange(`C${targetRow}`).setValue(post.event);
    sheet.getRange(`D${targetRow}`).setValue(post);
  
    targetRow++;
  }
  const firstRecord = pj[0]
  
  // SendGridのEvent WebhookではHTTPステータスコードさえあれば良いけど念のため
  const result = {
    message: "hello"
  }
  
  const response = ContentService.createTextOutput();
  response.setMimeType(ContentService.MimeType.JSON);
  response.setContent(JSON.stringify(result));
  
  return response;
}
  
// Unixタイムスタンプを日時に直す
function toDateTime(unixTimestamp) {
  const dtFormat = new Intl.DateTimeFormat('ja-JP', {
    dateStyle: 'medium',
    timeStyle: 'medium',
    timeZone: 'Asia/Tokyo'
  });
    
  return dtFormat.format(new Date(unixTimestamp * 1e3));
}

 

SendGridの設定

Event Webhookの設定

ドキュメントに従い、Event Webhookの設定をします。
Event Webhook - ドキュメント | SendGrid

HTTP Post URL には、上記のGoolge Apps Scriptで作成したWebアプリのURLを指定します。

その後、 Test Your Integration をクリックし、Googleスプレッドシートにテストデータが記載されればOKです。

 
あとは忘れずに ENABLE にした後 Save します。

なお、Event Webhookの設定が反映されるまでは少々時間がかかります。

そのため、設定後即バウンスするメールを送信してもEvent Webhookが動作しないことがあります。

 

APIキーの設定

送信用のAPIキーを作成します。
APIキーの管理 - ドキュメント | SendGrid

 

X-SMTPAPIヘッダを持つメールをSMTP送信

準備ができたので、SMTP APIX-SMTPAPI ヘッダを持つメールを送信してみます。
Integrating with the SMTP API - ドキュメント | SendGrid

X-SMTPAPIヘッダを自分で作成するのは手間なため、SendGridの公式ライブラリを使います。
APIライブラリ - ドキュメント | SendGrid

今回はPythonからSMTP送信するため、 smtpapi-python を使います。
https://github.com/sendgrid/smtpapi-python

今回は独自の文字列とメールの件名を X-SMTPAPI へ入れて送信してみます。

# smtpy_sender.py

import os
import smtplib
from email.mime.text import MIMEText

from smtpapi import SMTPAPIHeader
from dotenv import load_dotenv
load_dotenv()


def main():
    server = create_smtp_server()

    from_email = os.environ['FROM_EMAIL']
    to_email = [
        os.environ['NOT_FOUND_EMAIL'],
        os.environ['EXISTS_EMAIL'],
    ]

    body = 'hello'
    message = MIMEText(body)
    message['From'] = from_email
    message['TO'] = ','.join(to_email)

    subject = 'メールバウンステスト'
    message['Subject'] = subject
    message['X-SMTPAPI'] = create_smtpapi_header(subject)

    server.sendmail(from_email, to_email, message.as_string())
    server.quit()


def create_smtp_server():
    host = 'smtp.sendgrid.net'
    port = 587
    user = 'apikey'
    password = os.environ['SENDGRID_API_KEY']
    server = smtplib.SMTP(host, port)
    server.starttls()
    server.login(user, password)
    return server


def create_smtpapi_header(subject):
    header = SMTPAPIHeader()
    header.set_unique_args({
        'foo': 'ふー',
        'subject': subject
    })
    return header.json_string()
    

if __name__ == '__main__':
    main()

 
なお、メール送信アプリで必要な情報は .env ファイルを用意して python-dotenv にて読み込むようにしました。
https://github.com/theskumar/python-dotenv

SENDGRID_API_KEY=
FROM_EMAIL=
NOT_FOUND_EMAIL=
EXISTS_EMAIL=

 

実行結果

上記のPythonスクリプトpython stmp_sender.py として実行した時の結果を確認します。

 

Googleスプレッドシート

Googleスプレッドシートにバウンスしたメールアドレスの情報が書き込まれました。

f:id:thinkAmi:20210328181124p:plain

また、X-SMTPAPIヘッダに入れた項目も、 foo=ふー のように書き込まれていました。

そのため、 Unique Arguments を使うことで、バウンスしたときに日本語の識別情報を受け取れそうでした。

 

SendGridのコンソール

SendGridのコンソールにある SuppressionsBounceにも、通知のあったメールアドレスが書き込まれます。

 

受信メールのヘッダ

正常に受信したメールのヘッダを見てみましたが、X-SMTPAPI ヘッダはありませんでした。

SendGridから送信されるときに削除されるようです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/sendgrid_event_webhook-sample

お名前comの独自ドメイン + お名前メールな環境で、SendGrid の Domain Authentication によるDKIM/SPF認証を設定してみた

手元の自作アプリにて、SendGridでメールを送信するための準備として、Domain AuthenticationによるDKIM/SPF認証の設定をしたときのメモを残します。

 

目次

 

環境

  • SendGrid本体でアカウント作成
  • お名前.comで契約

 

調査

Domain Authentication用CNAMEレコードをどこに登録するか

SendGridでメールを送信するための準備として、Domain Authentication の設定を行います。
SendGrid 新人成長記 第六回 Domain Authentication: メールの到達率を高めるために | SendGridブログ

 
今回の環境はお名前.comの独自ドメイン + お名前メールなので、お名前メールの方でDNSにCNAMEレコードを追加すれば良さそうです。
お名前.com お名前メール 活用ガイド

 
ただ、お名前メール ライトプランの契約内容を読んだところ、サブドメインが使えないことに気づきました。
お名前メール 月あたり46円(税込)からのメールアドレス取得|お名前.com

サブドメインが使えないとすれば、SendGrid の Domain Authentication 用のCNAMEレコードをお名前メールのDNSに作成できません。

もしかしたらスタンダードプランに上げれば使えるかもしれませんが、他に何か良い方法がないかを調べてみました。

 
すると、

などの記事より、

  • ネームサーバは、 お名前.com
    • 01.dnsv.jp、02.dnsv.jp、03.dnsv.jp、04.dnsv.jp
  • お名前.comのネームサーバに以下のレコードを登録
    • SendGrid用のCNAMEレコードを登録
    • お名前メール用のMXレコードを登録

とすればいけそうでした。

今回の環境ではお名前メールはほとんど使ってなかったため、もし失敗してメールが届かなくなっても良かったこともあり、試してみることにしました。

 

設定

SendGrid にて、Domain Authentication用レコードを作成

Settings > Sender Authentication > Domain Authentication より、設定を行います。

なお、今回、独自ドメインとして example.com を使いますが、実際には自分が取得しているドメインとなります。

項目 理由など
DNS host I'm not sure お名前.comがリストになかったため
Would you also like to brand the links for this domain? No 今回はLink Brandingの設定はしないため
From domain example.com 自分が取得している独自ドメインを設定
Use automated security チェックする CNAMEレコードでドメイン認証するため
Use custom return path チェックしない 使わないため
Use custom DKIM selector チェックする 既存のサブドメインとの重複をさけるため
DKIM selector my 任意の値で良い

 
設定後、以下の3つのCNAMEレコードが表示されます。

これらをお名前.comのネームサーバに登録します。

 

お名前.comのネームサーバーを使うように設定

以下の手順に従い、現在のネームサーバをお名前.comにします。
ネームサーバーの変更|お名前.com Navi ガイド|ドメイン取るならお名前.com

ネームサーバー設定にて、 現在のネームサーバー情報お名前.com となっていればOKです。

 

お名前.comのネームサーバへDNSレコードを登録

以下の手順に従い、お名前.comのネームサーバへDNSレコードを登録します。
DNS関連機能の設定:DNSレコード設定|お名前.com Navi ガイド|ドメイン取るならお名前.com

登録するレコードは、SendGridのDomain Authentication用の3レコード、および、MXレコードとなります。

CNAMEレコードはSendGridで表示された

を登録します。

 
MXレコードは、お名前メールのコントロールパネルにある サーバー情報SMTP・POPサーバの番号を使って設定します。
お名前.com お名前メール 活用ガイド

例えば、番号が smtp10.gmoserver.jp の場合は、

項目
ホスト名 example.com
TYPE MX
TTL 3600
VALUE mx10.gmoserver.jp
優先 10

として登録します。

 

SendGridにてverifyする

SendGridのDomain Authenticationのページにある Verify ボタンをクリックし、3つのCNAMEレコードが verified になることを確認します。

なお、登録後反映されるまで最大72時間かかるようですので、気長に待ちます。

 
Verifiedになっているかどうかは、SendGridの画面のほか、SendGridの List all Domains APIでも確認できます。
Domain Whitelabel - ドキュメント | SendGrid

 
curl

> curl https://api.sendgrid.com/v3/whitelabel/domains --header 'authorization: <APIキー>'

を実行すると、Verifiedになっていれば

[
    {
        "id": 123,
        "user_id": 456,
        "subdomain": "em9999",
        "domain": "example.com",
        "username": "user_name",
        "ips": [],
        "custom_spf": false,
        "default": false,
        "legacy": false,
        "automatic_security": true,
        "valid": true,
        "dns": {
            "mail_cname": {
                "valid": true,
                "type": "cname",
                "host": "em9999.example.com",
                "data": "u123.xxx.sendgrid.net"
            },
            "dkim1": {
                "valid": true,
                "type": "cname",
                "host": "my._domainkey.example.com",
                "data": "my.domainkey.u123.xxx.sendgrid.net"
            },
            "dkim2": {
                "valid": true,
                "type": "cname",
                "host": "my2._domainkey.example.com",
                "data": "my2.domainkey.u123.xxx.sendgrid.net"
            }
        },
        "last_validation_attempt_at": 1615966760
    }
]

のように表示されます。

 

動作確認

SendGridから受信したメールの確認

SendGridのMarketing機能などを使って、自分のGmail宛にメールを送信してみます。

すると、 Authentication-Results ヘッダが Domain Authentication 設定前は

Authentication-Results: mx.google.com;
  dkim=pass header.i=@sendgrid.net header.s=smtpapi header.b=xxx;
  arc=pass (i=1 spf=pass spfdomain=sendgrid.net dkim=pass dkdomain=sendgrid.net);
  spf=pass (google.com: domain of xxx@gmail.com designates xxx.xxx.xxx.xxx as permitted sender) 

だったような内容が、設定後は

Authentication-Results: mx.google.com;
  dkim=pass header.i=@example.com header.s=my header.b=xxx;
  spf=pass (google.com: domain of bounces@em9999.example.com designates xxx.xxx.xxx.xxx as permitted sender) 

のように変わっていました。

また、Gmailからも sendgrid.net 経由 のような表示が消えていました。

これにより、Domain AuthenticationによるDKIM/SPF認証は成功しているようでした。

Windows10 + aws-vault + AWS CDK + Serverless Frameworkにて、最低限の権限を持つユーザでAWS Lambda + API GatewayなAPIを作ってみた

前回の記事で、 aws-vaultAWS CLIを連携させて使えるようになりました。
Windows + aws-vaultにて、AWSのアクセスキーを保護し、 AWS CLIを AssumeRole で使えるようにしてみた - メモ的な思考的な

 
今回は、 aws-vault + Serverless Frameworkを連携させ、AWS Lambda + API Gateway を使ったAPIを作成してみます。

また、Serverless Frameworkでデプロイする時のユーザについて、公式では

Search for and select AdministratorAccess ... Note that the above steps grant the Serverless Framework administrative access to your account. While this makes things simple when you are just starting out, we recommend that you create and use more fine grained permissions once you determine the scope of your serverless applications and move them into production.

https://www.serverless.com/framework/docs/providers/aws/guide/credentials/

と書かれています。

ただ、どのような権限があれば最低限となるのか分からなかったため、試してみることにします。

なお、手動で権限を与えるのは手間なので、今回は AWS CDK for TypeScript を使って必要なIAMまわりを作成し、最後にデプロイ用ユーザに手動で割り当てるだけにします*1

 
目次

 

環境

  • Windows10 (1909, 18363.1379)
  • aws-vault v6.2.0
  • Serverless Framework 2.25.2
    • Plugin 4.4.3
    • SDK 2.3.2
    • Components: 3.7.0
  • AWS CDK for TypeScript 1.90.1 (build 0aee440)
  • nvm-windows 1.1.7
    • WindowsにおけるNode.jsのバージョン管理ツール
  • Node.js 14.15.5

 
なお、前回の環境から引き続きで行っているため、

  • aws-vault はインストール済
  • aws-vault で AdministratorAccess にAssumeRoleして、AWSリソースを操作可能

な状況とします。

一方、nvm-windowsやNode.js、CDKは今回初めてインストールするものとします。

 

Node.js環境の構築

Serverless FrameworkやAWS CDKはNode.jsを使うため、Node.js環境を構築します。

 

nvm-windowsのインストール

Node.jsをそのままインストールしてもよいのですが、今後色々試すことを考えて複数バージョンを使えるようにしておきます。

Windowsではどのようなツールがあるかを調べたところ、以下にまとまっていました。
Windows における Node.js バージョン管理マネージャの選択(nvm-windows, nodist 等) - clock-up-blog

 
最近見かけるWindowsのパッケージマネージャの一つ Scoop のMainバケットには何が含まれているかを確認したところ、 nvm-windows は含まれているものの、nodist はありませんでした。そこで今回は nvm-windows を使うことにしました。
https://github.com/ScoopInstaller/Main/blob/master/bucket/nvm.json

 
次に nvm-windowsのインストールについてです。

Scoopを使ってインストールすることも考えましたが、今後 winget が登場するとまた変わるのかなと思いました。
https://github.com/microsoft/winget-cli

 
そこで今回は、Githubから nvm-windows をダウンロード・インストールしました。
https://github.com/coreybutler/nvm-windows

 

Node.jsのインストール

管理者権限でWindows Terminalを起動し、 nvm-windows を使ってNode.jsをインストールします。

まずは使えるNode.jsのバージョンを確認します。

>nvm list available

|   CURRENT    |     LTS      |  OLD STABLE  | OLD UNSTABLE |
|--------------|--------------|--------------|--------------|
|    15.9.0    |   14.15.5    |   0.12.18    |   0.11.16    |
|    15.8.0    |   14.15.4    |   0.12.17    |   0.11.15    |
...

This is a partial list. For a complete list, visit https://nodejs.org/download/release

 
バージョンリストより、Node.js LTSの最新をインストールします。

>nvm install 14.15.5

Downloading node.js version 14.15.5 (64-bit)...
Complete
Creating %USERPROFILE%\AppData\Roaming\nvm\temp

Downloading npm version 6.14.11... Complete
Installing npm v6.14.11...

Installation complete. If you want to use this version, type

nvm use 14.15.5

 
使用するNode.jsを有効化し、バージョンを確認します。

>nvm use 14.15.5
Now using node v14.15.5 (64-bit)

>node -v
v14.15.5

 
これでNode.jsが使えるようになりました。

 

Serverless Frameworkのセットアップ

続いてServerless Frameworkをセットアップし、AdministratorAccess権限を持ったユーザでデプロイできるかを確認してみます。

 

インストール

Node.jsのグローバルにインストールします。

>npm install -g serverless

+ serverless@2.25.2

 

動作確認

Serverless Framework の aws-python3 テンプレートを使って、aws-vaultと組み合わせてデプロイできるかを確認してみます。
https://www.serverless.com/framework/docs/providers/aws/guide/services#creation

>serverless create --template aws-python3 --path hello
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "path\to\hello"
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v2.25.2
 -------'

Serverless: Successfully generated boilerplate for template: "aws-python3"

 
デフォルトの serverless.yml では us-east-1 リージョンにデプロイされてしまうため、東京リージョンへと変更します。

provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  region: ap-northeast-1  # 追加

 
念のためデプロイ前のLambdaの状態を aws-vault + AWS CLI にて確認してみましたが、何もありません。

>aws-vault exec admin -- aws lambda list-functions
{
    "Functions": []
}

 
Serverless Frameworkを使って、AdministratorAccess権限にてデプロイしてみます。

aws-vaultと組み合わせて使うので、 aws-vault exec admin -- <serverless frameworkのコマンド> という形で実行します。

>aws-vault exec admin -- serverless deploy -v
...
Serverless: Stack update finished...
Service Information
service: hello
stage: dev
region: ap-northeast-1
stack: hello-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  hello: hello-dev-hello
layers:
  None

Stack Outputs
HelloLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:<account_id>:function:hello-dev-hello:1
ServerlessDeploymentBucketName: hello-dev-serverlessdeploymentbucket-xxx

 
Lambdaもできています。

>aws-vault exec admin -- aws lambda list-functions
Enter token for arn:aws:iam::<account_id>:mfa/gate: xxx
{
    "Functions": [
        {
            "FunctionName": "hello-dev-hello",
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:<account_id>:function:hello-dev-hello",
            "Runtime": "python3.8",
            "Role": "arn:aws:iam::<account_id>:role/hello-dev-ap-northeast-1-lambdaRole",
            "Handler": "handler.hello",
            "CodeSize": 640,
            "Description": "",
            "Timeout": 6,
            "MemorySize": 1024,
            "LastModified": "2021-02-21T01:55:02.396+0000",
            "CodeSha256": "xxx",
            "Version": "$LATEST",
            "TracingConfig": {
                "Mode": "PassThrough"
            },
            "RevisionId": "xxx",
            "PackageType": "Zip"
        }
    ]
}

 
続いて、Serverless Frameworkを使って削除してみます。

>av exec admin -- serverless remove -v
...
Serverless: Stack delete finished...

 
Lambdaもなくなっていました。

>aws-vault exec admin -- aws lambda list-functions
{
    "Functions": []
}

 

Serverless FrameworkでデプロイするためのIAMまわりを考える

ここまでは AdministratorAccess 権限でデプロイしてきました。

ただ、これだと権限が広すぎるため、必要最低限の権限を持つIAMまわりを考えてみます。

 

必要な権限について

今回は

をServerless Frameworkで自動生成するための権限を考えてみます。

 
Serverless Frameworkで最低限の権限でデプロイする方法を調べたところ、以下のissueや記事がありました。

 
今回は

  • IAMユーザはAssumeRoleする権限を所持
  • デプロイする権限やCloudFormationはAssumeRoleして使う

とするため、以下の2つのIAMロールを用意します。

  • aws-vault exec <profile> -- serverless deploy するためのIAMロール
  • デプロイするため上記ロールから、CloudFormationへPassRoleされた時のIAMロール
    • Serverless FrameworkはCloudFormationを使ってAWSリソースを構築するため

 
一方、AWS Lambdaから他のAWSリソースは使わないため、AWS Lambda用のロールはServerless Frameworkで自動生成されるものを使います。

なお、自動生成されるロール(<PROJECT>-<STAGE>-<REGION>-lambdaRole)は、以下のインラインポリシーを持って生成されます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "logs:CreateLogStream",
                "logs:CreateLogGroup"
            ],
            "Resource": [
                "arn:aws:logs:<region>:<account_id>:log-group:/aws/lambda/<PROJECT>-<STAGE>*:*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:a<region>:<account_id>:log-group:/aws/lambda/<PROJECT>-<STAGE>*:*:*"
            ],
            "Effect": "Allow"
        }
    ]
}

 
次に、各IAMロールに必要な権限を考えてみます。

serverless deploy でできるリソースですが、

  • AWS
    • CloudFormationスタック
    • デプロイ中間ファイル配置用S3バケット
    • LambdaのIAMロール
    • Lambdaのロググループ
    • Lambdaのfunction

serverless deployで何が更新されるのか確認してみました(Serverless Framework / AWS) | DevelopersIO

の他、今回はAPI Gatewayまわりも作成されます。

そのため、各IAMロールで必要な権限を考えてみます*2

 

デプロイするための権限を持つIAMロール

デプロイするときは

  • CloudFormationを操作する権限
  • S3に対して中間ファイルを操作する権限
  • CloudFormationにPassRoleする権限

の3つがあれば良さそうです。

また、上記の各権限に対し、Serverless Frameworkで必要な Resource のみに制限します。

 

CloudFormationのための権限を持つIAMロール

実際のAWSリソースを生成するときは、AWSリソースごとの権限があれば良さそうです。

  • S3に対して中間ファイルを操作する権限
  • API Gatewayを操作する権限
  • ログ(CloudWatch)を操作する権限
  • AWS Lambdaを操作する権限

 
また、今回はLambdaの権限をServerless Frameworkで生成するため、以下の権限も必要です。

  • IAMを操作する権限
  • Lambda用ロールにPassRoleする権限

 
こちらも上記の各権限に対し、Serverless Frameworkで必要な Resource のみに制限します。

 

その他IAMまわりで必要なもの

デプロイするためのIAMロールですが、デプロイユーザがAssumeRoleできる必要があります。

そのため、

  1. デプロイするためのIAMロールに対してAssumeRoleできるIAMポリシーを作成
  2. 上記1.のポリシーを割り当てたIAMグループを作成
  3. 上記3のグループに、デプロイするユーザを所属させる

も行います。

 

AWS CDKでIAMまわりを作成する

上記で考えたIAMまわりを手動で作成してもよいのですが、

  • 後で同じようなことをしたくても、使い回しがきかない
  • IAMポリシーのResourceで対象を制限するときに、AWSアカウントIDや対象リソースをtypoしそう

という問題があります。

そこで、AWS CDK + TypeScriptにてIAMまわりを作成します。
@aws-cdk/aws-iam module · AWS CDK

なお、冒頭で書いたとおり、グループへの割当は手動のままとします。

 

AWS CDKまわりの環境構築

CDK本体をインストールします。

>npm install -g aws-cdk

+ aws-cdk@1.90.1

 
続いてCDK appを作成します。今回はTypeScriptで作成します。

なお、 cdk init は空のディレクトリでないと実行できないことから、IAMモジュールの追加は後で行います。

> cdk init app --language typescript
...
✅ All done!

 
最後に、IAMモジュールをインストールします。

> npm install -S @aws-cdk/aws-iam
...
+ @aws-cdk/aws-iam@1.90.1

 

CDK全体の構成

生成されたTypeScriptファイルは、以下の2つがありました。

  • bin/cdk.ts
  • lib/cdk-stack.ts

このうち、cdk.tsはcdk-stack.tsに含まれるクラス CdkStackインスタンス化しているだけなので、実装は cdk-stack.tsCdkStack クラスに行えば良さそうです。

 
まずは、CdkStack.ts に全体像を作ります。

今回は複数のリソースを作成することから、各リソースのまとまりごとにメソッドを3つに分割しておきます。

  • createRoleForCfn()
    • CloudFormation用のIAMロールを作成するメソッド
  • createRoleForDeployUser()
    • デプロイするユーザがAssumeRoleできるIAMロールを作成するメソッド
  • createGroupOfDeployUser()
    • デプロイするユーザが所属するIAMグループを作成するメソッド

 

import * as cdk from '@aws-cdk/core';
import IAM = require('@aws-cdk/aws-iam')
import { Effect } from '@aws-cdk/aws-iam';

// Serverless Frameworkのプロジェクトとステージ
const PROJECT = 'hello-sls'
const STAGE = '*'  // どのステージにも適用できるようにした(必要に応じて、ステージを分ける)

export class CdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const cfnRole = this.createRoleForCfn();
    const deployRole = this.createRoleForDeployUser(cfnRole);
    this.createGroupOfDeployUser(deployRole);
  }
}

 

CloudFormation用のIAMロールをCDKで実装

デプロイするためのIAMロールを作る際CloudFormation用IAMロールのARNが必要となるため、まずはCloudFormation用から作成します。

作成順は

  1. IAMポリシーのステートメント
  2. ステートメントをまとめたユーザ管理ポリシー
  3. ユーザ管理ポリシーを含めたIAMロール

となります。

 

IAMポリシーのステートメント群を作成

IAM.PolicyStatement クラスを使って、IAMポリシーのステートメントを1つずつ作成していきます。

AWSアカウントIDは this.account 、リージョンは this.region でそれぞれ参照できます。

また、resourcesで今回使うServerless Frameworkのリソースだけに絞ります。

なお、API Gatewayの場合、ResourceだけではServerless Frameworkのリソースのみに限定できなかったため、広めの権限を与えています。

// CFnでリソースを操作するロールを作成
createRoleForCfn(): IAM.Role {
  const statements = [];

  // Serverless FrameworkがLambda用Roleを作るときに、権限を渡してあげる
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'iam:PassRole'
    ],
    resources: [
      `arn:aws:iam::${this.account}:role/${PROJECT}-${STAGE}-${this.region}-lambdaRole`
    ]
  }));

  // CFnがS3からデータを取得できるようにする
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:*'
    ],
    resources: [
      `arn:aws:s3:::${PROJECT}-${STAGE}`,
      `arn:aws:s3:::${PROJECT}-${STAGE}/`,
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:ListAllMyBuckets',
      's3:CreateBucket',
    ],
    resources: [
      '*'
    ]
  }));

  // API Gatewayまわりの権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'apigateway:GET',
      'apigateway:PATCH',
      'apigateway:POST',
      'apigateway:PUT',
      'apigateway:DELETE'
    ],
    resources: [
      `arn:aws:apigateway:${this.region}::/restapis`,
      `arn:aws:apigateway:${this.region}::/restapis/*`
    ]
  }));

  // Lambdaがログを出力する先であるCloudWatchまわりの権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'logs:DescribeLogGroups',
    ],
    resources: [
      `arn:aws:logs:${this.region}:${this.account}:log-group::log-stream:*`
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'logs:CreateLogGroup',
      'logs:CreateLogStream',
      'logs:DeleteLogGroup',
      'logs:DeleteLogStream',
      'logs:DescribeLogStreams',
      'logs:FilterLogEvents'
    ],
    resources: [
      `arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/${PROJECT}-${STAGE}:log-stream:*`
    ]
  }));

  // Serverless FrameworkがLambda用ロールを扱えるようにするための権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      "iam:GetRole",
      "iam:GetRolePolicy",
      "iam:CreateRole",
      "iam:DeleteRole",
      "iam:DeleteRolePolicy",
      "iam:PutRolePolicy"
    ],
    resources: [
      `arn:aws:iam::${this.account}:role/${PROJECT}-${STAGE}-${this.region}-lambdaRole`
    ]
  }));

  // Lambdaまわりの権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'lambda:GetFunction',
      'lambda:CreateFunction',
      'lambda:DeleteFunction',
      'lambda:UpdateFunctionConfiguration',
      'lambda:UpdateFunctionCode',
      'lambda:ListVersionsByFunction',
      'lambda:PublishVersion',
      'lambda:CreateAlias',
      'lambda:DeleteAlias',
      'lambda:UpdateAlias',
      'lambda:GetFunctionConfiguration',
      'lambda:AddPermission',
      'lambda:RemovePermission',
      'lambda:InvokeFunction'
    ],
    resources: [
      `arn:aws:lambda:${this.region}:${this.account}:function:${PROJECT}-${STAGE}`
    ]
  }));

  // 2. ステートメントをまとめたユーザ管理ポリシーを書く
  // 3. ユーザ管理ポリシーを含めたIAMロール
}

 

ステートメントをまとめたユーザ管理ポリシー

続いて、ステートメントをまとめたユーザ管理ポリシーを IAM.ManagedPolicy クラスを使って実装します。

statementesに、ステートメント群を指定します。

const cfnPolicy = new IAM.ManagedPolicy(
  this,
  'thinkAmiCfnPolicy',
  {
    managedPolicyName: 'thinkAmi-Serverless-CFn',
    statements: statements
  }
);

 

ユーザ管理ポリシーを含めたIAMロール

最後にIAMロールを作成します。

assumedBy にて、CloudFormationでしか使えないように指定します。なお、CloudFormationはAWSのサービスのため、 assumedBy で使うクラスは IAM.ServicePrincipal です。

return new IAM.Role(
  this,
  'thinkAmiCfnRole',
  {
    roleName: 'thinkAmi-Serverless-CFn-Role',
    assumedBy: new IAM.ServicePrincipal('cloudformation.amazonaws.com'),
    managedPolicies: [
      cfnPolicy
    ]
  }
);

 

デプロイするためのIAMロール

次に、デプロイするためのIAMロールを作成します。

作成順はCloudFormation用のIAMロールと同様です。

 

IAMポリシーのステートメント

CloudFormationで作成するAWSリソースに対する権限はCloudFormation用のIAMロールに任せるため、 iam:PassRole します。

あとは必要な権限を追加します。

// Serverless FrameworkでAWSリソースを扱うためのロールを作成
createRoleForDeployUser(cfnRole: IAM.Role): IAM.Role {
  const statements = []

  // CFnでのリソース作成をCFn用ロールにPassRoleする権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'iam:PassRole',
    ],
    resources: [
      cfnRole.roleArn
    ]
  }));

  // S3を使ってデプロイするための権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:*',
    ],
    resources: [
      `arn:aws:s3:::${PROJECT}-${STAGE}`,
      `arn:aws:s3:::${PROJECT}-${STAGE}/*`
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:ListAllMyBuckets',
      's3:CreateBucket'
    ],
    resources: [
      '*'
    ]
  }));

  // CFnを扱うための権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'cloudformation:CreateStack',
      'cloudformation:UpdateStack',
      'cloudformation:DeleteStack'
    ],
    resources: [
      `arn:aws:cloudformation:${this.region}:${this.account}:stack/${PROJECT}-${STAGE}/*`
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'cloudformation:Describe*',
      'cloudformation:List*',
      'cloudformation:Get*',
      'cloudformation:ValidateTemplate'
    ],
    resources: [
      '*'
    ]
  }));

  // Paramter Storeから値を取得する権限
  // serverless.yml中のdeploymentRoleにて指定するARNをParamter Storeに設定する
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'ssm:GetParameter'
    ],
    resources: [
      `arn:aws:ssm:${this.region}:${this.account}:parameter/hello-sls/CFn-Role`
    ]
  }));
// ...
}

 

ステートメントをまとめたユーザ管理ポリシー

書き方はCloudFormationのときと同じです。

// ユーザ管理ポリシーとして作成
const deployPolicy = new IAM.ManagedPolicy(
  this,
  'thinkAmiDeployPolicy',
  {
    managedPolicyName: 'thinkAmi-Serverless-Deploy',
    statements: statements
  }
);

 

ユーザ管理ポリシーを含めたIAMロール

同じようにIAMロールを作成します。

ただ、AssumeRoleの対象がアカウントIDであるため、 IAM.AccountPrincipal クラスに this.account を渡しています。

// デプロイするユーザ用ロールとして作成
return new IAM.Role(
  this,
  'thinkAmiDeployRole',
  {
    roleName: 'thinkAmi-Serverless-Deploy-Role',
    assumedBy: new IAM.AccountPrincipal(this.account),
    managedPolicies: [deployPolicy]
  }
);

 

デプロイするユーザが所属するグループを作成

デプロイ用IAMロールに対してAssumeRoleできるIAMポリシーをIAMグループに割り当てます。

// Deployするユーザが所属するグループを作成
// Serverless FrameworkでAWSリソースを扱うためのロールに対してAssumeRoleできる権限を、このグループに割り当てる
createGroupOfDeployUser(deployRole: IAM.Role) {
  const statements = [];

  // AssumeRoleする権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'sts:AssumeRole'
    ],
    resources: [
      deployRole.roleArn
    ]
  }));

  // ユーザ管理ポリシーとして作成
  const assumePolicy = new IAM.ManagedPolicy(
    this,
    'thinkAmiAssumePolicy',
    {
      managedPolicyName: 'thinkAmi-Serverless-Assume-By-User',
      statements: statements
    }
  );

  // グループに割り当て
  new IAM.Group(
    this,
    'thinkAmiAssumeGroup',
    {
      groupName: 'thinkAmi-Serverless-Assume',
      managedPolicies:[assumePolicy]
    }
  );
}

 

CloudFormationテンプレートの確認

cdk synth を使うことで、CDKで実装した内容がCloudFormationテンプレートとして表示されます。

そのため、CDKでデプロイする前にCloudFormation用テンプレートを確認できます。

# cdk.json ファイルがある階層で実施
>cdk synth

 

CDKでのデプロイ

IAMまわりを作成するため、AdministratorAccess権限を持つIAMロールで実行します。

>aws-vault exec admin -- cdk deploy -v
...
 ✅  CdkStack

Stack ARN:
arn:aws:cloudformation:<region>:<account_id>:stack/CdkStack/xxx

 

手動での設定
ユーザをグループに割り当て

手動にて、 thinkAmi-Serverless-Assume グループへデプロイユーザを割り当てます。

 

aws-vault用にデプロイロール設定を追加

既存のgateユーザがデプロイロールを使えるようにするため、 .aws\config へ追記します。

[default]
region=ap-northeast-1
output=json

[profile gate]


# 以下を追加
[profile hello]
source_profile = gate
role_arn = <thinkAmi-Serverless-Deploy-RoleのARN>
role_session_name = thinkAmiHelloDeploy
mfa_serial = <gateユーザが持つMFAのARN>

 

Systems ManagerのParameter Storeにデプロイ用ロールのARNをセット

デプロイ用ロールのARNをserverless.ymlに設定する必要があるものの、ARNの値をハードコーディングしたくありません。

そこで今回は、Systems ManagerのParameter Storeに値を手動で設定します。
Secrets Management for AWS Powered Serverless Applications

名前 種類
/hello-sls/CFn-Role デプロイ用IAM RoleのARN SecureString

 
ここまでで、Serverless Frameworkでデプロイするのに必要な権限まわりの準備は終わりました。

 

Serverless Frameworkによる実装

続いて、Serverless Framewrokでの設定を行います。

 

serverless.yamlでの設定

Serverless FrameworkでデプロイするAWSリソースなどを serverless.yml に記載します。

 

serviceまわり

service名は hello-sls とします。

また、frameworkVersionはデフォルトの 2 のままとします。

 
なお、Serverless Frameworkの2系では、Parameter Storeから値が取得できなくてもデプロイが継続されてしまいます。

一方、今後リリースされる3系ではエラーになるようです。
https://www.serverless.com/framework/docs/deprecations/#PROVIDER_IAM_SETTINGS

そこで今回は、3系と同じくParameter Storeより値が取得できなければエラーとなるように設定します。

service: hello-sls
frameworkVersion: '2'

# Parameter Storeより値が取得できなければエラー
unresolvedVariablesNotificationMode: error

 

provider

通常設定するものの他、Serverless Framework 3系の機能を先取りするよう設定します。

まずはAPI Gatewayの名前を <service>-<stage> とするよう、 apiGateway.shouldStartNameWithService を設定します。
https://www.serverless.com/framework/docs/deprecations/#AWS_API_GATEWAY_NAME_STARTING_WITH_SERVICE

 
また、Serverless FrameworkでCloudFormationを実行するRoleの指定について、今までは provider.cfnRole だったものが今後は provider.iam.deploymentRole になるため、その変更も行います。
https://www.serverless.com/framework/docs/deprecations/#AWS_API_GATEWAY_NAME_STARTING_WITH_SERVICE

 
なお、 deploymentRole の値は Parameter Storeから取得するように指定します。
Secrets Management for AWS Powered Serverless Applications

今回のParamter Storeは SecureString としたため、serverless.ymlで指定する場合は末尾に ~true を付与します。  

provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  region: ap-northeast-1
  stage: development

  apiGateway:
    shouldStartNameWithService: true

  iam:
    deploymentRole: ${ssm:/hello-sls/CFn-Role~true}

 

functions

ここからはAPI GatewayとLambdaの設定を行います。

handler.pyhello 関数をLambdaで実行するための設定を行います。

functions:
  hello:
    handler: handler.hello

 
また、API Gatewayも作成します。今回は以下の内容でAPI Gatewayを作成します。

項目
パス /req
メソッド GET
統合リクエス Lambda
マッピングテンプレート - リクエスト本文のパススルー リクエストの Content-Type ヘッダーに一致するテンプレートがない場合
マッピングテンプレート 定義なし

 
serverless.ymlでは以下の通りになります。

なお、 template では2つのContent-Typeに null を設定しています。何も設定しないとデフォルトのテンプレートが設定されてしまうためです。
https://www.serverless.com/framework/docs/providers/aws/events/apigateway#custom-request-templates

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: req
          method: get
          integration: lambda
          request:
            passThrough: WHEN_NO_MATCH
            template:
              application/json: null
              application/x-www-form-urlencoded: null

 
詳細は公式ドキュメントにも記載があります。
Serverless Framework - AWS Lambda Events - API Gateway

 
以上で serverless.yml の設定は完了です。

 

Lambdaの実装

serverless.yml に設定した handler.py を実装します。

今回は動作確認が取れれば良いので、生成されたLambdaをそのまま使います。

import json

def hello(event, context):
    body = {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "input": event
    }

    response = {
        "statusCode": 200,
        "body": json.dumps(body)
    }

    return response

    # Use this code if you don't use the http event with the LAMBDA-PROXY
    # integration
    """
    return {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "event": event
    }
    """

 

デプロイと動作確認

デプロイ

ここまでで準備ができたため、Serverless Frameworkによるデプロイを行います。

デプロイが成功すると、Service Informationが表示されます。

>aws-vault exec hello -- serverless deploy -v

Enter token for arn:aws:iam::<account_id>:mfa/<user>: xxx
...
Serverless: Stack update finished...
Service Information
service: hello-sls
stage: development
region: ap-northeast-1
stack: hello-sls-development
resources: 11
api keys:
  None
endpoints:
  GET - https://path.to.ap-northeast-1.amazonaws.com/development/req
functions:
  hello: hello-sls-development-hello
layers:
  None

Stack Outputs
HelloLambdaFunctionQualifiedArn: arn:aws:lambda:<region>:<account_id>:function:hello-sls-development-hello:8
ServiceEndpoint: https://path.to.ap-northeast-1.amazonaws.com/development
ServerlessDeploymentBucketName: hello-sls-development-serverlessdeploymentbucket-xxx

 

動作確認

ここまでで Lambda + API GatewayAPIができました。

curl でアクセスしてみたところ、APIが動作するのを確認できました。

>curl -i https://path.to.ap-northeast-1.amazonaws.com/development/req

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 121
...
{"statusCode": 200, "body": "{\"message\": \"Go Serverless v1.0! Your function executed successfully!\", \"input\": {}}"}

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/sls_hello_with_cdk_by_min_perm

*1:割り当てるところまでやってしまうとユーザ間での使い回しが面倒なため、一部手動を残しました

*2:と、さくっと書きましたが、実際にはトライアンドエラーでした。「serverless deploy」で失敗したら、CloudFormationのログを確認し、どの権限が不足しているかを確認しながら進めました

Windows + aws-vaultにて、AWSのアクセスキーを保護し、 AWS CLIを AssumeRole で使えるようにしてみた

AWSのIAMアカウントを保護するために、

  • IAMユーザにはほとんど権限を与えない
    • MFAは有効化
  • IAMユーザはIAMグループに所属
  • IAMグループに対し、特定のIAMロールへAssumeRoleするIAMポリシーを割り当て

を行ったりします。

また、aws-vault により、ローカルにあるAWSのアクセスキーを保護したり、AWS CLIを AssumeRole して使ったりします。

 

ただ、上記の参考ページではWindowsではどうなるかが書かれていなかったため、試してみた時のメモを残します。

 

目次

 

環境

 

準備

IAMユーザの作成

aws-vault を使って AssumeRole を試すために、以下の内容でIAMユーザを作成します。

  • 付与するポリシー: AmazonS3FullAccess
    • AssumeRoleできるか検証するため、S3のみFullAccess
  • AWS API Keyを有効化
  • コンソールのパスワードを無効化
    • プログラムでのみ利用可能

 

AWS CLI のインストール

aws-vaultとともに使うため、セットアップされていない場合は以下の手順で AWS CLI をインストールします。

以下より、64bitのWindowsインストーラーをダウンロード・インストールします。
AWS コマンドラインインターフェイス(CLI: AWSサービスを管理する統合ツール)| AWS

インストール後、初期セットアップを行います。

>aws configure

AWS Access Key ID [None]: xxx
AWS Secret Access Key [None]: xxx
Default region name [None]: ap-northeast-1
Default output format [None]: json

 

aws-vaultを使う

aws-vault のインストール

aws-vaultのGithubのREADMEによると、Windows用のインストール方法はいくつか用意されています。
https://github.com/99designs/aws-vault

今回はパッケージマネージャを使わず、実行可能なファイルを使います。

 
Releaseページから aws-vault-windows-386.exe をダウンロードし、任意のフォルダに置きます。

また、今回使いやすくするため、

  • ユーザー環境変数 Path に、ダウンロードしたexeがあるディレクトリを追加
  • 都度 aws-vault-windows-386.exe を入力するのが手間なので、ダウンロードしたファイルを aws-vault.exe にリネーム *1

としました。

その後、Windows Terminalを起動してバージョンが表示されればOKです。

>aws-vault --version
v6.2.0

 

aws-vault 用環境変数を追加

今回、aws-vaultの Vaulting Backends として Encrypted file を使います。
https://github.com/99designs/aws-vault#vaulting-backends  
そこで、以下のユーザー環境変数を追加します。

  • キー: AWS_VAULT_BACKEND
  • 値: file

なお、ファイルは %USERPROFILE%\.awsvault\keys に保管されます。  
 
また、aws-vaultのパスワードを毎回入力するのが手間な場合は、以下もユーザー環境変数に追加します。ただし、環境変数を見るとパスワードが丸見えになるので注意が必要です。

  • キー:AWS_VAULT_FILE_PASSPHRASE
  • 値: aws-vault向けファイルのパスワード

 

aws-vaultにIAMユーザを追加

上記で作成したIAMユーザ gate を追加します。

>aws-vault add gate

Enter Access Key ID: xxx
Enter Secret Access Key: xxx
Enter passphrase to unlock %USERPROFILE%/.awsvault/keys/:
Added credentials to profile "gate" in vault

 

aws-vault経由でAWS CLIが動作するか確認します。IAMユーザに権限のあるS3は操作できました。

>aws-vault exec gate -- aws s3api list-buckets

Enter passphrase to unlock %USERPROFILE%/.awsvault/keys/:
{
    "Buckets": [],
    "Owner": {
        "DisplayName": "xxx",
        "ID": "xxx"
    }
}

 
一方、権限のないLambdaの操作はできません。

>aws-vault exec gate -- aws lambda list-functions

An error occurred (AccessDeniedException) when calling the ListFunctions operation ...

 
動作は良さそうだったため、 %USERPROFILE%/.aws/credentials より、すべての内容を削除します。

これでAWSのアクセスキーが平文で保存しなくて済むようになりました。

 

AssumeRoleを使う

ここまではIAMユーザにIAMポリシーが紐付いていました。

次はIAMユーザにIAMポリシーを直接紐付けるのではなく、管理者IAMロールに対してAssumeRoleするように変更します。

 

管理者IAMロールの作成

以下の内容で管理者IAMロールを作成します。

  • 名前:thinkAmiAdmin
  • ポリシーにはAWS 管理ポリシーの AdministratorAccess をセット
  • 信頼されたエンティティに、AWSアカウントIDを設定

 

AssumeRoleできるIAMポリシーを作成

まずはAssumeRoleできるIAMポリシーを作成します。

AssumeRole先を制限するため、Resourceに上記で作成した thinkAmiAdmin を指定します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": "arn:aws:iam::<account_id>:role/thinkAmiAdmin"
        }
    ]
}

 

AssumeRoleできるIAMポリシーを、IAMユーザに追加

続いて、上記のIAMポリシーをIAMユーザに追加します。

これにより、IAMユーザには

  • AmazonS3FullAccess
  • thinkAmiAdminにAssumeRole可能

な権限が付与されます。

 

aws-vault向けに .aws/config を編集

AWS CLIからAssumeRoleできるよう、.aws/config を変更します。

[profile gate]

[profile admin]
source_profile = gate
role_arn = arn:aws:iam::<account_id>:role/thinkAmiAdmin

 

動作確認

AWS CLIを使って確認します。

AssumeRoleでLambdaまわりも操作できています。

>aws-vault exec admin -- aws lambda list-functions
{
    "Functions": []
}

 

参考:パスワードレスサインインについて

さらにIAMユーザをセキュアにするため、書籍「クラウド破産を回避するAWS実践ガイド - KOS-MOS - BOOTH」に従い、パスワードレスサインインを試してみました。

すると、IAMユーザに追加したMFAを入力するだけで、ログイン済のAWS コンソールがブラウザに表示されました。

>aws-vault login admin

Enter token for arn:aws:iam::<account_id>:mfa/gate: xxx

# ブラウザが起動し、AWSコンソールでログイン済になる

*1:手元では更に短い 「av」 にしていますが、Blog上ではわかりやすいよう 「aws-vault」 にしています

Python + PuLP + ortoolpy による組合せ最適化を使って、行事の当番表を作ってみた

最近、行事の当番表を作る機会がありました。

行事の回数や当番対象の人数がそれなりだったこと、「今日の当番は何でこの組み合わせなの?」と質問された時に「プログラムが勝手にやりました」と答えたかったことから、プログラムを作って解決することにしました。

 
目次

環境

  • Python 3.9.1
  • ortoolpy 0.2.38
  • pandas 1.2.0
  • numpy 1.19.5
  • openpyxl 3.0.6
    • Excelへ出力する時に使用

 

ランダムな組み合わせ編

実装

当番をする人の組み合わせは自由とのことでした。

その他の条件を聞いたところ、以下でした。

  • 行事は18回
  • 一行事あたり3名の当番が必要
  • 一人1回当番をすれば良い

 
そこで、上記の条件を満たしつつ、「行事ごとにグループを作り、そのグループへランダムに人を割り当てる」仕様にて、Pythonスクリプトを書きました。

# shuffle.py
GROUP_COUNT = 18

def divide_users(users):
    current_index = 0
    
    # 当番の人のリストを要素として持つ、行事のリスト
    groups = [[] for _ in range(GROUP_COUNT)]

    random.shuffle(users)

    for user in users:
        groups[current_index].append(user)

        if current_index < GROUP_COUNT - 1:
            current_index += 1
        else:
            current_index = 0

    return groups

 

テスト

動作を確認するため、テストコードを用意します。

# test_shuffle.py
import unittest
from shuffle import divide_users

class TestShuffle(unittest.TestCase):
    def test_divide_users(self):
        users = [f'user{i}' for i in range(1, 54 + 1)]
        group = divide_users(users)

        user_set = set()
        for users in group:
            # 1当番3名か
            self.assertEqual(len(users), 3)

            # 1回のみ割り当てられているか
            for user in users:
                self.assertNotIn(user, user_set)
                user_set.add(user)


if __name__ == '__main__':
    unittest.main()

 
実行したところ、テストがパスしました。

% python test_shuffle.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

 
これで良さそうです。

 

組合せ最適化編

前置き

ひとまず作ってみたものの、現実には「この日はちょっと用事があるので、当番はできない」がありそうです。

そこで各個人で「行事ごとに、当番可/不可を記入」したリストを用意してもらう前提で、プログラムを改修することにしました。

 
当番表の作成はアルバイトのシフトを組む感じなので、組合せ最適化とかナーススケジューリングの方面でやり方を探したところ、参考となる記事がありました。

 
遺伝的アルゴリズムは面白そうだったのですが、今の自分だと yak shaving になりそうだったので、組合せ最適化で作ることにしました。

 

目的関数と制約条件

ナーススケジューリングの場合、一人が複数回シフトに入ります。一方、今回は一人1回のシフトです。

そこで、組合せ最適化でナーススケジューリング問題を解く - Qiita をベースにしつつ、目的関数と制約条件を差し替えることにしました。

 
ベースコードでは目的関数として複数挙げられています。ただ、今回は「必要人数差」だけそうです。これが最小になるものを求めます。

次に制約条件ですが、こちらはベースコードとはだいぶ異なります。

  • 1当番あたり3人
  • 一人あたり1回の当番
    • 複数回は不可
  • その人が当番可能な時に割り当てる
    • 無理に当番をお願いすることはできない

 
また、各個人からもらったリストを元に、当番可/不可のExcel表を作成します。その書式は以下のとおりです。

    • 行事の開催回数
    • 当番対象者
  • セル
    • 1 が当番可
    • 0 が当番

 
イメージはこんな感じです。

f:id:thinkAmi:20210202082108p:plain

 

実装

Excelを読んで当番のグループを作る関数を用意します。

 

全体像

個別の実装は後述するとして、まずは全体像です。

def divide_users():
    # デフォルトだと 54 x 54 で読まれるので、不要行は読み込まないようにする
    skip_rows = [i + GROUP_COUNT for i in range(1, USER_COUNT - GROUP_COUNT + 1)]
    df = pd.read_excel(FILE_NAME, header=0, index_col=0, skiprows=skip_rows)

    # 当番回数
    event_count = df.shape[0]
    # print(f'{type(box_size)}: {box_size}')
    # => <class 'int'>: 18

    # ユーザ数
    user_count = df.shape[1]
    # print(f'{type(user_size)}: {user_size}')
    # => <class 'int'>: 54

    # 数理モデル
    model = LpProblem()

    # 変数を準備(当番/非当番の2値なので、0-1変数リスト)
    # https://docs.pyq.jp/python/math_opt/pdopt.html
    var_schedule = np.array(addbinvars(event_count, user_count))
    df['必要人数差'] = addvars(event_count)

    # 重み
    weight = 1

    # 目的関数の割り当て
    model += lpSum(df.必要人数差) * weight

    # 制約
    # 1当番あたり3人
    for idx, row in df.iterrows():
        model += row.必要人数差 >= (lpSum(var_schedule[row.name]) - 3)
        model += row.必要人数差 >= -(lpSum(var_schedule[row.name]) - 3)

    # 一人あたり1回当番すればよい
    for user in range(user_count):
        scheduled = [var_schedule[event, user] for event in range(event_count)]
        model += lpSum(pd.Series(scheduled)) <= 1

    # 当番可能なイベントだけ割り当てる
    df_rev = df[df.columns].apply(lambda r: 1 - r[df.columns], 1)
    for (_, d), (_, s) in zip(df_rev.iterrows(), pd.DataFrame(var_schedule).iterrows()):
        model += lpDot(d, s) <= 0

    # 実行
    model.solve()

    # 結果取得
    vectorized_results = np.vectorize(value)(var_schedule).astype(int)
    # print(type(vectorized_results))
    # => <class 'numpy.ndarray'>

    group = [[] for _ in range(event_count)]
    for i, vectorized_result in enumerate(vectorized_results):
        for result, name in zip(vectorized_result, df.columns):
            if result * name:
                group[i].append(name)

    return group

 

個別に見る

まずはpandasでExcelからデータを読み込みます。

    skip_rows = [i + GROUP_COUNT for i in range(1, USER_COUNT - GROUP_COUNT + 1)]
    df = pd.read_excel(FILE_NAME, header=0, index_col=0, skiprows=skip_rows)

    # 当番回数
    event_count = df.shape[0]

    # ユーザ数
    user_count = df.shape[1]

 
数理モデルを作成します。

model = LpProblem()

 
次に、ortoolpyを使った変数を用意します。
データ分析と最適化 — Pythonオンライン学習サービス PyQ(パイキュー)ドキュメント

今回は当番/非当番の2値を持っていれば良いので、0-1変数リストとします。

# スケジュールされるもの
var_schedule = np.array(addbinvars(event_count, user_count))

# スケジュールした時の人数差
df['必要人数差'] = addvars(event_count)

 
次は目的関数です。重みを乗じた上で値が最小となるものを探します。

# 重み
weight = 1

# 目的関数の割り当て
model += lpSum(df.必要人数差) * weight

 
制約条件その1は「1当番あたり3人」です。こちらはベースと変わりません。

for idx, row in df.iterrows():
    model += row.必要人数差 >= (lpSum(var_schedule[row.name]) - 3)
    model += row.必要人数差 >= -(lpSum(var_schedule[row.name]) - 3)

 
制約条件その2は「一人あたりの当番は1回」です。

ユーザーごとにイベントで当番が割り当てられたかをサマリし、回数が1になっているものとします。

for user in range(user_count):
    scheduled = [var_schedule[event, user] for event in range(event_count)]
    model += lpSum(pd.Series(scheduled)) <= 1

 
制約条件その3は「当番可能なときだけ割り当てる」です。
Pythonでシフトを自動作成するアプリを作成、運用した話 | 機械学習、ウェブ開発、自動化の備忘録

df_rev = df[df.columns].apply(lambda r: 1 - r[df.columns], 1)
for (_, d), (_, s) in zip(df_rev.iterrows(), pd.DataFrame(var_schedule).iterrows()):
    model += lpDot(d, s) <= 0

 
あとは、実行した後、当番グループを作っています。

# 実行
model.solve()

# 結果取得
vectorized_results = np.vectorize(value)(var_schedule).astype(int)
# print(type(vectorized_results))
# => <class 'numpy.ndarray'>

group = [[] for _ in range(event_count)]
for i, vectorized_result in enumerate(vectorized_results):
    for result, name in zip(vectorized_result, df.columns):
        if result * name:
            group[i].append(name)

return group

 

テスト

今回もテストコードにて確認します。

 

テストデータ作成

テストをするために、適当な当番可/不可一覧のExcelファイルを生成します。

当番可能なのは、一人あたり全体の6割とします。

import copy
import random

import openpyxl

OK = [1] * 10  # 18回中6割がOK
NG = [0] * 8

FILE_NAME = 'users_for_optimization.xlsx'
GROUP_COUNT = 18
USER_COUNT = 54


def main():
    wb = openpyxl.Workbook()
    ws = wb.worksheets[0]

    # タイトル行
    name_cell = ws.cell(row=1, column=1)
    name_cell.value = '開催番号'

    for i in range(2, USER_COUNT + 2):
        name_cell = ws.cell(row=1, column=i)
        name_cell.value = f'ユーザ_{i - 1}'

    # 開催回数列
    for i in range(2, GROUP_COUNT + 2):
        group_cell = ws.cell(row=i, column=1)
        group_cell.value = i - 2

    # 54人分のデータ
    for i in range(2, USER_COUNT + 2):
        ok = copy.deepcopy(OK)
        ng = copy.deepcopy(NG)
        total = ok + ng
        random.shuffle(total)

        # 18回の情報を埋める
        row = 2
        while total:
            result = total.pop()
            cell = ws.cell(row=row, column=i)
            cell.value = result

            row += 1

    wb.save(FILE_NAME)


if __name__ == "__main__":
    main()

 

テストコード

関数 divide_users() の戻り値が条件を満たしているかを確認します。

import unittest

import openpyxl

from make_data import FILE_NAME, USER_COUNT, GROUP_COUNT
from optimization import divide_users


class TestOptimization(unittest.TestCase):
    def create_ok_list(self):
        ok_list = {i: [] for i in range(18)}

        wb = openpyxl.load_workbook(FILE_NAME)
        ws = wb.worksheets[0]

        for col_index in range(2, USER_COUNT + 2):
            user_name = ws.cell(row=1, column=col_index).value

            for row_index in range(2, GROUP_COUNT + 2):
                if ws.cell(row=row_index, column=col_index).value == 1:
                    ok_list[row_index - 2].append(user_name)

        return ok_list

    def test_divide_users(self):
        group = divide_users()

        user_set = set()
        for users in group:
            # 1当番3名か
            self.assertEqual(len(users), 3)

            # 1回のみ割り当てられているか
            for user in users:
                self.assertNotIn(user, user_set)
                user_set.add(user)

        # 自分の希望した場所のみか
        ok_list = self.create_ok_list()
        for i, users in enumerate(group):
            for user in users:
                self.assertIn(user, ok_list[i])


if __name__ == '__main__':
    unittest.main()

 
実行するとテストをパスするため、これで良さそうです。

% python test_optimization.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.154s

OK

 

ソースコード

Githubに上げました。

https://github.com/thinkAmi/toban_kuji

2020年の振り返りと2021年の目標

例年通り、2020年の振り返りと2021年の目標っぽいものを書いてみます。

 

2020年の振り返り

2019年の振り返りと2020年の目標 - メモ的な思考的なで立てた目標を振り返ってみます。

 

色々な分野の素振り

2020年は公私ともにいろんな言語やフレームワークにさわりました。

深さはそれぞれですが、以下のような感じでした。

 

レーニングの復活

まずはウォーキングから、ということで2020年の新年から始めました。

ドラクエウォークをお供にウォーキングを続けたところ、360万歩程度歩けました。

f:id:thinkAmi:20210101203247p:plain

 
夏過ぎには1日49kmくらい歩いたり、ランニングもできるようになりました。

 

引き続き、何らかの試験を受ける

残ってたITストラテジスト試験を受けようと思いましたが、2021年春に延期されたため、結局何も受けず...
IPA 独立行政法人 情報処理推進機構:情報処理技術者試験:令和3年度春期試験の実施予定について

 

その他

COVID-19の影響もあり、2020年は全般的にパブリックな活動が減ってしまいました。

イベント

ほとんどオンラインでしたが通しでは参加できず、Blogにも残せませんでした。

オフラインの方が時間確保しやすいのを実感しました。

 

GitHub

2020年はアウトプットが足りてないですね...

f:id:thinkAmi:20201231123830p:plain

 

2021年の目標っぽいもの

新年から新天地ということもあり、今年も自分の中で完結できる

  • 新しい環境に溶け込む
  • レーニングの継続と筋トレの復活

を目標っぽいものにします。

 
というところで、今年もよろしくお願いします。