Afternoon Log

日々のことや、技術的な備忘録を吐き出していくつもり

常時SSL化対応

こんにちはこんばんは、おはようございます。

自分のwebページをHTTPS対応致しました。
SSL証明書の種類にはDV(Domain Validation)、OV(Organization Validation)、EV(Extended Validation)の3種類ありますが、
個人ですし最初のドメイン認証証明書にします。
ところで、Validationは検証の意味合いだと思っているのですけど認証って言っちゃって良いんです?

閑話休題

認証局はどこ使うかですが、流行のLet's Encryptを利用します。
無料で楽に取得できますし、自動更新スクリプトを組むこともできますからね。
結果、この先色々と大変ではあったんだけどね……。
なので、章立てしながら進めていきます。

構成

まずWebページですが、ECSを利用していてコンテナ内に静的ファイルを配置してnginxで配信しています。
最初に考えたのは、アプリケーション前にロードバランサなどを配置してそこをSSL終端にすることです。
疎結合なアプリケーションを考えると、アプリケーション自体とSSLは分離して置いた方が良いですからね。
なのでAWSにあるALB(Application Load Balancer)の導入を考えたのですが、月に2,000円ほどしそうでした。
高い。
流石に1コンテナでバランシングする必要もないアプリケーションでこれは高すぎる……。
SSL終端をLBに持ってくるのは諦めよう。
と言うわけでWebアプリケーションをSSL終端にします。
nginxとアプリケーション自体を分離するのも全然ありなのですが、まずは分離する前に目標を達成することを優先します。

証明書取得の自動化

方針が決まったので早速実装していきます。
ファイルを次の様に更新します。

Dockerfile

FROM node:12.18.4-buster

# install nginx
RUN curl -fsSL https://nginx.org/keys/nginx_signing.key | apt-key add -
RUN echo 'deb http://nginx.org/packages/debian/ stretch nginx\n\
deb-src http://nginx.org/packages/debian/ stretch nginx' > /etc/apt/sources.list.d/nginx.list
- RUN apt update && apt remove -y nginx && apt install -y nginx && apt clean
+ RUN apt update && apt remove -y nginx && apt install -y nginx openssl certbot && apt clean

COPY ./file/mu-web.conf /etc/nginx/conf.d/

# start next
# ENV NODE_ENV=production
WORKDIR /var/www
COPY ./app app/
RUN cd app && npm install && npm run export

EXPOSE 80

- CMD /usr/sbin/nginx -g "daemon off;"
+ COPY run.sh /etc/nginx/run.sh
+ RUN chmod +x /etc/nginx/run.sh
+ CMD /etc/nginx/run.sh

opensslは自己証明書のため、
certbotはLet's Encryptによる証明書取得のためにインストールします。
後半の変更は、証明書取得のコマンドを叩きたいのでシェルスクリプトにまとめただけです。

run.sh

#!/bin/bash

DOMAIN="mu-elma.net"
CERT_DIR="/etc/letsencrypt/live"

if [ ! -d "${CERT_DIR}/${DOMAIN}" ]; then
  mkdir -p "${CERT_DIR}/${DOMAIN}"
  openssl req -new -newkey rsa:2048 -sha256 -x509 -nodes \
    -set_serial 1 \
    -subj "/C=JP/ST=Tokyo/L=null/O=null/OU=null/CN=${DOMAIN}" \
    -out "${CERT_DIR}/${DOMAIN}/fullchain.pem" \
    -keyout "${CERT_DIR}/${DOMAIN}/privkey.pem"
  chmod 400 "${CERT_DIR}/${DOMAIN}/privkey.pem"
fi
nginx

if [ ${RUN_ENV} = "production" ]; then
  rm -rf "${CERT_DIR}/${DOMAIN}"
  certbot certonly -n --keep-until-expiring --agree-tos \
    --webroot --webroot-path /var/www/app \
    -m ${MAIL_ADDRESS} -d ${DOMAIN}
  nginx -s reload
fi

while true
do
    sleep 10
done

このシェルスクリプトは大きく3つの処理に分類されていています。
まずは自己証明書の準備です。
なぜ自己証明書が必要なのかというと、certbotによる証明書取得を成功させるためです。
certbotによる証明書取得の仕組みは調べて貰うと分かるのですが、
簡単に言うとhttpでアクセスできる箇所にドメイン認証用のファイルを配置し、それを確認することで証明書が発行されます。
つまり、その時点ではnginxによってアプリケーションが起動していないといけません。
コンテナを最初に起動した時はまだ証明書を取得していないので、nginxが証明書を見つけられずに起動失敗したはずです。
その失敗を回避するために、nginxの起動の前に自己証明書を発行しています。

次に、certbotによる証明書発行です。
環境変数を用いて、発行できる条件を制限しています。(環境変数名はちょっと微妙かも)
これはdocker buildした時に証明書を取りに行かないようにするためです。
たしかbuildしたときにもコマンドが実行される認識なんですが、
buildする環境はローカルだったりCircleCIだったりするので、この時点ではコンテナとドメインが結びつかず失敗すると思っています。
その後、自己証明書を削除します。
certbotで上書きしてくれるかなと思っていたのですが、上書きされなかったのでちゃんと消します。
証明書ができたらreloadします。

最後に無限ループの設置です。
今までnginxをフォアグランド実行でコンテナを終了させないようにしていました。
それが今回シェルスクリプトに格納したので、同様にできなかったんですよね。
最後にバックグラウンドからフォアグランドに持ってきたらうまく行くのかもしれないですが、
無限ループの設置しちゃった方が楽なのでこっちを選択しました。

.conf

