Всем привет! Меня зовут Александр. Я являюсь техническим руководителем отдела интеграции в компании Visiology. Хочу поделиться с вами нашим велосипедом, как мы перевели наш CI/CD сервер на работу по HTTPS.
Исходные данные
Был сервер TeamCity, который уже был развёрнут в Docker контейнере и работал по HTTP. А хотелось заставить его работать по HTTPS без сложных настроек и без регистрации и смс бесплатно + получить универсальное решение для любого веб приложения. «Сложные» настройки TeamCity можно найти тут, а роадмап, в котором всё это обещают из коробки, тут. Времени ждать у нас нет, а задачу надо делать, так что поехали.
Кому лень всё читать, то можно заглянуть в репозиторий, все шаги я разбил по коммитам, так же там удобно смотреть изменения между шагами.
Простое веб приложение
Для начала потренируемся на кошках простом Python приложении.
main.py
import http.server import socketserver PORT = 8000 class SimpleHandler(http.server.SimpleHTTPRequestHandler): def do_GET(self) -> None: self.send_response(200, 'Simple-Server-Response') self.send_header('Content-type', 'text/plain') self.end_headers() self.wfile.write(b'Hello!\n') with socketserver.TCPServer(("", PORT), SimpleHandler) as httpd: print("serving at port", PORT) httpd.serve_forever()
Простое приложение, которое поднимается на 8000 порту и отвечает «Hello!». Проверим.
test@test:~/simple-server$ curl http://127.0.0.1:8000 Hello! test@test:~/simple-server$
Схематично это будет выглядеть вот так
Запакуем в Docker
Сделаем теперь из него Docker контейнер, чтобы не «засорять» хостовую машину.
Dockerfile
FROM python:3.9-alpine EXPOSE 8000 COPY main.py /main.py ENTRYPOINT ["python"] CMD ["main.py"]
Супер! Билдим, запускаем, проверяем.
test@test:~/simple-server$ docker build -t simple-server . ... Successfully built 43d18cb628fc Successfully tagged simple-server:latest test@test:~/simple-server$ docker run -dt --name simple-server -p 8000:8000 simple-server 7f2d3e770b3ed0f0bfabbd1849486d8c2be99c7eab05ff3c452091e9d4417aec test@test:~/simple-server$ curl http://127.0.0.1:8000 Hello! test@test:~/simple-server$
Теперь, если у нас есть «белый» айпишник и привязанное DNS имя, то можно постучаться к этому серверу извне. Примерно так и работал наш сервер TeamCity, но было страшно за передачу паролей по HTTP, поэтому решено было перейти на HTTPS.
Мы выбрали решение на основе бесплатных сертификатов от Let’s Encrypt. Если выкинуть всю воду, то можно свести к трём шагам:
-
Получаем сертификат
-
Подкладываем его nginx серверу
-
Запускаем контейнеры приложения, nginx, certbot (для обновления сертификата)
За основу я взял эту статью, но как всегда не обошлось без напильника. Потом я нашёл «всё в одном месте», там всё очень хорошо расписано.
Добавим nginx
Для начала запустим наш сервер в связке с nginx.
Создадим новую папку, например, server_data, внутри неё сделаем ещё папку data, а в ней файл nginx.conf
nginx.conf
server { resolver 127.0.0.11; listen 80; # public server port set $simple_server_url http://simple-server:8000; location / { proxy_pass $simple_server_url; } }
127.0.0.11 — DNS сервер в докер сети по умолчанию
слушаем на 80 порту
устанавливем переменную simple_server_url, а не сразу пишем в секции location, потому что перед стартом nginx все proxy_pass должны быть доступны, а если контейнер с приложением к этому моенту не стартанёт, то nginx не запустится
simple-server — здесь DNS имя контейнера с нашим python приложением
В папке server_data создадим файл docker-compose.yml
docker-compose.yml
version: '3.8' services: simple-server: container_name: simple-server image: simple-server restart: unless-stopped networks: nginx_net: nginx: container_name: nginx image: nginx:1.21.1 restart: unless-stopped volumes: - ./data/nginx.conf:/etc/nginx/conf.d/default.conf ports: - 80:80 networks: nginx_net: networks: nginx_net:
Запускаем docker-compose up -d из папки server_data. Проверяем уже на 80 порту, потому что наше приложение теперь находится за nginx.
test@test:~/server_data$ docker-compose up -d Creating nginx ... done Creating simple-server ... done test@test:~/server_data$ curl http://127.0.0.1 Hello! test@test:~/server_data$
Отлично! Теперь у нас есть приложение, которое работает за nginx по HTTP. Самое время перевести нашу конфигурацию на HTTPS. Для дальнейшего продолжения убедитесь, что в файле nginx.conf значение переменной $server_url — верное DNS имя именно этой машины, без этого получить SSL сертификат не получится.
Получаем сертификат
Скрипт из статьи у меня не заработал из-за битых ссылок, решил взять другой отсюда.
Для данного скрипта важны названия сервисов из docker-compose.yml.
init-letsencrypt.sh
#!/bin/bash if ! [ -x "$(command -v docker-compose)" ]; then echo 'Error: docker-compose is not installed.' >&2 exit 1 fi domains=(example.org www.example.org) rsa_key_size=4096 data_path="./data/certbot" email="" # Adding a valid address is strongly recommended staging=1 # Set to 1 if you're testing your setup to avoid hitting request limits if [ -d "$data_path" ]; then read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then exit fi fi if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then echo "### Downloading recommended TLS parameters ..." mkdir -p "$data_path/conf" curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" echo fi echo "### Creating dummy certificate for $domains ..." path="/etc/letsencrypt/live/$domains" mkdir -p "$data_path/conf/live/$domains" docker-compose run --rm --entrypoint "\ openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ -keyout '$path/privkey.pem' \ -out '$path/fullchain.pem' \ -subj '/CN=localhost'" certbot echo echo "### Starting nginx ..." docker-compose up --force-recreate -d nginx echo echo "### Deleting dummy certificate for $domains ..." docker-compose run --rm --entrypoint "\ rm -Rf /etc/letsencrypt/live/$domains && \ rm -Rf /etc/letsencrypt/archive/$domains && \ rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot echo echo "### Requesting Let's Encrypt certificate for $domains ..." #Join $domains to -d args domain_args="" for domain in "${domains[@]}"; do domain_args="$domain_args -d $domain" done # Select appropriate email arg case "$email" in "") email_arg="--register-unsafely-without-email" ;; *) email_arg="--email $email" ;; esac # Enable staging mode if needed if [ $staging != "0" ]; then staging_arg="--staging"; fi docker-compose run --rm --entrypoint "\ certbot certonly --webroot -w /var/www/certbot \ $staging_arg \ $email_arg \ $domain_args \ --rsa-key-size $rsa_key_size \ --agree-tos \ --force-renewal" certbot echo echo "### Reloading nginx ..." docker-compose exec nginx nginx -s reload
В скрипте init-letsencrypt.shменяем значения переменных в 8, 11 и 12 строчке. Значение параметра staging лучше оставить 1, пока скрипт не отработает без ошибок. Если вдруг ваше DNS имя содержит не только латинские буквы, дефис и цифры, то адрес надо писать в формате Punycode.
Ещё нужно будет поменять файлы docker-compose.yml и nginx.conf. В файле docker-compose.yml добавляется сервис certbot, а в сервисе nginx добавляются общие с certbot volumes, открываем 443 порт и добавляем секцию command, которая перезагружает nginx каждые 6 часов, чтобы подгрузить новые сертификаты, если они изменились. Сервис certbot отвечает за взаимодействие с сервером Let’s Encrypt и обновлением SSL сертификата. Приведу файл целиком.
docker-compose.yml
version: '3.8' services: simple-server: container_name: simple-server image: simple-server restart: unless-stopped networks: nginx_net: nginx: container_name: nginx image: nginx:1.21.1 restart: unless-stopped volumes: - ./data/nginx.conf:/etc/nginx/conf.d/default.conf - ./data/certbot/conf:/etc/letsencrypt - ./data/certbot/www:/var/www/certbot ports: - 80:80 - 443:443 command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" networks: nginx_net: certbot: container_name: certbot image: certbot/certbot:v1.17.0 restart: unless-stopped entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" networks: nginx_net: volumes: - ./data/certbot/conf:/etc/letsencrypt - ./data/certbot/www:/var/www/certbot networks: nginx_net:
Но ещё требуется изменить файл nginx.conf, в него добавится специальная секция location, которая будет перенаправлять запросы к certbot.
nginx.conf
server { resolver 127.0.0.11; listen 80; # public server port location /.well-known/acme-challenge/ { root /var/www/certbot; } set $simple_server_url http://simple-server:8000; location / { proxy_pass $simple_server_url; } }
Запускаем скрипт init-letsencrypt.sh с параметром staging=1. Если всё настроено правильно, то будет сообщение с поздравлением.

Тогда можно запускать скрипт с параметром staging=0. Если что-то пошло не так, а у меня такое было пару раз, то советую проверить следующее:
-
Проверить правильность конфига nginx.
-
Проверить
docker-compose.ymlна наличие общихvolumeуnginxиcertbot. -
Добавить
set -xв начало скрипта для дебага (будут выводиться исполняемые команды). -
Для дебага в скрипте
init-letsencrypt.shможно убрать аргумент--rmв 69 строчке, тогда контейнер не удалится и можно будет вытащить из него лог. И добавить аргумент-vперед\в 73 строчке для более полного логирования. -
Если совсем беда, то можно включить
debugлогирование наnginx.
Не забудьте запустить скрипт с параметром staging=0, иначе сертификат не появится и HTTPS работать не будет.
Окончательная настройка
Итак, осталось совсем чуть-чуть. Нужно настроить nginx для работы по HTTPS и всё. Откроем 443 порт, добавим путь к SSL сертификатам, пути к настройкам letsencrypt и редирект с HTTP на HTTPS. Во всех примерах ниже будет фигурировать <URL>, его нужно менять на ваш, в файле ниже это 2, 10 и 11 строчка. Не забываем про Punycode, если требуется. Пару раз при тесте certbot создавал папки с префиксом www., это ещё одна возможная причина неуспешной конфигурации.
nginx.conf
server { resolver 127.0.0.11; listen 80; # public server port listen 443 ssl; ssl_certificate /etc/letsencrypt/live/<URL>/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/<URL>/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location /.well-known/acme-challenge/ { root /var/www/certbot; } if ($server_port = 80) { set $https_redirect 1; } if ($host ~ '^www\.') { set $https_redirect 1; } if ($https_redirect = 1) { return 301 https://$server_url$request_uri; } set $simple_server_url http://simple-server:8000; location / { proxy_pass $simple_server_url; } }
Запускаем наши сервисы
docker-compose up --force-recreate -d
И заходим в браузере на <URL> по HTTP, должен сработать редирект на HTTPS.

Всё получилось, поздравляю! Таким способом можно перевести любое веб приложение на работу по HTTPS, даже для такого, которое не поддерживает HTTPS из коробки.
Перейдём к TeamCity
TeamCity можно долго оптимизировать, в этой статье я возьму «минимальную» конфигурацию, чтобы «просто запустилось». Для TeamCity единственное отличие — это настройка nginx.conf для работы с сокетами и некоторые дополнительные оптимизации. Берём рекомендованный конфиг отсюда и объединяем с нашим БЕЗ строчкии http. Ещё важное отличие, что для работы TeamCity нужна правильно настроенная секция server_name, в примере с Python приложением её можно было не заполнять. Получаем такой:
nginx.conf
# ... default settings here proxy_read_timeout 1200; proxy_connect_timeout 240; client_max_body_size 0; # maximum size of an HTTP request. 0 allows uploading large artifacts to TeamCity map $http_upgrade $connection_upgrade { # WebSocket support default upgrade; '' ''; } server { server_name <URL> www.<URL>; # public server host name resolver 127.0.0.11; listen 80; # public server port listen 443 ssl; # public server port ssl_certificate /etc/letsencrypt/live/<URL>/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/<URL>/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location /.well-known/acme-challenge/ { root /var/www/certbot; } if ($server_port = 80) { set $https_redirect 1; } if ($host ~ '^www\.') { set $https_redirect 1; } if ($https_redirect = 1) { return 301 https://$server_url$request_uri; } set $teamcity_server_url http://teamcity-server-instance:8111; location / { # public context (should be the same as internal context) proxy_pass $teamcity_server_url; # full internal address proxy_http_version 1.1; proxy_set_header Host $server_name:$server_port; proxy_set_header X-Forwarded-Host $http_host; # necessary for proper absolute redirects and TeamCity CSRF check proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Upgrade $http_upgrade; # WebSocket support proxy_set_header Connection $connection_upgrade; # WebSocket support } }
И файл docker-compose.yml
docker-compose.yml
version: '3.8' services: teamcity_server: container_name: teamcity-server-instance image: jetbrains/teamcity-server:2021.1 restart: unless-stopped tty: true networks: nginx_net: nginx: container_name: nginx image: nginx:1.21.1 restart: unless-stopped volumes: - ./data/nginx.conf:/etc/nginx/conf.d/default.conf - ./data/certbot/conf:/etc/letsencrypt - ./data/certbot/www:/var/www/certbot ports: - 80:80 - 443:443 command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" networks: nginx_net: certbot: container_name: certbot image: certbot/certbot:v1.17.0 restart: unless-stopped entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" networks: nginx_net: volumes: - ./data/certbot/conf:/etc/letsencrypt - ./data/certbot/www:/var/www/certbot networks: nginx_net:
Запускаем с помощью команды docker-compose up -d, переходим в браузер по URL и настраиваем TeamCity.
Добавляем агента
Добавим билд агента, который может быть расположен в любой точке мира. Для этого достаточно запустить на любой машине с докером такую команду
docker run -dt -e SERVER_URL="https://<URL>" \ --name teamcity-agent-instance jetbrains/teamcity-agent:2021.1
Агент должен появиться в веб интерфейсе TeamCity на вкладке с агентами.
Агент в локальной сети
Предположим, что у нас есть 2 сервера в локальной сети. Первый будет отвечать за сервер TeamCity, а второй будет билд агентом, но у него не будет доступа в интернет. Будем считать, что сеть защённая и там можно общаться по HTTP. Тогда запуск агента с таким параметром SERVER_URL="https://<URL>" не подойдёт, а подойдёт SERVER_URL="http://<SERVER_LOCAL_IP>". Придётся ещё поменять конфиг nginx — добавить туда секцию для локальной сети
nginx.conf
# ... default settings here proxy_read_timeout 1200; proxy_connect_timeout 240; client_max_body_size 0; # maximum size of an HTTP request. 0 allows uploading large artifacts to TeamCity map $http_upgrade $connection_upgrade { # WebSocket support default upgrade; '' ''; } server { resolver 127.0.0.11; listen 80; # server port server_name <SERVER_LOCAL_IP>; # private server host name set $teamcity_url http://teamcity-server-instance:8111; location / { # public context (should be the same as internal context) proxy_pass $teamcity_url; # full internal address proxy_http_version 1.1; proxy_set_header Host $server_name:$server_port; proxy_set_header X-Forwarded-Host $http_host; # necessary for proper absolute redirects and TeamCity CSRF check proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Upgrade $http_upgrade; # WebSocket support proxy_set_header Connection $connection_upgrade; # WebSocket support } } server { resolver 127.0.0.11; listen 80; # public server port listen 443 ssl; server_name <URL>; # public server host name ssl_certificate /etc/letsencrypt/live/<URL>/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/<URL>/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location /.well-known/acme-challenge/ { root /var/www/certbot; } if ($server_port = 80) { set $https_redirect 1; } if ($host ~ '^www\.') { set $https_redirect 1; } if ($https_redirect = 1) { return 301 https://<URL>$request_uri; } set $teamcity_server_url http://teamcity-server-instance:8111; location / { proxy_pass $teamcity_server_url; proxy_http_version 1.1; proxy_set_header Host $server_name:$server_port; proxy_set_header X-Forwarded-Host $http_host; # necessary for proper absolute redirects and TeamCity CSRF check proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Upgrade $http_upgrade; # WebSocket support proxy_set_header Connection $connection_upgrade; # WebSocket support } }
На вкладке в с агентами не должно быть изменений, но теперь общение между сервером и агентом будет идти в локальной сети.
Ещё улучшений?
А ещё можно развернуть сервер и билд агент в Docker Swarm или Kubernetes, но это уже совсем другая история «лучшее — враг хорошего», и мы решили остановиться. Полученная конфигурация позволила решить все наши задачи в полном объёме и не требует трудоёмкой поддержки. Добавление агентов происходит одной командой, что очень удобно.
Ещё раз приведу ссылку на репозиторий с шагами. Спасибо за внимание!
UPD
Добавил статью о Проблемы при сборке Docker образов внутри Docker контейнеров на TeamCity
ссылка на оригинал статьи https://habr.com/ru/articles/571324/
Добавить комментарий