シェルスクリプトもJavaScriptで＋Angularのコンテナで環境変数で設定の切り替え

[Node.js Advent Calendar 2018](https://qiita.com/advent-calendar/2018/nodejs)の12/22のエントリーです。昨日は @jkr_2255 さんでした。

## TL;DR

次の要件を達成するのを目標にして、実現できました

* 開発時も標準の開発サーバーの``--configuration=production``の仕組みで環境が切り替えられます
* デプロイ用のDockerコンテナで、``-e ENV=staging``の環境変数で設定が切り替えられます
* デプロイ用、開発時の設定ファイルは共有できます
* デプロイ時に、環境変数で設定していない環境の情報が外部からアクセス可能になったりしません
* PerlとかAWKとかsedとかはもう捨てて、シェルスクリプトもJavaScriptでやっちゃいましょう！

Angular v6を使っていますが、サーバーサイドレンダリングをしてなくて、最終的に静的HTMLと.jsファイルになる系のSingle Page Applicationなら応用可能かと思います。

# はじめに

Single Page Applicationを配信する場合、静的ファイル配信のためのミドルウェアをJavaとかGoとかPythonとかNodeのサーバーに組み込んでもろもろやったりもしてきましたが、Dockerとかコンテナの時代なので、静的ファイル配信特化のNginxコンテナを立てて、裏のサーバーはAPIサーバーに徹する方が、言語が変わっても細かい設定をいちいち行わなくても良いので良いかなと思っています。

で、コンテナの場合には、もろもろの設定は環境変数で行える方が良いです。staging/production/development環境用のイメージをそれぞれ何個も作るよりは、1つのイメージで、環境変数で切り替えられるようにした方が何かと便利です。

Next.jsで開発していたときは、Node.jsが裏で動いているので環境変数を読み込んでレスポンスのHTMLに設定を埋め込むとか余裕でした。ですが素のAngularだと、特定環境向けにビルド（各環境ごとに別のDockerイメージが必要）と、実行時にAPP_INITIALIZERを使ってサーバーにわざわざ設定を取りに行くぐらいしか選択肢が提供されていません。もちろん、ビルドは静的なHTMLファイルになってしまうので、環境変数を見る仕組みがないので仕方がないのですが、実行時にサーバーに問い合わせるやり方はダサくて、余計な通信のラウンドトリップも発生して、[パフォーマンスに厳しい人](https://qiita.com/damele0n/items/f4050649de023a948178)にはきっと「は？お前やる気あるの？」とか言われてしまいそうなので、実行時ペナルティのない方法、DRYな方法をトライして見ました。

# プロジェクトの準備

環境変数で設定を切り替えるサンプルアプリを作ります。いつもの通りにプロジェクトを作ります。

```bash
$ ng new env-config
```

開発中のサービスは、dev環境とか、staging環境ではタイトルの横に「dev」みたいなラベルを表示したいとします。こんな感じのテンプレートにします。

```html:src/app/app.component.html
<div style="text-align:center">
  <h1>
    Config Example <span [style.color]="color">{{label}}</span>
  </h1>
</div>
```

設定ファイルを書き換えます。ラベルのテキストと色を付与します。

```ts:src/environments/environment.ts
export const CONFIG = {
  production: false,
  label: 'dev',
  color: 'red'
};
```

```ts:src/environments/environment.prod.ts
export const CONFIG = {
  production: true,
  label: '',
  color: ''
};
```

直接参照するのではなくて、environmentsフォルダにindex.tsというエントリポイントのスクリプトを置いておきます。今は型情報をつけているだけですが、あとで少し書き換わります。

```ts:src/environments/index.ts 
// バージョン1
export interface IEnvironment {
  production: boolean;
  label: string;
  color: string;
}
import { CONFIG } from './environment';

export const environment = CONFIG as IEnvironment;
```

さきほどのHTMLページの裏側のTypeScriptコードはこんな感じです。設定を読み込んで、それをプロパティに入れているだけです。

```ts:src/app/app.component.ts
import { Component } from '@angular/core';
import { environment } from '../environments';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  color = environment.color;
  label = environment.label;
}
```

main.tsがエラーになってしまうので、ついでに直します。

```ts:src/main.ts
import { environment } from './environments'; // index.tsができたので末尾の/environmentが不要
```

とりあえず、サンプルアプリはこれでほぼ完成

# 開発環境の設定切り替え

ネットを調べると古い情報もよく出てくるので、一旦、v6のやり方（v7も？）、ということでまとめます。


開発サーバー起動時は次のように環境名を設定します。古い文献だと、``--env=prod``と書く、と書かれていたりしますが、今は``--configuration``です。

```bash
$ ng serve --configuration=prod
```

とりあえず、開発環境の環境の追加と切り替えはできました。まあ、この切り替えだけではドキュメントに書かれてきた内容と大差ないので、わざわざ新しいエントリーを書いた意味はありません。もうちょっとだけお付き合いください。

# 環境変数を見て環境を切り替えるDockerコンテナの作成

ビルドしたHTML/JavaScriptのファイルを封じ込めたNginxコンテナを作ります。Cloud FrontとかのCDNに配布する方法もありますが、今回はDockerにしています。バックエンドもフロントエンドも全部Dockerコンテナでバージョン管理した方が良いですしね。

今回の作戦は、コンテナの起動時に環境変数を見て、index.htmlを書き換える方向で行きます。サーバーサイドincludeとかも考えたのですが、Nginx側には凝った設定を入れないようにしました。

今回は1ページしかないので、やらなくても良いといえば良いのですが、NginxはSingle Page Application用の設定として、try_filesを入れて、Routerで動的に作られたページにアクセスしてもきちんとページが表示できるようにしてあげます。

```nginx:nginx/nginx.conf
server {
  listen 80;
  server_name localhost;

  proxy_set_header    Host    $host;
  proxy_set_header    X-Real-IP    $remote_addr;
  proxy_set_header    X-Forwarded-Host       $host;
  proxy_set_header    X-Forwarded-Server    $host;
  proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
  server_tokens off;

  location / {
    root /public;
    index index.html index.htm;
    try_files $uri $uri/ /index.html =404;
  }
}
```

シェルスクリプトスキルが弱いのでちょっと冗長だと思いますが、```ENV```という環境変数を参照して、さきほどの設定ファイルを選んで、```<script>var CONFIG = {設定};</script>```という形式にリライトして、index.htmlの```</head>```の閉じタグの前に埋め込んでいます。

```bash:nginx/rewrite.sh
#!/bin/bash

echo "Front End Environment: ${ENV}"

case ${ENV} in
"production") CONFIG=`cat environments/environment.prod.ts | tr -d '\n'` ;;
"staging") CONFIG=`cat environments/environment.stg.ts | tr -d '\n'` ;;
*) CONFIG=`cat environments/environment.ts | tr -d '\n'` ;;
esac

CONFIG=`echo "${CONFIG}" | sed -e "s/export const/var/"`

echo "CONFIG: ${CONFIG}"

CONFIG="<script>${CONFIG}</script></head>"
CONFIG=`echo "${CONFIG}" | sed -e 's#/#\\\\/#g'`
sed -i -e "s/<\/head>/${CONFIG}/g" public/index.html
```

最後に、これらをビルドするDockerfileです。マルチステージビルドしています。Angularはnode-sassを使っているので、ビルド側のイメージではGCCとか、Pythonとか、node-gypとかも必要なので入れています。実行の方ではシェルスクリプトの実行用にbashとかsedとか入れています。

```Dockerfile:Dockerfile
FROM node:10.13.0-slim as builder
WORKDIR /work
RUN apt-get update \
    && apt-get install --no-install-recommends -y python build-essential unzip \
    && apt-get clean
RUN npm install -g node-gyp
ADD package.json .
ADD package-lock.json .
RUN npm install
ADD angular.json .
ADD tsconfig.json .
ADD src src
RUN npm run build


FROM nginx:stable-alpine as runner
RUN apk add --no-cache bash sed coreutils
ADD ./nginx/nginx.conf /etc/nginx/conf.d/default.conf
ADD ./src/environments/*.ts /environments/
COPY --from=builder /work/dist/env-config public
EXPOSE 80
ENTRYPOINT ["bash", "-c", "./rewrite.sh; nginx -g \"daemon off;\""]
```

最後に、先ほど実装した設定情報のエントリーポイントのコードを書き換えて、グローバルな```CONFIG```という変数があったらそっちを読みに行くようにします。開発者コンソールから雑に書き換えられて挙動が変わってもアレなので、雑にdeep copyしています。

```ts:src/environments/index.ts
export interface IEnvironment {
  production: boolean;
  label: string;
  color: string;
}
import { CONFIG } from './environment';

export const environment = window['CONFIG'] ? JSON.parse(JSON.stringify(window['CONFIG'])) as IEnvironment : CONFIG as IEnvironment;
```

こちらの方は、Dockerに与える```-e```オプションで環境が切り替えられます。

```bash
# ビルド
$ docker build -t envconfig:latest .

# 実行
$ docker run -e ENV=staging -p 8080:80 envconfig
```


これで、当初目標としていた次の4つの項目がクリアできました。めでたしめでたし。

* 開発時も標準の開発サーバーの``--configuration=production``の仕組みで環境が切り替えられます
* デプロイ用のDockerコンテナで、``-e ENV=staging``の環境変数で設定が切り替えられます
* デプロイ用、開発時の設定ファイルは共有できます
* デプロイ時に、環境変数で設定していない環境の情報が外部からアクセス可能になったりしません

# もう、sedとか辞めてもいいんじゃないですかね

さて。サーバーでちょっとしたテキスト処理というと、Perl、AWK、sedあたりが活用されてきました。たいていサーバーに最初から入っている、というのが理由でしたが、コンテナ時代になって、必ずしもそうではなくなりました。で、Alpineに対してあとから追加すると、それだけでイメージサイズが太ってしまいます。

例えば、人材派遣会社の人とかと話をしても「Perlはもう人が集まらない」といいます。ちょっと前のベンチャーとかだと、Perlの強いひとが初期の基盤を作っていたりしますが、若い人があまりやらないので、新しい人材がなかなか増えません。Perlに限らず、この手の古き良きツールはまあ、新規で積極的に使わなくもいいのかなぁ、と思います。

代わりになるような、テキスト処理ができて、若い人も臆することなく、ウェブサイトを調べなくてもすぐに書ける言語を探してみます。

フルサイズの言語は流石に大きいですね。bash、sed、coreutils（trとか）も以外と大きい。組込み用の処理系を見ると、Rubyとかmicropythonとかは確かにサイズが小さいのですが、mrubyは必要な機能を設定ファイルに追加してビルドし直しが必要とのこと。micropythonもreパッケージとかなさそう(open関数は使える)。そんなわけで、low.jsでやってみようと思います。

| 処理系 | サイズ |
|:-:|:-:|
| mruby  | 0.56MB  |
| micropython  | 1.83MB  |
| low.js  |  3.19MB |
| bash, sed, coreutils  |  5.11MB |
| Ruby  | 40.79MB  |
| Node.js  | 63.09MB  |
| Python  | 73.79MB  |
| Perl  | 219.59MB   |

low.jsは聞き慣れない人も多いと思いますが、V8の代わりに、小さいJS実装である[DukTape](https://duktape.org/)というJavaScriptエンジンを組み込んで、Node.jsクローンのAPIをいくつか持ってきたミニNode.jsです。DukTapeはES5と少し古いです。といっても組み込みの正規表現は使えますし、また、[利用できるパッケージ一覧](https://www.neonious.com/Documentation/lowjs)によると、child_processパッケージはないですが、fsパッケージのファイル読み書きぐらいはできそうです。フル機能の各種スクリプト言語と比べるとサイズが1/10以下で、bash/sedとかと比べてもサイズが小さいです。

せっかくなのでTypeScriptをシェルスクリプト代わりにしてみましょう。まずはNodeの一部互換なのでNode.jsの型定義ファイルを取り込みます。low.js専用の型定義は今の所ありません。

```sh
$ npm install @types/node
```

先程のシェルスクリプトをTypeScriptに書き換えました。

```ts:deploy/rewrite.ts
#!/bin/bash

const { readFileSync, writeFileSync } = require("fs");

console.log(`Front End Environment: ${process.env.ENV}`);

let filePath = "environments/environment.ts";
switch (process.env.ENV) {
  case "production":
    filePath = "environments/environment.prod.ts";
    break;
  case "staging":
    filePath = "environments/environment.stg.ts";
    break;
}
let CONFIG: string = readFileSync(filePath, "utf8");
CONFIG = CONFIG.replace(/\n/g, "").replace("export const", "var");

console.log(`CONFIG: ${CONFIG}`);

let index: string = readFileSync("public/index.html", "utf8");
index = index.replace("</head>", `<script>${CONFIG}</script></head>`);
writeFileSync("public/index.html", index, "utf8");
```

low.jsがES5しか実行できないので、忘れずにターゲットをes5にしておきます。

```json:deploy/tsconfig.json
{
  "compilerOptions": {
    "target": "es5",             // 大事
    "lib": ["dom", "es2017"],
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "module": "commonjs",
    "outDir": "./dist"
  },
  "include": ["./src/**/*"]
}
```

ビルドしたときにこのTypeScriptも一緒にビルドされるようにしておきましょう。

```json:package.json
  "scripts": {
    "postbuild": "tsc -p ./deploy"
  }
```

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

* deployフォルダをbuilderイメージでADDするようにした
* runnerイメージではビルド済みlow.jsをダウンロードしてくるようにした
* 最後の実行はbashではなくてashにしつつ（-cぐらいはAlpineのashでも使える)low.jsで実行するようにした。

```Dockerfile:deploy/Dockerfile
FROM node:10.13.0-slim as builder
WORKDIR /work
RUN apt-get update \
    && apt-get install --no-install-recommends -y python build-essential unzip \
    && apt-get clean
RUN npm install -g node-gyp
ADD package.json .
ADD package-lock.json .
RUN npm install
ADD angular.json .
ADD tsconfig.json .
ADD deploy deploy
ADD src src
RUN npm run build


FROM nginx:stable-alpine as runner
RUN apk add --no-cache curl && \
    curl http://www.lowjs.org/api/downloadLowjs?file=lowjs-linux-x86_64-20181125.tar.gz -o lowjs.tar.gz && \
    tar xvzf lowjs.tar.gz && \
    apk del curl
ADD ./nginx/nginx.conf /etc/nginx/conf.d/default.conf
ADD ./src/environments/*.ts /environments/
COPY --from=builder /work/dist/env-config public
COPY --from=builder /work/deploy/dist/rewrite.js .
EXPOSE 80
ENTRYPOINT ["ash", "-c", "lowjs-linux-x86_64-20181125/bin/low ./rewrite.js; nginx -g \"daemon off;\""]
```

Dockerのイメージサイズも、29.5MBから、27.5MBで少しだけ小さくなりました。

# おまけ: Angularの環境の追加方法

プロジェクト生成時はstagingのがないので、それも追加してみましょう。まずは設定ファイルを追加します。

```ts:src/environments/environment.stg.ts
export const CONFIG = {
  production: true,
  label: 'stg',
  color: 'blue'
};
```

環境の種類を定義しているのは、``angular.json``です。``projects/(アプリ名)/architect/build/configurations``以下と、``projects/(アプリ名)/architect/serve/configurations``以下を修正する必要があります。

新しい環境をbuildの方には次のような感じで足します。productionからコピーしてきて、ファイル名変更のところだけ修正しました。

```json:angular.json
"staging": {
  "fileReplacements": [
    {
      "replace": "src/environments/environment.ts",
      "with": "src/environments/environment.stg.ts"
    }
  ],
  "optimization": true,
  "outputHashing": "all",
  "sourceMap": false,
  "extractCss": true,
  "namedChunks": false,
  "aot": true,
  "extractLicenses": true,
  "vendorChunk": false,
  "buildOptimizer": true
}
```

serveの方は、次の行を足すだけです。この2箇所を変更すると自由に環境が増やせます。

```json:angular.json
"staging": {
  "browserTarget": "env-config:build:staging"
}
```

これでstagingの環境も追加されました。

```bash
$ ng serve --configuration=staging
```

明日は @kysnm です。超期待ですよね！