server {
  listen 80;
  server_name mu-elma.net;
+   root /var/www/app;
+   index index.html;
+
+   location ^~ /.well-known/acme-challenge {
+     default_type "text/plain";
+     try_files $uri =404;
+   }
+ }
+ 
+ server {
+   listen 443;
+   server_name mu-elma.net;
+   ssl on;
+   ssl_certificate /etc/letsencrypt/live/mu-elma.net/fullchain.pem;
+   ssl_certificate_key /etc/letsencrypt/live/mu-elma.net/privkey.pem;

  root /var/www/app/out;
  index index.html;

  location / {
    try_files $uri $uri/index.html $uri.html =404;
    expires -1;
  }
}

80ポートで動かしている内容をまずは443ポートに切り替えます。
そこに証明書のPATHを指定します。
次に、80番ポートでcertbot用のエントリポイントを準備してあげます。
認証用以外は不要なので404を返します。
今回は後日実装という形にしたのですが、80番の他のアクセスを443へリダイレクトさせた方が良いですね。

.circleci/composition.yml

version: "3"
services:
  mu-web-service:
    image: ${DOCKER_IMAGE}
    ports:
      - "80:80"
+          - "443:443"
+      environment:
+          RUN_ENV: "production"

dockerを動かす時に443でも受け付けてくれるように更新します。
このファイルはECSでサービスを動かすときに利用しているdocekr-composeのファイルです。
序でに環境変数もセットしちゃいます。
これは先述した、docker run したときに証明書を取得できるようにするためのものです。

デプロイ完了したら、AWSのセキュリティグループで443を開けてあげます。
そしてHTTPSでアクセスできることを確認します。

f:id:mura_elma:20210214183243p:plain

……やった。やったぜ。

以上で大まかな設定はできました。
後は、色々と追加で設定したことを記載します。

Appendix 1. 秘密情報の用意

コードの中に{MAIL_ADDRESS}があったと思います。
gitでコード管理しているので、メールアドレスを記載したくなかったんですよね。
そのためメールアドレスを秘密情報として利用できるようにします。
まずAWS System Managerからパラメータストアで秘密情報を登録します。
「安全な文字列」「現在のアカウント」でメールアドレスを登録します。
次はこれを利用する側の設定を行います。
ECSから秘密情報を取得するためには、ssm:GetParametersの権限が必要なのですが、
それをタスク実行ロールに与える必要があります。
自分はタスク実行ロールを用意していなかったので、まずはこのタスク実行ロールを用意します。

docs.aws.amazon.com

作成できたら、IAMのロールから作成したecsTaskExecutionRoleを選択します。
権限とロールを紐付けるのはポリシーなのですが、わざわざこれのためにポリシー作るのもちょっと面倒だったので
今回はインラインポリシーを利用して紐付けを行います。
他にも、ecsコマンドを実行しているユーザーはタスク実行ロールではないので、
ユーザーからIAMロールへECSを利用できるようにiam:PassRoleの権限をユーザーに割り当てているポリシーに追加します。
AWSでの設定が終わったらタスク定義に反映します。

.circleci/ecs-params.yml

version: 1
task_definition:
+  task_execution_role: ecsTaskExecutionRole
  services:
    mu-web-service:
      mem_limit: 524288000 # 500MB
      mem_reservation: 262144000 # 250MB
+      secrets:
+          - value_from: mu-developer-mail
+             name: MAIL_ADDRESS

タスク実行ロールには、先ほど作ったIAMロールの名前を。secretsには利用する秘密情報を記述します。
value_fromにはSystem Manager上で登録した名前、nameには値を入れる環境変数名を指定します。
無事に利用できていたら、ECSのタスク定義から環境変数を見ることができます。

Appendix 2. AWS CLI の更新

最初にパイプラインを回した時、pipのインストールが失敗しました。

#!/bin/bash -eo pipefail
curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py"
sudo python get-pip.py
sudo apt install python-dev
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 1884k  100 1884k    0     0  23.2M      0 --:--:-- --:--:-- --:--:-- 23.2M
Traceback (most recent call last):
  File "get-pip.py", line 24244, in <module>
    main()
  File "get-pip.py", line 199, in main
    bootstrap(tmpdir=tmpdir)
  File "get-pip.py", line 82, in bootstrap
    from pip._internal.cli.main import main as pip_entry_point
  File "/tmp/tmpyQDl6R/pip.zip/pip/_internal/cli/main.py", line 60
    sys.stderr.write(f"ERROR: {exc}")
                                   ^
SyntaxError: invalid syntax

Exited with code exit status 1
CircleCI received exit code 1

どうやら pipのインストールがpython 3.6以上じゃないといけないようになった感じでした。
3.6以上じゃないの……?という疑問はありますが、なんとかせねばなりません。
しかし、pythonのバージョンアップをやるのは絶対大変だと思って調べていると、
どうやらAWS CLIのバージョンが1系のままで、今は2系があるとのことでした。

docs.aws.amazon.com

見ると、pipの準備が不要そう。
なので、ごっそりpip準備のパイプラインは削除し、AWS CLI v2をインストールします。
すると今度は、ECRのログイン方法が変わっているとのことでした。
なので、これも次の様に書き換えました。

- run:
     name: Login ECR
     command: aws ecr get-login-password | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com

感想

思った以上に、色々やる必要が出てきて大変でした。記事書くのも大変でした。
そして、今回構築して思ったのですがcertbot証明書発行のコマンドは次成功するのか?というところです。
既に発行しているじゃんって拒否られないことを祈るのみです……。
また、今回結構疲れちゃったので自動更新の仕組みまでは導入していません。
CI環境から定期実行させるだけで良いと思うので、またいずれ構築していきたいと思います。
github Action使っても良いかな?

今回のgithub
github.com