Last active
March 6, 2023 13:38
-
-
Save turgayozgur/4d9dbbc39dbe30aef2ac84b44a9e18bb to your computer and use it in GitHub Desktop.
ASPNET Core Zero Downtime Deployment to Linux with Nginx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
set -euo pipefail | |
IFS=$'\n\t' | |
# How to use? | |
# https://medium.com/@ozgurtrgy/aspnet-core-zero-downtime-deployment-to-linux-with-nginx-b8b230bf1577 | |
# the variables depend on you. | |
PACKAGE_TAR_NAME="artifacts.tar.gz" | |
VERSION="1005" | |
ASPNETCORE_ENV="Production" | |
APP_NAME="Example.Application.Name" | |
DLL_NAME="Example.Web.dll" | |
HEALTHCHECK_PORT=8081 # should be between 8000 and 9000. | |
DOMAIN_NAME="example.com" | |
REMOVE_NON_ASCII_COOKIES="true" # true if you are using ASPNET Core version 2.1 or below, otherwise false. | |
# other variables. | |
SERVICE_FILE_DIR="/etc/systemd/system" | |
NGINX_FILE_DIR="/etc/nginx/sites-available" | |
APP_ROOT_DIR="/var/aspnetcore" | |
APP_DIR="$APP_ROOT_DIR/$APP_NAME" | |
HEALTHCHECK_PATH="healthcheck" | |
APP_URL_LOCAL="http://localhost" | |
## | |
# Main routine | |
## | |
main() { | |
local randomFreePort=$(getRandomFreePort) | |
local deploypath=$(getDeployPath) | |
local suffix=$(getSuffix) | |
local appurl="$APP_URL_LOCAL:$randomFreePort"; | |
local healthcheckurl="$appurl/$HEALTHCHECK_PATH" | |
local servicefile=$(getServiceFilePath ${suffix}) | |
extractPackage $deploypath | |
replaceSettings $deploypath | |
configureDataProtection | |
configurePermissions $deploypath | |
runService $servicefile $deploypath $appurl $healthcheckurl $suffix | |
configureNginx $deploypath $appurl $healthcheckurl | |
checkCorrectVersionIsUp $HEALTHCHECK_PORT $suffix | |
destroyOldService $servicefile | |
echo "Successfully finished." > /dev/stdout | |
} | |
## | |
# Get the directory that created recently. It should be created when new package fetched. | |
## | |
getLatestDir() { | |
echo $(echo $(cd "$APP_DIR" && echo $(ls -t | head -1))) | |
} | |
## | |
# Get the current deployment path with version. | |
## | |
getDeployPath() { | |
echo "$APP_DIR/$(getLatestDir)" | |
} | |
## | |
# Get the suffix from current deployment dir name. Suffix is the number that comes after the "-" | |
## | |
getSuffix() { | |
local latestdir=$(getLatestDir) | |
[[ $latestdir =~ "-" ]] && echo "-${latestdir##*-}" || echo "" | |
} | |
## | |
# Get random port between 5000 and 5300 that free. | |
## | |
getRandomFreePort() { | |
while :; do randomport="`shuf -i 5000-5300 -n 1`"; ss -lpn | grep -q ":$randomport " || break; done | |
echo $randomport | |
} | |
## | |
# Extract the deployment package. | |
## | |
extractPackage() { | |
local deploypath=$1 | |
(cd $deploypath | |
sudo tar -xzf $PACKAGE_TAR_NAME --strip-components=1 && sudo rm $PACKAGE_TAR_NAME | |
) | |
} | |
## | |
# Replace app setting tmpl variables to environment variables. | |
## | |
replaceSettings() { | |
local deploypath=$1 | |
(cd $deploypath | |
keys=($(grep -o '{{.*}}' appsettings.json.tmpl | sed "s/{{.//g" | sed "s/}}//g")) | |
echo "Variables are replacing..." > /dev/stdout | |
#set appsetting.environment.json from environment variables. | |
for key in "${keys[@]}" | |
do | |
value=${!key} # Fetched from environment variables. You can fetch your variables whereever you want. | |
[[ $value =~ Unrecognized* ]] && (>&2 echo $value; exit 1;) | |
sudo sed -i -e "s%{{.$key}}%$value%g" appsettings.json.tmpl | |
done | |
sudo mv appsettings.json.tmpl appsettings.$ASPNETCORE_ENV.json | |
) | |
} | |
## | |
# Get the service file path. | |
## | |
getServiceFilePath() { | |
local suffix=${1-""} | |
local servicefilename="$APP_NAME-$VERSION$suffix.service" | |
echo "$SERVICE_FILE_DIR/$servicefilename" | |
} | |
## | |
# Configure and run the new service. | |
## | |
runService() { | |
local servicefile=$1 | |
local deploypath=$2 | |
local appurl=$3 | |
local healthcheckurl=$4 | |
local suffix=${5-""} | |
local servicefilename="${servicefile##*/}" | |
local service=( | |
"[Unit]" | |
"Description=Example .ASPNET Web App running on Ubuntu. app: $APP_NAME version: $VERSION" | |
"" | |
"[Service]" | |
"WorkingDirectory=$deploypath" | |
"ExecStart=/usr/bin/dotnet $deploypath/$DLL_NAME" | |
"Restart=always" | |
"RestartSec=30" | |
"KillMode=mixed" # https://stackoverflow.com/a/40971615 | |
"SyslogIdentifier=$APP_NAME" | |
"User=www-data" | |
"LimitNOFILE=640000" | |
"Environment=ASPNETCORE_ENVIRONMENT=$ASPNETCORE_ENV" | |
"Environment=ASPNETCORE_URLS=$appurl" | |
"Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false" | |
"Environment=DEPLOYMENT_VERSION=$VERSION$suffix" | |
"" | |
"[Install]" | |
"WantedBy=multi-user.target" ) | |
echo "Service is updating..." > /dev/stdout | |
#configure | |
[ -f $servicefile ] && sudo rm $servicefile | |
for line in "${service[@]}"; do | |
echo $line | sudo tee --append $servicefile > /dev/null | |
done | |
#run | |
echo "Start the service..." > /dev/stdout | |
sudo systemctl daemon-reload | |
sudo systemctl restart $servicefilename | |
sudo systemctl enable $servicefilename 2>&1 # to run on startup. | |
echo "The service successfully started." > /dev/stdout | |
#wait for the service is up. | |
echo "Waiting for the service to up and running..." > /dev/stdout | |
local end=$((SECONDS+180)) # 3 min to wait service up. | |
local serviceisup=0 | |
while [ $SECONDS -lt $end ]; do | |
if [[ "$(curl -s -o /dev/null -w ''%{http_code}'' $healthcheckurl)" != "200" ]]; then | |
sleep 10; # seconds | |
else | |
serviceisup=1 | |
break; | |
fi | |
done | |
[ $serviceisup -ne 1 ] && ( (>&2 echo "Service not responding :("); sudo rm $servicefile; sudo systemctl daemon-reload; exit 1; ) | |
echo "Service is up and running!" > /dev/stdout | |
} | |
## | |
# Configure the nginx config file. | |
## | |
configureNginx() { | |
local deploypath=$1 | |
local appurl=$2 | |
local healthcheckurl=$3 | |
# to removing non-ascii characters from cookie. This is a workaround for kerstel 400 issue that will be fixed in 2.2 relase. | |
# https://github.com/aspnet/KestrelHttpServer/issues/2884 | |
local removenonasciiblock=("") | |
local setnonasciicookieline="" | |
if [ $REMOVE_NON_ASCII_COOKIES == "True" ]; then | |
local removenonasciiblock=( | |
' set_by_lua_block $cookie_ascii {' | |
' local cookie = ngx.var.http_cookie' | |
" if cookie == nil or cookie == '' then return cookie end" | |
' local cookie_ascii, n, err = ngx.re.gsub(cookie, "[^\\x00-\\x7F]", "")' | |
' return cookie_ascii' | |
' }' | |
) | |
local setnonasciicookieline=' proxy_set_header Cookie $cookie_ascii;' | |
fi | |
local rootlocation=( | |
" location / {" | |
"${removenonasciiblock[@]}" | |
" root $deploypath;" | |
" proxy_pass $appurl;" | |
" proxy_buffering off;" # https://medium.com/@mshanak/soved-dotnet-core-too-many-open-files-in-system-when-using-postgress-with-entity-framework-c6e30eeff6d1 | |
" proxy_read_timeout 7200;" | |
" proxy_http_version 1.1;" | |
" proxy_set_header Upgrade \$http_upgrade;" | |
" proxy_set_header Connection keep-alive;" | |
" proxy_set_header Host \$host;" | |
" proxy_cache_bypass \$http_upgrade;" | |
" proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" | |
" proxy_set_header X-Forwarded-Proto \$scheme;" | |
"$setnonasciicookieline" | |
" fastcgi_buffers 16 16k;" | |
" fastcgi_buffer_size 32k;" | |
" }" | |
) | |
local server=( | |
"server {" | |
" listen 80;" | |
" server_name $DOMAIN_NAME;" | |
"${rootlocation[@]}" | |
"}" ) | |
local healthcheckserver=( | |
"server {" | |
" listen $healthcheckport;" | |
"${rootlocation[@]}" | |
"}" | |
) | |
local nginxconfigfile="$NGINX_FILE_DIR/$APP_NAME" | |
echo "nginx configuring..." > /dev/stdout | |
[ -f $nginxconfigfile ] && sudo rm $nginxconfigfile | |
# server for all of environments. | |
for line in "${server[@]}"; do | |
echo $line | sudo tee --append $nginxconfigfile > /dev/null | |
done | |
# healtcheck server to use the app by ip. | |
if [[ $healthcheckport =~ ^8[0-9]{3}$ ]]; then # healthcheckport should be between 8000 and 9000. | |
for line in "${healthcheckserver[@]}"; do | |
echo $line | sudo tee --append $nginxconfigfile > /dev/null | |
done | |
fi | |
sudo ln -sfn $nginxconfigfile /etc/nginx/sites-enabled/ | |
sudo systemctl reload nginx | |
echo "nginx successfully configured." > /dev/stdout | |
} | |
## | |
# After the request directly to nginx, check the correct version number we see. | |
## | |
checkCorrectVersionIsUp() { | |
local healthcheckport=$1 | |
local suffix=${2-""} | |
local machineip=$(hostname -I) | |
local versioncheckurl="http://${machineip}:${healthcheckport}/healthcheck" | |
local versioncheckurl=$(echo ${versioncheckurl//[[:blank:]]/}) | |
echo "Waiting for the correct version is up to deleting old one... Check url: $versioncheckurl" > /dev/stdout | |
local end=$((SECONDS+60)) # 1 min to wait service switch. | |
while [ $SECONDS -lt $end ]; do | |
if [[ "$(curl -s -o /dev/null -w ''%{http_code}'' $versioncheckurl)" == "200" ]]; then | |
local deploynumber=$(curl -s "$versioncheckurl" | jq -r '.Version') | |
if [ "$deploynumber" == "$VERSION$suffix" ]; then | |
echo "The correct version is up now!" > /dev/stdout | |
break; | |
else | |
sleep 2; # seconds | |
fi | |
fi | |
done | |
} | |
## | |
# Destroy old one. | |
## | |
destroyOldService() { | |
local serviceFile=$1 | |
echo "Stopping and deleting old service..." > /dev/stdout | |
for oldservicefile in $SERVICE_FILE_DIR/$APP_NAME-*; do | |
if [[ -f "$oldservicefile" ]] && [[ "$servicefile" != "$oldservicefile" ]]; then | |
local oldservicefilename="${oldservicefile##*/}" | |
sudo systemctl stop $oldservicefilename || true | |
sudo systemctl disable $oldservicefilename 2>&1 || true | |
sudo rm $oldservicefile | |
sudo systemctl daemon-reload | |
echo "$oldservicefile stopped and deleted." > /dev/stdout | |
fi | |
done | |
echo "Old services stopped and deleted." > /dev/stdout | |
} | |
## | |
# Execute main routine. | |
## | |
main $@ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment