miyasakura’s diary

日記です。

ECSで動いているIP制限ありのサービスをLet's EncryptでSSL化

シングルサーバーで動かすちょっとした社内向けサービスの依頼があったのでサクッと作ってAWS上にECSを使ってデプロイしたんですが、さすがにhttpsにしてほしいという話になりました。

ALB配置すればいいだけなんですがそのために毎月2000円も払うほどのものでもないので簡単にhttps化できないものかと考えました。

シングルサーバーでDockerをhttps化するといえば nginx-proxy + letsencrypt-nginx-proxy-companion の組み合わせが王道かなと思っているのですが、社内IPにアクセスを閉じているためhttp-01の認証方式が使えません。

どうしたものかと少し悩んだのですが、letsencrypt-nginx-proxy-companionを書き換えて dns-01 に対応させてしまうことにしました。

やったこと

1. letsencrypt-nginx-proxy-companionをdns-01に対応させる

GitHubリポジトリGitHub - JrCs/docker-letsencrypt-nginx-proxy-companion: LetsEncrypt companion container for nginx-proxy これなんですが、中を見ると simp_le というLet's Encryptのクライアントを使っているようです。これはhttp-01の方式にしか対応していなさそうなので、これを certbot + certbot-dns-route53 プラグインという構成に変更します。

やり方としては上記のリポジトリをフォークしてソースコード読んでそれっぽく対応させます。simp_le から certbot への入れ替えればOKかなと思ったのですが、証明書や鍵の保存場所が異なるのでそこも nginx-proxy 側のソースを読みつつ適切なファイル名でコピーしてあげる必要がありました。

Use dns-01 instead of http-01 · kenjimiyao/docker-letsencrypt-nginx-proxy-companion@59514cb · GitHub

ちゃんとやってると大変なので、かなり適当です。

これを dockerhub に push します。(機密情報は無いのでパブリックリポジトリで問題なし)

2. docker-composeで動かしてみる

いきなりECSだと大変なので適当にEC2サーバーを作って実行してうまく動くことを確認します。

まずEC2のInstance Roleには Route 53の操作権限が必要です。

route53:ListHostedZones
route53:GetChange
route53:ChangeResourceRecordSets

そしてdocker-compose.ymlを作成します。

nginxの後ろで動くアプリケーションが必要ですが、すぐに動くサーバーアプリケーションが思いつかなかったのでこれも謎に作りました。

FROM python:3.7.4-alpine

EXPOSE 3000
CMD ["python", "-m", "http.server", "3000"]

で 色々試しながら docker-compose は下記のような感じに。複数ファイルに分けるパターンもあるようですがひとまず1ファイルで。

docker-composeのバージョンが2の例が多くて少し悩みました(3だとvolumes_fromが使えない)

version: '3'
services:
  app:
    image: kenjimiyao/hello-world
    ports:
      - 3000:3000
    environment:
      VIRTUAL_HOST: supertest.example.com
      LETSENCRYPT_HOST: supertest.example.com
      LETSENCRYPT_EMAIL: asdf@example.com

  nginx-proxy:
    image: jwilder/nginx-proxy
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./certs:/etc/nginx/certs:ro
      - proxy:/etc/nginx/vhost.d
      - proxy:/usr/share/nginx/html
    restart: always
    labels:
      - "com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy"

  letsencrypt:
    image: kenjimiyao/docker-letsencrypt-nginx-proxy-companion
    container_name: letsencrypt
    depends_on:
      - nginx-proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./certs:/etc/nginx/certs:rw
      - ./letsencrypt:/etc/letsencrypt:rw
      - proxy:/etc/nginx/vhost.d
      - proxy:/usr/share/nginx/html
    restart: always

volumes:
  proxy:

3. ECSに対応させる

そのままecs-cliでデプロイもできるみたいですが私は基本的に CloudFormation を使う人なのでCloudFormationのテンプレートにします。

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      TaskRoleArn: !Ref TaskRole
      Volumes:
        - Name: volume-1
          Host:
            SourcePath: '/var/run/docker.sock'
        - Name: volume-2
          Host:
            SourcePath: '/opt/certs'
        - Name: volume-3
          Host:
            SourcePath: '/opt/letsencrypt'
        - Name: proxy
      ContainerDefinitions:

        - Name: nginx-proxy
          Essential: 'true'
          Image: jwilder/nginx-proxy
          PortMappings:
            - ContainerPort: 443
              HostPort: 443
          MemoryReservation: 100
          Memory: 100
          MountPoints:
            - ReadOnly: true
              ContainerPath: /tmp/docker.sock
              SourceVolume: volume-1
            - ReadOnly: true
              ContainerPath: /etc/nginx/certs
              SourceVolume: volume-2
            - ReadOnly: false
              ContainerPath: /etc/nginx/vhost.d
              SourceVolume: proxy
            - ReadOnly: false
              ContainerPath: /usr/share/nginx/html
              SourceVolume: proxy
          DockerLabels:
            "com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy": "true"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: 'nginx-proxy'

        - Name: letsencrypt
          DependsOn:
            - Condition: 'START'
              ContainerName: nginx-proxy
          Essential: 'true'
          Image: kenjimiyao/docker-letsencrypt-nginx-proxy-companion
          MemoryReservation: 100
          Memory: 100
          MountPoints:
            - ReadOnly: true
              ContainerPath: /var/run/docker.sock
              SourceVolume: volume-1
            - ReadOnly: false
              ContainerPath: /etc/nginx/certs
              SourceVolume: volume-2
            - ReadOnly: false
              ContainerPath: /etc/letsencrypt
              SourceVolume: volume-3
            - ReadOnly: false
              ContainerPath: /etc/nginx/vhost.d
              SourceVolume: proxy
            - ReadOnly: false
              ContainerPath: /usr/share/nginx/html
              SourceVolume: proxy
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: 'letsencrypt'

        - Name: app
          Essential: 'true'
          Image: kenjimiyao/hello-world
          MemoryReservation: 300
          Memory: 300
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: 'app'
          PortMappings:
            - ContainerPort: 8080
              HostPort: 8080
          Environment:
            - Name: BUILD_NUMBER
              Value: !Ref Build
            - Name: DATABASE_URL
              Value: !Ref DatabaseUrl
            - Name: VIRTUAL_HOST
              Value: supertest.example.com
            - Name: LETSENCRYPT_HOST
              Value: supertest.example.com
            - Name: LETSENCRYPT_EMAIL
              Value: supertest@example.com

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: [ecs-tasks.amazonaws.com]
            Action: ['sts:AssumeRole']
      Path: /
      Policies:
        - PolicyName: ecs-execution-policy
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - 'ecr:GetAuthorizationToken'
                  - 'ecr:BatchCheckLayerAvailability'
                  - 'ecr:GetDownloadUrlForLayer'
                  - 'ecr:BatchGetImage'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                  - 'route53:ListHostedZones'
                  - 'route53:GetChange'
                  - 'route53:ChangeResourceRecordSets'
                Resource: '*'

以上でhttpsアクセスができるようになりました。

これが正しい方針かと言われると自信は無いというか普通ならALB使う気がしますが、ちょっとやってみたかったのでLet's Encryptにチャレンジしてみました。

一応永続化してますが、Taskがそれほど落ちない前提であれば永続化しなくても大丈夫だと思うのでFargateも対応できるんじゃないかなと思っています。

こういったことならDockerを使うんじゃなくて普通にサーバーを立てたほうが早かったりするんですが、これから自分が作るサービスはとにかく通常のサーバー管理をしたくないのでこういう知見を自分の中に貯めていきたいなと思います。