Как стать автором
Обновить
54.86
Visiology
Компания-разработчик российской BI-платформы

Переходим на HTTPS за 15 минут на примере TeamCity

Время на прочтение11 мин
Количество просмотров8.3K

Всем привет! Меня зовут Александр. Я являюсь техническим руководителем отдела интеграции в компании 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. Если выкинуть всю воду, то можно свести к трём шагам:

  1. Получаем сертификат

  2. Подкладываем его nginx серверу

  3. Запускаем контейнеры приложения, 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. Если что-то пошло не так, а у меня такое было пару раз, то советую проверить следующее:

  1. Проверить правильность конфига nginx.

  2. Проверить docker-compose.yml на наличие общих volume у nginx и certbot.

  3. Добавить set -x в начало скрипта для дебага (будут выводиться исполняемые команды).

  4. Для дебага в скрипте init-letsencrypt.sh можно убрать аргумент --rm в 69 строчке, тогда контейнер не удалится и можно будет вытащить из него лог. И добавить аргумент -v перед \ в 73 строчке для более полного логирования.

  5. Если совсем беда, то можно включить 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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Про что ещё рассказать?
41.67% Использование шаблонов в TeamCity для сборок Docker образов5
0% Интеграция TeamCity с Yandex Container Registry0
25% Настройка Build chain в TeamCity для сборок с разными VCS3
33.33% Проблемы при сборке Docker образов внутри Docker контейнеров4
Проголосовали 12 пользователей. Воздержались 7 пользователей.
Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+3
Комментарии2

Публикации

Информация

Сайт
ru.visiology.su
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Иван Вахмянин