React + TypeScript + Django REST frameworkでToDo管理アプリを作ってみた

先日、大岡由佳さんの「りあクト! 第3.1版 (2020年12月26日 初版第1刷発行)」を読みました。書籍では理由や経緯などが書かれており、とてもためになりました。ありがとうございました。

 
そこで、Reactの理解を深めるために何か作ろうと考え、よくあるToDo管理アプリを作ってみたことから、その時のメモを残します。

 

目次

 

環境

  • React 17.0.2
  • TypeScript 4.1.2
  • Python 3.9.5
  • Django 3.2.2
  • Django REST framework 3.12.4
  • PyCharm Professional 2021.1.1

 
今回は各段階を追ってアプリを作成していきます。

アプリのイメージははこんな感じで、段階ごとの見た目はほとんど変わりません。

f:id:thinkAmi:20210510223401p:plain
ToDo管理アプリ

 

やることとやらないこと

やること
  • Reactまわり
    • TypeScriptで書く
    • React Router の5系で書く
    • Hooksを使う
  • Django REST frameworkまわり
    • ModelViewSetを使ってお手軽に書く

 

やらないこと

React & TypeScriptに慣れるため、以下をやらないことにしました。

 

環境構築

Python

最初のうちは Django REST frameworkを使いませんが、忘れないよう venv で環境を作っておきます。

% python --version
Python 3.9.5

% python -m venv env

 

CRAによる、React環境構築
% npx create-react-app frontend --template typescript

We suggest that you begin by typing:

  cd frontend
  yarn start

Happy hacking!

 

ESLint

CRAでESLintはインストールされているため、init します。

% yarn eslint --init

? How would you like to use ESLint? … 
❯ To check syntax, find problems, and enforce code style

? What type of modules does your project use? … 
❯ JavaScript modules (import/export)

? Which framework does your project use? … 
❯ React

? Does your project use TypeScript? › Yes

? Where does your code run? …  (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser

? How would you like to define a style for your project? … 
❯ Use a popular style guide

? Which style guide do you want to follow? … 
❯ Airbnb: https://github.com/airbnb/javascript

? What format do you want your config file to be in? … 
❯ JavaScript

Checking peerDependencies of eslint-config-airbnb@latest
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

eslint-plugin-react@^7.21.5 @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint@^5.16.0 || ^6.8.0 || ^7.2.0 eslint-plugin-import@^2.22.1 eslint-plugin-jsx-a11y@^6.4.1 eslint-plugin-react-hooks@^4 || ^3 || ^2.3.0 || ^1.7.0 @typescript-eslint/parser@latest

? Would you like to install them now with npm? › No

 
エラーが出ました。

ESLint couldn't find the config "airbnb" to extend from. Please check that the name of the config is correct.

The config "airbnb" was referenced from the config file in "".

If you still have problems, please stop by https://eslint.org/chat/help to chat with the team.

error Command failed with exit code 2.

 
足りていないものを、yarnでインストールします。

% yarn add eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react-hooks @typescript-eslint/parser@latest

info All dependencies
├─ @typescript-eslint/eslint-plugin@4.22.1
├─ @typescript-eslint/parser@4.22.1
├─ eslint-config-airbnb-base@14.2.1
├─ eslint-config-airbnb@18.2.1
├─ eslint-plugin-import@2.22.1
├─ eslint-plugin-jsx-a11y@6.4.1
├─ eslint-plugin-react-hooks@4.2.0
└─ eslint-plugin-react@7.23.2

 
設定ファイルを追加・更新します。

全部載せると長いので、リンクをはっておくだけにします。

 

Prettier

インストールします。

% yarn add -D prettier eslint-config-prettier

info All dependencies
├─ eslint-config-prettier@8.3.0
└─ prettier@2.2.1

»  TypeSync v0.8.0
✔  2 new typings added.

📦 frontend — package.json (2 new typings added, 0 unused typings removed)
├─ + @types/prettier
└─ + @types/testing-library__jest-dom

✨  Go ahead and run npm install or yarn to install the packages that were added.


% yarn

success Saved lockfile.

 
設定ファイルを追加します。

 
ESLintとPrettierが衝突していないか確認します。

% npx eslint-config-prettier 'src/**/*.{js,jsx,ts,tsx}'
No rules that are unnecessary or conflict with Prettier were found.

 

package.jsonのscriptにeslintやprettierを追加します。

"fix": "npm run -s format && npm run -s lint:fix",
"format": "prettier --write --loglevel=warn 'src/**/*.{js,jsx,ts,tsx,gql,graphql,json}'",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}",
"lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'",
"lint:conflict": "eslint --print-config .eslintrc.js | eslint-config-prettier-check",
"preinstall": "typesync"

 

