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