PyCharmの設定

今回はProfessional版を使うので、prettier pluginを追加します。

その後、以下の設定を行います。

 

ESLintの設定

Languages & Frameworks > JavaScript > Code Quality Tools > ESLint

[x] Automatic ESLint configuration

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx,html,vue}

[ ] Run eslint --fix on save

 

Prettierの設定

Languages & Frameworks > JavaScript > Prettier

Prettier package: <プロジェクトの node_modules にある prettierを指定>

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx}

[x] On code reformat
[x] ON save

 
Prettierの設定ができているかは、index.tsx

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root'),
);

ワンライナーにした状態で保存した時に、上記のように修正されればOKです。

 

huskyとlint-staged

インストールします。

% yarn add -D husky lint-staged

 
package.jsondevDependencies の下に追加します。

"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "src/**/*.{js,jsx,ts,tsx}": [
    "prettier --write --loglevel=warn",
    "eslint --fix"
  ],
  "src/**/*.{json}": [
    "prettier --write --loglevel=warn"
  ]
}

 

ディレクトリ構成

今回は段階を追ってアプリを作成していくため、

  • srcの下に component を作る
  • componentの下に、 v1 のような各段階ごとのcomponentを入れるディレクトリを作る

のようなディレクトリ構造としました。

 

第1段階:入力フォームとリスト表示を1つのコンポーネントに入れたバージョン

まずは入力フォームとリスト表示を1つのコンポーネントに入れてみます。

React Routerと使用するバージョンについて

各段階でURLを変えるため、React Routerを使います。

りあクト!では v5 と v6 の話がありましたが、今のところまだ v6 はリリースされていないため、今回は v5 系を使います。

 

インストール
% yarn add react-router react-router-dom

% yarn

 

App.tsx の実装

App.tsx にReact Routerの実装を行います。

今回は

な書き方とします。

import React, { VFC } from 'react';
import './App.css';
import { Switch, Route, Redirect } from 'react-router';
import V1Home from 'components/v1/V1Home';
import Home from './components/Home';

const App: VFC = () => (
  <div className="container">
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>

      <Route exact path="/v1">
        <V1Home />
      </Route>

      <Redirect to="/" />
    </Switch>
  </div>
);

export default App;

 

V1Home.tsx

React Routerから呼ばれるコンポーネントです。この中で V1FormList コンポーネントを呼び出しています。

import { VFC } from 'react';
import V1FormList from './V1FormList';

const V1Home: VFC = () => (
  <>
    <h1>タスクリスト</h1>
    <V1FormList />
  </>
);

export default V1Home;

 

V1FormList.tsx

全体像はこんな感じです。以降で詳しく見ていきます。

import { FormEvent, useEffect, useRef, useState, VFC } from 'react';

type Task = {
  id: number;
  content: string;
};

const V1FormList: VFC = () => {
  const [content, setContent] = useState('');
  const [tasks, setTasks] = useState<Task[]>([]);

  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.content}</li>
        ))}
      </ul>
    </>
  );
};

export default V1FormList;

 

State Hookの利用

上記では、input要素で入力した値を保管するために State Hook を使っています。
ステートフックの利用法 – React

 
まずは入力値を入れておく State Hookを用意します。

const [content, setContent] = useState('');

 
次に、入力した値はリストとして表示したいため、リスト表示用の State Hook も用意します。

Stateの型は上記の content を配列で持つ string[] でも良いのですが、リスト表示する時の key を与えたいため、型を用意します。
リストと key – React

type Task = {
  id: number;
  content: string;
};

この型を使った State を用意します。

const [tasks, setTasks] = useState<Task[]>([]);

 
フォームで登録ボタンを押した時に、リスト表示用の State に値を追加する関数を用意します。

const addTask = (e: FormEvent) => {
  e.preventDefault();
  setTasks([...tasks, { id: tasks.length, content }]);
  setContent('');
};

ここで、addTask関数の引数は FormEvent型 を想定しています。
Forms and Events | React TypeScript Cheatsheets

また、 preventDefault() を使ってデフォルトのイベントをキャンセルし、バックエンドにリクエストが飛ばないようにします。

 
また、配列のStateでは、Array.pushが使えません。

そのため、スプレッド構文を使って、Stateに値を追加しています。

setTasks([...tasks, { id: tasks.length, content }]);

 
最後の setContent(''); は、リスト表示用Stateに値を入れたら画面のinput要素をクリアするために用意しています。

 
あとは

  • formの onSubmit に、追加用関数 addTask を割り当て
  • inputの onChange に、content Stateの更新処理を追加
  • inputの value に、 onChangeで更新した値を割り当て
  • ulの中で、 tasks Stateのデータを元に表示

とします。

return (
  <>
    <form onSubmit={addTask}>
      <input
        type="text"
        name="task"
        onChange={(e) => setContent(e.target.value)}
        value={content}
      />
      <button type="submit">登録</button>
    </form>

    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

 

フォーカスの指定

ここで、「登録ボタンを押した後、input要素にフォーカスを戻したい」という要望がある場合、どのように実装すればよいかを調べてみました。

りあクト!Ⅱの p192 より、Reactでは、 useRefuseEffect を使うと良さそうでした。

const taskRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
  taskRef.current?.focus();
});

 
ちなみに、型定義や useRefの引数から null を抜くと、以下のエラーとなりました。

TS2322: Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'LegacyRef<HTMLInputElement>| undefined'.
Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'RefObject<HTMLInputElement>'.
Types of property 'current' are incompatible.
Type 'HTMLInputElement | undefined' is not assignable to type 'HTMLInputElement | null'.
Type 'undefined' is not assignable to type 'HTMLInputElement | null'.

 
この taskRef を input に紐付けます。

<input
  ..
  ref={taskRef}
  ..
/>

 

動作確認

以下でReactが起動しますので、タスクを色々いれてみます。

% yarn start

 

これで第1段階が完成しました。コミットは こちら です。

 

第2段階:FormとListのコンポーネントを分離する

第1段階では ListForm.tsx の中に Form と List が含まれていました。

次はそれらを分離してみます。

 

Stateをどこに持たせるか

分離するにあたり、 State をどこに持たせるかを考えます。

content は Form でしか使っていませんが、 tasks は Form と List の両方で使っています。

Reactの流儀によると、

共通の親コンポーネントか、その階層構造でさらに上位の別のコンポーネントが state を持っているべきである

https://ja.reactjs.org/docs/thinking-in-react.html#step-4-identify-where-your-state-should-live

とのことなので、今回の共通の親である V2Home.tsxtasks Stateを持たせます。

const V2Home: VFC = () => {
  const [tasks, setTasks] = useState<Task[]>([]);

  return (
    <>
      <h1>タスクリスト (v2)</h1>
      <V2Form tasks={tasks} setTasks={setTasks} />
      <V2List tasks={tasks} />
    </>
  );
};

 

型定義

ここで、 Task 型はどのコンポーネントでも使うので、 src/components/Task.ts に型定義を置いておきます。

export type Task = {
  id: number;
  content: string;
};

なお、Task typeをV2Home.tsxに持たせ、

// Home
import V2Form from './V2Form';

// Form
import { Task } from './V2Home';

のように import したところ

ESLint: Dependency cycle detected.(import/no-cycle)

というESLintのエラーが出ました。

 
あとは、FormとListを分離します。

 

V2Form.tsx

このコンポーネントがpropsで受け取る型は

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

とします。

なお、setTasksの型 Dispatch<SetStateAction<Task[]>> については、PyCharmで提案されたものをそのまま入れました。

Formの全体像はこちら。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V2Form: VFC<Props> = ({ tasks, setTasks }) => {
  const [content, setContent] = useState('');
  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>
    </>
  );
};

 

V2List.tsx

Listも、分離したもの + propsの型定義を用意します。

type Props = {
  tasks: Task[];
};

const V2List: VFC<Props> = ({ tasks }) => (
  <>
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

これで第2段階が完成しました。コミットは こちら です。

 

第3段階:Django REST framework製バックエンドと連携する

ここまでは入力した値は State にしか保存していませんでした。そのため、リロードしたら消えてしまいます。

そこで次はDBを持つバックエンドを用意して、データを永続化します。

 

Django REST framework アプリの作成

今回はReactの習得がメインなので、バックエンドは慣れている Django REST framework (以降、DRF) で作ります。

今回はCRUDができれば良いので、ModelViewSetを使ってお手軽に作ります。

詳しい実装は省略しますが、気になる場合は以下の書籍を参考にしてください。
現場で使える Django REST Framework の教科書(第2版) - あきよこブログ(akiyoko blog) - BOOTH

 
DRFをインストールします。

(env) % pip install django djangorestframework
...
Successfully installed asgiref-3.3.4 django-3.2.2 djangorestframework-3.12.4 pytz-2021.1 sqlparse-0.4.1

 
Djangoプロジェクトとアプリを作ります。

(env) % django-admin startproject config .
(env) % python manage.py startapp task
(env) % python manage.py startapp apiv1

 

なお、DRFとReactを連携する際、DRFとReactが

  • Reactはポート3000で起動
  • DRFはポート8000で起動

するため、別オリジンとなります。

そこで、 django-cors-headers を追加でインストールと設定を行い、ReactからのDRFアクセスを許可します。
adamchainz/django-cors-headers: Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS)

(env) % pip install django-cors-headers

 
ちなみに、手元にあるDRFの教科書(2019/9/22 v1.0.0)で使っていた 3.1.0では、別オリジンの設定名は CORS_ORIGIN_WHITELIST でしたが、今回使った 3.7.0 では CORS_ALLOWED_ORIGINS に変わっていました。

CORS_ALLOWED_ORIGINS = (
    'http://localhost:3000',
    'http://127.0.0.1:3000',
)

 
 
PyCharmにDjangoを認識させます。

# Languages & Frameworks > Django

[x] Enable Django support

Django project root : <root dir>
Settings: config/settings.py
Manage script: manage.py

 

準備ができたため、 このコミットのようなアプリを作ります。

アプリを作り終えたらマイグレーションします。

(env) % python manage.py makemigrations

(env) % python manage.py migrate

 
あとはDRFを起動し、Browsable APIhttp://localhost:8000/api/v1/tasks/ にアクセス、データの登録などができることを確認します。

 

axiosのインストール

ReactからDRFへリクエストを飛ばすために、axiosをインストールします。

% yarn add axios

% yarn

 

FormコンポーネントDRFと連携する

連携するため、

  • 初期値のロード
  • 登録ボタンを押すと、DRF側へリクエストが飛んで保存

の2つを実装します。

 

初期値のロード

初期値をロードする Effect Hook を用意します。
React Hooksでデータを取得する方法 - Qiita

useEffect(() => {
  console.log('called!');
  const fetchData = async () => {
    const response: AxiosResponse<ApiGetResponse> = await axios.get(API_URL);
    setTasks(response.data);
  };

  void fetchData();
}, [setTasks]);

 

登録ボタンを押すと、DRF側へリクエストが飛んで保存

ボタンを押したらリクエストを飛ばすため、addTask関数の中に処理を追加します。
reactjs - How to POST request using axios with React Hooks? - Stack Overflow

axiosを使って非同期でリクエストを飛ばすため、 async/awaitも使います。

const addTask = async (e: FormEvent) => {
  e.preventDefault();

  const requestData = { content };
  try {
    const response: AxiosResponse<ApiPostResponse> = await axios.post(
      API_URL,
      requestData,
    );

    setTasks([...tasks, { id: response.data.id, content }]);
    setContent('');
  } catch (error) {
    alert('エラーでした');
  }
};

 
なお、axiosを使う時の型ですが、AxiosResponseは

import axios, { AxiosResponse } from 'axios';

と、axios から importします。

また、axiosのレスポンスのdataの型は、

type ApiGetResponse = {
  id: number;
  content: string;
}[];

type ApiPostResponse = {
  id: number;
  content: string;
};

と用意し、

const response: AxiosResponse<ApiPostResponse> = await axios.post()

のように渡します。

 
これで第3段階も完成です。コミットは3つに分かれています。

 

第4段階:削除ボタンを付ける

今までは作成するだけだったので、削除ボタンを付けてみます。

Listの方に付けるため、まずは削除する関数を用意します。

removeTask関数の中では、

  • axiosで削除
  • tasks Stateに、引数で渡された task.id と一致しないものだけセット

します。

const removeTask = async (taskId: number) => {
  try {
    await axios.delete(`${API_URL}${taskId}/`);
    setTasks(tasks.filter((task) => task.id !== taskId));
  } catch (error) {
    alert('エラーでした');
  }
};

 
そして、Listのコンポーネントの中でも setTasks Hookを使うようになったため、型定義とpropsを追加します。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V4List: VFC<Props> = ({ tasks, setTasks }) => { ... }

 
これで完成です。コミットは こちら

 

ソースコード

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

また、今回実装した機能がまとまっているプルリクはこちらです。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf/pull/1

Python + openpyxlを使って、月末日を除く五十日始まりのカレンダーを作成してみた

Pythonの標準モジュール calendar では、カレンダーを作るための便利な関数が用意されています。
calendar --- 一般的なカレンダーに関する関数群 — Python 3.9.4 ドキュメント

例えば、 monthcalendar で年月を指定すると週ごとに日付リストが得られるため、これを元にしたカレンダーが作りやすいです。

>>> import calendar
>>> import pprint

>>> pprint.pprint(calendar.monthcalendar(2021, 4))
[[0, 0, 0, 1, 2, 3, 4],
 [5, 6, 7, 8, 9, 10, 11],
 [12, 13, 14, 15, 16, 17, 18],
 [19, 20, 21, 22, 23, 24, 25],
 [26, 27, 28, 29, 30, 0, 0]]

 
ただ、この calendar モジュールではカレンダーの初日が1日に固定されます。

そんな中、五十日(ごとおび)始まりのカレンダーを作る機会があったため、メモを残します。

 

目次

 

環境

 

仕様

Wikipediaによると、五十日とは

五十日(ごとおび)とは、毎月5日・10日・15日・20日・25日と、30日または月末日のことである。

五十日 - Wikipedia

とのことです。

月により月末日が変わる点が厄介でしたが、今回は「月末の五十日は月初の五十日と1日しか異ならないし、月末始まりのカレンダーは使わない」ということだったので、月末の五十日は仕様対象外としました。

最終的には

  • カレンダーの初日に当たる年月日をコマンドラインから入力
  • カレンダーの罫線や週の数は不変のため、テンプレートとしてExcelファイルを用意し、そこに日付を埋めていく
  • 29日以降の日付を入力したらエラーにする
    • 月末の扱いが手間なので、一番日数が少ない2月に合わせた

という仕様としました。

なお、Excel製テンプレートはこんな感じです。

f:id:thinkAmi:20210429130754p:plain

 

プログラムの構成

大きく分けて

  • 年月日の入力
  • カレンダー用データの作成
  • Excelへの埋め込み

の3つの構成としました。

 

年月の入力

ここは普通に入力を受け取るだけです。

def input_values():
    print('開始する年を入力してください')
    yyyy = input()

    print('開始する月を入力してください')
    mm = input()

    print('開始する日を入力してください')
    dd = input()

    try:
        if int(dd) > 28:
            print('29日以降はサポート対象外です')
            return None
        return datetime.datetime(int(yyyy), int(mm), int(dd))
    except:
        print('日付ではありません')
        return None

 

カレンダー用データの作成

扱いやすそうだった、標準モジュール calendar.monthcalendar の戻り値の形式に合わせてデータを作成します。

まずは「開始日」〜「翌月の開始日-1日」を作成します。

# カレンダーの初日から最終日までの日付を作る
dates = []
while True:
    dates.append(current_date)
    current_date += datetime.timedelta(days=1)

    # ここまでで翌月の当日になっていたら、処理を終了する
    if start_at == current_date.day:
        break

# ここまでの結果 (2021/7/10始まりの場合)
# [
#   datetime.datetime(2021, 7, 10, 0, 0), ... , datetime.datetime(2021, 8, 9, 0, 0)
# ]

 
続いて、テンプレートよりカレンダーは日曜日始まりとする調整を行います。

カレンダー初日が日曜日でない場合は、標準モジュールのようにダミーの値を先頭に挿入します。今回は None を挿入しました。

# 先頭は日曜日始まり
weekday_of_first_day = dates[0].weekday()
if weekday_of_first_day != 6:  # 日曜日以外
    # 日曜日始まりでない場合、開始日の曜日以前はダミー(None)を入れておく
    for _ in range(weekday_of_first_day + 1):
        dates.insert(0, None)

# ここまでの結果
# [
#  None, None, None, None, None, None,
#  datetime.datetime(2021, 7, 10, 0, 0), ... , datetime.datetime(2021, 8, 9, 0, 0)
# ]

 
ここまででひと月分のカレンダーデータができました。

テンプレートに埋めやすくするため、カレンダーデータを週ごと(7要素ごと)に分割します。

なお、月によっては最後の週だけが7つの要素にならないことがあるため、以下の記事を参考に itertools.zip_longest を使って足りない分はNoneを埋め込みます。
リストをn個ずつのサブリストに分割 (Python) - おぎろぐはてブロ

dates_by_calendar = [item for item in itertools.zip_longest(*[iter(dates)] * 7)]

# ここまでの結果
# [
#  (None, None, None, None, None, None, datetime.datetime(2021, 7, 10, 0, 0)),
#  (datetime.datetime(2021, 7, 11, 0, 0), ... , datetime.datetime(2021, 7, 17, 0, 0)),
#  ...
#  (datetime.datetime(2021, 8, 8, 0, 0), datetime.datetime(2021, 8, 9, 0, 0), None, None, None, None, None)
# ]

 
後はこれを12ヶ月分繰り返します。

全体像はこんな感じです。

def create_calendar(current_date):
    start_at = current_date.day
    calendar = []

    for _ in range(12):
        # カレンダーの初日から最終日までの日付を作る
        dates = []
        while True:
            dates.append(current_date)
            current_date += datetime.timedelta(days=1)

            # ここまでで翌月の当日になっていたら、処理を終了する
            if start_at == current_date.day:
                break

        # 先頭を埋める
        # 先頭は日曜日始まり
        weekday_of_first_day = dates[0].weekday()
        if weekday_of_first_day != 6:  # 日曜日以外
            # 日曜日始まりでない場合、開始日の曜日以前はダミー(None)を入れておく
            for _ in range(weekday_of_first_day + 1):
                dates.insert(0, None)

        # リストを一週間ごと(7要素ごと)のリストへ分割し、最後の要素が足りない場合はNoneを入れる
        dates_by_calendar = [item for item in itertools.zip_longest(*[iter(dates)] * 7)]
        calendar.append(dates_by_calendar)

    return calendar

 

Excelへの埋め込み

openpyxlを使って埋め込みます。

Excleのセル数に合わせて細かいことをしていますが、コメント通りです。

def to_excel(calendar):
    wb = openpyxl.load_workbook('template_cal.xlsx')

    sheet = wb.copy_worksheet(wb['テンプレート'])
    sheet.title = '結果'

    plot(sheet, calendar)

    wb.save(f'cal_{datetime.datetime.now().strftime("%Y%m%d%H%M%S")}.xlsx')


def plot(sheet, calendar):
    for i, weeks_of_month in enumerate(calendar, 1):
        is_first_time = True

        mod_col = i % 3
        if mod_col == 1:  # 左端のカレンダーに入力
            pos_col = 2
        elif mod_col == 2:  # 中央のカレンダーに入力
            pos_col = 11
        else:
            pos_col = 20  # 右のカレンダーに入力

        if 1 <= i <= 3:  # 1行目のカレンダーに入力
            pos_row = 4
        elif 4 <= i <= 6:  # 2行目のカレンダーに入力
            pos_row = 13
        elif 7 <= i <= 9:  # 3行目のカレンダーに入力
            pos_row = 22
        else:
            pos_row = 31  # 4行目のカレンダーに入力

        # 一ヶ月のうちの一週間分の日付を取得する
        for row_index, current_week in enumerate(weeks_of_month):
            # 日付をセルに設定する
            for col_index, current_date in enumerate(current_week):
                if current_date:  # ダミーは印字しない
                    # そのカレンダーに初めて日付を設定する場合、タイトルも設定する
                    if is_first_time:
                        sheet.cell(row=pos_row-2, column=pos_col, value=f'{current_date.month}月')
                        is_first_time = False

                    sheet.cell(
                        row=pos_row+row_index, column=pos_col+col_index, value=current_date.day
                    )

 
これで必要な関数はできたため、 main 関数でそれぞれを呼び出せば完成です。

def main():
    input_date = input_values()
    if not input_date:
        print('終了します')
        return

    calendar = create_calendar(input_date)
    to_excel(calendar)
    print('作成しました')

 

動作確認

実際に動かしてみます。

% python run.py     
開始する年を入力してください
2021
開始する月を入力してください
7
開始する日を入力してください
10
作成しました

 
できあがりです。

f:id:thinkAmi:20210429132235p:plain

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/gotobi_calendar

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++;
  }

  // 2023/07/30 13:04 追記 ここから
  // コメントいただいた通り、この行は不要
  //const firstRecord = pj[0]
  // 2023/07/30 13:04 追記 ここまで  

  // 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スプレッドシートにバウンスしたメールアドレスの情報が書き込まれました。

また、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」 にしています